@ian-pascoe/pi-minimal-subagents 0.1.0 → 0.2.0

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.
@@ -21,9 +21,9 @@ export async function shutdownMinimalSubagentsSession(
21
21
  }
22
22
 
23
23
  while (true) {
24
+ if (!rootIdleGate.isRootIdle()) await rootIdleGate.waitForRootIdle();
24
25
  await coordinator.waitForSettledOperations();
25
26
  if (rootIdleGate.isRootIdle()) break;
26
- await rootIdleGate.waitForRootIdle();
27
27
  }
28
28
  await coordinator.shutdownAfterSettling();
29
29
  }
@@ -1,15 +1,25 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import { Type, type TSchema } from "typebox";
2
+ import { Type } from "typebox";
3
3
  import { THINKING_LEVELS } from "./minimal-subagents-capabilities.js";
4
4
 
5
5
  const SessionContextSchema = StringEnum(["inherit", "compact", "omit"] as const);
6
6
  const ProjectContextSchema = StringEnum(["inherit", "omit"] as const);
7
7
  const DelegationSchema = StringEnum(["none", "fanout"] as const);
8
8
  const ThinkingLevelSchema = StringEnum(THINKING_LEVELS);
9
- const ToolSelectionSchema = Type.Union([
10
- StringEnum(["none", "read", "modify"] as const),
11
- Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }),
12
- ]);
9
+ const ToolSelectionSchema = Type.Union(
10
+ [
11
+ StringEnum(["none", "read", "modify"] as const),
12
+ Type.Array(Type.String({ minLength: 1 }), {
13
+ uniqueItems: true,
14
+ description:
15
+ "Exact ordinary tool names. Coordinator tools are injected separately and must not appear here. Arrays are not bundle names; use the string preset `read` or `modify` for bundled capabilities.",
16
+ }),
17
+ ],
18
+ {
19
+ description:
20
+ 'Use the string preset "read" for read, grep, find, and ls; use "modify" for the read bundle plus bash, edit, and write. An array grants exactly those named tools (ordinary tools only); coordinator tools are injected separately.',
21
+ },
22
+ );
13
23
  const FRIENDLY_AGENT_ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$";
14
24
  const CANONICAL_AGENT_ID_PATTERN =
15
25
  "^(?:root\\.)?[A-Za-z0-9][A-Za-z0-9_-]{0,63}(?:\\.[A-Za-z0-9][A-Za-z0-9_-]{0,63})*$";
@@ -20,8 +30,9 @@ function canonicalAgentIdSchema(description?: string) {
20
30
 
21
31
  /** Build all six strict TypeBox schemas, including the refreshed runtime model enum. */
22
32
  export function createCoordinatorToolSchemas(modelIds: readonly string[]) {
23
- const explicitModelSchema: TSchema =
24
- modelIds.length > 0 ? StringEnum(modelIds as [string, ...string[]]) : Type.Never();
33
+ const [firstModelId, ...remainingModelIds] = modelIds;
34
+ const explicitModelSchema =
35
+ firstModelId === undefined ? Type.Never() : StringEnum([firstModelId, ...remainingModelIds]);
25
36
  return {
26
37
  subagent: Type.Object({
27
38
  task: Type.String({ minLength: 1, description: "Task for the persistent child agent" }),
@@ -49,6 +60,9 @@ export function createCoordinatorToolSchemas(modelIds: readonly string[]) {
49
60
  }),
50
61
  subagent_wait: Type.Object({
51
62
  agent_id: canonicalAgentIdSchema("Direct child canonical agent ID"),
63
+ turn_id: Type.Optional(
64
+ Type.String({ minLength: 1, description: "Exact retained child turn ID" }),
65
+ ),
52
66
  timeout_ms: Type.Optional(Type.Integer({ minimum: 0 })),
53
67
  }),
54
68
  subagent_status: Type.Object({
@@ -9,6 +9,7 @@ import {
9
9
  type ToolDefinition,
10
10
  type ToolRenderResultOptions,
11
11
  } from "@earendil-works/pi-coding-agent";
12
+ import type { Static } from "typebox";
12
13
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
13
14
  import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
14
15
  import {
@@ -16,8 +17,17 @@ import {
16
17
  renderCoordinatorToolResult,
17
18
  type CoordinatorToolName,
18
19
  } from "./minimal-subagents-rendering.js";
20
+ import type { CoordinatorToolCallInput } from "./minimal-subagents-render-contract.js";
19
21
  import type { createCoordinatorToolSchemas } from "./minimal-subagents-tool-schemas.js";
20
- import type { CallerSnapshot, SpawnParameters } from "./minimal-subagents-types.js";
22
+ import type {
23
+ AgentMessageResult,
24
+ CallerSnapshot,
25
+ CancelResult,
26
+ DeleteResult,
27
+ SpawnResult,
28
+ StatusResult,
29
+ WaitResult,
30
+ } from "./minimal-subagents-types.js";
21
31
 
22
32
  const ORDINARY_CHILD_COORDINATOR_TOOL_NAMES = new Set([
23
33
  "agent_message",
@@ -25,8 +35,15 @@ const ORDINARY_CHILD_COORDINATOR_TOOL_NAMES = new Set([
25
35
  "subagent_status",
26
36
  ]);
27
37
 
28
- interface CoordinatorToolDefinitionOptions {
29
- coordinator: MinimalSubagentsCoordinator;
38
+ /** Coordinator operations consumed by the six public coordinator tool definitions. */
39
+ export type CoordinatorToolOperations = Pick<
40
+ MinimalSubagentsCoordinator,
41
+ "spawn" | "inspectStatus" | "sendAgentMessage" | "wait" | "status" | "cancel" | "delete"
42
+ >;
43
+
44
+ /** Dependencies and caller policy used to create caller-bound coordinator tools. */
45
+ export interface CoordinatorToolDefinitionOptions {
46
+ coordinator: CoordinatorToolOperations;
30
47
  callerId: string;
31
48
  allowFanoutTools?: boolean;
32
49
  modelRoles?: readonly MinimalSubagentsModelRole[];
@@ -36,16 +53,38 @@ interface CoordinatorToolDefinitionOptions {
36
53
  onAttention?: (message: string) => void;
37
54
  }
38
55
 
56
+ /** Arguments consumed by the wait tool's narrow coordinator execution seam. */
57
+ export type CoordinatorWaitToolParameters = Static<
58
+ ReturnType<typeof createCoordinatorToolSchemas>["subagent_wait"]
59
+ >;
60
+
61
+ /** Forward one typed wait-tool request without requiring an unrelated Pi execution context. */
62
+ export function executeCoordinatorWaitTool(
63
+ coordinator: Pick<CoordinatorToolOperations, "wait">,
64
+ callerId: string,
65
+ parameters: CoordinatorWaitToolParameters,
66
+ signal: AbortSignal | undefined,
67
+ ): Promise<WaitResult> {
68
+ return coordinator.wait(
69
+ callerId,
70
+ parameters.agent_id,
71
+ parameters.timeout_ms,
72
+ signal,
73
+ parameters.turn_id,
74
+ );
75
+ }
76
+
39
77
  function buildModelRolePromptGuidelines(
40
78
  modelRoles: readonly MinimalSubagentsModelRole[],
41
79
  ): string[] | undefined {
42
80
  if (modelRoles.length === 0) return undefined;
43
- const roleLines = modelRoles.map(
44
- (role) => ` - ${role.name} → ${role.model}${role.hint ? ` — ${role.hint}` : ""}`,
45
- );
81
+ const roleLines = modelRoles.map((role) => {
82
+ const thinkingGuidance = role.thinkingLevel ? `, thinking_level=${role.thinkingLevel}` : "";
83
+ return ` - ${role.name} → model=${role.model}${thinkingGuidance}${role.hint ? ` — ${role.hint}` : ""}`;
84
+ });
46
85
  return [
47
86
  ["Configured model roles are guidance, not constraints:", ...roleLines].join("\n"),
48
- "Choose a model based on the task. Choose thinking_level independently.",
87
+ "Choose a model based on the task. A listed thinking_level is a preference, not a constraint. Callers choose thinking_level independently for roles without one.",
49
88
  ];
50
89
  }
51
90
 
@@ -60,15 +99,24 @@ async function runCoordinatorToolActivity<T>(
60
99
  }
61
100
  }
62
101
 
102
+ type CoordinatorToolResultDetails =
103
+ | SpawnResult
104
+ | AgentMessageResult
105
+ | WaitResult
106
+ | StatusResult
107
+ | CancelResult
108
+ | DeleteResult
109
+ | { agent_id: string; status: "waiting"; elapsed_ms: number };
110
+
63
111
  function createCoordinatorToolRendering(toolName: CoordinatorToolName) {
64
112
  return {
65
- renderCall: (args: Record<string, unknown>, theme: Theme) =>
113
+ renderCall: (args: CoordinatorToolCallInput, theme: Theme) =>
66
114
  renderCoordinatorToolCall(toolName, args, theme),
67
115
  renderResult: (
68
- result: AgentToolResult<unknown>,
116
+ result: AgentToolResult<CoordinatorToolResultDetails>,
69
117
  renderOptions: ToolRenderResultOptions,
70
118
  theme: Theme,
71
- context: { args: Record<string, unknown>; isError: boolean },
119
+ context: { args: CoordinatorToolCallInput; isError: boolean },
72
120
  ) =>
73
121
  renderCoordinatorToolResult(
74
122
  toolName,
@@ -81,7 +129,9 @@ function createCoordinatorToolRendering(toolName: CoordinatorToolName) {
81
129
  };
82
130
  }
83
131
 
84
- function structuredToolResult(result: unknown) {
132
+ function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
133
+ result: TDetails,
134
+ ): AgentToolResult<TDetails> {
85
135
  const json = JSON.stringify(result, null, 2);
86
136
  const truncated = truncateHead(json, {
87
137
  maxBytes: DEFAULT_MAX_BYTES,
@@ -93,7 +143,7 @@ function structuredToolResult(result: unknown) {
93
143
  };
94
144
  }
95
145
 
96
- function failedStructuredOperation(prefix: string, result: unknown): never {
146
+ function failedStructuredOperation(prefix: string, result: DeleteResult): never {
97
147
  const json = JSON.stringify(result);
98
148
  const truncated = truncateHead(json, {
99
149
  maxBytes: DEFAULT_MAX_BYTES,
@@ -103,7 +153,7 @@ function failedStructuredOperation(prefix: string, result: unknown): never {
103
153
  }
104
154
 
105
155
  function callerSourceTurnId(
106
- coordinator: MinimalSubagentsCoordinator,
156
+ coordinator: CoordinatorToolOperations,
107
157
  callerId: string,
108
158
  toolCallId: string,
109
159
  ): string {
@@ -131,17 +181,17 @@ export function createCoordinatorToolDefinitions(
131
181
  return runCoordinatorToolActivity(options, async () => {
132
182
  const result = await options.coordinator.spawn(
133
183
  options.callerId,
134
- parameters as SpawnParameters,
184
+ parameters,
135
185
  options.captureCaller(context),
136
186
  );
137
187
  const status = options.coordinator.inspectStatus(result.agent_id);
138
- return {
139
- ...structuredToolResult(result),
140
- details: {
141
- ...result,
142
- ...(status && "agent" in status ? { agent: status.agent } : {}),
143
- },
188
+ const details: SpawnResult & {
189
+ agent?: import("./minimal-subagents-types.js").AgentDetail;
190
+ } = {
191
+ ...result,
144
192
  };
193
+ if (status && "agent" in status) details.agent = status.agent;
194
+ return structuredToolResult(details);
145
195
  });
146
196
  },
147
197
  ...createCoordinatorToolRendering("subagent"),
@@ -151,7 +201,7 @@ export function createCoordinatorToolDefinitions(
151
201
  name: "agent_message",
152
202
  label: "Agent Message",
153
203
  description:
154
- "Send one mid-turn coordination message to a direct parent, direct sibling, or direct child when the recipient must act before the caller's turn finishes.",
204
+ "Send one mid-turn coordination message to a direct parent, direct sibling, or direct child. The result says whether it was delivered through an active wait, queued for the recipient, or failed.",
155
205
  promptSnippet: "Coordinate required mid-turn action with one adjacent agent",
156
206
  parameters: options.schemas.agent_message,
157
207
  async execute(toolCallId, parameters) {
@@ -174,7 +224,7 @@ export function createCoordinatorToolDefinitions(
174
224
  name: "subagent_wait",
175
225
  label: "Subagent Wait",
176
226
  description:
177
- "Wait for one direct child's exact active turn, or return its latest settled turn immediately. Timeout never cancels the child.",
227
+ "Wait for one direct child's oldest observable turn, or select an exact retained turn_id. A Wait Event containing a Coordination Message may arrive first as event=message; call again for the terminal turn result. A successful wait durably claims that turn so later messages and its terminal result return through wait without duplicate automatic delivery. Timeout never cancels the child.",
178
228
  promptSnippet: "Wait for one direct child's exact turn",
179
229
  parameters: options.schemas.subagent_wait,
180
230
  async execute(_toolCallId, parameters, signal, onUpdate) {
@@ -193,10 +243,10 @@ export function createCoordinatorToolDefinitions(
193
243
  waitingInterval.unref?.();
194
244
  try {
195
245
  return await runCoordinatorToolActivity(options, async () => {
196
- const result = await options.coordinator.wait(
246
+ const result = await executeCoordinatorWaitTool(
247
+ options.coordinator,
197
248
  options.callerId,
198
- parameters.agent_id,
199
- parameters.timeout_ms,
249
+ parameters,
200
250
  signal,
201
251
  );
202
252
  return {
@@ -22,6 +22,12 @@ export type AgentAvailability = "available" | "unavailable";
22
22
  /** Classifies active and terminal persistent subagent turn outcomes. */
23
23
  export type TurnStatus = "running" | "completed" | "failed" | "cancelled" | "interrupted";
24
24
 
25
+ /** Describes the canonical model and resolved thinking level currently used by a Child Agent. */
26
+ export interface RuntimeProfile {
27
+ model: string;
28
+ thinking_level: ThinkingLevel;
29
+ }
30
+
25
31
  /** Defines the validated launch contract accepted by the subagent tool. */
26
32
  export interface SpawnParameters {
27
33
  task: string;
@@ -52,23 +58,37 @@ export interface TurnResult {
52
58
  elapsed_ms?: number;
53
59
  }
54
60
 
55
- /** Reports delivery of one direct message to an authorized adjacent agent. */
61
+ /** Reports whether one direct message was handed to a wait, queued, or failed. */
62
+ export type AgentMessageDisposition = "delivered-via-wait" | "queued" | "failed";
63
+
56
64
  export interface AgentMessageResult {
57
65
  agent_id: string;
58
- delivered: boolean;
66
+ message_id: string;
67
+ disposition: AgentMessageDisposition;
59
68
  error?: string;
60
69
  }
61
70
 
62
- /** Provides bounded hierarchy and usage data for one persistent agent. */
63
- export interface AgentSummary {
71
+ /** Reports one coordination message returned before the source turn settles. */
72
+ export interface WaitMessageResult {
73
+ event: "message";
74
+ agent_id: string;
75
+ turn_id: string;
76
+ message_id: string;
77
+ delivery_id?: string;
78
+ message: string;
79
+ }
80
+
81
+ /** Reports one terminal child turn returned by subagent_wait. */
82
+ export type WaitResult = WaitMessageResult | ({ event: "turn" } & TurnResult);
83
+
84
+ /** Provides bounded hierarchy, usage, and best-known Runtime Profile data for one persistent agent. */
85
+ export interface AgentSummary extends RuntimeProfile {
64
86
  agent_id: string;
65
87
  parent_id: string;
66
88
  state: AgentState;
67
89
  availability: AgentAvailability;
68
90
  active_turn_id?: string;
69
91
  latest_turn?: Pick<TurnResult, "turn_id" | "status">;
70
- model: string;
71
- thinking_level: ThinkingLevel;
72
92
  tools: string[];
73
93
  elapsed_ms?: number;
74
94
  latest_activity?: string;
@@ -88,7 +108,7 @@ export interface RecentAgentMessage {
88
108
  /** Extends summary status with launch, dependency, and recent-message diagnostics. */
89
109
  export interface AgentDetail extends AgentSummary {
90
110
  session_file?: string;
91
- launch_contract: Record<string, unknown>;
111
+ launch_contract: LaunchContract;
92
112
  capability_ceiling: string[];
93
113
  spawn_entry_id: string;
94
114
  recent_messages: RecentAgentMessage[];
@@ -125,11 +145,9 @@ export interface DeleteResult {
125
145
  }
126
146
 
127
147
  /** Persists immutable context, model, thinking, and ordinary-tool launch choices. */
128
- export interface LaunchContract {
148
+ export interface LaunchContract extends RuntimeProfile {
129
149
  session_context: SessionContextMode;
130
150
  project_context: ProjectContextMode;
131
- model: string;
132
- thinking_level: ThinkingLevel;
133
151
  tools: ToolSelection | undefined;
134
152
  ordinary_tools: string[];
135
153
  delegation?: DelegationMode;
@@ -162,6 +180,8 @@ export interface CoordinatorMessage {
162
180
  source_agent_id: string;
163
181
  destination_agent_id?: string;
164
182
  source_turn_id: string;
183
+ message_id: string;
184
+ delivery_id?: string;
165
185
  status?: TurnStatus;
166
186
  elapsed_ms?: number;
167
187
  usage?: Usage;
@@ -172,6 +192,7 @@ export interface CoordinatorMessage {
172
192
  export interface ChildAgentRuntime {
173
193
  readonly sessionFile: string;
174
194
  readonly sessionId: string;
195
+ readonly sessionLeafId: string | undefined;
175
196
  readonly isRunning: boolean;
176
197
  runPrompt(
177
198
  task: string,
@@ -180,20 +201,22 @@ export interface ChildAgentRuntime {
180
201
  callerThinkingLevel: ThinkingLevel,
181
202
  ): Promise<RuntimeTurnOutcome>;
182
203
  runMessage(message: CoordinatorMessage): Promise<RuntimeTurnOutcome>;
183
- /** Steer one typed coordinator message into the child session. */
184
- steerCoordinatorMessage(message: CoordinatorMessage): Promise<void>;
204
+ /** Queue one typed coordinator message into the child session. */
205
+ queueCoordinatorMessage(message: CoordinatorMessage): Promise<void>;
185
206
  abort(): Promise<void>;
186
207
  dispose(): void;
208
+ /** Return the live Runtime Profile, or undefined when the SDK session has no model. */
209
+ getRuntimeProfile(): RuntimeProfile | undefined;
187
210
  snapshotCommittedMessages(): AgentMessage[];
188
- hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string): boolean;
211
+ hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean;
189
212
  getUsage(): Usage | undefined;
190
- cloneSession(): Promise<{ sessionFile: string; sessionId: string }>;
191
213
  }
192
214
 
193
215
  /** Identifies one writable child JSONL session owned by a coordinator root. */
194
216
  export interface PersistedSessionIdentity {
195
217
  sessionFile: string;
196
218
  sessionId: string;
219
+ sessionLeafId?: string;
197
220
  }
198
221
 
199
222
  /** Combines a persisted agent record with first-launch imported context. */
@@ -211,15 +234,28 @@ export interface AgentSessionFactory {
211
234
  resolveRestorationMissingDependencies(agent: PersistedAgent): Promise<string[]>;
212
235
  resolveThinkingLevel(modelId: string, requested: ThinkingLevel): ThinkingLevel;
213
236
  modelSupportsImages(modelId: string): boolean;
237
+ /** Clone a child leaf owned by the active source root during confirmed shutdown. */
214
238
  cloneSession(agent: PersistedAgent): Promise<PersistedSessionIdentity>;
215
- trashSessionFile(sessionFile: string): Promise<void>;
239
+ /** Clone a source-owned leaf recovered from a proven destination branch. */
240
+ cloneForkSourceSession(
241
+ agent: PersistedAgent,
242
+ sourceRootSessionId: string,
243
+ ): Promise<PersistedSessionIdentity>;
244
+ /** Append and verify destination-root ownership for one fork clone. */
245
+ adoptForkSessionOwnership(
246
+ agent: PersistedAgent,
247
+ sourceRootSessionId: string,
248
+ ): Promise<PersistedSessionIdentity>;
249
+ trashSession(agent: PersistedAgent): Promise<void>;
216
250
  }
217
251
 
218
252
  /** Abstracts root message delivery and durable delivery-evidence lookup. */
219
253
  export interface RootConversationEndpoint {
220
- /** Steer one typed coordinator message into the root conversation. */
221
- steerCoordinatorMessage(message: CoordinatorMessage): Promise<void>;
222
- hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string): boolean;
254
+ /** Queue one typed coordinator message into the root conversation. */
255
+ queueCoordinatorMessage(message: CoordinatorMessage): Promise<void>;
256
+ hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean;
257
+ /** Report whether automatic delivery can start a new root turn without racing an active wait. */
258
+ isIdle(): boolean;
223
259
  }
224
260
 
225
261
  /** Stores root-owned agent identity, launch contract, availability, and latest activity. */
@@ -233,6 +269,7 @@ export interface PersistedAgent {
233
269
  spawn_entry_id: string;
234
270
  session_file?: string;
235
271
  session_id?: string;
272
+ session_leaf_id?: string;
236
273
  clone_error?: string;
237
274
  launch_contract: LaunchContract;
238
275
  capability_ceiling: string[];
@@ -246,7 +283,7 @@ export interface PersistedAgent {
246
283
  deleted?: boolean;
247
284
  }
248
285
 
249
- /** Records whether successful output was observed through wait or automatic messaging. */
286
+ /** Records which conversation path owns one successful terminal result. */
250
287
  export type DeliveryPath = "wait" | "message";
251
288
 
252
289
  /** Stores a keyed successful result until destination evidence settles delivery. */
@@ -256,26 +293,44 @@ export interface PersistedDelivery {
256
293
  destination_agent_id: string;
257
294
  path: DeliveryPath;
258
295
  settled: boolean;
296
+ sequence?: number;
259
297
  result?: TurnResult;
260
298
  error?: string;
261
299
  }
262
300
 
263
- /** Checkpoints all live agents, deletion tombstones, and delivery records for one root. */
301
+ /** Stores one durable Coordination Message until destination evidence settles it. */
302
+ export interface PersistedCoordinationDelivery {
303
+ delivery_id: string;
304
+ sequence: number;
305
+ destination_agent_id: string;
306
+ path: DeliveryPath;
307
+ settled: boolean;
308
+ message: CoordinatorMessage;
309
+ error?: string;
310
+ }
311
+
312
+ /** Checkpoints live agents, tombstones, and only pending delivery-ledger items for one root. */
264
313
  export interface RegistrySnapshot {
265
314
  agents: PersistedAgent[];
266
315
  tombstones: string[];
267
316
  deliveries: PersistedDelivery[];
317
+ coordination_deliveries?: PersistedCoordinationDelivery[];
318
+ wait_claimed_turns?: string[];
319
+ next_delivery_sequence?: number;
268
320
  }
269
321
 
270
322
  /** Clones a registry snapshot while recording the source root session file. */
271
323
  export interface ForkSnapshot extends RegistrySnapshot {
324
+ /** Canonical Pi root session file from which the selected fork branch originated. */
272
325
  source_root_session_file: string;
326
+ /** Root identity that every cloned Child Session provenance record must match. */
327
+ source_root_session_id: string;
273
328
  }
274
329
 
275
330
  /** Appends root-owned registry events to the active root conversation branch. */
276
331
  export interface RegistryWriter {
277
332
  readonly rootSessionId: string;
278
- append(event: import("./minimal-subagents-registry.js").RegistryEventV1): void;
333
+ append(event: import("./minimal-subagents-registry.js").RegistryEventV2): void;
279
334
  }
280
335
 
281
336
  /** Describes concise lifecycle notices surfaced through Pi UI notifications. */