@narumitw/pi-subagents 2.1.0 → 2.1.2

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 (31) hide show
  1. package/README.md +23 -12
  2. package/dist/chunks/{chunk-PABJJYP6.ts → chunk-2LMJU25E.ts} +12 -41
  3. package/dist/chunks/chunk-2LMJU25E.ts.map +7 -0
  4. package/dist/chunks/{chunk-FXI45N3J.ts → chunk-6NSJVPXX.ts} +4 -14
  5. package/dist/chunks/{chunk-FXI45N3J.ts.map → chunk-6NSJVPXX.ts.map} +2 -2
  6. package/dist/chunks/{chunk-VBDGNNLM.ts → chunk-NLT67IZS.ts} +137 -29
  7. package/dist/chunks/chunk-NLT67IZS.ts.map +7 -0
  8. package/dist/chunks/{chunk-DIHBUR2E.ts → chunk-YU53SHA7.ts} +2 -2
  9. package/dist/chunks/{completion-delivery-JVLNQRWX.ts → completion-delivery-RSJU6BXL.ts} +3 -3
  10. package/dist/chunks/{config-ui-DDKERQHI.ts → config-ui-ABHYNGQ7.ts} +4 -4
  11. package/dist/chunks/{consult-PQ6PRAKC.ts → consult-LJU3IQY5.ts} +2 -2
  12. package/dist/chunks/{persistence-XHPJBZL7.ts → persistence-UY3PY6E5.ts} +2 -2
  13. package/dist/chunks/{registry-XDXPECWF.ts → registry-BT54L6CY.ts} +2 -2
  14. package/dist/index.ts +322 -60
  15. package/dist/index.ts.map +3 -3
  16. package/docs/async-runtime-protocol.md +17 -2
  17. package/package.json +2 -2
  18. package/src/completion-requirement.ts +201 -30
  19. package/src/consult-registration.ts +4 -12
  20. package/src/session-guidance-contract.ts +399 -0
  21. package/src/stateful-guidance.ts +3 -20
  22. package/src/stateful-registration.ts +4 -31
  23. package/src/subagents-extension.ts +56 -66
  24. package/dist/chunks/chunk-PABJJYP6.ts.map +0 -7
  25. package/dist/chunks/chunk-VBDGNNLM.ts.map +0 -7
  26. /package/dist/chunks/{chunk-DIHBUR2E.ts.map → chunk-YU53SHA7.ts.map} +0 -0
  27. /package/dist/chunks/{completion-delivery-JVLNQRWX.ts.map → completion-delivery-RSJU6BXL.ts.map} +0 -0
  28. /package/dist/chunks/{config-ui-DDKERQHI.ts.map → config-ui-ABHYNGQ7.ts.map} +0 -0
  29. /package/dist/chunks/{consult-PQ6PRAKC.ts.map → consult-LJU3IQY5.ts.map} +0 -0
  30. /package/dist/chunks/{persistence-XHPJBZL7.ts.map → persistence-UY3PY6E5.ts.map} +0 -0
  31. /package/dist/chunks/{registry-XDXPECWF.ts.map → registry-BT54L6CY.ts.map} +0 -0
@@ -14,7 +14,12 @@ Omitting the field or using `background` preserves prior behavior and does not c
14
14
  `AgentRegistry` owns requirement transitions with the child turn and persisted completion outbox.
15
15
  Tool-result `details.agent.completionRequirements` provides fork-sensitive branch evidence.
16
16
  Session restoration retains exact requirements found on the active branch and treats sessions without visible subagent state as a possible compacted continuation.
17
- The `context` hook owns one canonical hidden `pi-subagent-required-completions` block and replaces any older copy on every provider context assembly.
17
+ The successful lifecycle tool result and delivered completion message are the ordinary model-visible requirement handoff.
18
+ When a resume changes a pending run to cancelled and interrupted while its stale handoff remains in model context, `before_agent_start` appends one hidden versioned transition after that handoff.
19
+ This append-only transition also applies when leading summaries retain the stale handoff, prevents duplicate publication on later turns, and participates in fork-sensitive branch reconstruction.
20
+ If leading compaction or branch summaries remove the handoff, the `context` hook restores one canonical hidden `pi-subagent-required-completions` fallback immediately after the summaries.
21
+ A branch-local custom session entry records the exact restored boundary so reload and tree navigation reconstruct the correct historical fallback.
22
+ The fallback remains at that fixed boundary for the leading-summary epoch, while a later completion or cancellation transition supersedes it at the conversation tail.
18
23
  `CompletionDeliveryBroker` owns exact completion visibility acknowledgement and asks the registry to mark the corresponding requirement visible.
19
24
  No timer, waiter, or UI object owns requirement truth.
20
25
 
@@ -31,10 +36,20 @@ The runtime bounds retained requirement records per agent and rejects a sixty-fi
31
36
  ## Parent behavior
32
37
 
33
38
  Pending and available requirements remain final-answer dependencies.
34
- Visible requirements no longer appear in the canonical context block.
39
+ A newly established canonical fallback omits requirements already visible at that boundary.
40
+ A fallback retained from an earlier request remains historical prefix context after visibility changes, and the later completion message supplies the superseding state.
35
41
  Cancelled requirements are terminal and must be reported rather than silently treated as successful evidence.
36
42
  A failed, partial, interrupted, stale, or cancelled child never satisfies mutating acceptance or independent-verification requirements merely because its turn settled.
37
43
 
44
+ ## Prompt-cache boundary
45
+
46
+ Provider-visible subagent tool definitions and prompt metadata remain stable within one configured tool-surface epoch.
47
+ A versioned hidden session-guidance message carries the bounded agent catalog, completion delivery, capacity, cwd policy, and consultation resource policy without placing mutable values in leading tool metadata.
48
+ The initial guidance contract is persisted once before the first agent turn when no equivalent retained contract exists.
49
+ A successfully applied live policy change appends a superseding guidance contract without triggering a model turn.
50
+ Compaction restores missing guidance and required-completion fallbacks in deterministic order after leading summaries.
51
+ These rules preserve normalized cache-eligible prefixes across ordinary turns but do not guarantee a provider-reported cache hit.
52
+
38
53
  ## Budget termination
39
54
 
40
55
  Omitted limits use runtime or agent policy and are recorded as runtime-sourced telemetry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,7 +53,7 @@
53
53
  "typescript": "7.0.2"
54
54
  },
55
55
  "dependencies": {
56
- "@narumitw/pi-tui-kit": "^0.58.0",
56
+ "@narumitw/pi-tui-kit": "^0.58.1",
57
57
  "proper-lockfile": "^4.1.2"
58
58
  },
59
59
  "repository": {
@@ -14,6 +14,9 @@ export const CompletionRequirementModeSchema = StringEnum(COMPLETION_REQUIREMENT
14
14
 
15
15
  export const COMPLETION_REQUIREMENT_VERSION = "pi-subagents:completion-requirement:v1" as const;
16
16
  export const COMPLETION_REQUIREMENT_CONTEXT_TYPE = "pi-subagent-required-completions";
17
+ export const COMPLETION_REQUIREMENT_TRANSITION_TYPE = "pi-subagent-required-completion-transition";
18
+ export const COMPLETION_REQUIREMENT_TRANSITION_VERSION =
19
+ "pi-subagents:completion-requirement-transition:v1" as const;
17
20
  const MAX_REQUIREMENTS_PER_AGENT = 20;
18
21
  export const MAX_UNRESOLVED_REQUIRED_COMPLETIONS = 64;
19
22
 
@@ -159,6 +162,24 @@ export function completionRequirementsFromBranch(
159
162
  }
160
163
  continue;
161
164
  }
165
+ if (
166
+ message.role === "custom" &&
167
+ message.customType === COMPLETION_REQUIREMENT_TRANSITION_TYPE
168
+ ) {
169
+ const details = message.details;
170
+ if (!details || typeof details !== "object" || Array.isArray(details)) continue;
171
+ const detailRecord = details as Record<string, unknown>;
172
+ if (detailRecord.version !== COMPLETION_REQUIREMENT_TRANSITION_VERSION) continue;
173
+ const requirements = Array.isArray(detailRecord.records) ? detailRecord.records : [];
174
+ for (const requirement of requirements) {
175
+ if (!isCompletionRequirementRecord(requirement) || requirement.state !== "cancelled") {
176
+ continue;
177
+ }
178
+ observedState = true;
179
+ records.set(completionRequirementKey(requirement), { ...requirement });
180
+ }
181
+ continue;
182
+ }
162
183
  if (message.role !== "custom" || message.customType !== "pi-subagent-completion") continue;
163
184
  const details = message.details;
164
185
  if (!details || typeof details !== "object" || Array.isArray(details)) continue;
@@ -187,22 +208,98 @@ export function pendingRequiredCompletionCount(
187
208
  ).length;
188
209
  }
189
210
 
211
+ export function createRequiredCompletionTransition(
212
+ messages: ContextEvent["messages"],
213
+ agents: readonly ManagedAgent[],
214
+ ) {
215
+ const summarized = leadingSummaryBoundary(messages) > 0;
216
+ const state = modelRequirementState(agents);
217
+ const visible = completionRequirementsFromBranch(messages).records;
218
+ const records = state.records.filter((record) => {
219
+ if (record.state !== "cancelled") return false;
220
+ const retained = visible.get(completionRequirementKey(record));
221
+ if (modelRequirementStateMatches(retained, record)) return false;
222
+ return !summarized || retained !== undefined;
223
+ });
224
+ if (records.length === 0) return undefined;
225
+ return {
226
+ role: "custom" as const,
227
+ customType: COMPLETION_REQUIREMENT_TRANSITION_TYPE,
228
+ content: requiredCompletionTransitionContent(records, state.omittedCancelled),
229
+ display: false,
230
+ details: {
231
+ version: COMPLETION_REQUIREMENT_TRANSITION_VERSION,
232
+ records: records.map((record) => ({ ...record })),
233
+ },
234
+ timestamp: 0,
235
+ };
236
+ }
237
+
190
238
  export function reconcileRequiredCompletionContext(
191
239
  messages: ContextEvent["messages"],
192
240
  agents: readonly ManagedAgent[],
241
+ restorationPredecessors: readonly string[] = [],
242
+ restoredBoundaryContent?: string,
193
243
  ): ContextEvent["messages"] {
194
- const withoutPrior = messages.filter(
195
- (message) =>
196
- message.role !== "custom" || message.customType !== COMPLETION_REQUIREMENT_CONTEXT_TYPE,
197
- );
244
+ const existing = messages.filter(isRequiredCompletionContextMessage);
245
+ const withoutPrior = messages.filter((message) => !isRequiredCompletionContextMessage(message));
246
+ const state = modelRequirementState(agents);
247
+ const summaryBoundary = leadingSummaryBoundary(withoutPrior);
248
+ const currentContent =
249
+ state.records.length > 0 && !hasModelVisibleRequirementState(withoutPrior, state.records)
250
+ ? requiredCompletionContextContent(state.records, state.omittedCancelled)
251
+ : undefined;
252
+ const content = summaryBoundary > 0 ? (restoredBoundaryContent ?? currentContent) : undefined;
253
+ let insertionBoundary = summaryBoundary;
254
+ while (insertionBoundary < withoutPrior.length) {
255
+ const predecessor = withoutPrior[insertionBoundary];
256
+ if (
257
+ predecessor?.role !== "custom" ||
258
+ !restorationPredecessors.includes(predecessor.customType)
259
+ ) {
260
+ break;
261
+ }
262
+ insertionBoundary += 1;
263
+ }
264
+ if (
265
+ content !== undefined &&
266
+ existing.length === 1 &&
267
+ messages[insertionBoundary] === existing[0] &&
268
+ existing[0]?.content === content &&
269
+ hasCompletionRequirementContextVersion(existing[0])
270
+ ) {
271
+ return messages;
272
+ }
273
+ if (existing.length === 0 && content === undefined) return messages;
274
+ if (content === undefined) return withoutPrior;
275
+ return [
276
+ ...withoutPrior.slice(0, insertionBoundary),
277
+ {
278
+ role: "custom",
279
+ customType: COMPLETION_REQUIREMENT_CONTEXT_TYPE,
280
+ content,
281
+ display: false,
282
+ details: { version: COMPLETION_REQUIREMENT_VERSION },
283
+ timestamp: 0,
284
+ },
285
+ ...withoutPrior.slice(insertionBoundary),
286
+ ];
287
+ }
288
+
289
+ interface ModelRequirementRecord {
290
+ state: CompletionRequirementState;
291
+ runId: string;
292
+ generation: number;
293
+ terminalState: AgentLifecycleState | undefined;
294
+ }
295
+
296
+ function modelRequirementState(agents: readonly ManagedAgent[]): {
297
+ records: CompletionRequirementRecord[];
298
+ omittedCancelled: number;
299
+ } {
198
300
  const allRecords = agents
199
301
  .flatMap((agent) =>
200
- (agent.completionRequirements ?? []).map((requirement) => ({
201
- state: requirement.state,
202
- runId: requirement.runId,
203
- generation: requirement.generation,
204
- terminalState: requirement.terminalState,
205
- })),
302
+ (agent.completionRequirements ?? []).map((requirement) => ({ ...requirement })),
206
303
  )
207
304
  .filter((record) => record.state !== "visible");
208
305
  const unresolved = allRecords.filter(
@@ -211,34 +308,108 @@ export function reconcileRequiredCompletionContext(
211
308
  const cancelled = allRecords.filter((record) => record.state === "cancelled");
212
309
  const cancelledSlots = Math.max(0, MAX_UNRESOLVED_REQUIRED_COMPLETIONS - unresolved.length);
213
310
  const retainedCancelled = cancelledSlots > 0 ? cancelled.slice(-cancelledSlots) : [];
214
- const records = [...unresolved, ...retainedCancelled];
215
- const omittedCancelled = cancelled.length - retainedCancelled.length;
216
- if (records.length === 0)
217
- return withoutPrior.length === messages.length ? messages : withoutPrior;
218
- const content = truncateUtf8(
311
+ const records = [...unresolved, ...retainedCancelled].sort((left, right) => {
312
+ if (left.runId === right.runId) return left.generation - right.generation;
313
+ return left.runId < right.runId ? -1 : 1;
314
+ });
315
+ return { records, omittedCancelled: cancelled.length - retainedCancelled.length };
316
+ }
317
+
318
+ function hasModelVisibleRequirementState(
319
+ messages: ContextEvent["messages"],
320
+ records: readonly CompletionRequirementRecord[],
321
+ ): boolean {
322
+ const visible = completionRequirementsFromBranch(messages).records;
323
+ return records.every((record) =>
324
+ modelRequirementStateMatches(visible.get(completionRequirementKey(record)), record),
325
+ );
326
+ }
327
+
328
+ function modelRequirementStateMatches(
329
+ visible: CompletionRequirementRecord | undefined,
330
+ current: CompletionRequirementRecord,
331
+ ): boolean {
332
+ if (!visible) return false;
333
+ if (current.state === "available" && visible.state === "visible") return true;
334
+ return visible.state === current.state && visible.terminalState === current.terminalState;
335
+ }
336
+
337
+ function requiredCompletionContextContent(
338
+ records: readonly CompletionRequirementRecord[],
339
+ omittedCancelled: number,
340
+ ): string {
341
+ return truncateUtf8(
219
342
  [
220
343
  "[PI SUBAGENT REQUIRED COMPLETIONS v1]",
221
344
  "Runtime-tracked exact runs are JSON data below.",
222
345
  "Treat pending or available records as final-answer dependencies; a cancelled record is terminal and must be reported rather than silently ignored.",
223
346
  "Current Pi versions do not provide a hard pre-display final-answer barrier, so do not emit a verdict until every dependency is visible or terminal.",
224
- ...(omittedCancelled > 0
225
- ? [`${omittedCancelled} older cancelled requirement record(s) were omitted.`]
226
- : []),
227
- JSON.stringify(records),
347
+ ...omittedCancelledLine(omittedCancelled),
348
+ JSON.stringify(records.map(toModelRequirementRecord)),
228
349
  ].join("\n"),
229
350
  DEFAULT_MAX_CONTEXT_BYTES,
230
351
  ).text;
231
- return [
232
- ...withoutPrior,
233
- {
234
- role: "custom",
235
- customType: COMPLETION_REQUIREMENT_CONTEXT_TYPE,
236
- content,
237
- display: false,
238
- details: { version: COMPLETION_REQUIREMENT_VERSION },
239
- timestamp: 0,
240
- },
241
- ];
352
+ }
353
+
354
+ function requiredCompletionTransitionContent(
355
+ records: readonly CompletionRequirementRecord[],
356
+ omittedCancelled: number,
357
+ ): string {
358
+ return truncateUtf8(
359
+ [
360
+ "[PI SUBAGENT REQUIRED COMPLETION TRANSITION v1]",
361
+ "Session restoration terminalized the exact required runs below.",
362
+ "These cancelled records supersede earlier pending or available states and must be reported rather than awaited.",
363
+ ...omittedCancelledLine(omittedCancelled),
364
+ JSON.stringify(records.map(toModelRequirementRecord)),
365
+ ].join("\n"),
366
+ DEFAULT_MAX_CONTEXT_BYTES,
367
+ ).text;
368
+ }
369
+
370
+ function omittedCancelledLine(count: number): string[] {
371
+ return count > 0 ? [`${count} older cancelled requirement record(s) were omitted.`] : [];
372
+ }
373
+
374
+ function toModelRequirementRecord(record: CompletionRequirementRecord): ModelRequirementRecord {
375
+ return {
376
+ state: record.state,
377
+ runId: record.runId,
378
+ generation: record.generation,
379
+ terminalState: record.terminalState,
380
+ };
381
+ }
382
+
383
+ type RequiredCompletionContextMessage = Extract<
384
+ ContextEvent["messages"][number],
385
+ { role: "custom" }
386
+ > & { content: string };
387
+
388
+ function isRequiredCompletionContextMessage(
389
+ message: ContextEvent["messages"][number],
390
+ ): message is RequiredCompletionContextMessage {
391
+ return message.role === "custom" && message.customType === COMPLETION_REQUIREMENT_CONTEXT_TYPE;
392
+ }
393
+
394
+ function hasCompletionRequirementContextVersion(
395
+ message: RequiredCompletionContextMessage,
396
+ ): boolean {
397
+ return (
398
+ typeof message.details === "object" &&
399
+ message.details !== null &&
400
+ !Array.isArray(message.details) &&
401
+ (message.details as Record<string, unknown>).version === COMPLETION_REQUIREMENT_VERSION
402
+ );
403
+ }
404
+
405
+ function leadingSummaryBoundary(messages: ContextEvent["messages"]): number {
406
+ let index = 0;
407
+ while (index < messages.length) {
408
+ const role = messages[index]?.role;
409
+ if (role !== "compactionSummary" && role !== "branchSummary") break;
410
+ index += 1;
411
+ }
412
+ return index;
242
413
  }
243
414
 
244
415
  export function isCompletionRequirementRecord(
@@ -3,10 +3,6 @@ import { cachedModuleLoader, throwIfAborted } from "./cached-module-loader.js";
3
3
  import type { RegisterSubagentConsultOptions } from "./consult.js";
4
4
  import { renderConsultCall, renderConsultResult } from "./consult-render.js";
5
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
6
 
11
7
  interface ConsultExecutionModule {
12
8
  executeSubagentConsult: typeof import("./consult.js").executeSubagentConsult;
@@ -20,7 +16,7 @@ export function registerSubagentConsult(
20
16
  pi: ExtensionAPI,
21
17
  options: RegisterSubagentConsultOptions,
22
18
  dependencies: ConsultRegistrationDependencies = {},
23
- ): (catalog: string) => void {
19
+ ): void {
24
20
  const loadExecution = cachedModuleLoader(
25
21
  dependencies.loadExecution ?? (() => import("./consult.js")),
26
22
  );
@@ -41,12 +37,12 @@ export function registerSubagentConsult(
41
37
  pi.on("session_start", () => cancelAndWaitForWork("Subagent consultation session replaced"));
42
38
  pi.on("session_shutdown", () => cancelAndWaitForWork("Subagent consultation session shut down"));
43
39
 
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.`;
40
+ const description =
41
+ "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. The current working-directory policy, trusted-target resource policy, and available agent definitions are published in the pi-subagents session-guidance message. Allowed targets without effective trust inherit no target or project resources. This is not a filesystem sandbox.";
46
42
  const definition: ToolDefinition<typeof SubagentConsultParams, ConsultDetails> = {
47
43
  name: "subagent_consult",
48
44
  label: "Consult Read-only Subagent",
49
- description: baseDescription(),
45
+ description,
50
46
  promptSnippet: "Consult one constrained read-only subagent and wait for its answer",
51
47
  promptGuidelines: [
52
48
  "Use subagent_consult only for bounded read-only evidence gathering when an independent perspective is worth making the main agent wait.",
@@ -103,10 +99,6 @@ export function registerSubagentConsult(
103
99
  if (event.toolName !== "subagent_consult") return;
104
100
  if ((event.details as ConsultDetails | undefined)?.isError) return { isError: true };
105
101
  });
106
- return (catalog: string) => {
107
- definition.description = catalog ? `${baseDescription()}\n\n${catalog}` : baseDescription();
108
- pi.registerTool<typeof SubagentConsultParams, ConsultDetails>(definition);
109
- };
110
102
  }
111
103
 
112
104
  function combineAbortSignals(