@ryan_nookpi/pi-extension-subagent 0.5.10 → 0.5.12

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/index.ts CHANGED
@@ -31,6 +31,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
31
31
  import { SubagentAsyncTasks } from "./async-task-lifecycle.js";
32
32
  import { HANG_CHECK_INTERVAL_MS } from "./constants.js";
33
33
  import { SUBAGENT_COMMANDS, SUBAGENT_SHORTCUTS, type SubagentCommandName } from "./registration-manifest.js";
34
+ import { handleRunningReminderContext, ReminderAnchors } from "./running-reminder.js";
34
35
 
35
36
  interface SubagentCore {
36
37
  store: import("./store.js").SubagentStore;
@@ -120,9 +121,11 @@ export default function (pi: ExtensionAPI) {
120
121
 
121
122
  let hangCheckTimer: ReturnType<typeof setInterval> | undefined;
122
123
  let sessionGeneration = 0;
124
+ const reminderAnchors = new ReminderAnchors();
123
125
 
124
126
  pi.on("session_start", (_event, ctx) => {
125
127
  const generation = ++sessionGeneration;
128
+ reminderAnchors.clear();
126
129
  const sessionId = ctx.sessionManager.getSessionId();
127
130
  // Fence tool admission synchronously, before lazy lifecycle work can yield.
128
131
  try {
@@ -148,6 +151,7 @@ export default function (pi: ExtensionAPI) {
148
151
 
149
152
  pi.on("session_shutdown", async (event) => {
150
153
  ++sessionGeneration;
154
+ reminderAnchors.clear();
151
155
  asyncTasks.shutdown();
152
156
  if (hangCheckTimer) {
153
157
  clearInterval(hangCheckTimer);
@@ -168,6 +172,21 @@ export default function (pi: ExtensionAPI) {
168
172
  return c.commands.handleBeforeAgentStart(event, ctx, c.store);
169
173
  });
170
174
 
175
+ // before_agent_start misses turns started by a delivered completion or a steer; context covers every
176
+ // LLM call, including the ones inside a tool loop. Reminders are pinned to the message they were first
177
+ // rendered after, so mid-turn requests keep them without moving the cached prefix.
178
+ // No core means no run was ever started, so there is nothing to wait for.
179
+ pi.on("context", (event, ctx) => {
180
+ if (!core || core.store.disposed) return;
181
+ let sessionFile: string | undefined;
182
+ try {
183
+ sessionFile = ctx.sessionManager.getSessionFile?.();
184
+ } catch {
185
+ sessionFile = undefined;
186
+ }
187
+ return handleRunningReminderContext(reminderAnchors, event.messages, core.store, sessionFile);
188
+ });
189
+
171
190
  // If input arrives while the core is still loading, awaiting here lets the
172
191
  // live handler-array dispatch reach the shortcut handlers registerAll adds.
173
192
  pi.on("input", async () => {
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.10",
3
+ "version": "0.5.12",
4
4
  "description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,6 +51,7 @@
51
51
  "retry.ts",
52
52
  "run-utils.ts",
53
53
  "runner.ts",
54
+ "running-reminder.ts",
54
55
  "session.ts",
55
56
  "starter-pack.ts",
56
57
  "store.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;
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Per-request reminder of subagent runs whose results have not reached the model yet.
3
+ *
4
+ * Imports nothing beyond the leaf time-utils module so index.ts can register the context handler
5
+ * without loading the core.
6
+ */
7
+
8
+ import type { SubagentStore } from "./store.js";
9
+ import type { CommandRunState } from "./types.js";
10
+ import { formatElapsedSince } from "./utils/time-utils.js";
11
+
12
+ export const RUNNING_REMINDER_CUSTOM_TYPE = "subagent-running-reminder";
13
+ const MAX_REMINDER_TASK_LENGTH = 60;
14
+ /** Enough to cover a long turn; the oldest anchors fall off a session that runs agents for hours. */
15
+ const MAX_ANCHORS = 50;
16
+ /** A hosted runtime can serve a few sessions from one extension instance. */
17
+ const MAX_ANCHOR_SCOPES = 4;
18
+
19
+ type ReminderStore = Pick<SubagentStore, "globalLiveRuns" | "batchGroups" | "pipelines">;
20
+
21
+ function normalizeSessionFile(value: string | undefined | null): string {
22
+ return typeof value === "string" ? value.replace(/[\r\n\t]+/g, "").trim() : "";
23
+ }
24
+
25
+ function shortTask(run: CommandRunState): string {
26
+ const raw = (run.displayTask || run.task || "").replace(/\s+/g, " ").trim().replaceAll('"', "'");
27
+ return raw.length > MAX_REMINDER_TASK_LENGTH ? `${raw.slice(0, MAX_REMINDER_TASK_LENGTH - 3)}...` : raw;
28
+ }
29
+
30
+ /**
31
+ * Runs started from this session whose completion still goes to the model. Ownership matches
32
+ * tool-execute's origin check: an unknown session file on either side counts as the same session.
33
+ *
34
+ * A finished run stays listed only while someone still owes its delivery: its own held completion, or
35
+ * the batch or pipeline summary it belongs to. Entries whose group was already evicted are orphans and
36
+ * must not keep the model quiet forever.
37
+ */
38
+ export function collectPendingRuns(store: ReminderStore, currentSessionFile: string | undefined): CommandRunState[] {
39
+ const current = normalizeSessionFile(currentSessionFile);
40
+ const runs: CommandRunState[] = [];
41
+ for (const entry of store.globalLiveRuns.values()) {
42
+ const run = entry.runState;
43
+ if (run.removed || run.deliveryMode === "humanOnly") continue;
44
+ if (run.status !== "running" && !deliveryStillOwed(store, entry.pendingCompletion !== undefined, run)) continue;
45
+ const origin = normalizeSessionFile(entry.originSessionFile);
46
+ if (current && origin && current !== origin) continue;
47
+ runs.push(run);
48
+ }
49
+ return runs.sort((left, right) => left.startedAt - right.startedAt || left.id - right.id);
50
+ }
51
+
52
+ function deliveryStillOwed(store: ReminderStore, hasPendingCompletion: boolean, run: CommandRunState): boolean {
53
+ if (hasPendingCompletion) return true;
54
+ if (run.batchId && store.batchGroups.has(run.batchId)) return true;
55
+ return Boolean(run.pipelineId && store.pipelines.has(run.pipelineId));
56
+ }
57
+
58
+ export function formatRunningReminder(runs: readonly CommandRunState[], now: number): string | undefined {
59
+ if (runs.length === 0) return undefined;
60
+ const items = runs.map((run) => {
61
+ const task = shortTask(run);
62
+ const head = `subagent run #${run.id} ${run.agent}${task ? ` "${task}"` : ""}`;
63
+ if (run.status !== "running") return `${head} finished, result not delivered yet`;
64
+ return `${head} ${formatElapsedSince(run.startedAt, now)}`;
65
+ });
66
+ return `Still running or awaiting delivery (results NOT delivered yet): ${items.join("; ")}. Do not state their final outcome, counts, hashes, or artifacts until their completion notification arrives or you read their final status yourself; partial output you read via the tool may be described as in-progress.`;
67
+ }
68
+
69
+ interface Anchor {
70
+ /** Frozen when the anchor is created, so later requests repeat it byte for byte. */
71
+ text: string;
72
+ timestamp: number;
73
+ }
74
+
75
+ interface AnchorScope {
76
+ anchors: Map<string, Anchor>;
77
+ /** Ids the most recently created anchor already names, so a shrinking set does not add anchors. */
78
+ covered: ReadonlySet<string>;
79
+ }
80
+
81
+ /**
82
+ * Pi clones the messages for every request, so an anchor cannot hold a message reference. Role,
83
+ * timestamp, and the id of a tool result or custom message identify the same message across requests.
84
+ */
85
+ function anchorKey(message: unknown): string | undefined {
86
+ const candidate = message as
87
+ | { role?: unknown; timestamp?: unknown; toolCallId?: unknown; customType?: unknown }
88
+ | undefined;
89
+ if (!candidate || typeof candidate.role !== "string" || typeof candidate.timestamp !== "number") return undefined;
90
+ const detail =
91
+ typeof candidate.toolCallId === "string"
92
+ ? candidate.toolCallId
93
+ : typeof candidate.customType === "string"
94
+ ? candidate.customType
95
+ : "";
96
+ return `${candidate.role}|${candidate.timestamp}|${detail}`;
97
+ }
98
+
99
+ /**
100
+ * Keeps the reminder visible inside a tool loop without breaking the prompt cache.
101
+ *
102
+ * Appending a freshly rendered reminder to every request would change the tail each time and invalidate
103
+ * the cached prefix. Instead each reminder is pinned to the message it was first rendered after and
104
+ * replayed there verbatim, so a request built later in the same turn only grows at the end.
105
+ */
106
+ export class ReminderAnchors {
107
+ /** Keyed by session file: a hosted runtime can route another session's request through this instance. */
108
+ private readonly scopes = new Map<string, AnchorScope>();
109
+
110
+ clear(): void {
111
+ this.scopes.clear();
112
+ }
113
+
114
+ isEmpty(): boolean {
115
+ for (const scope of this.scopes.values()) if (scope.anchors.size > 0) return false;
116
+ return true;
117
+ }
118
+
119
+ apply<T>(
120
+ session: string,
121
+ messages: readonly T[],
122
+ pending: { ids: readonly string[]; render: () => string | undefined },
123
+ now: number,
124
+ ): { messages: T[] } | undefined {
125
+ const known = this.scopes.get(session);
126
+ if (pending.ids.length === 0 && !known?.anchors.size) return undefined;
127
+ const scope = known ?? this.openScope(session);
128
+ recordAnchor(scope, messages, pending, now);
129
+ if (scope.anchors.size === 0) return undefined;
130
+ return insertAnchors(scope.anchors, messages);
131
+ }
132
+
133
+ private openScope(session: string): AnchorScope {
134
+ const scope: AnchorScope = { anchors: new Map(), covered: new Set() };
135
+ this.scopes.set(session, scope);
136
+ while (this.scopes.size > MAX_ANCHOR_SCOPES) {
137
+ const oldest = this.scopes.keys().next().value;
138
+ if (oldest === undefined) break;
139
+ this.scopes.delete(oldest);
140
+ }
141
+ return scope;
142
+ }
143
+ }
144
+
145
+ function recordAnchor<T>(
146
+ scope: AnchorScope,
147
+ messages: readonly T[],
148
+ pending: { ids: readonly string[]; render: () => string | undefined },
149
+ now: number,
150
+ ): void {
151
+ if (pending.ids.length === 0) return;
152
+ const last = messages.at(-1);
153
+ const key = anchorKey(last);
154
+ if (!key) return;
155
+ const continuesTurn = (last as { role?: unknown } | undefined)?.role === "toolResult";
156
+ // Mid-turn requests only earn an anchor once work the model has not been warned about shows up.
157
+ if (continuesTurn && pending.ids.every((id) => scope.covered.has(id))) return;
158
+ const text = pending.render();
159
+ if (!text) return;
160
+ scope.anchors.set(key, { text, timestamp: now });
161
+ scope.covered = new Set(pending.ids);
162
+ while (scope.anchors.size > MAX_ANCHORS) {
163
+ const oldest = scope.anchors.keys().next().value;
164
+ if (oldest === undefined) break;
165
+ scope.anchors.delete(oldest);
166
+ }
167
+ }
168
+
169
+ function insertAnchors<T>(anchors: ReadonlyMap<string, Anchor>, messages: readonly T[]): { messages: T[] } | undefined {
170
+ const result: T[] = [];
171
+ let inserted = false;
172
+ for (const message of messages) {
173
+ result.push(message);
174
+ const key = anchorKey(message);
175
+ const anchor = key ? anchors.get(key) : undefined;
176
+ if (!anchor) continue;
177
+ result.push({
178
+ role: "custom",
179
+ customType: RUNNING_REMINDER_CUSTOM_TYPE,
180
+ content: anchor.text,
181
+ display: false,
182
+ timestamp: anchor.timestamp,
183
+ } as unknown as T);
184
+ inserted = true;
185
+ }
186
+ return inserted ? { messages: result } : undefined;
187
+ }
188
+
189
+ /**
190
+ * context handler body. Pi fires context before every LLM call, and does not persist the returned
191
+ * messages, so the reminder never enters the transcript.
192
+ */
193
+ export function handleRunningReminderContext<T>(
194
+ anchors: ReminderAnchors,
195
+ messages: readonly T[],
196
+ store: ReminderStore | null | undefined,
197
+ currentSessionFile: string | undefined,
198
+ now = Date.now(),
199
+ ): { messages: T[] } | undefined {
200
+ const runs = store ? collectPendingRuns(store, currentSessionFile) : [];
201
+ return anchors.apply(
202
+ normalizeSessionFile(currentSessionFile),
203
+ messages,
204
+ { ids: runs.map((run) => String(run.id)), render: () => formatRunningReminder(runs, now) },
205
+ now,
206
+ );
207
+ }