@trim21/personal-pi-extensions 0.0.170 → 0.0.172

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/spawn-agent.ts +157 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.170",
3
+ "version": "0.0.172",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -75,5 +75,5 @@
75
75
  "prettier --write"
76
76
  ]
77
77
  },
78
- "packageManager": "pnpm@11.20.0"
78
+ "packageManager": "pnpm@11.21.0"
79
79
  }
@@ -29,7 +29,10 @@ import type { AgentMessage, AgentToolResult } from "@earendil-works/pi-agent-cor
29
29
  import {
30
30
  type AgentSessionEvent,
31
31
  type ExtensionAPI,
32
+ type ExtensionUIContext,
32
33
  getMarkdownTheme,
34
+ type RpcExtensionUIRequest,
35
+ type RpcExtensionUIResponse,
33
36
  truncateTail,
34
37
  withFileMutationQueue,
35
38
  } from "@earendil-works/pi-coding-agent";
@@ -176,18 +179,18 @@ async function writePromptToTempFile(agentName: string, prompt: string): Promise
176
179
 
177
180
  export function buildSubagentArgs(
178
181
  agent: AgentConfig,
179
- task: string,
182
+ _task: string,
180
183
  systemPromptPath: string | undefined,
181
184
  ): string[] {
182
- // --mode json: emit events as JSON lines; -p: single-shot answer;
183
- // --no-session: ephemeral, do not persist. --no-extensions disables
185
+ // RPC mode emits agent and extension UI events as JSON lines and accepts
186
+ // dialog responses over stdin. --no-session keeps the child ephemeral.
187
+ // --no-extensions disables
184
188
  // extension discovery; only the extensions explicitly loaded below (the
185
189
  // unconditional guards plus per-tool overrides) run inside the subagent.
186
- const args: string[] = ["--mode", "json", "-p", "--no-session", "--no-extensions"];
190
+ const args: string[] = ["--mode", "rpc", "--no-session", "--no-extensions"];
187
191
 
188
192
  // Protection layers that must be present in every subagent regardless of
189
- // its declared toolset: the workspace write guard and the bwrap sandbox
190
- // (forced read-only for subagents via PI_SUBAGENT_CHILD=1).
193
+ // its declared toolset: the workspace write guard and the bwrap sandbox.
191
194
  for (const ext of UNCONDITIONAL_EXTENSIONS) {
192
195
  args.push("-e", extensionPath(ext));
193
196
  }
@@ -210,7 +213,6 @@ export function buildSubagentArgs(
210
213
  }
211
214
  args.push("--tools", tools.join(","));
212
215
  if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
213
- args.push(`Task: ${task}`);
214
216
  return args;
215
217
  }
216
218
 
@@ -218,12 +220,86 @@ export function buildSubagentArgs(
218
220
 
219
221
  type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
220
222
 
223
+ function dialogOptions(signal: AbortSignal | undefined, timeout: number | undefined) {
224
+ return {
225
+ ...(signal && { signal }),
226
+ ...(timeout !== undefined && { timeout }),
227
+ };
228
+ }
229
+
230
+ /** Forward one RPC extension UI request to the parent session. */
231
+ export async function forwardSubagentUIRequest(
232
+ request: RpcExtensionUIRequest,
233
+ ui: ExtensionUIContext,
234
+ signal?: AbortSignal,
235
+ ): Promise<RpcExtensionUIResponse | undefined> {
236
+ switch (request.method) {
237
+ case "select": {
238
+ const value = await ui.select(
239
+ request.title,
240
+ request.options,
241
+ dialogOptions(signal, request.timeout),
242
+ );
243
+ return value === undefined
244
+ ? { type: "extension_ui_response", id: request.id, cancelled: true }
245
+ : { type: "extension_ui_response", id: request.id, value };
246
+ }
247
+ case "confirm": {
248
+ const confirmed = await ui.confirm(
249
+ request.title,
250
+ request.message,
251
+ dialogOptions(signal, request.timeout),
252
+ );
253
+ return { type: "extension_ui_response", id: request.id, confirmed };
254
+ }
255
+ case "input": {
256
+ const value = await ui.input(
257
+ request.title,
258
+ request.placeholder,
259
+ dialogOptions(signal, request.timeout),
260
+ );
261
+ return value === undefined
262
+ ? { type: "extension_ui_response", id: request.id, cancelled: true }
263
+ : { type: "extension_ui_response", id: request.id, value };
264
+ }
265
+ case "editor": {
266
+ const value = await ui.editor(request.title, request.prefill);
267
+ return value === undefined
268
+ ? { type: "extension_ui_response", id: request.id, cancelled: true }
269
+ : { type: "extension_ui_response", id: request.id, value };
270
+ }
271
+ case "notify": {
272
+ ui.notify(request.message, request.notifyType);
273
+ return undefined;
274
+ }
275
+ case "setStatus": {
276
+ ui.setStatus(request.statusKey, request.statusText);
277
+ return undefined;
278
+ }
279
+ case "setWidget": {
280
+ ui.setWidget(request.widgetKey, request.widgetLines, {
281
+ placement: request.widgetPlacement,
282
+ });
283
+ return undefined;
284
+ }
285
+ case "setTitle": {
286
+ ui.setTitle(request.title);
287
+ return undefined;
288
+ }
289
+ case "set_editor_text": {
290
+ ui.setEditorText(request.text);
291
+ return undefined;
292
+ }
293
+ }
294
+ }
295
+
221
296
  export async function runAgent(
222
297
  agent: AgentConfig,
223
298
  task: string,
224
299
  cwd: string,
225
300
  signal: AbortSignal | undefined,
226
301
  onUpdate: OnUpdateCallback | undefined,
302
+ parentUI?: ExtensionUIContext,
227
303
  ): Promise<SubagentDetails> {
228
304
  const result: SubagentDetails = {
229
305
  agent: agent.name,
@@ -253,10 +329,8 @@ export async function runAgent(
253
329
  const proc = spawn(invocation.command, invocation.args, {
254
330
  cwd,
255
331
  shell: false,
256
- stdio: ["ignore", "pipe", "pipe"],
257
- // Mark the child as a subagent so extensions running inside it (e.g.
258
- // bwrap's subagent policy) can recognize and treat it accordingly.
259
- env: { ...process.env, PI_SUBAGENT_CHILD: "1" },
332
+ stdio: ["pipe", "pipe", "pipe"],
333
+ env: { ...process.env },
260
334
  });
261
335
 
262
336
  let logLines: string[] = [];
@@ -281,10 +355,57 @@ export async function runAgent(
281
355
 
282
356
  let buffer = "";
283
357
 
358
+ const sendRpc = (message: object) => {
359
+ proc.stdin.write(`${JSON.stringify(message)}\n`);
360
+ };
361
+
362
+ let requestedShutdown = false;
363
+ const requestShutdown = () => {
364
+ if (requestedShutdown) return;
365
+ requestedShutdown = true;
366
+ proc.stdin.end();
367
+ };
368
+
284
369
  const processLine = (line: string) => {
285
370
  if (!line.trim()) return;
286
- const event = parseJsonEvent(line);
287
- if (!event) return;
371
+ const record = parseJsonRecord(line);
372
+ if (!record) return;
373
+
374
+ if (record.type === "extension_ui_request") {
375
+ const request = record as RpcExtensionUIRequest;
376
+ if (!parentUI) {
377
+ if (
378
+ request.method === "select" ||
379
+ request.method === "confirm" ||
380
+ request.method === "input" ||
381
+ request.method === "editor"
382
+ ) {
383
+ sendRpc({ type: "extension_ui_response", id: request.id, cancelled: true });
384
+ }
385
+ return;
386
+ }
387
+ void forwardSubagentUIRequest(request, parentUI, signal)
388
+ .then((response) => {
389
+ if (response) sendRpc(response);
390
+ return;
391
+ })
392
+ .catch(() => {
393
+ sendRpc({ type: "extension_ui_response", id: request.id, cancelled: true });
394
+ });
395
+ return;
396
+ }
397
+
398
+ if (record.type === "response") {
399
+ if (record.command === "prompt" && record.success === false) {
400
+ result.errorMessage =
401
+ typeof record.error === "string" ? record.error : "Subagent prompt was rejected";
402
+ result.stopReason = "error";
403
+ requestShutdown();
404
+ }
405
+ return;
406
+ }
407
+
408
+ const event = record as AgentSessionEvent;
288
409
 
289
410
  switch (event.type) {
290
411
  case "message_update": {
@@ -323,6 +444,10 @@ export async function runAgent(
323
444
 
324
445
  break;
325
446
  }
447
+ case "agent_settled": {
448
+ requestShutdown();
449
+ break;
450
+ }
326
451
  // No default
327
452
  }
328
453
  };
@@ -338,6 +463,12 @@ export async function runAgent(
338
463
  result.stderr += data.toString();
339
464
  });
340
465
 
466
+ proc.stdin.on("error", (error) => {
467
+ if (!requestedShutdown) result.stderr += error.message;
468
+ });
469
+
470
+ sendRpc({ type: "prompt", message: `Task: ${task}` });
471
+
341
472
  const exitCode = await new Promise<number>((resolve) => {
342
473
  proc.on("close", (code) => {
343
474
  if (buffer.trim()) processLine(buffer);
@@ -372,12 +503,10 @@ export async function runAgent(
372
503
  }
373
504
 
374
505
  /**
375
- * Parse one line of the subagent's `--mode json` event stream into a typed
376
- * event. Non-JSON lines and non-event records (e.g. the session header) are
377
- * rejected. The cast here is the single trust boundary: downstream branches
378
- * are fully type-narrowed via the `AgentSessionEvent` discriminated union.
506
+ * Parse one line of the subagent's RPC stream into a JSON object. Non-JSON
507
+ * lines and records without a type discriminator are rejected.
379
508
  */
380
- function parseJsonEvent(line: string): AgentSessionEvent | null {
509
+ function parseJsonRecord(line: string): Record<string, unknown> | null {
381
510
  let raw: unknown;
382
511
  try {
383
512
  raw = JSON.parse(line);
@@ -386,7 +515,7 @@ function parseJsonEvent(line: string): AgentSessionEvent | null {
386
515
  }
387
516
  if (typeof raw !== "object" || raw === null) return null;
388
517
  if (typeof (raw as Record<string, unknown>).type !== "string") return null;
389
- return raw as AgentSessionEvent;
518
+ return raw as Record<string, unknown>;
390
519
  }
391
520
 
392
521
  /** Session entry customType used to mark the injected subagent list. */
@@ -395,7 +524,7 @@ export function formatAgentListSection(agents: AgentConfig[]): string {
395
524
  return [
396
525
  "## Available subagents",
397
526
  "",
398
- "You can delegate tasks to the following subagent types by calling the `spawn_agent` tool with their name in the `agent` parameter:",
527
+ "You can delegate tasks to the following subagent types by calling the `spawn-agent` tool with their name in the `agent` parameter:",
399
528
  "",
400
529
  ...lines,
401
530
  ].join("\n");
@@ -460,7 +589,14 @@ export default function spawnAgent(pi: ExtensionAPI) {
460
589
  };
461
590
  }
462
591
 
463
- const result = await runAgent(agent, params.task, ctx.cwd, signal, onUpdate);
592
+ const result = await runAgent(
593
+ agent,
594
+ params.task,
595
+ ctx.cwd,
596
+ signal,
597
+ onUpdate,
598
+ ctx.hasUI ? ctx.ui : undefined,
599
+ );
464
600
 
465
601
  const isError =
466
602
  result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";