@henryqw/pi-subagent 25.0.1 → 25.0.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.
package/README.md CHANGED
@@ -126,7 +126,9 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
126
126
 
127
127
  `dependsOn` controls scheduling. `contextFrom` also schedules dependencies and carries completed text outputs in the declared order. Do not repeat the same task in both fields. Changesets require focused task checks; a graph with changesets requires final checks. A canonical root `package.json` test script is required: the runner adds `pnpm test` to final checks if missing, and Main runs it on the combined integration checkout before promotion. Checks and judgments bind to exact Git identities; candidate drift, Main drift, mutation during validation, ambiguity, or conflicts stop promotion and retain evidence.
128
128
 
129
- `delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. If delivery is missed, inspect `subagent_status`.
129
+ `delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. Session replacement or shutdown suppresses stale delivery and logs the request ID and reason, without prompts or credentials. Synchronous delivery failures are reported without automatic replay; Pi reports asynchronous `send_message` failures through its native runtime. If delivery is missed, inspect `subagent_status`.
130
+
131
+ Completed text answers are saved independently of cleanup. Status and terminal follow-ups include a bounded accepted-answer preview and cleanup evidence. Ignored automatic artifacts can retain a checkout without violating the read-only contract; no ignored files are deleted to make cleanup pass. Tracked or untracked changes, changed checkout identity, or unproved inspection still require attention: the completed answer remains saved but is not accepted as dependency context or retried automatically. Retained text checkouts require deliberate manual inspection and cleanup; a retained checkout alone is not evidence that the Role edited source.
130
132
 
131
133
  Isolated workers can start while Main has staged, unstaged, or untracked changes: they use only Main's pinned committed branch, HEAD, and tree. Those local edits are neither copied into worker worktrees nor stashed, staged, reset, or merged automatically. Main must remain at that committed identity while workers run; branch or HEAD drift blocks new waves. The worker produces a committed candidate without tracked or untracked changes, then runs focused preliminary checks and optional judgment. Ignored generated files do not block candidacy, but prevent automatic worktree cleanup; inspect them before deleting retained work. The runner records each ready candidate immediately but **does not integrate it automatically**. Main can inspect `subagent_status` and use `subagent_stage` to select the exact candidate in an owned integration worktree while siblings are still working; conflicts are resolved there. Stage and resolve are available during an active wave, but rejection and revision wait for the wave to settle. `subagent_integrate` can advance dependent tasks from a staged snapshot, validate the combined tip with full root checks and optional final judgment after the wave settles, and promote it to Main only after exact validation. A definitive failed combined check/review allows one committed correction in the integration checkout followed by another validation. Main must explicitly promote; promotion and cleanup never silently discard retained work.
132
134
 
package/dist/runner.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
- import { createChildWorktree, finalizeChildWorktree } from "./worktree.js";
2
+ import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
3
+ import { runGit } from "./git-process.js";
3
4
  import { IntegrationGit } from "./integration-git.js";
4
5
  import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reviewEvidencePasses, sameIdentity, RUN_STATE_VERSION, } from "./schema.js";
5
6
  import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
@@ -526,7 +527,9 @@ export class IsolatedRunner {
526
527
  const { states: entries, invalidIds } = await this.store.list(root);
527
528
  return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state }) => ({
528
529
  id: state.request.id, name: state.request.goal,
529
- status: ["completed", "aborted"].includes(state.status) && (state.tasks.some((task) => task.kind === "changeset" && task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
530
+ status: ["completed", "aborted"].includes(state.status) && (state.tasks.some((task) => task.kind === "changeset"
531
+ ? task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed"))
532
+ : task.attempts.some((attempt) => attempt.cleanup && attempt.cleanup.outcome !== "pruned"))
530
533
  || state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
531
534
  || (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true)))
532
535
  ? `${state.status} · retained` : state.status,
@@ -2035,6 +2038,7 @@ export class IsolatedRunner {
2035
2038
  if (!attempt || attempt.status !== "running")
2036
2039
  throw new Error(`Text task ${task.taskId} has no running attempt.`);
2037
2040
  const prompt = buildTextTaskPrompt(state.request.goal, request, resolveTextTaskContexts(state, request));
2041
+ let checkoutFailure;
2038
2042
  const result = await this.callProductive(handle, scope, async (context) => {
2039
2043
  const wave = state.waves.at(-1);
2040
2044
  if (!wave?.taskIds.includes(task.taskId))
@@ -2071,12 +2075,19 @@ export class IsolatedRunner {
2071
2075
  prepare: async () => ({ launch, task: prompt, cwd: isolated.cwd }),
2072
2076
  });
2073
2077
  });
2078
+ // Read-only evidence excludes ignored artifacts; deletion safety still includes them.
2079
+ const inspection = await inspectWorktreeDirty(isolated.path, runGit, false);
2080
+ const head = await runGit(["rev-parse", "--verify", "HEAD"], isolated.path);
2081
+ const branch = await runGit(["symbolic-ref", "--quiet", "HEAD"], isolated.path);
2082
+ checkoutFailure = inspection.failure ? `read-only checkout inspection failed: ${inspection.failure}`
2083
+ : inspection.dirty ? "read-only contract violated: tracked or untracked changes remain"
2084
+ : head.code !== 0 || branch.code !== 0 ? "read-only checkout identity could not be proved"
2085
+ : head.stdout.trim() !== isolated.baseCommit || branch.stdout.trim() !== `refs/heads/${isolated.branch}`
2086
+ ? "read-only contract violated: checkout HEAD or branch changed" : undefined;
2074
2087
  }
2075
2088
  finally {
2076
- const cleanup = await finalizeChildWorktree(isolated);
2077
- if (cleanup.outcome !== "pruned") {
2078
- throw new Error(`Isolated text task changed its checkout; work was retained at ${cleanup.path} on ${cleanup.branch}.`);
2079
- }
2089
+ // Retention is evidence, not proof of source edits, and must not mask an answer or executor error.
2090
+ attempt.cleanup = await finalizeChildWorktree(isolated);
2080
2091
  }
2081
2092
  return result;
2082
2093
  });
@@ -2091,16 +2102,30 @@ export class IsolatedRunner {
2091
2102
  if (Buffer.byteLength(output, "utf8") > MAX_PERSISTED_RUNTIME_TEXT_BYTES) {
2092
2103
  throw new Error(`Text task executor output exceeds ${MAX_PERSISTED_RUNTIME_TEXT_BYTES} UTF-8 bytes.`);
2093
2104
  }
2094
- const actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMainBase({ root: state.root }, context));
2095
- if (!sameIdentity(actualMain, state.main))
2096
- throw new Error("Main drifted during text task execution.");
2097
- const wave = state.waves.at(-1);
2098
- if (wave.base.head !== state.main.head) {
2099
- const generation = state.integration.generations.at(-1);
2100
- if (!generation?.worktree || generation.status === "superseded" || !sameIdentity(generation.combinedTip, wave.base)) {
2101
- throw new Error("Staged dependency snapshot was superseded during text task execution.");
2105
+ try {
2106
+ if (checkoutFailure)
2107
+ throw new Error(`Isolated text task ${checkoutFailure}; inspect ${attempt.cleanup.path} on ${attempt.cleanup.branch}.`);
2108
+ if (attempt.cleanup?.outcome === "recovery")
2109
+ throw new Error(`Isolated text task cleanup requires recovery: ${attempt.cleanup.note}`);
2110
+ const actualMain = await this.callProductive(handle, scope, async (context) => await this.gitRuntime.inspectMainBase({ root: state.root }, context));
2111
+ if (!sameIdentity(actualMain, state.main))
2112
+ throw new Error("Main drifted during text task execution.");
2113
+ const wave = state.waves.at(-1);
2114
+ if (wave.base.head !== state.main.head) {
2115
+ const generation = state.integration.generations.at(-1);
2116
+ if (!generation?.worktree || generation.status === "superseded" || !sameIdentity(generation.combinedTip, wave.base)) {
2117
+ throw new Error("Staged dependency snapshot was superseded during text task execution.");
2118
+ }
2119
+ await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope, true);
2102
2120
  }
2103
- await this.requireExactIntegration(handle, generation, this.stageReceipts(generation), scope, true);
2121
+ }
2122
+ catch (error) {
2123
+ this.rethrowStopped(error);
2124
+ // Retain the completed answer without admitting it as dependency context.
2125
+ attempt.status = "completed";
2126
+ attempt.output = { text: output };
2127
+ this.attention(task, `Task dispatch was interrupted: ${errorText(error)}`);
2128
+ throw error;
2104
2129
  }
2105
2130
  attempt.status = "completed";
2106
2131
  attempt.failure = undefined;
package/dist/schema.d.ts CHANGED
@@ -877,6 +877,24 @@ declare const TextTaskAttemptSchema: Type.TObject<{
877
877
  output: Type.TOptional<Type.TObject<{
878
878
  text: Type.TString;
879
879
  }>>;
880
+ cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
881
+ outcome: Type.TLiteral<"pruned">;
882
+ path: Type.TString;
883
+ branch: Type.TString;
884
+ }>, Type.TObject<{
885
+ outcome: Type.TLiteral<"retained">;
886
+ path: Type.TString;
887
+ branch: Type.TString;
888
+ commits: Type.TInteger;
889
+ dirty: Type.TBoolean;
890
+ }>, Type.TObject<{
891
+ outcome: Type.TLiteral<"recovery">;
892
+ path: Type.TString;
893
+ branch: Type.TString;
894
+ note: Type.TString;
895
+ commits: Type.TOptional<Type.TInteger>;
896
+ dirty: Type.TOptional<Type.TBoolean>;
897
+ }>]>>;
880
898
  }>;
881
899
  declare const TextTaskStateSchema: Type.TObject<{
882
900
  taskId: Type.TString;
@@ -889,6 +907,24 @@ declare const TextTaskStateSchema: Type.TObject<{
889
907
  output: Type.TOptional<Type.TObject<{
890
908
  text: Type.TString;
891
909
  }>>;
910
+ cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
911
+ outcome: Type.TLiteral<"pruned">;
912
+ path: Type.TString;
913
+ branch: Type.TString;
914
+ }>, Type.TObject<{
915
+ outcome: Type.TLiteral<"retained">;
916
+ path: Type.TString;
917
+ branch: Type.TString;
918
+ commits: Type.TInteger;
919
+ dirty: Type.TBoolean;
920
+ }>, Type.TObject<{
921
+ outcome: Type.TLiteral<"recovery">;
922
+ path: Type.TString;
923
+ branch: Type.TString;
924
+ note: Type.TString;
925
+ commits: Type.TOptional<Type.TInteger>;
926
+ dirty: Type.TOptional<Type.TBoolean>;
927
+ }>]>>;
892
928
  }>>;
893
929
  failure: Type.TOptional<Type.TString>;
894
930
  }>;
@@ -903,6 +939,24 @@ declare const TaskStateSchema: Type.TUnion<[Type.TObject<{
903
939
  output: Type.TOptional<Type.TObject<{
904
940
  text: Type.TString;
905
941
  }>>;
942
+ cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
943
+ outcome: Type.TLiteral<"pruned">;
944
+ path: Type.TString;
945
+ branch: Type.TString;
946
+ }>, Type.TObject<{
947
+ outcome: Type.TLiteral<"retained">;
948
+ path: Type.TString;
949
+ branch: Type.TString;
950
+ commits: Type.TInteger;
951
+ dirty: Type.TBoolean;
952
+ }>, Type.TObject<{
953
+ outcome: Type.TLiteral<"recovery">;
954
+ path: Type.TString;
955
+ branch: Type.TString;
956
+ note: Type.TString;
957
+ commits: Type.TOptional<Type.TInteger>;
958
+ dirty: Type.TOptional<Type.TBoolean>;
959
+ }>]>>;
906
960
  }>>;
907
961
  failure: Type.TOptional<Type.TString>;
908
962
  }>, Type.TObject<{
@@ -1717,6 +1771,24 @@ declare const RunStateSchema: Type.TObject<{
1717
1771
  output: Type.TOptional<Type.TObject<{
1718
1772
  text: Type.TString;
1719
1773
  }>>;
1774
+ cleanup: Type.TOptional<Type.TUnion<[Type.TObject<{
1775
+ outcome: Type.TLiteral<"pruned">;
1776
+ path: Type.TString;
1777
+ branch: Type.TString;
1778
+ }>, Type.TObject<{
1779
+ outcome: Type.TLiteral<"retained">;
1780
+ path: Type.TString;
1781
+ branch: Type.TString;
1782
+ commits: Type.TInteger;
1783
+ dirty: Type.TBoolean;
1784
+ }>, Type.TObject<{
1785
+ outcome: Type.TLiteral<"recovery">;
1786
+ path: Type.TString;
1787
+ branch: Type.TString;
1788
+ note: Type.TString;
1789
+ commits: Type.TOptional<Type.TInteger>;
1790
+ dirty: Type.TOptional<Type.TBoolean>;
1791
+ }>]>>;
1720
1792
  }>>;
1721
1793
  failure: Type.TOptional<Type.TString>;
1722
1794
  }>, Type.TObject<{
package/dist/schema.js CHANGED
@@ -291,6 +291,17 @@ const TextTaskAttemptSchema = Type.Object({
291
291
  status: Type.Union([Type.Literal("running"), Type.Literal("completed"), Type.Literal("failed"), Type.Literal("superseded")]),
292
292
  failure: OptionalRuntimeTextSchema,
293
293
  output: Type.Optional(TextTaskOutputSchema),
294
+ cleanup: Type.Optional(Type.Union([
295
+ Type.Object({ outcome: Type.Literal("pruned"), path: RuntimeTextSchema, branch: RuntimeTextSchema }, { additionalProperties: false }),
296
+ Type.Object({
297
+ outcome: Type.Literal("retained"), path: RuntimeTextSchema, branch: RuntimeTextSchema,
298
+ commits: Type.Integer({ minimum: 0 }), dirty: Type.Boolean(),
299
+ }, { additionalProperties: false }),
300
+ Type.Object({
301
+ outcome: Type.Literal("recovery"), path: RuntimeTextSchema, branch: RuntimeTextSchema, note: RuntimeTextSchema,
302
+ commits: Type.Optional(Type.Integer({ minimum: 0 })), dirty: Type.Optional(Type.Boolean()),
303
+ }, { additionalProperties: false }),
304
+ ])),
294
305
  }, { additionalProperties: false });
295
306
  const TextTaskStateSchema = Type.Object({
296
307
  taskId: IdSchema,
@@ -679,7 +690,7 @@ function validateTextTaskState(taskState) {
679
690
  ? "running"
680
691
  : "completed";
681
692
  if (taskState.status === "needs_attention"
682
- ? latest && !["failed", "superseded"].includes(latest.status)
693
+ ? latest && !["failed", "superseded", "completed"].includes(latest.status)
683
694
  : latest?.status !== expectedLatestStatus) {
684
695
  throw new Error(`Text task ${taskState.taskId} status ${taskState.status} has an incompatible latest attempt.`);
685
696
  }
@@ -281,16 +281,24 @@ function publicState(state: RunState, preferredTaskId?: string) {
281
281
  status: state.status,
282
282
  accepted: state.accepted,
283
283
  main: state.main,
284
- tasks: state.tasks.map((task) => ({
285
- taskId: task.taskId, status: task.status,
286
- ...(task.kind === "changeset" && state.integration.candidates.length ? { attempts: task.attempts.map((attempt) => ({
287
- number: attempt.number, superseded: attempt.superseded === true,
288
- workerId: attempt.allocations.flatMap((item) => item.kind === "agent" && item.status === "owned" ? [item.agentName] : [])[0],
289
- termination: attempt.termination?.status,
290
- worktree: attempt.allocations.flatMap((item) => item.kind === "worktree" && item.status === "owned" && item.worktree ? [item.worktree.path] : [])[0],
291
- cleanup: attempt.cleanup.filter((step) => step.status !== "completed"),
292
- })) } : {}),
293
- })),
284
+ tasks: state.tasks.map((task) => {
285
+ const textAttempt = task.kind === "text" ? task.attempts.at(-1) : undefined;
286
+ return {
287
+ taskId: task.taskId, status: task.status,
288
+ ...(task.status === "completed" && textAttempt?.output ? { output: boundedPublicText(textAttempt.output.text) } : {}),
289
+ ...(textAttempt?.cleanup ? { cleanup: {
290
+ ...textAttempt.cleanup,
291
+ ...(textAttempt.cleanup.outcome === "recovery" ? { note: boundedPublicText(textAttempt.cleanup.note) } : {}),
292
+ } } : {}),
293
+ ...(task.kind === "changeset" && state.integration.candidates.length ? { attempts: task.attempts.map((attempt) => ({
294
+ number: attempt.number, superseded: attempt.superseded === true,
295
+ workerId: attempt.allocations.flatMap((item) => item.kind === "agent" && item.status === "owned" ? [item.agentName] : [])[0],
296
+ termination: attempt.termination?.status,
297
+ worktree: attempt.allocations.flatMap((item) => item.kind === "worktree" && item.status === "owned" && item.worktree ? [item.worktree.path] : [])[0],
298
+ cleanup: attempt.cleanup.filter((step) => step.status !== "completed"),
299
+ })) } : {}),
300
+ };
301
+ }),
294
302
  final: { status: state.final.status },
295
303
  integration: {
296
304
  ...(state.integration.refresh ? { refresh: state.integration.refresh } : {}),
@@ -499,9 +507,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
499
507
  const sessionId = ctx.sessionManager?.getSessionId();
500
508
  const key = `${root}\0${id}`;
501
509
  if (jobOwners.has(key)) throw new Error(`Pi Subagent request ${id} is already active in this session runtime.`);
502
- const canDeliver = () => !sessionClosed && sessionEpoch === epoch
503
- && ctx.sessionManager?.getSessionId() === sessionId
504
- && latestCtx?.sessionManager?.getSessionId() === sessionId;
510
+ const deliverySuppression = () => sessionClosed ? "session closed"
511
+ : sessionEpoch !== epoch ? "session generation changed"
512
+ : ctx.sessionManager?.getSessionId() !== sessionId ? "launching session changed"
513
+ : latestCtx?.sessionManager?.getSessionId() !== sessionId ? "current session changed"
514
+ : undefined;
515
+ const canDeliver = () => deliverySuppression() === undefined;
505
516
  const controller = new AbortController();
506
517
  activeJobs.add(controller);
507
518
  jobOwners.set(key, canDeliver);
@@ -517,6 +528,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
517
528
  if (signal?.aborted) abortBeforeAck();
518
529
  else signal?.addEventListener("abort", abortBeforeAck, { once: true });
519
530
  let latestState: RunState | undefined;
531
+ let durableAcknowledged = false;
520
532
  let accept!: (state: RunState) => void;
521
533
  let reject!: (error: unknown) => void;
522
534
  const durable = new Promise<RunState>((resolve, fail) => { accept = resolve; reject = fail; });
@@ -525,14 +537,19 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
525
537
  if (state.root !== root || state.request.id !== id) return;
526
538
  latestState = state;
527
539
  if (!acknowledged(state)) return;
540
+ durableAcknowledged = true;
528
541
  for (const candidate of state.integration.candidates) notified.add(`${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`);
529
542
  stateListeners.delete(listener);
530
543
  removeTurnAbort();
531
544
  accept(state);
532
545
  };
533
546
  stateListeners.add(listener);
534
- const deliver = (text: string, response?: RunResponse) => {
535
- if (!canDeliver()) return;
547
+ const deliver = (kind: "candidate" | "attention" | "terminal", text: string, response?: RunResponse) => {
548
+ const suppression = deliverySuppression();
549
+ if (suppression) {
550
+ console.warn(`Pi Subagent ${id}: ${kind} delivery suppressed (${suppression}); durable request retained.`);
551
+ return;
552
+ }
536
553
  const state = response?.state ?? latestState;
537
554
  try {
538
555
  const details = state ? {
@@ -547,13 +564,14 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
547
564
  details,
548
565
  }, { triggerTurn: true, deliverAs: "followUp" });
549
566
  } catch {
550
- console.error(`Pi Subagent ${id} result delivery failed; use subagent_status to recover.`);
567
+ console.error(`Pi Subagent ${id}: ${kind} delivery failed (sendMessage threw); use subagent_status to recover. No delivery replayed.`);
551
568
  if (ctx.hasUI) ctx.ui.notify(`Pi Subagent ${id} result delivery failed; use subagent_status to recover.`, "error");
552
569
  }
553
570
  };
554
571
  candidateListener = (state) => {
555
- if (state.root !== root || state.request.id !== id || state.status !== "running" || !canDeliver()) return;
572
+ if (state.root !== root || state.request.id !== id) return;
556
573
  latestState = state;
574
+ if (state.status !== "running") return;
557
575
  for (const task of state.tasks) {
558
576
  if (task.status !== "needs_attention") {
559
577
  attentionNotified.delete(task.taskId);
@@ -561,13 +579,13 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
561
579
  }
562
580
  if (attentionNotified.has(task.taskId)) continue;
563
581
  attentionNotified.add(task.taskId);
564
- deliver(`Pi Subagent ${id}: ${task.taskId} needs attention. Inspect subagent_status for the saved worker evidence; do not replay an uncertain prompt.`);
582
+ deliver("attention", `Pi Subagent ${id}: ${task.taskId} needs attention. Inspect subagent_status for the saved worker evidence; do not replay an uncertain prompt.`);
565
583
  }
566
584
  for (const candidate of state.integration.candidates) {
567
585
  const identity = `${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`;
568
586
  if (candidate.decision || notified.has(identity)) continue;
569
587
  notified.add(identity);
570
- deliver(`Pi Subagent ${id}: ${candidate.taskId} ready to integrate. Inspect the exact candidate with subagent_status and stage it now; other workers may still be running.`);
588
+ deliver("candidate", `Pi Subagent ${id}: ${candidate.taskId} ready to integrate. Inspect the exact candidate with subagent_status and stage it now; other workers may still be running.`);
571
589
  }
572
590
  };
573
591
  stateListeners.add(candidateListener);
@@ -576,21 +594,21 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
576
594
  finish();
577
595
  stateListeners.delete(listener);
578
596
  removeTurnAbort();
579
- if (!latestState || !acknowledged(latestState)) {
597
+ if (!durableAcknowledged) {
580
598
  reject(new Error(`Pi Subagent ${id} finished without a durable acknowledgement.`));
581
599
  return;
582
600
  }
583
- deliver(`${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
601
+ deliver("terminal", `${response.text}\n\nState: ${JSON.stringify(publicState(response.state))}`, response);
584
602
  },
585
603
  (error: unknown) => {
586
604
  finish();
587
605
  stateListeners.delete(listener);
588
606
  removeTurnAbort();
589
- if (!latestState || !acknowledged(latestState)) {
607
+ if (!durableAcknowledged) {
590
608
  reject(error);
591
609
  return;
592
610
  }
593
- deliver(`Pi Subagent ${id} stopped: ${boundedPublicText(error instanceof Error ? error.message : String(error))}. Use subagent_status to inspect the durable request and subagent_resume or subagent_abort for recovery.`);
611
+ deliver("terminal", `Pi Subagent ${id} stopped: ${boundedPublicText(error instanceof Error ? error.message : String(error))}. Use subagent_status to inspect the durable request and subagent_resume or subagent_abort for recovery.`);
594
612
  },
595
613
  );
596
614
  const state = await durable;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "25.0.1",
3
+ "version": "25.0.2",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",