@narumitw/pi-subagents 0.54.0 → 1.0.1

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.
@@ -12,6 +12,9 @@ const COMPLETION_BATCH_DELAY_MS = 10;
12
12
 
13
13
  interface CompletionMetadata {
14
14
  protocol: typeof PI_SUBAGENTS_RPC_PROTOCOL;
15
+ completionId: string;
16
+ runId: string;
17
+ generation: number;
15
18
  agentId: string;
16
19
  agent: string;
17
20
  state: string;
@@ -38,13 +41,15 @@ type CompletionPi = Pick<ExtensionAPI, "sendMessage">;
38
41
 
39
42
  export interface CompletionDeliveryBrokerOptions {
40
43
  onDeliveryError?: (error: unknown) => void;
41
- onDelivered?: (completions: readonly AgentTurnCompletion[], deliveredAt: number) => void;
44
+ onAcknowledged?: (completions: readonly AgentTurnCompletion[], acknowledgedAt: number) => void;
42
45
  now?: () => number;
43
46
  }
44
47
 
45
48
  /** Owns bounded completion batching and at most one idle-root wake for one parent session. */
46
49
  export class CompletionDeliveryBroker {
47
50
  private pending: AgentTurnCompletion[] = [];
51
+ private readonly knownCompletionIds = new Set<string>();
52
+ private awaitingParentAck: AgentTurnCompletion[] = [];
48
53
  private flushTimer?: NodeJS.Timeout;
49
54
  private wakeInFlight = false;
50
55
  private closed = false;
@@ -57,7 +62,8 @@ export class CompletionDeliveryBroker {
57
62
  ) {}
58
63
 
59
64
  enqueue(completion: AgentTurnCompletion): void {
60
- if (this.closed) return;
65
+ if (this.closed || this.knownCompletionIds.has(completion.completionId)) return;
66
+ this.knownCompletionIds.add(completion.completionId);
61
67
  this.pending.push(completion);
62
68
  this.scheduleFlush();
63
69
  }
@@ -72,6 +78,14 @@ export class CompletionDeliveryBroker {
72
78
  this.scheduleFlush();
73
79
  }
74
80
 
81
+ onParentContext(messages: readonly unknown[]): void {
82
+ this.acknowledgeVisible(completionIdsFromContext(messages));
83
+ if (this.awaitingParentAck.length > 0) {
84
+ this.pending = [...this.awaitingParentAck.splice(0), ...this.pending];
85
+ }
86
+ this.scheduleFlush();
87
+ }
88
+
75
89
  onParentSettled(): void {
76
90
  this.wakeInFlight = false;
77
91
  this.scheduleFlush();
@@ -88,18 +102,21 @@ export class CompletionDeliveryBroker {
88
102
  let canWake = this.shouldWakeRoot();
89
103
  for (let index = 0; index < batches.length; index++) {
90
104
  const triggerTurn = canWake && index === batches.length - 1;
91
- const message = buildCompletionMessage(batches[index]);
105
+ const batch = batches[index];
106
+ const message = buildCompletionMessage(batch);
92
107
  if (triggerTurn) this.wakeInFlight = true;
108
+ this.awaitingParentAck.push(...batch);
93
109
  try {
94
110
  this.pi.sendMessage(message, { deliverAs: "steer", triggerTurn });
95
- this.notifyDelivered(batches[index]);
96
111
  } catch (primaryError) {
112
+ this.removeAwaiting(batch);
97
113
  if (triggerTurn) this.wakeInFlight = false;
98
114
  canWake = false;
115
+ this.awaitingParentAck.push(...batch);
99
116
  try {
100
117
  this.pi.sendMessage(message, { deliverAs: "nextTurn", triggerTurn: false });
101
- this.notifyDelivered(batches[index]);
102
118
  } catch (fallbackError) {
119
+ this.removeAwaiting(batch);
103
120
  this.pending = [...batches.slice(index).flat(), ...this.pending];
104
121
  try {
105
122
  this.options.onDeliveryError?.(
@@ -122,6 +139,8 @@ export class CompletionDeliveryBroker {
122
139
  if (this.flushTimer) clearTimeout(this.flushTimer);
123
140
  this.flushTimer = undefined;
124
141
  this.pending = [];
142
+ this.awaitingParentAck = [];
143
+ this.knownCompletionIds.clear();
125
144
  }
126
145
 
127
146
  private scheduleFlush(): void {
@@ -132,14 +151,34 @@ export class CompletionDeliveryBroker {
132
151
  }, COMPLETION_BATCH_DELAY_MS);
133
152
  }
134
153
 
135
- private notifyDelivered(completions: readonly AgentTurnCompletion[]): void {
154
+ private acknowledgeVisible(visibleIds: ReadonlySet<string>): void {
155
+ if (visibleIds.size === 0) return;
156
+ const completions = [...this.awaitingParentAck, ...this.pending].filter((completion) =>
157
+ visibleIds.has(completion.completionId),
158
+ );
159
+ if (completions.length === 0) return;
160
+ const acknowledgedIds = new Set(completions.map((completion) => completion.completionId));
161
+ this.awaitingParentAck = this.awaitingParentAck.filter(
162
+ (completion) => !acknowledgedIds.has(completion.completionId),
163
+ );
164
+ this.pending = this.pending.filter(
165
+ (completion) => !acknowledgedIds.has(completion.completionId),
166
+ );
167
+ for (const completion of completions) this.knownCompletionIds.delete(completion.completionId);
136
168
  try {
137
- this.options.onDelivered?.(completions, (this.options.now ?? Date.now)());
169
+ this.options.onAcknowledged?.(completions, (this.options.now ?? Date.now)());
138
170
  } catch {
139
- // Delivery already succeeded, so observer failures cannot requeue it.
171
+ // Context assembly already observed the message, so observer failures cannot retract it.
140
172
  }
141
173
  }
142
174
 
175
+ private removeAwaiting(completions: readonly AgentTurnCompletion[]): void {
176
+ const removed = new Set(completions.map((completion) => completion.completionId));
177
+ this.awaitingParentAck = this.awaitingParentAck.filter(
178
+ (completion) => !removed.has(completion.completionId),
179
+ );
180
+ }
181
+
143
182
  private isRootIdle(): boolean {
144
183
  try {
145
184
  return this.ctx.isIdle();
@@ -158,6 +197,33 @@ export class CompletionDeliveryBroker {
158
197
  }
159
198
  }
160
199
 
200
+ function completionIdsFromContext(messages: readonly unknown[]): Set<string> {
201
+ const ids = new Set<string>();
202
+ for (const message of messages) {
203
+ if (!message || typeof message !== "object" || Array.isArray(message)) continue;
204
+ const record = message as Record<string, unknown>;
205
+ if (record.role !== "custom" || record.customType !== "pi-subagent-completion") continue;
206
+ const details = record.details;
207
+ if (!details || typeof details !== "object" || Array.isArray(details)) continue;
208
+ const metadata = details as Record<string, unknown>;
209
+ if (
210
+ metadata.protocol === PI_SUBAGENTS_RPC_PROTOCOL &&
211
+ typeof metadata.completionId === "string"
212
+ ) {
213
+ ids.add(metadata.completionId);
214
+ }
215
+ if (!Array.isArray(metadata.completions)) continue;
216
+ for (const completion of metadata.completions) {
217
+ if (!completion || typeof completion !== "object" || Array.isArray(completion)) continue;
218
+ const item = completion as Record<string, unknown>;
219
+ if (item.protocol === PI_SUBAGENTS_RPC_PROTOCOL && typeof item.completionId === "string") {
220
+ ids.add(item.completionId);
221
+ }
222
+ }
223
+ }
224
+ return ids;
225
+ }
226
+
161
227
  function chunkCompletions(completions: AgentTurnCompletion[]): AgentTurnCompletion[][] {
162
228
  const batches: AgentTurnCompletion[][] = [];
163
229
  for (let index = 0; index < completions.length; index += MAX_COMPLETIONS_PER_MESSAGE) {
@@ -203,6 +269,9 @@ function buildCompletionMessage(completions: AgentTurnCompletion[]): CompletionM
203
269
  function completionMetadata(completion: AgentTurnCompletion): CompletionMetadata {
204
270
  return {
205
271
  protocol: PI_SUBAGENTS_RPC_PROTOCOL,
272
+ completionId: completion.completionId,
273
+ runId: completion.runId,
274
+ generation: completion.generation,
206
275
  agentId: completion.agent.id,
207
276
  agent: completion.agent.agent,
208
277
  state: completion.agent.state,
@@ -233,6 +302,9 @@ export function buildDetachedCompletionMessage(completion: AgentTurnCompletion):
233
302
  [
234
303
  "Message Type: SUBAGENT_COMPLETION",
235
304
  `Protocol: ${PI_SUBAGENTS_RPC_PROTOCOL}`,
305
+ `Completion ID: ${completion.completionId}`,
306
+ `Run ID: ${completion.runId}`,
307
+ `Generation: ${completion.generation}`,
236
308
  `Agent ID: ${completion.agent.id}`,
237
309
  `Agent: ${agentName}`,
238
310
  `Task: ${task}`,
@@ -0,0 +1,89 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { cachedModuleLoader } from "./cached-module-loader.js";
3
+ import { showSubagentHelp, showSubagentStatus } from "./config-status.js";
4
+ import type { SubagentMenuOwner, SubagentSettingsRuntime } from "./config-ui.js";
5
+
6
+ const SUBCOMMANDS = [
7
+ { value: "settings", label: "settings", description: "Configure subagent user settings" },
8
+ { value: "status", label: "status", description: "Show effective subagent settings" },
9
+ { value: "help", label: "help", description: "Show subagent settings help" },
10
+ ];
11
+
12
+ type ConfigUiModule = Pick<
13
+ typeof import("./config-ui.js"),
14
+ "showSubagentManager" | "showSubagentSettings"
15
+ >;
16
+
17
+ export interface ConfigRegistrationDependencies {
18
+ loadConfigUi?: () => Promise<ConfigUiModule>;
19
+ }
20
+
21
+ export function registerSubagentConfigLifecycle(pi: ExtensionAPI): SubagentMenuOwner {
22
+ const owner: SubagentMenuOwner = { generation: 0, controller: new AbortController() };
23
+ pi.on("session_start", () => {
24
+ owner.generation += 1;
25
+ owner.controller.abort(new DOMException("Subagent session replaced", "AbortError"));
26
+ owner.controller = new AbortController();
27
+ });
28
+ pi.on("session_shutdown", () => {
29
+ owner.generation += 1;
30
+ owner.controller.abort(new DOMException("Subagent session shut down", "AbortError"));
31
+ });
32
+ return owner;
33
+ }
34
+
35
+ export function registerSubagentConfigCommand(
36
+ pi: ExtensionAPI,
37
+ runtime: SubagentSettingsRuntime,
38
+ owner = registerSubagentConfigLifecycle(pi),
39
+ dependencies: ConfigRegistrationDependencies = {},
40
+ ): void {
41
+ const loadConfigUi = cachedModuleLoader(
42
+ dependencies.loadConfigUi ?? (() => import("./config-ui.js")),
43
+ );
44
+ pi.registerCommand("subagents", {
45
+ description: "Manage current-session subagents and user settings",
46
+ getArgumentCompletions(prefix: string) {
47
+ const normalized = prefix.trim().toLowerCase();
48
+ const matches = SUBCOMMANDS.filter((item) => item.value.startsWith(normalized));
49
+ return matches.length > 0 ? matches : null;
50
+ },
51
+ async handler(args, ctx) {
52
+ const subcommand = args.trim().toLowerCase();
53
+ if (!subcommand && ctx.mode !== "tui") {
54
+ showSubagentStatus(ctx, runtime);
55
+ return;
56
+ }
57
+ if (subcommand === "status") {
58
+ showSubagentStatus(ctx, runtime);
59
+ return;
60
+ }
61
+ if (subcommand === "help") {
62
+ showSubagentHelp(ctx, runtime);
63
+ return;
64
+ }
65
+ if (!subcommand || subcommand === "settings") {
66
+ const generation = owner.generation;
67
+ const controller = owner.controller;
68
+ const isCurrent = () =>
69
+ generation === owner.generation &&
70
+ controller === owner.controller &&
71
+ !controller.signal.aborted;
72
+ let configUi: ConfigUiModule;
73
+ try {
74
+ configUi = await loadConfigUi();
75
+ } catch (error) {
76
+ if (!isCurrent()) return;
77
+ throw error;
78
+ }
79
+ if (!isCurrent()) return;
80
+ if (!subcommand) await configUi.showSubagentManager(pi, ctx, runtime, owner);
81
+ else await configUi.showSubagentSettings(ctx, runtime, owner);
82
+ return;
83
+ }
84
+ if (ctx.mode === "tui" || ctx.hasUI) {
85
+ ctx.ui.notify(`Unknown /subagents subcommand: ${subcommand}`, "warning");
86
+ }
87
+ },
88
+ });
89
+ }
package/src/config-ui.ts CHANGED
@@ -168,7 +168,7 @@ function registerSubagentPrimaryCommand(
168
168
  });
169
169
  }
170
170
 
171
- async function showSubagentManager(
171
+ export async function showSubagentManager(
172
172
  pi: ExtensionAPI,
173
173
  ctx: ExtensionCommandContext,
174
174
  runtime: SubagentSettingsRuntime,
@@ -681,7 +681,7 @@ async function showSubagentManager(
681
681
  });
682
682
  }
683
683
 
684
- async function showSubagentSettings(
684
+ export async function showSubagentSettings(
685
685
  ctx: ExtensionCommandContext,
686
686
  runtime: SubagentSettingsRuntime,
687
687
  owner: SubagentMenuOwner,
@@ -0,0 +1,132 @@
1
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { cachedModuleLoader, throwIfAborted } from "./cached-module-loader.js";
3
+ import type { RegisterSubagentConsultOptions } from "./consult.js";
4
+ import { renderConsultCall, renderConsultResult } from "./consult-render.js";
5
+ import { type ConsultDetails, SubagentConsultParams } from "./consult-tool.js";
6
+ import {
7
+ DEFAULT_CONSULT_RESOURCE_POLICY,
8
+ DEFAULT_CONSULTATION_CWD_POLICY,
9
+ } from "./settings/inspection.js";
10
+
11
+ interface ConsultExecutionModule {
12
+ executeSubagentConsult: typeof import("./consult.js").executeSubagentConsult;
13
+ }
14
+
15
+ export interface ConsultRegistrationDependencies {
16
+ loadExecution?: () => Promise<ConsultExecutionModule>;
17
+ }
18
+
19
+ export function registerSubagentConsult(
20
+ pi: ExtensionAPI,
21
+ options: RegisterSubagentConsultOptions,
22
+ dependencies: ConsultRegistrationDependencies = {},
23
+ ): (catalog: string) => void {
24
+ const loadExecution = cachedModuleLoader(
25
+ dependencies.loadExecution ?? (() => import("./consult.js")),
26
+ );
27
+ let generation = 0;
28
+ const active = new Set<AbortController>();
29
+ const activeWork = new Set<Promise<unknown>>();
30
+ const cancelActive = (reason: string) => {
31
+ generation++;
32
+ for (const controller of active) {
33
+ controller.abort(new DOMException(reason, "AbortError"));
34
+ }
35
+ active.clear();
36
+ };
37
+ const cancelAndWaitForWork = async (reason: string) => {
38
+ cancelActive(reason);
39
+ await Promise.allSettled([...activeWork]);
40
+ };
41
+ pi.on("session_start", () => cancelAndWaitForWork("Subagent consultation session replaced"));
42
+ pi.on("session_shutdown", () => cancelAndWaitForWork("Subagent consultation session shut down"));
43
+
44
+ const baseDescription = () =>
45
+ `Run one ephemeral subagent synchronously under enforced read-only tool and resource policies and return its answer. The child can use only the effective subset of Pi's built-in read, grep, find, and ls tools. Shell commands, file writes, extension tools, detached lifecycle operations, and persistent agent state are disabled. Working-directory target policy: ${options.getSettings()?.cwdPolicy?.consultation ?? DEFAULT_CONSULTATION_CWD_POLICY}; configured trusted-target resources: ${options.getSettings()?.consult?.resources ?? DEFAULT_CONSULT_RESOURCE_POLICY}; allowed targets without effective trust inherit no target/project resources. This is not a filesystem sandbox.`;
46
+ const definition: ToolDefinition<typeof SubagentConsultParams, ConsultDetails> = {
47
+ name: "subagent_consult",
48
+ label: "Consult Read-only Subagent",
49
+ description: baseDescription(),
50
+ promptSnippet: "Consult one constrained read-only subagent and wait for its answer",
51
+ promptGuidelines: [
52
+ "Use subagent_consult for bounded reconnaissance, planning, or review whose result is required in the current turn.",
53
+ "Set subagent_consult timeoutMs to the shortest realistic work deadline for the task difficulty; split oversized consultations instead of extending the deadline merely to compensate for broad scope.",
54
+ "Implementation-shaped tasks remain read-only and can return only analysis or instructions.",
55
+ ],
56
+ parameters: SubagentConsultParams,
57
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
58
+ const ownerGeneration = generation;
59
+ const ownedController = new AbortController();
60
+ active.add(ownedController);
61
+ const combined = combineAbortSignals(signal, ownedController.signal);
62
+ const work = (async () => {
63
+ throwIfAborted(combined.signal, "Subagent consultation loading was cancelled");
64
+ let executionModule: ConsultExecutionModule;
65
+ try {
66
+ executionModule = await loadExecution();
67
+ } catch (error) {
68
+ throwIfAborted(combined.signal, "Subagent consultation loading was cancelled");
69
+ throw error;
70
+ }
71
+ throwIfAborted(combined.signal, "Subagent consultation loading was cancelled");
72
+ if (ownerGeneration !== generation) {
73
+ throw new DOMException("Subagent consultation owner was replaced", "AbortError");
74
+ }
75
+ return executionModule.executeSubagentConsult(
76
+ params,
77
+ combined.signal,
78
+ onUpdate,
79
+ ctx,
80
+ options,
81
+ () => ownerGeneration === generation,
82
+ );
83
+ })();
84
+ activeWork.add(work);
85
+ try {
86
+ return await work;
87
+ } finally {
88
+ combined.dispose();
89
+ active.delete(ownedController);
90
+ activeWork.delete(work);
91
+ }
92
+ },
93
+ renderCall(args, theme) {
94
+ return renderConsultCall(args, theme);
95
+ },
96
+ renderResult(result, renderOptions, theme, context) {
97
+ return renderConsultResult(result, renderOptions, theme, context);
98
+ },
99
+ };
100
+ pi.registerTool<typeof SubagentConsultParams, ConsultDetails>(definition);
101
+ pi.on("tool_result", (event) => {
102
+ if (event.toolName !== "subagent_consult") return;
103
+ if ((event.details as ConsultDetails | undefined)?.isError) return { isError: true };
104
+ });
105
+ return (catalog: string) => {
106
+ definition.description = catalog ? `${baseDescription()}\n\n${catalog}` : baseDescription();
107
+ pi.registerTool<typeof SubagentConsultParams, ConsultDetails>(definition);
108
+ };
109
+ }
110
+
111
+ function combineAbortSignals(
112
+ external: AbortSignal | undefined,
113
+ owned: AbortSignal,
114
+ ): { signal: AbortSignal; dispose(): void } {
115
+ if (!external) return { signal: owned, dispose() {} };
116
+ const controller = new AbortController();
117
+ const sources = [external, owned];
118
+ const listeners = sources.map((source) => {
119
+ const listener = () => {
120
+ if (!controller.signal.aborted) controller.abort(source.reason);
121
+ };
122
+ if (source.aborted) listener();
123
+ else source.addEventListener("abort", listener, { once: true });
124
+ return { source, listener };
125
+ });
126
+ return {
127
+ signal: controller.signal,
128
+ dispose() {
129
+ for (const { source, listener } of listeners) source.removeEventListener("abort", listener);
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,95 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { type Static, Type } from "typebox";
3
+ import type { AgentScope, ConsultResourcePolicy } from "./agents/types.js";
4
+ import { THINKING_LEVELS } from "./agents/types.js";
5
+ import { DEFAULT_MAX_CONTEXT_BYTES, MAX_SUBAGENT_TIMEOUT_MS } from "./limits.js";
6
+
7
+ const ConsultScopeSchema = StringEnum(["user", "project", "both"] as const, {
8
+ default: "user",
9
+ description: "Agent definition scope. Project scopes require a trusted project.",
10
+ });
11
+ const ConsultThinkingSchema = StringEnum(THINKING_LEVELS);
12
+
13
+ export const SubagentConsultParams = Type.Object(
14
+ {
15
+ agent: Type.String({ minLength: 1 }),
16
+ task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
17
+ agentScope: Type.Optional(ConsultScopeSchema),
18
+ confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
19
+ cwd: Type.Optional(Type.String({ minLength: 1 })),
20
+ timeoutMs: Type.Optional(
21
+ Type.Number({
22
+ minimum: 1,
23
+ maximum: MAX_SUBAGENT_TIMEOUT_MS,
24
+ description:
25
+ "Work deadline selected for the consultation difficulty. On expiry, Pi aborts the work and makes one separately bounded summary attempt.",
26
+ }),
27
+ ),
28
+ thinkingLevel: Type.Optional(ConsultThinkingSchema),
29
+ },
30
+ { additionalProperties: false },
31
+ );
32
+
33
+ export type SubagentConsultParams = Static<typeof SubagentConsultParams>;
34
+
35
+ export interface ConsultProgressActivity {
36
+ type: "text" | "toolCall";
37
+ text?: string;
38
+ name?: "read" | "grep" | "find" | "ls";
39
+ args?: Record<string, string | number | boolean>;
40
+ }
41
+
42
+ export interface ConsultProgress {
43
+ phase: "starting" | "running";
44
+ recentActivity: ConsultProgressActivity[];
45
+ recentActivityTotal: number;
46
+ actualProvider?: string;
47
+ actualModel?: string;
48
+ usage: {
49
+ input: number;
50
+ output: number;
51
+ cacheRead: number;
52
+ cacheWrite: number;
53
+ cost: number;
54
+ contextTokens: number;
55
+ turns: number;
56
+ };
57
+ }
58
+
59
+ export interface ConsultDetails {
60
+ agent: string;
61
+ agentSource: string;
62
+ agentScope: AgentScope;
63
+ cwd: string;
64
+ model?: string;
65
+ thinkingLevel?: string;
66
+ timeoutMs: number;
67
+ policy: {
68
+ requestedTools: string[] | null;
69
+ effectiveTools: string[];
70
+ cwdBoundary: "current-workspace" | "external";
71
+ targetTrust: {
72
+ kind: string;
73
+ projectTrusted: boolean;
74
+ sourcePath?: string;
75
+ warning?: string;
76
+ };
77
+ requestedResources: ConsultResourcePolicy;
78
+ effectiveResources: {
79
+ policy: ConsultResourcePolicy;
80
+ projectResources: boolean;
81
+ contextFiles: boolean;
82
+ skills: boolean;
83
+ promptTemplates: boolean;
84
+ };
85
+ resourceDowngradeReason?: string;
86
+ extensions: "disabled";
87
+ sessionPersistence: "disabled";
88
+ retainedAgent: false;
89
+ };
90
+ child?: Record<string, unknown>;
91
+ progress?: ConsultProgress;
92
+ cancelled?: boolean;
93
+ isError?: boolean;
94
+ truncated?: boolean;
95
+ }