@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.
@@ -18,6 +18,7 @@ export const PI_CAPABILITIES = {
18
18
  supportsReasoningStream: true,
19
19
  supportsToolInvocations: true,
20
20
  supportsSteering: true,
21
+ supportsExtensionUi: true,
21
22
  };
22
23
  /** Build Pi argv, appending (never replacing) the system prompt and optional `--mcp-config`. */
23
24
  export function buildPiArgs(base, opts) {
@@ -41,12 +42,54 @@ function modelIdFrom(model) {
41
42
  return rec.name;
42
43
  return undefined;
43
44
  }
45
+ /** Dialog methods block for a client answer; every other extension UI method is fire-and-forget
46
+ * (docs/rpc.md § Extension UI Protocol). The single place the blocking set is encoded — a future Pi
47
+ * release that adds a method only ever touches this constant. */
48
+ const DIALOG_METHODS = new Set(["select", "confirm", "input", "editor"]);
49
+ /**
50
+ * Translate a raw Pi `extension_ui_request` event onto the provider-neutral UI channel
51
+ * (features/extension-ui-rpc.md § Provider contract extension). All Pi-specific knowledge — which
52
+ * methods block, surface-key namespacing, clear-by-omission, the `timeout` field name — lives here;
53
+ * nothing above this adapter learns Pi's vocabulary.
54
+ *
55
+ * Surface-key namespacing is mandatory, not cosmetic: Pi's own docs reuse the same key
56
+ * (`"my-ext"`) for both `statusKey` and `widgetKey` (rpc.md:1273,1289) because the natural pattern
57
+ * is an extension naming everything after itself. Un-namespaced, a status tick would silently
58
+ * delete that extension's own widget.
59
+ */
60
+ function translateUiRequest(rec) {
61
+ const { type: _type, id, method, ...payload } = rec;
62
+ const methodName = method;
63
+ let surfaceKey;
64
+ let removed = false;
65
+ if (methodName === "setStatus") {
66
+ surfaceKey = `status:${rec.statusKey}`;
67
+ removed = rec.statusText === undefined;
68
+ }
69
+ else if (methodName === "setWidget") {
70
+ surfaceKey = `widget:${rec.widgetKey}`;
71
+ removed = rec.widgetLines === undefined;
72
+ }
73
+ else if (methodName === "setTitle") {
74
+ surfaceKey = "title";
75
+ }
76
+ return {
77
+ requestId: id,
78
+ method: methodName,
79
+ expectsResponse: DIALOG_METHODS.has(methodName),
80
+ payload,
81
+ ...(surfaceKey !== undefined ? { surfaceKey } : {}),
82
+ ...(removed ? { removed: true } : {}),
83
+ ...(typeof rec.timeout === "number" ? { timeoutMs: rec.timeout } : {}),
84
+ };
85
+ }
44
86
  class PiAgentSession {
45
87
  transport;
46
88
  provider;
47
89
  id = randomUUID();
48
90
  capabilities = PI_CAPABILITIES;
49
91
  subscribers = new Set();
92
+ uiSubscribers = new Set();
50
93
  history = [];
51
94
  eventMapper = createPiEventMapper();
52
95
  modes = [];
@@ -59,17 +102,13 @@ class PiAgentSession {
59
102
  this.mode = opts.config.modeId ?? null;
60
103
  this.sessionFile = opts.sessionFile;
61
104
  transport.onEvent((raw) => {
62
- // Auto-respond to extension UI dialogs so the agent never blocks waiting on a client that
63
- // isn't wired for them (POC). Safe default: cancel (extension receives undefined/false).
64
105
  const rec = raw;
65
106
  if (rec?.type === "extension_ui_request") {
66
- const method = rec.method;
67
- if (method === "select" ||
68
- method === "confirm" ||
69
- method === "input" ||
70
- method === "editor") {
71
- this.transport.notify("extension_ui_response", { id: rec.id, cancelled: true });
72
- }
107
+ // Forward to the daemon's UI channel — write no response here; the daemon answers, or
108
+ // nobody does (features/extension-ui-rpc.md § Behavior & algorithms).
109
+ const req = translateUiRequest(rec);
110
+ for (const cb of this.uiSubscribers)
111
+ cb(req);
73
112
  return;
74
113
  }
75
114
  const event = this.eventMapper.map(raw);
@@ -238,6 +277,15 @@ class PiAgentSession {
238
277
  this.transport.notify("respond_to_permission", { requestId, response });
239
278
  return Promise.resolve();
240
279
  }
280
+ onUiRequest(cb) {
281
+ this.uiSubscribers.add(cb);
282
+ return () => this.uiSubscribers.delete(cb);
283
+ }
284
+ /** Body spread first, `id` stamped last — a response containing an `id`/`type` key cannot
285
+ * redirect which dialog it resolves (features/extension-ui-rpc.md § Public contract). */
286
+ respondToUi(providerRequestId, response) {
287
+ this.transport.notify("extension_ui_response", { ...response, id: providerRequestId });
288
+ }
241
289
  describePersistence() {
242
290
  return {
243
291
  provider: this.provider,
@@ -25,6 +25,8 @@ import { createPasswordAuth, resolvePasswordHash } from "../auth/password-auth.j
25
25
  import { expandHome } from "../files/resolve-path.js";
26
26
  import { loadConfig } from "../config/daemon-config.js";
27
27
  import { createDaemonLogger } from "../logging/logger.js";
28
+ import { registerAgentUiHandlers } from "../agent/agent-ui/agent-ui-rpc.js";
29
+ import { AgentUiService } from "../agent/agent-ui/agent-ui-service.js";
28
30
  import { AgentManager } from "../agent/agent-manager.js";
29
31
  import { AgentService, getTimeline } from "../agent/agent-service.js";
30
32
  import { SessionOperationsService } from "../agent/session-operations.js";
@@ -177,12 +179,6 @@ export function startDaemon(opts) {
177
179
  });
178
180
  // ── Real provider resolution (pi spawns `pi --mode rpc`; mock is opt-in) ─────
179
181
  const resolveClient = (provider) => resolveProviderClient(provider, config, { logger });
180
- // ── Disk-persisted agent manager (recovers agents on boot) ───────────────────
181
- const manager = new AgentManager({
182
- home,
183
- saveAgent: (record) => saveAgent(home, record),
184
- loadAllAgents: () => loadAllAgents(home),
185
- });
186
182
  // ── Broadcast helper ─────────────────────────────────────────────────────────
187
183
  // See `wrapSessionEnvelope` above for the full rationale.
188
184
  const broadcast = (sessions, message) => {
@@ -200,13 +196,26 @@ export function startDaemon(opts) {
200
196
  const relayCapabilityStore = createInMemoryCapabilityStore();
201
197
  const sessionsHolder = { sessions: [] };
202
198
  const getActiveSessions = () => [...sessionsHolder.sessions, ...relaySessions];
199
+ // ── Extension UI bridge (features/extension-ui-rpc.md) — constructed before the manager so
200
+ // `onSessionAttached` below can close over it.
201
+ const agentUiService = new AgentUiService({ broadcast, getActiveSessions, logger });
202
+ // ── Disk-persisted agent manager (recovers agents on boot) ───────────────────
203
+ const manager = new AgentManager({
204
+ home,
205
+ saveAgent: (record) => saveAgent(home, record),
206
+ loadAllAgents: () => loadAllAgents(home),
207
+ onSessionAttached: (agentId, session) => agentUiService.attach(agentId, session),
208
+ logger,
209
+ });
203
210
  // Forward archive/delete lifecycle events to every connected client (multi-tab/multi-client
204
211
  // sync) — `agent_update` for status changes is already broadcast per-call-site; these two are
205
- // manager-internal and only reach clients via this subscription.
212
+ // manager-internal and only reach clients via this subscription. Also sweeps the extension-UI
213
+ // bridge: every pending dialog and retained surface for the agent is cancelled/dropped.
206
214
  manager.subscribe((event) => {
207
215
  if (event.type === "agent_archived" || event.type === "agent_deleted") {
208
216
  logger.info({ agentId: event.agentId }, event.type.replace("_", " "));
209
217
  broadcast(getActiveSessions(), { type: "session", message: event });
218
+ agentUiService.sweep(event.agentId, "aborted");
210
219
  }
211
220
  });
212
221
  const registry = new HandlerRegistry(logger);
@@ -230,6 +239,7 @@ export function startDaemon(opts) {
230
239
  registerTimelineHandler(registry, { manager, resolveClient });
231
240
  const permissionService = new PermissionService({ manager, broadcast });
232
241
  permissionService.registerHandlers(registry, getActiveSessions);
242
+ registerAgentUiHandlers(registry, { service: agentUiService, logger });
233
243
  // ── Directory listing (agents) ────────────────────────────────────────────────
234
244
  registry.register("list_agents_request", (ctx) => ({
235
245
  type: "list_agents_response",
@@ -1,4 +1,5 @@
1
1
  import type { Server as HttpServer } from "node:http";
2
+ import { AgentManager } from "../agent/agent-manager.js";
2
3
  import { type Logger } from "../logging/logger.js";
3
4
  export interface DevBootstrapOptions {
4
5
  host: string;
@@ -19,6 +20,11 @@ export interface DevBootstrapHandle {
19
20
  httpServer: HttpServer;
20
21
  serverId: string;
21
22
  logger: Logger;
23
+ /** Exposed for tests only (e.g. sprint-066/task-004's daemon-level extension-UI test): lets a
24
+ * test reach a created agent's live `MockAgentSession` to script a UI dialog via
25
+ * `emitUiRequest` — there is no WS RPC for that, by design (a real Pi process is what would
26
+ * normally emit these). Not part of the RPC surface. */
27
+ manager: AgentManager;
22
28
  close(): Promise<void>;
23
29
  }
24
30
  export declare function startDevDaemon(opts: DevBootstrapOptions): DevBootstrapHandle;
@@ -13,6 +13,8 @@ import { expandHome } from "../files/resolve-path.js";
13
13
  import { createHttpServer } from "../http/http-server.js";
14
14
  import { createWebSocketServer } from "../ws/ws-server.js";
15
15
  import { HandlerRegistry, routeTextFrame } from "../ws/router.js";
16
+ import { registerAgentUiHandlers } from "../agent/agent-ui/agent-ui-rpc.js";
17
+ import { AgentUiService } from "../agent/agent-ui/agent-ui-service.js";
16
18
  import { AgentManager } from "../agent/agent-manager.js";
17
19
  import { AgentService } from "../agent/agent-service.js";
18
20
  import { SessionOperationsService } from "../agent/session-operations.js";
@@ -28,19 +30,6 @@ import { wrapSessionEnvelope } from "./bootstrap.js";
28
30
  export function startDevDaemon(opts) {
29
31
  const serverId = opts.serverId ?? randomUUID();
30
32
  const logger = opts.logger ?? createDaemonLogger(undefined);
31
- // ── In-memory agent manager (no disk persistence in dev mode) ──────────────
32
- const agentsById = new Map();
33
- const manager = new AgentManager({
34
- home: "/tmp/pi-studio-dev",
35
- saveAgent: async (record) => {
36
- agentsById.set(record.id, record);
37
- },
38
- loadAllAgents: async () => [...agentsById.values()],
39
- deleteAgent: async (_cwd, id) => agentsById.delete(id),
40
- });
41
- // ── Provider resolution: mock only in dev ───────────────────────────────────
42
- const mockClient = createMockClient({ turnDelayMs: opts.mockTurnDelayMs });
43
- const resolveClient = (_provider) => mockClient;
44
33
  // ── Broadcast helper ─────────────────────────────────────────────────────────
45
34
  // See `bootstrap.ts`'s `wrapSessionEnvelope` for the full rationale.
46
35
  const broadcast = (sessions, message) => {
@@ -58,11 +47,34 @@ export function startDevDaemon(opts) {
58
47
  // mutable holder so they can be registered before the WS server exists.
59
48
  const sessionsHolder = { sessions: [] };
60
49
  const getActiveSessions = () => sessionsHolder.sessions;
50
+ // ── Extension UI bridge (features/extension-ui-rpc.md) — constructed before the manager so
51
+ // `onSessionAttached` below can close over it. Registered here (unlike production-only
52
+ // `provider_auth`/`file_watch`): the mock provider is this family's designated producer, and the
53
+ // dev daemon is mock-only, so a UI family unexercisable here would be untestable exactly where a
54
+ // sibling UI scope needs to develop against it.
55
+ const agentUiService = new AgentUiService({ broadcast, getActiveSessions, logger });
56
+ // ── In-memory agent manager (no disk persistence in dev mode) ──────────────
57
+ const agentsById = new Map();
58
+ const manager = new AgentManager({
59
+ home: "/tmp/pi-studio-dev",
60
+ saveAgent: async (record) => {
61
+ agentsById.set(record.id, record);
62
+ },
63
+ loadAllAgents: async () => [...agentsById.values()],
64
+ deleteAgent: async (_cwd, id) => agentsById.delete(id),
65
+ onSessionAttached: (agentId, session) => agentUiService.attach(agentId, session),
66
+ logger,
67
+ });
68
+ // ── Provider resolution: mock only in dev ───────────────────────────────────
69
+ const mockClient = createMockClient({ turnDelayMs: opts.mockTurnDelayMs });
70
+ const resolveClient = (_provider) => mockClient;
61
71
  // Forward archive/delete lifecycle events to every connected client (see bootstrap.ts's
62
- // production twin for the full rationale).
72
+ // production twin for the full rationale). Also sweeps the extension-UI bridge: every pending
73
+ // dialog and retained surface for the agent is cancelled/dropped on archive or delete.
63
74
  manager.subscribe((event) => {
64
75
  if (event.type === "agent_archived" || event.type === "agent_deleted") {
65
76
  broadcast(getActiveSessions(), { type: "session", message: event });
77
+ agentUiService.sweep(event.agentId, "aborted");
66
78
  }
67
79
  });
68
80
  // ── Handler registry ────────────────────────────────────────────────────────
@@ -86,6 +98,7 @@ export function startDevDaemon(opts) {
86
98
  registerTimelineHandler(registry, { manager, resolveClient });
87
99
  const permissionService = new PermissionService({ manager, broadcast });
88
100
  permissionService.registerHandlers(registry, getActiveSessions);
101
+ registerAgentUiHandlers(registry, { service: agentUiService, logger });
89
102
  // ── list_agents_request: minimal directory listing (not in scope elsewhere) ─
90
103
  registry.register("list_agents_request", (ctx) => {
91
104
  const agents = manager.list().map((m) => ({
@@ -289,6 +302,7 @@ export function startDevDaemon(opts) {
289
302
  httpServer,
290
303
  serverId,
291
304
  logger,
305
+ manager,
292
306
  close: async () => {
293
307
  logger.info("dev daemon shutting down");
294
308
  await wsHandle.close();
@@ -13,4 +13,5 @@ export declare function createDaemonRuntimeInfo(input?: {
13
13
  mode?: "production" | "development";
14
14
  }): DaemonRuntimeInfo;
15
15
  export * from "./bootstrap.js";
16
+ export * from "./dev-bootstrap.js";
16
17
  //# sourceMappingURL=index.d.ts.map
@@ -20,4 +20,8 @@ export function createDaemonRuntimeInfo(input = {}) {
20
20
  }
21
21
  // Production daemon bootstrap: startDaemon(), DaemonOptions, DaemonHandle, wrapSessionEnvelope().
22
22
  export * from "./bootstrap.js";
23
+ // Dev daemon bootstrap (mock provider, in-memory): reachable from outside the package so
24
+ // cross-package E2E (e.g. packages/cli's extension-UI SDK test, sprint-067/task-004) can host a
25
+ // real dev daemon without a second, duplicated bootstrap.
26
+ export * from "./dev-bootstrap.js";
23
27
  //# sourceMappingURL=index.js.map
@@ -14,6 +14,9 @@ export const CURATED_PACKS = {
14
14
  { source: "npm:pi-web-access", addedIn: "0.0.73" },
15
15
  // Powerline-style status bar.
16
16
  { source: "npm:pi-powerline-footer", addedIn: "0.0.73" },
17
+ // Structured questionnaire tool: typed multiple-choice/free-text prompts instead of the
18
+ // model guessing when it lacks information it should ask for.
19
+ { source: "npm:@juicesharp/rpiv-ask-user-question", addedIn: "0.0.93" },
17
20
  ],
18
21
  },
19
22
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@av-pi-studio/server",
3
- "version": "0.0.93",
3
+ "version": "0.0.94",
4
4
  "bin": {
5
5
  "pi-studio-daemon": "dist/daemon/main.js"
6
6
  },
@@ -24,9 +24,9 @@
24
24
  "clean": "rm -rf dist *.tsbuildinfo"
25
25
  },
26
26
  "dependencies": {
27
- "@av-pi-studio/highlight": "^0.0.93",
28
- "@av-pi-studio/protocol": "^0.0.93",
29
- "@av-pi-studio/relay": "^0.0.93",
27
+ "@av-pi-studio/highlight": "^0.0.94",
28
+ "@av-pi-studio/protocol": "^0.0.94",
29
+ "@av-pi-studio/relay": "^0.0.94",
30
30
  "@earendil-works/pi-coding-agent": "^0.84.1",
31
31
  "@xterm/headless": "^6.0.0",
32
32
  "bcryptjs": "^3.0.3",