@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 +1 -1
- package/delivery-guard.ts +103 -0
- package/index.ts +4 -1
- package/lifecycle.ts +7 -0
- package/package.json +2 -1
- package/runner.ts +103 -21
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 (
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
882
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
993
|
-
|
|
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 (
|
|
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;
|