@ryan_nookpi/pi-extension-subagent 0.5.11 → 0.5.13

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
@@ -41,7 +41,7 @@ Equivalent AI tool call:
41
41
  { "command": "subagent run worker --isolated -- implement the requested change and run tests" }
42
42
  ```
43
43
 
44
- Tool launches are asynchronous. Wait for the automatic completion or failure follow-up instead of polling immediately.
44
+ Tool launches are asynchronous. Wait for the automatic completion or failure follow-up instead of polling immediately. Pi drops queued messages when you press Escape; if a completion was still queued when the run stops, it is added to the session without starting a new turn, and the agent sees it with your next message.
45
45
 
46
46
  ## Agent definitions
47
47
 
@@ -0,0 +1,103 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ type SendMessage = ExtensionAPI["sendMessage"];
4
+ type Message = Parameters<SendMessage>[0];
5
+
6
+ const GUARD_ID_KEY = "deliveryGuardId";
7
+
8
+ function guardId(message: unknown): string | undefined {
9
+ const details = (message as { details?: unknown } | undefined)?.details;
10
+ if (!details || typeof details !== "object") return undefined;
11
+ const id = (details as Record<string, unknown>)[GUARD_ID_KEY];
12
+ return typeof id === "string" ? id : undefined;
13
+ }
14
+
15
+ /**
16
+ * Pi keeps steer and follow-up messages in an in-memory queue until the agent loop picks them up, and
17
+ * Escape clears that queue without restoring custom messages. A completion queued while the agent was
18
+ * busy would vanish. This tracks every turn-triggering message queued during a run, and when the run
19
+ * settles without the message reaching the model, appends it to the session without starting a turn:
20
+ * the user sees it now, and the model sees it on the next prompt.
21
+ *
22
+ * Extensions cannot tell Escape from other aborts: Pi's pending count covers only typed messages, not
23
+ * extension messages. An abort while the model streams keeps the original queued, and Pi delivers it on
24
+ * the next run after the restored copy. Both carry the same guard id, so the model input keeps only the first.
25
+ */
26
+ export function guardQueuedDeliveries(pi: ExtensionAPI): ExtensionAPI {
27
+ let context: ExtensionContext | undefined;
28
+ let nextId = 0;
29
+ const queued = new Map<string, Message>();
30
+
31
+ const busy = () => {
32
+ try {
33
+ return context ? !context.isIdle() : false;
34
+ } catch {
35
+ return false;
36
+ }
37
+ };
38
+
39
+ const restored = new Set<string>();
40
+
41
+ pi.on("agent_start", (_event, ctx) => {
42
+ context = ctx;
43
+ });
44
+ pi.on("message_end", (event) => {
45
+ const id = guardId(event.message);
46
+ if (id) queued.delete(id);
47
+ });
48
+ pi.on("agent_settled", (_event, ctx) => {
49
+ context = ctx;
50
+ if (queued.size === 0) return;
51
+ const lost = [...queued.entries()];
52
+ queued.clear();
53
+ for (const [id, message] of lost) {
54
+ restored.add(id);
55
+ pi.sendMessage(message, { triggerTurn: false });
56
+ }
57
+ });
58
+ pi.on("context", (event) => {
59
+ if (restored.size === 0) return;
60
+ const seen = new Set<string>();
61
+ let dropped = false;
62
+ const messages = event.messages.filter((message) => {
63
+ const id = guardId(message);
64
+ if (!id || !restored.has(id)) return true;
65
+ if (!seen.has(id)) {
66
+ seen.add(id);
67
+ return true;
68
+ }
69
+ dropped = true;
70
+ return false;
71
+ });
72
+ return dropped ? { messages } : undefined;
73
+ });
74
+ pi.on("session_shutdown", () => {
75
+ queued.clear();
76
+ restored.clear();
77
+ context = undefined;
78
+ });
79
+
80
+ return new Proxy(pi, {
81
+ get(target, property, receiver) {
82
+ if (property !== "sendMessage") return Reflect.get(target, property, receiver);
83
+ const send: SendMessage = (message, options) => {
84
+ // An idle send starts its own run with the message as the prompt, so nothing can drop it.
85
+ if (options?.triggerTurn !== true || options.deliverAs === "nextTurn" || !busy()) {
86
+ target.sendMessage(message, options);
87
+ return;
88
+ }
89
+ const id = String(++nextId);
90
+ const details = message.details && typeof message.details === "object" ? message.details : {};
91
+ const tagged = { ...message, details: { ...details, [GUARD_ID_KEY]: id } } as Message;
92
+ queued.set(id, tagged);
93
+ try {
94
+ target.sendMessage(tagged, options);
95
+ } catch (error) {
96
+ queued.delete(id);
97
+ throw error;
98
+ }
99
+ };
100
+ return send;
101
+ },
102
+ });
103
+ }
package/index.ts CHANGED
@@ -30,6 +30,7 @@
30
30
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
31
31
  import { SubagentAsyncTasks } from "./async-task-lifecycle.js";
32
32
  import { HANG_CHECK_INTERVAL_MS } from "./constants.js";
33
+ import { guardQueuedDeliveries } from "./delivery-guard.js";
33
34
  import { SUBAGENT_COMMANDS, SUBAGENT_SHORTCUTS, type SubagentCommandName } from "./registration-manifest.js";
34
35
  import { handleRunningReminderContext, ReminderAnchors } from "./running-reminder.js";
35
36
 
@@ -41,7 +42,9 @@ interface SubagentCore {
41
42
  lifecycle: typeof import("./lifecycle.js");
42
43
  }
43
44
 
44
- export default function (pi: ExtensionAPI) {
45
+ export default function (host: ExtensionAPI) {
46
+ // Completions are queued while the agent is busy; the guard restores any the queue drops on Escape.
47
+ const pi = guardQueuedDeliveries(host);
45
48
  const asyncTasks = new SubagentAsyncTasks(pi);
46
49
  let core: SubagentCore | null = null;
47
50
  let corePromise: Promise<SubagentCore> | null = null;
package/lifecycle.ts CHANGED
@@ -31,6 +31,13 @@ function reconcileRunWithPersistedSession(run: CommandRunState): void {
31
31
  run.lastActivityAt = snapshot.latestActivityAt;
32
32
  }
33
33
  if (!snapshot.isTerminal) return;
34
+ // A live Pi child can still retry a persisted error. Only its runner's
35
+ // completion marker proves that failure recovery has finished.
36
+ if (
37
+ !snapshot.completionMarker &&
38
+ (snapshot.terminalStopReason === "error" || snapshot.terminalStopReason === "aborted")
39
+ )
40
+ return;
34
41
 
35
42
  const exitCode =
36
43
  snapshot.completionMarker?.exitCode ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-extension-subagent",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,6 +32,7 @@
32
32
  "config.ts",
33
33
  "constants.ts",
34
34
  "context-limits.ts",
35
+ "delivery-guard.ts",
35
36
  "diagnostics.ts",
36
37
  "display-task.ts",
37
38
  "escalation.ts",
package/runner.ts CHANGED
@@ -762,6 +762,9 @@ async function runPiAgent(
762
762
  let lastProcessSignal: NodeJS.Signals | null = null;
763
763
  let lastEventAt = Date.now();
764
764
  let sawAgentEnd = false;
765
+ // Covers both backoff and the resumed attempt, until Pi reports recovery
766
+ // or a final agent_end. Raw history can retain errors throughout this time.
767
+ let retryPending = false;
765
768
  let settleReason = "unknown";
766
769
  let unparsedStdoutCount = 0;
767
770
  const unparsedStdoutTail: string[] = [];
@@ -813,10 +816,24 @@ async function runPiAgent(
813
816
  }, 5000);
814
817
  };
815
818
 
819
+ const clearCompletionFallbacks = () => {
820
+ if (agentEndFallbackTimer) clearTimeout(agentEndFallbackTimer);
821
+ if (terminalMessageFallbackTimer) clearTimeout(terminalMessageFallbackTimer);
822
+ agentEndFallbackTimer = undefined;
823
+ terminalMessageFallbackTimer = undefined;
824
+ };
825
+
816
826
  const syncFromPersistedSession = (allowResolve: boolean): boolean => {
817
827
  if (!sessionFile) return false;
818
828
  const snapshot = readPersistedSessionSnapshot(sessionFile, { startOffset: persistedSessionBaseOffset });
819
- if (snapshot.terminalStopReason && !currentResult.stopReason) {
829
+ const unconfirmedFailure =
830
+ !snapshot.completionMarker &&
831
+ (snapshot.terminalStopReason === "error" || snapshot.terminalStopReason === "aborted");
832
+ if (
833
+ snapshot.terminalStopReason &&
834
+ !currentResult.stopReason &&
835
+ (procExited || settled || (!retryPending && !unconfirmedFailure))
836
+ ) {
820
837
  currentResult.stopReason = snapshot.terminalStopReason;
821
838
  }
822
839
  if (snapshot.messages.length > currentResult.messages.length) {
@@ -824,7 +841,18 @@ async function runPiAgent(
824
841
  currentResult.liveText = undefined;
825
842
  emitUpdate();
826
843
  }
827
- if (!allowResolve || !snapshot.isTerminal || settled || procExited || wasAborted) return false;
844
+ // Persistence can beat stdout. An error response alone is not proof
845
+ // that Pi has finished: agent_end/auto_retry_start decide its recovery.
846
+ if (
847
+ !allowResolve ||
848
+ !snapshot.isTerminal ||
849
+ settled ||
850
+ procExited ||
851
+ wasAborted ||
852
+ retryPending ||
853
+ unconfirmedFailure
854
+ )
855
+ return false;
828
856
 
829
857
  const forcedCode =
830
858
  snapshot.completionMarker?.exitCode ??
@@ -855,15 +883,44 @@ async function runPiAgent(
855
883
  return;
856
884
  }
857
885
  lastEventAt = Date.now();
886
+ // Once the local context guard settles the run, late shutdown events
887
+ // must not replace its failure with the child's last toolUse response.
888
+ if (contextGuardTripped) return;
889
+
890
+ if (event.type === "auto_retry_start") {
891
+ retryPending = true;
892
+ sawAgentEnd = false;
893
+ clearCompletionFallbacks();
894
+ return;
895
+ }
896
+
897
+ if (event.type === "auto_retry_end") {
898
+ retryPending = false;
899
+ if (event.success === false) {
900
+ currentResult.stopReason = "error";
901
+ if (event.finalError) currentResult.errorMessage = event.finalError;
902
+ sawAgentEnd = true;
903
+ writeCompletionMarkerOnce(1);
904
+ scheduleAgentEndForceResolve();
905
+ } else if (currentResult.stopReason === "error" || currentResult.stopReason === "aborted") {
906
+ // Successful recovery may arrive without its message_end on stdout.
907
+ // Wait for the successful agent_end instead of reusing the old error.
908
+ currentResult.stopReason = undefined;
909
+ currentResult.errorMessage = undefined;
910
+ } else if (currentResult.stopReason && currentResult.stopReason !== "toolUse") {
911
+ writeCompletionMarkerOnce(0);
912
+ scheduleTerminalMessageForceResolve();
913
+ }
914
+ return;
915
+ }
858
916
 
859
917
  if (event.type === "agent_start" || event.type === "turn_start") {
860
918
  sawAgentEnd = false;
919
+ currentResult.stopReason = undefined;
920
+ currentResult.errorMessage = undefined;
861
921
  currentResult.liveThinking = undefined;
862
922
  currentResult.thoughtText = undefined;
863
- if (terminalMessageFallbackTimer) {
864
- clearTimeout(terminalMessageFallbackTimer);
865
- terminalMessageFallbackTimer = undefined;
866
- }
923
+ clearCompletionFallbacks();
867
924
  return;
868
925
  }
869
926
 
@@ -873,21 +930,32 @@ async function runPiAgent(
873
930
  // its objects are freshly deserialized, so reference-based deduplication
874
931
  // would append every prior message again.
875
932
  const eventMessages = (event.messages ?? []) as Message[];
876
- const terminalMessage = [...eventMessages].reverse().find((msg) => Boolean((msg as any).stopReason));
933
+ const terminalMessage = [...eventMessages]
934
+ .reverse()
935
+ .find((msg) => msg.role === "assistant" && Boolean(msg.stopReason));
877
936
  if (terminalMessage) {
878
937
  const fingerprint = JSON.stringify(terminalMessage);
879
938
  const alreadyCaptured = currentResult.messages.some((msg) => JSON.stringify(msg) === fingerprint);
880
939
  if (!alreadyCaptured) currentResult.messages.push(terminalMessage);
881
- if (!currentResult.stopReason) {
882
- currentResult.stopReason = (terminalMessage as any).stopReason;
883
- if ((terminalMessage as any).errorMessage) {
884
- currentResult.errorMessage = (terminalMessage as any).errorMessage;
885
- }
886
- }
940
+ currentResult.stopReason = (terminalMessage as any).stopReason;
941
+ currentResult.errorMessage = (terminalMessage as any).errorMessage;
887
942
  } else if (currentResult.messages.length === 0 && eventMessages.length > 0) {
888
943
  currentResult.messages.push(eventMessages[eventMessages.length - 1]);
889
944
  }
890
- if (currentResult.stopReason && currentResult.stopReason !== "toolUse") {
945
+ if (event.willRetry === true) {
946
+ retryPending = true;
947
+ sawAgentEnd = false;
948
+ clearCompletionFallbacks();
949
+ return;
950
+ }
951
+ retryPending = false;
952
+ if (
953
+ currentResult.stopReason &&
954
+ currentResult.stopReason !== "toolUse" &&
955
+ // Older Pi versions omit willRetry; defer their failure marker
956
+ // until fallback so auto_retry_start can still cancel completion.
957
+ (currentResult.stopReason !== "error" || event.willRetry === false)
958
+ ) {
891
959
  writeCompletionMarkerOnce(
892
960
  currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0,
893
961
  );
@@ -977,7 +1045,7 @@ async function runPiAgent(
977
1045
  }
978
1046
  if (!currentResult.model && msg.model) currentResult.model = msg.model;
979
1047
  if (msg.stopReason) currentResult.stopReason = msg.stopReason;
980
- if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
1048
+ currentResult.errorMessage = msg.errorMessage;
981
1049
  currentResult.liveThinking = undefined;
982
1050
 
983
1051
  // Extract thoughtText from thinking block first line only
@@ -989,8 +1057,15 @@ async function runPiAgent(
989
1057
  }
990
1058
  }
991
1059
  const terminalStopReason = (msg as any).stopReason;
992
- if (terminalStopReason && terminalStopReason !== "toolUse") {
993
- writeCompletionMarkerOnce(terminalStopReason === "error" || terminalStopReason === "aborted" ? 1 : 0);
1060
+ // Error message_end precedes Pi's retry decision. Do not publish
1061
+ // completion or arm a timeout while it may still recover.
1062
+ if (
1063
+ terminalStopReason &&
1064
+ terminalStopReason !== "toolUse" &&
1065
+ terminalStopReason !== "error" &&
1066
+ !retryPending
1067
+ ) {
1068
+ writeCompletionMarkerOnce(terminalStopReason === "aborted" ? 1 : 0);
994
1069
  scheduleTerminalMessageForceResolve();
995
1070
  }
996
1071
  }
@@ -1062,11 +1137,18 @@ async function runPiAgent(
1062
1137
  };
1063
1138
 
1064
1139
  function scheduleTerminalMessageForceResolve() {
1065
- if (!currentResult.stopReason || currentResult.stopReason === "toolUse" || settled || procExited) return;
1140
+ if (
1141
+ !currentResult.stopReason ||
1142
+ currentResult.stopReason === "toolUse" ||
1143
+ retryPending ||
1144
+ settled ||
1145
+ procExited
1146
+ )
1147
+ return;
1066
1148
  if (terminalMessageFallbackTimer) clearTimeout(terminalMessageFallbackTimer);
1067
1149
 
1068
1150
  terminalMessageFallbackTimer = setTimeout(() => {
1069
- if (settled || procExited || wasAborted) return;
1151
+ if (settled || procExited || wasAborted || retryPending) return;
1070
1152
 
1071
1153
  const forcedCode = currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0;
1072
1154
 
@@ -1082,12 +1164,12 @@ async function runPiAgent(
1082
1164
  // (e.g. lingering extension timers/transports). In that case, force
1083
1165
  // resolve after a short quiet period so runs do not remain "running" forever.
1084
1166
  function scheduleAgentEndForceResolve() {
1085
- if (!sawAgentEnd || settled || procExited) return;
1167
+ if (!sawAgentEnd || retryPending || settled || procExited) return;
1086
1168
  if (agentEndFallbackTimer) clearTimeout(agentEndFallbackTimer);
1087
1169
 
1088
1170
  const marker = lastEventAt;
1089
1171
  agentEndFallbackTimer = setTimeout(() => {
1090
- if (settled || procExited || wasAborted) return;
1172
+ if (settled || procExited || wasAborted || retryPending) return;
1091
1173
  if (lastEventAt !== marker) return;
1092
1174
 
1093
1175
  const forcedCode = currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0;