@av-pi-studio/server 0.0.93 → 0.0.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import type { AgentStatus } from "@av-pi-studio/protocol";
2
2
  import type { AgentRecord } from "../persistence/entity-schemas.js";
3
+ import type { Logger } from "../logging/logger.js";
3
4
  import type { AgentSession } from "./provider-contract.js";
4
5
  /**
5
6
  * AgentManager — the single source of truth for agent lifecycle state
@@ -48,6 +49,13 @@ export interface AgentManagerDeps {
48
49
  deleteAgent?: (cwd: string, id: string) => Promise<boolean>;
49
50
  /** Loop service hook: recover `running` loops as `stopped` with an interruption log entry. */
50
51
  onRecoverLoops?: () => Promise<void> | void;
52
+ /** Session-attach hook (features/extension-ui-rpc.md § New/changed files) — invoked at the end of
53
+ * `attachSession()`, the single choke point every spawn/resume/import path already funnels
54
+ * through (`agent-service.ts:104`, `:228`). A throwing hook is logged and never prevents the
55
+ * session from being attached. Optional: existing constructions that pass none behave exactly
56
+ * as before. */
57
+ onSessionAttached?: (agentId: string, session: AgentSession) => void;
58
+ logger?: Logger;
51
59
  now?: () => string;
52
60
  }
53
61
  export declare class AgentManager {
@@ -82,8 +82,17 @@ export class AgentManager {
82
82
  }
83
83
  attachSession(id, session) {
84
84
  const managed = this.agents.get(id);
85
- if (managed)
86
- managed.session = session;
85
+ if (!managed)
86
+ return;
87
+ managed.session = session;
88
+ if (!this.deps.onSessionAttached)
89
+ return;
90
+ try {
91
+ this.deps.onSessionAttached(id, session);
92
+ }
93
+ catch (err) {
94
+ this.deps.logger?.warn({ agentId: id, err: err instanceof Error ? err.message : String(err) }, "agent-manager: onSessionAttached hook failed");
95
+ }
87
96
  }
88
97
  /**
89
98
  * Snapshot and persist the live session's resume handle (e.g. Pi's on-disk JSONL session file)
@@ -0,0 +1,20 @@
1
+ import type { Logger } from "../../logging/logger.js";
2
+ import type { HandlerRegistry } from "../../ws/router.js";
3
+ import type { AgentUiService } from "./agent-ui-service.js";
4
+ /**
5
+ * Wires the two `agent_ui_*` RPCs (swe/features/extension-ui-rpc.md § Public contract) onto
6
+ * `AgentUiService`. Modelled directly on `registerFileWatchHandlers`/`registerProviderAuthHandlers`:
7
+ * a thin adapter that stamps no policy of its own — ownership, first-wins resolution, and every
8
+ * error code already live in the service (task-003). Never throws for a domain failure
9
+ * (`not_found`/`unsupported` travel in `payload`); `requestId` is stamped by the router, not here.
10
+ *
11
+ * Registered in **both** bootstraps (task-004's deviation from the production-only
12
+ * `provider_auth`/`file_watch` families) — the mock provider is this family's designated producer,
13
+ * so the dev daemon must be able to drive it end to end.
14
+ */
15
+ export interface AgentUiRpcDeps {
16
+ service: AgentUiService;
17
+ logger?: Logger;
18
+ }
19
+ export declare function registerAgentUiHandlers(registry: HandlerRegistry, deps: AgentUiRpcDeps): void;
20
+ //# sourceMappingURL=agent-ui-rpc.d.ts.map
@@ -0,0 +1,24 @@
1
+ export function registerAgentUiHandlers(registry, deps) {
2
+ const { service } = deps;
3
+ registry.register("agent_ui_respond_request", (ctx) => {
4
+ const uiRequestId = String(ctx.message.uiRequestId ?? "");
5
+ const response = (ctx.message.response ?? {});
6
+ const result = service.respond(uiRequestId, response);
7
+ return {
8
+ type: "agent_ui_respond_response",
9
+ payload: result.error ? { ok: result.ok, error: result.error } : { ok: result.ok },
10
+ };
11
+ });
12
+ registry.register("agent_ui_list_request", (ctx) => {
13
+ const agentId = typeof ctx.message.agentId === "string" ? ctx.message.agentId : undefined;
14
+ return {
15
+ type: "agent_ui_list_response",
16
+ payload: {
17
+ ok: true,
18
+ pending: service.listPending(agentId),
19
+ surfaces: service.listSurfaces(agentId),
20
+ },
21
+ };
22
+ });
23
+ }
24
+ //# sourceMappingURL=agent-ui-rpc.js.map
@@ -0,0 +1,55 @@
1
+ import type { AgentUiPendingRequest, AgentUiSurface } from "@av-pi-studio/protocol";
2
+ import type { Logger } from "../../logging/logger.js";
3
+ import type { Session } from "../../ws/session.js";
4
+ import type { AgentSession, ProviderUiResponse } from "../provider-contract.js";
5
+ export interface AgentUiServiceDeps {
6
+ broadcast: (sessions: Iterable<Session>, message: unknown) => void;
7
+ getActiveSessions: () => Iterable<Session>;
8
+ logger?: Logger;
9
+ /** Timer seams so tests use fake timers instead of wall-clock waits. */
10
+ setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
11
+ clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
12
+ }
13
+ export declare class AgentUiService {
14
+ private readonly broadcastFn;
15
+ private readonly getActiveSessions;
16
+ private readonly logger;
17
+ private readonly setTimer;
18
+ private readonly clearTimer;
19
+ private readonly pending;
20
+ private readonly surfaces;
21
+ private readonly channels;
22
+ private readonly loggedUnknownMethods;
23
+ constructor(deps: AgentUiServiceDeps);
24
+ /**
25
+ * `AgentManager.onSessionAttached` hook (task-004 wires the call site; nothing calls this yet).
26
+ * Sweeps first — a forced respawn (`spawnOrResumeSession` always spawns fresh) leaves undead
27
+ * dialogs whose provider ids belong to a dead process, so any prior pending/surfaces/channel for
28
+ * this agent are cancelled as `"aborted"` before the new session's channel is ever subscribed.
29
+ */
30
+ attach(agentId: string, session: AgentSession): void;
31
+ /** First-answer-wins. Unknown/already-resolved/fire-and-forget ids all report `not_found` — a
32
+ * fire-and-forget request was never inserted into `pending` in the first place. */
33
+ respond(uiRequestId: string, response: ProviderUiResponse): {
34
+ ok: boolean;
35
+ error?: string;
36
+ };
37
+ listPending(agentId?: string): AgentUiPendingRequest[];
38
+ listSurfaces(agentId?: string): AgentUiSurface[];
39
+ /**
40
+ * Session-terminal only (archive / delete / re-attach) — NEVER called on interrupt. For each
41
+ * pending entry: best-effort cancel toward **the entry's own captured session** (never a freshly
42
+ * attached one — on the attach-path sweep the new session never issued those provider ids),
43
+ * broadcast the resolution, then drop the agent's pending entries, surfaces, and — critically —
44
+ * call the stored channel `Unsubscribe` before dropping it, so a post-sweep emission from a dying
45
+ * session cannot re-create a surface for an agent that no longer has a live channel.
46
+ */
47
+ sweep(agentId: string, reason: string): void;
48
+ private onProviderRequest;
49
+ /** Pi's own timeout elapsed — Pi already auto-resolved the dialog on its side (docs/rpc.md: "the
50
+ * client does not need to track timeouts"). Deliberately does NOT call `respondToUi`: answering
51
+ * again would target an id Pi has already dropped. */
52
+ private expire;
53
+ private broadcastResolved;
54
+ }
55
+ //# sourceMappingURL=agent-ui-service.d.ts.map
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * Correlates the provider UI channel into the daemon's wire family
4
+ * (features/extension-ui-rpc.md § Behavior & algorithms — the pseudocode there is normative). The
5
+ * only stateful piece of the bridge, and deliberately **payload-blind**: every Pi-specific decision
6
+ * (which methods block, surface-key namespacing, clear-by-omission, envelope stamping) was already
7
+ * made by the adapter (task-002); this service does no `method` string comparison beyond the
8
+ * unknown-method diagnostic log.
9
+ *
10
+ * Four behaviors here each prevent a concrete defect — see the task's own "Context / why":
11
+ * - **Wire ids are daemon-minted, never the provider's.** `ProviderUiRequest.requestId` is only
12
+ * promised unique per-process; keying a daemon-global map by it would let one agent's dialog
13
+ * shadow another's. `pending` is keyed by a minted UUID; providers never see it.
14
+ * - **Interrupt touches nothing.** Dialogs are not turn-scoped (`pi-background-tasks` raises
15
+ * questions outside any turn) and surfaces are agent-lifetime state. `sweep` runs ONLY on
16
+ * session-terminal events (archive/delete/re-attach) — nothing else cancels.
17
+ * - **Expiry never answers.** Pi auto-resolves its own timed dialogs; the mirrored timer here
18
+ * exists solely so clients dismiss in step. Sending a second response would target a dead id.
19
+ * - **Resolution broadcasts unconditionally.** A `respondToUi` throw (dead stdin after a crash)
20
+ * still broadcasts from `finally` — otherwise every other client keeps a ghost dialog that no
21
+ * longer appears in `agent_ui_list_response`.
22
+ */
23
+ /** Extension UI method vocabulary as documented (rpc.md § Extension UI Protocol), used ONLY to
24
+ * decide whether an incoming method is diagnosable-as-unknown for the info log below — the single
25
+ * sanctioned exception to "no method string comparison" in this service. Duplicated here rather
26
+ * than imported from the Pi adapter: this service must stay provider-agnostic (never import
27
+ * `providers/pi/*` per root AGENTS.md § Key invariants #3), so this list is deliberately a
28
+ * diagnostics-only echo, not a source of behavior. */
29
+ const KNOWN_METHODS = new Set([
30
+ "select",
31
+ "confirm",
32
+ "input",
33
+ "editor",
34
+ "notify",
35
+ "setStatus",
36
+ "setWidget",
37
+ "setTitle",
38
+ "set_editor_text",
39
+ ]);
40
+ export class AgentUiService {
41
+ broadcastFn;
42
+ getActiveSessions;
43
+ logger;
44
+ setTimer;
45
+ clearTimer;
46
+ pending = new Map();
47
+ surfaces = new Map();
48
+ channels = new Map();
49
+ loggedUnknownMethods = new Set();
50
+ constructor(deps) {
51
+ this.broadcastFn = deps.broadcast;
52
+ this.getActiveSessions = deps.getActiveSessions;
53
+ this.logger = deps.logger;
54
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
55
+ this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
56
+ }
57
+ /**
58
+ * `AgentManager.onSessionAttached` hook (task-004 wires the call site; nothing calls this yet).
59
+ * Sweeps first — a forced respawn (`spawnOrResumeSession` always spawns fresh) leaves undead
60
+ * dialogs whose provider ids belong to a dead process, so any prior pending/surfaces/channel for
61
+ * this agent are cancelled as `"aborted"` before the new session's channel is ever subscribed.
62
+ */
63
+ attach(agentId, session) {
64
+ this.sweep(agentId, "aborted");
65
+ if (!session.onUiRequest)
66
+ return; // provider opted out; nothing to do, no error
67
+ const unsubscribe = session.onUiRequest((req) => this.onProviderRequest(agentId, session, req));
68
+ this.channels.set(agentId, unsubscribe);
69
+ }
70
+ /** First-answer-wins. Unknown/already-resolved/fire-and-forget ids all report `not_found` — a
71
+ * fire-and-forget request was never inserted into `pending` in the first place. */
72
+ respond(uiRequestId, response) {
73
+ const entry = this.pending.get(uiRequestId);
74
+ if (!entry)
75
+ return { ok: false, error: "not_found" };
76
+ if (!entry.session.respondToUi)
77
+ return { ok: false, error: "unsupported" };
78
+ this.pending.delete(uiRequestId);
79
+ if (entry.timer)
80
+ this.clearTimer(entry.timer);
81
+ try {
82
+ entry.session.respondToUi(entry.providerRequestId, response);
83
+ }
84
+ catch (err) {
85
+ // The answer was still accepted (first-wins already consumed it) — swallow + log so the
86
+ // resolution broadcast below still fires; otherwise every other client keeps a ghost dialog.
87
+ this.logger?.warn({
88
+ agentId: entry.agentId,
89
+ requestId: uiRequestId,
90
+ method: entry.method,
91
+ err: errorMessage(err),
92
+ }, "agent-ui: respondToUi failed");
93
+ }
94
+ finally {
95
+ this.broadcastResolved(uiRequestId, entry.agentId, "answered");
96
+ }
97
+ return { ok: true };
98
+ }
99
+ listPending(agentId) {
100
+ const result = [];
101
+ for (const [wireId, entry] of this.pending) {
102
+ if (agentId !== undefined && entry.agentId !== agentId)
103
+ continue;
104
+ result.push({
105
+ requestId: wireId,
106
+ agentId: entry.agentId,
107
+ method: entry.method,
108
+ expectsResponse: true,
109
+ payload: entry.payload,
110
+ ...(entry.surfaceKey !== undefined ? { surfaceKey: entry.surfaceKey } : {}),
111
+ ...(entry.timeoutMs !== undefined ? { timeoutMs: entry.timeoutMs } : {}),
112
+ createdAt: entry.createdAt,
113
+ });
114
+ }
115
+ return result;
116
+ }
117
+ listSurfaces(agentId) {
118
+ if (agentId !== undefined)
119
+ return [...(this.surfaces.get(agentId)?.values() ?? [])];
120
+ const result = [];
121
+ for (const agentSurfaces of this.surfaces.values())
122
+ result.push(...agentSurfaces.values());
123
+ return result;
124
+ }
125
+ /**
126
+ * Session-terminal only (archive / delete / re-attach) — NEVER called on interrupt. For each
127
+ * pending entry: best-effort cancel toward **the entry's own captured session** (never a freshly
128
+ * attached one — on the attach-path sweep the new session never issued those provider ids),
129
+ * broadcast the resolution, then drop the agent's pending entries, surfaces, and — critically —
130
+ * call the stored channel `Unsubscribe` before dropping it, so a post-sweep emission from a dying
131
+ * session cannot re-create a surface for an agent that no longer has a live channel.
132
+ */
133
+ sweep(agentId, reason) {
134
+ for (const [wireId, entry] of this.pending) {
135
+ if (entry.agentId !== agentId)
136
+ continue;
137
+ this.pending.delete(wireId);
138
+ if (entry.timer)
139
+ this.clearTimer(entry.timer);
140
+ if (entry.session.respondToUi) {
141
+ try {
142
+ entry.session.respondToUi(entry.providerRequestId, { cancelled: true });
143
+ }
144
+ catch (err) {
145
+ this.logger?.warn({ agentId, requestId: wireId, method: entry.method, err: errorMessage(err) }, "agent-ui: sweep respondToUi failed");
146
+ }
147
+ }
148
+ this.broadcastResolved(wireId, agentId, reason);
149
+ }
150
+ this.surfaces.delete(agentId);
151
+ const unsubscribe = this.channels.get(agentId);
152
+ if (unsubscribe) {
153
+ unsubscribe();
154
+ this.channels.delete(agentId);
155
+ }
156
+ }
157
+ onProviderRequest(agentId, session, req) {
158
+ const now = Date.now();
159
+ if (req.surfaceKey !== undefined) {
160
+ let agentSurfaces = this.surfaces.get(agentId);
161
+ if (!agentSurfaces) {
162
+ agentSurfaces = new Map();
163
+ this.surfaces.set(agentId, agentSurfaces);
164
+ }
165
+ if (req.removed) {
166
+ agentSurfaces.delete(req.surfaceKey);
167
+ }
168
+ else {
169
+ agentSurfaces.set(req.surfaceKey, {
170
+ agentId,
171
+ method: req.method,
172
+ surfaceKey: req.surfaceKey,
173
+ payload: req.payload,
174
+ updatedAt: now,
175
+ });
176
+ }
177
+ }
178
+ if (!KNOWN_METHODS.has(req.method)) {
179
+ const key = `${agentId}:${req.method}`;
180
+ if (!this.loggedUnknownMethods.has(key)) {
181
+ this.loggedUnknownMethods.add(key);
182
+ this.logger?.info({ agentId, method: req.method }, "agent-ui: unknown extension UI method");
183
+ }
184
+ }
185
+ const wireId = randomUUID();
186
+ if (req.expectsResponse) {
187
+ const entry = {
188
+ agentId,
189
+ providerRequestId: req.requestId,
190
+ session,
191
+ method: req.method,
192
+ payload: req.payload,
193
+ createdAt: now,
194
+ ...(req.surfaceKey !== undefined ? { surfaceKey: req.surfaceKey } : {}),
195
+ ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
196
+ };
197
+ if (req.timeoutMs)
198
+ entry.timer = this.setTimer(() => this.expire(wireId), req.timeoutMs);
199
+ this.pending.set(wireId, entry);
200
+ }
201
+ this.broadcastFn(this.getActiveSessions(), {
202
+ type: "session",
203
+ message: {
204
+ type: "agent_ui_request",
205
+ requestId: wireId,
206
+ agentId,
207
+ method: req.method,
208
+ expectsResponse: req.expectsResponse,
209
+ payload: req.payload,
210
+ ...(req.surfaceKey !== undefined ? { surfaceKey: req.surfaceKey } : {}),
211
+ ...(req.removed ? { removed: true } : {}),
212
+ ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
213
+ createdAt: now,
214
+ },
215
+ });
216
+ }
217
+ /** Pi's own timeout elapsed — Pi already auto-resolved the dialog on its side (docs/rpc.md: "the
218
+ * client does not need to track timeouts"). Deliberately does NOT call `respondToUi`: answering
219
+ * again would target an id Pi has already dropped. */
220
+ expire(wireId) {
221
+ const entry = this.pending.get(wireId);
222
+ if (!entry)
223
+ return;
224
+ this.pending.delete(wireId);
225
+ this.broadcastResolved(wireId, entry.agentId, "timeout");
226
+ }
227
+ broadcastResolved(uiRequestId, agentId, reason) {
228
+ this.broadcastFn(this.getActiveSessions(), {
229
+ type: "session",
230
+ message: { type: "agent_ui_resolved", requestId: uiRequestId, agentId, reason },
231
+ });
232
+ }
233
+ }
234
+ function errorMessage(err) {
235
+ return err instanceof Error ? err.message : String(err);
236
+ }
237
+ //# sourceMappingURL=agent-ui-service.js.map
@@ -1,12 +1,34 @@
1
1
  import { z } from "zod";
2
+ import type { AgentUiPendingRequest, AgentUiResponse } from "@av-pi-studio/protocol";
2
3
  /**
3
- * Daemon MCP server (features/mcp-server.md). Hosts orchestration tools at `/mcp/agents` so agents
4
- * can control other agents + the daemon. The tool set mirrors the WS/CLI control plane. When
5
- * `daemon.mcp.injectIntoAgents` is enabled, a per-agent `--mcp-config` is generated for Pi (adapter
6
- * OAuth disabled); user/project MCP files are never touched.
4
+ * Daemon MCP tool registry (features/mcp-server.md). Defines the orchestration tools that are
5
+ * *intended* to be hosted at `/mcp/agents` so agents can control other agents + the daemon, and
6
+ * mirrors the WS/CLI control plane. Owns the tool registry, `create_agent` semantics, and
7
+ * injection-config generation, all injectable for tests.
7
8
  *
8
- * The transport (HTTP server) is wired in bootstrap; this module owns the tool registry, the
9
- * `create_agent` semantics, and the injection-config generation, all injectable for tests.
9
+ * NOT REACHABLE AT RUNTIME. Nothing in this repo constructs an `McpServer` outside
10
+ * `mcp-server.test.ts`. Specifically missing:
11
+ * - no HTTP route serving `MCP_ENDPOINT_PATH` in either bootstrap;
12
+ * - no `McpBackend` implementation anywhere in production code;
13
+ * - no `daemon.mcp` config section (`enabled`/`injectIntoAgents` are read from deps only);
14
+ * - `buildPiArgs` accepts `mcpConfigPath`, but all three Pi spawn sites pass only
15
+ * `appendSystemPrompt`, so no agent is ever handed a `--mcp-config`.
16
+ * Sprint-010/task-001 built this registry and deferred the transport as "a bootstrap step"; that
17
+ * step was never taken. Consequence: every tool here is dormant — including
18
+ * `list_pending_permissions`/`respond_to_permission` (sprint-010) and
19
+ * `list_pending_ui_requests`/`respond_to_ui_request` (sprint-066/task-005) — and the
20
+ * agent-to-agent orchestration in `features/subagents.md` has never run.
21
+ *
22
+ * Two things a future wiring task must handle, neither of which is a simple hookup:
23
+ * 1. `callTool()` is a plain dispatcher, NOT an MCP protocol implementation. A real endpoint
24
+ * needs JSON-RPC plus the `initialize`/`tools/list`/`tools/call` handshake over streamable
25
+ * HTTP or SSE. That layer does not exist here.
26
+ * 2. `injectionConfig()` hardcodes `auth: false, oauth: false`, but the daemon's HTTP server is
27
+ * built with `authenticate: (req) => auth.authenticateHttp(req)` (bootstrap.ts) and production
28
+ * binds `0.0.0.0:6767` by default. Serving these tools unauthenticated would expose
29
+ * `create_agent`/`kill_agent`/`send_agent_prompt` to the network. Bind the route to loopback
30
+ * or carry a token in the injected config — the two existing code paths contradict each other
31
+ * today only because neither runs.
10
32
  */
11
33
  export declare const MCP_ENDPOINT_PATH = "/mcp/agents";
12
34
  export declare const MCP_SERVER_KEY = "pi-studio-agents";
@@ -60,6 +82,15 @@ export interface McpBackend {
60
82
  respondToPermission(requestId: string, response: unknown): {
61
83
  resolved: boolean;
62
84
  };
85
+ /** Extension UI (features/extension-ui-rpc.md § MCP mirror) — closes the deadlock where an
86
+ * orchestrating agent can't answer a child's extension questionnaire. Mirrors the permission
87
+ * pair's shape; its own error vocabulary ("unknown_ui_request"/"unsupported") is intentionally
88
+ * distinct — see the tool registration below for why. */
89
+ listPendingUiRequests(agentId?: string): AgentUiPendingRequest[];
90
+ respondToUiRequest(requestId: string, response: AgentUiResponse): {
91
+ resolved: boolean;
92
+ error?: string;
93
+ };
63
94
  listProviders(): unknown;
64
95
  inspectProvider?(providerId: string): unknown;
65
96
  listModels(providerId: string): unknown;
@@ -3,13 +3,34 @@ import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { PARENT_AGENT_ID_LABEL } from "./agent-manager.js";
5
5
  /**
6
- * Daemon MCP server (features/mcp-server.md). Hosts orchestration tools at `/mcp/agents` so agents
7
- * can control other agents + the daemon. The tool set mirrors the WS/CLI control plane. When
8
- * `daemon.mcp.injectIntoAgents` is enabled, a per-agent `--mcp-config` is generated for Pi (adapter
9
- * OAuth disabled); user/project MCP files are never touched.
6
+ * Daemon MCP tool registry (features/mcp-server.md). Defines the orchestration tools that are
7
+ * *intended* to be hosted at `/mcp/agents` so agents can control other agents + the daemon, and
8
+ * mirrors the WS/CLI control plane. Owns the tool registry, `create_agent` semantics, and
9
+ * injection-config generation, all injectable for tests.
10
10
  *
11
- * The transport (HTTP server) is wired in bootstrap; this module owns the tool registry, the
12
- * `create_agent` semantics, and the injection-config generation, all injectable for tests.
11
+ * NOT REACHABLE AT RUNTIME. Nothing in this repo constructs an `McpServer` outside
12
+ * `mcp-server.test.ts`. Specifically missing:
13
+ * - no HTTP route serving `MCP_ENDPOINT_PATH` in either bootstrap;
14
+ * - no `McpBackend` implementation anywhere in production code;
15
+ * - no `daemon.mcp` config section (`enabled`/`injectIntoAgents` are read from deps only);
16
+ * - `buildPiArgs` accepts `mcpConfigPath`, but all three Pi spawn sites pass only
17
+ * `appendSystemPrompt`, so no agent is ever handed a `--mcp-config`.
18
+ * Sprint-010/task-001 built this registry and deferred the transport as "a bootstrap step"; that
19
+ * step was never taken. Consequence: every tool here is dormant — including
20
+ * `list_pending_permissions`/`respond_to_permission` (sprint-010) and
21
+ * `list_pending_ui_requests`/`respond_to_ui_request` (sprint-066/task-005) — and the
22
+ * agent-to-agent orchestration in `features/subagents.md` has never run.
23
+ *
24
+ * Two things a future wiring task must handle, neither of which is a simple hookup:
25
+ * 1. `callTool()` is a plain dispatcher, NOT an MCP protocol implementation. A real endpoint
26
+ * needs JSON-RPC plus the `initialize`/`tools/list`/`tools/call` handshake over streamable
27
+ * HTTP or SSE. That layer does not exist here.
28
+ * 2. `injectionConfig()` hardcodes `auth: false, oauth: false`, but the daemon's HTTP server is
29
+ * built with `authenticate: (req) => auth.authenticateHttp(req)` (bootstrap.ts) and production
30
+ * binds `0.0.0.0:6767` by default. Serving these tools unauthenticated would expose
31
+ * `create_agent`/`kill_agent`/`send_agent_prompt` to the network. Bind the route to loopback
32
+ * or carry a token in the injected config — the two existing code paths contradict each other
33
+ * today only because neither runs.
13
34
  */
14
35
  export const MCP_ENDPOINT_PATH = "/mcp/agents";
15
36
  export const MCP_SERVER_KEY = "pi-studio-agents";
@@ -145,6 +166,22 @@ export class McpServer {
145
166
  const result = b.respondToPermission(args.requestId, args.response);
146
167
  return result.resolved ? { ok: true } : { ok: false, error: "unknown_permission" };
147
168
  });
169
+ // Extension UI (features/extension-ui-rpc.md § MCP mirror). Error vocabulary intentionally
170
+ // differs from `respond_to_permission`'s `unknown_permission`/the WS side's `not_found`: a
171
+ // `resolved:false` with no `error` (never happens for this backend) and an `unsupported`
172
+ // result are distinct outcomes a caller must not conflate with "unknown" — collapsing a live
173
+ // dialog on a provider without `respondToUi` into `unknown_ui_request` would report it as
174
+ // gone when it is actually still answerable over WS by a human.
175
+ this.registerTool("list_pending_ui_requests", z.object({ agentId: z.string().optional() }), (args) => ({ ok: true, requests: b.listPendingUiRequests(args.agentId) }));
176
+ this.registerTool("respond_to_ui_request", z.object({ requestId: z.string(), response: z.unknown() }), (args) => {
177
+ const result = b.respondToUiRequest(args.requestId, args.response);
178
+ if (result.resolved)
179
+ return { ok: true };
180
+ return {
181
+ ok: false,
182
+ error: result.error === "unsupported" ? "unsupported" : "unknown_ui_request",
183
+ };
184
+ });
148
185
  // Providers / models.
149
186
  this.registerTool("list_providers", z.object({}).passthrough(), () => ({
150
187
  ok: true,
@@ -109,6 +109,31 @@ export interface PendingPermission {
109
109
  toolName?: string;
110
110
  detail?: unknown;
111
111
  }
112
+ /**
113
+ * A provider UI request translated onto the generic channel (features/extension-ui-rpc.md § Provider
114
+ * contract extension). All Pi-specific knowledge (which methods block, surface-key namespacing,
115
+ * clear-by-omission, timeout field name) lives in the adapter that produces this — the daemon layers
116
+ * above never interpret `payload` or know `method`'s vocabulary.
117
+ */
118
+ export interface ProviderUiRequest {
119
+ /** Provider-scoped id (Pi's `id`). NEVER use this as a daemon-global map key — two agents may
120
+ * reuse the same provider id independently; the daemon mints its own wire id per request. */
121
+ requestId: string;
122
+ method: string;
123
+ expectsResponse: boolean;
124
+ payload: Record<string, unknown>;
125
+ /** Present only for retained (last-value-wins) surfaces; already namespaced by the adapter. */
126
+ surfaceKey?: string;
127
+ /** True ⇒ this update clears the surface at `surfaceKey` rather than setting it. */
128
+ removed?: boolean;
129
+ timeoutMs?: number;
130
+ }
131
+ /** The answer forwarded back to the provider for a dialog method. */
132
+ export interface ProviderUiResponse {
133
+ value?: string;
134
+ confirmed?: boolean;
135
+ cancelled?: boolean;
136
+ }
112
137
  /** Context passed when launching/resuming a session (cwd, env, logger, …). */
113
138
  export interface LaunchContext {
114
139
  cwd?: string;
@@ -211,6 +236,10 @@ export interface AgentSession {
211
236
  cycleModel?(): Promise<AgentCycleModelResult>;
212
237
  /** `/copy` — mirrors Pi RPC `get_last_assistant_text`. */
213
238
  getLastAssistantText?(): Promise<string | null>;
239
+ /** Subscribe to provider-emitted UI dialogs and fire-and-forget events. */
240
+ onUiRequest?(cb: (req: ProviderUiRequest) => void): Unsubscribe;
241
+ /** Answer a dialog by its provider-scoped `requestId` (never the daemon's minted wire id). */
242
+ respondToUi?(providerRequestId: string, response: ProviderUiResponse): void;
214
243
  }
215
244
  /** A provider client: creates/resumes sessions and exposes discovery. */
216
245
  export interface AgentClient {