@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.
- package/dist/agent/agent-manager.d.ts +8 -0
- package/dist/agent/agent-manager.js +11 -2
- package/dist/agent/agent-ui/agent-ui-rpc.d.ts +20 -0
- package/dist/agent/agent-ui/agent-ui-rpc.js +24 -0
- package/dist/agent/agent-ui/agent-ui-service.d.ts +55 -0
- package/dist/agent/agent-ui/agent-ui-service.js +237 -0
- package/dist/agent/mcp-server.d.ts +37 -6
- package/dist/agent/mcp-server.js +43 -6
- package/dist/agent/provider-contract.d.ts +29 -0
- package/dist/agent/providers/mock/mock-provider.d.ts +121 -2
- package/dist/agent/providers/mock/mock-provider.js +130 -1
- package/dist/agent/providers/mock/ui-script.d.ts +52 -0
- package/dist/agent/providers/mock/ui-script.js +224 -0
- package/dist/agent/providers/pi/agent.d.ts +1 -0
- package/dist/agent/providers/pi/agent.js +57 -9
- package/dist/daemon/bootstrap.js +17 -7
- package/dist/daemon/dev-bootstrap.d.ts +6 -0
- package/dist/daemon/dev-bootstrap.js +28 -14
- package/dist/daemon/index.d.ts +1 -0
- package/dist/daemon/index.js +4 -0
- package/dist/extensions/curated-packs.js +3 -0
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
* `
|
|
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;
|
package/dist/agent/mcp-server.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
* `
|
|
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 {
|