@ryan_nookpi/pi-extension-subagent 0.5.7 → 0.5.9
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/async-task-lifecycle.ts +281 -0
- package/async-task-provider.ts +623 -0
- package/commands.ts +446 -416
- package/index.ts +20 -2
- package/package.json +3 -1
- package/runner.ts +33 -1
- package/store.ts +1 -0
- package/tool-execute.ts +72 -24
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { AsyncTaskProvider, type TrackedTask } from "./async-task-provider.js";
|
|
4
|
+
import type { SingleResult } from "./types.js";
|
|
5
|
+
|
|
6
|
+
type Presence = TrackedTask["presence"];
|
|
7
|
+
interface Invocation {
|
|
8
|
+
lifecycle: SubagentAsyncTasks;
|
|
9
|
+
id: string;
|
|
10
|
+
controller: AbortController;
|
|
11
|
+
hidden: boolean;
|
|
12
|
+
children: Map<string, Presence>;
|
|
13
|
+
result?: TrackedTask["execution"];
|
|
14
|
+
}
|
|
15
|
+
const invocations = new AsyncLocalStorage<Invocation>();
|
|
16
|
+
const resources = new AsyncLocalStorage<(presence: Presence) => void>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pi can start the next agent turn synchronously inside sendMessage(triggerTurn).
|
|
20
|
+
* Run it outside this invocation's async context so tool calls in that turn start
|
|
21
|
+
* their own roots instead of attaching to an already finished one.
|
|
22
|
+
*/
|
|
23
|
+
function outsideInvocation<T>(work: () => T): T {
|
|
24
|
+
return invocations.exit(() => resources.exit(work));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function currentResourceObserver(): (presence: Presence) => void {
|
|
28
|
+
return resources.getStore() ?? (() => {});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Captured before the runner's first await; callbacks retain the original attempt. */
|
|
32
|
+
export async function trackSubagentRunner(
|
|
33
|
+
title: string,
|
|
34
|
+
signal: AbortSignal | undefined,
|
|
35
|
+
run: (signal: AbortSignal | undefined) => Promise<SingleResult>,
|
|
36
|
+
runId?: number,
|
|
37
|
+
): Promise<SingleResult> {
|
|
38
|
+
const invocation = invocations.getStore();
|
|
39
|
+
if (!invocation) return run(signal);
|
|
40
|
+
const { lifecycle, controller } = invocation;
|
|
41
|
+
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
42
|
+
if (combined.aborted) throw new Error("Subagent was aborted");
|
|
43
|
+
const id = await lifecycle.provider.reserve(
|
|
44
|
+
{ title, kind: "subagent", rootTaskId: invocation.id, details: runId === undefined ? undefined : { runId } },
|
|
45
|
+
combined,
|
|
46
|
+
);
|
|
47
|
+
if (!id) throw new Error("Subagent registration unavailable");
|
|
48
|
+
if (combined.aborted || !lifecycle.provider.start(id)) {
|
|
49
|
+
await lifecycle.provider.abandon(id);
|
|
50
|
+
throw new Error("Subagent admission closed before execution");
|
|
51
|
+
}
|
|
52
|
+
invocation.children.set(id, "unknown");
|
|
53
|
+
let presence: Presence = "unknown";
|
|
54
|
+
const observe = (next: Presence) => {
|
|
55
|
+
presence = next;
|
|
56
|
+
invocation.children.set(id, next);
|
|
57
|
+
lifecycle.provider.resource(id, next);
|
|
58
|
+
lifecycle.updatePresence(invocation);
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
const result = await resources.run(observe, () => run(combined));
|
|
62
|
+
lifecycle.provider.finish(
|
|
63
|
+
id,
|
|
64
|
+
result.stopReason === "aborted" || combined.aborted
|
|
65
|
+
? "cancelled"
|
|
66
|
+
: result.exitCode === 0
|
|
67
|
+
? "succeeded"
|
|
68
|
+
: "failed",
|
|
69
|
+
presence,
|
|
70
|
+
false,
|
|
71
|
+
);
|
|
72
|
+
return result;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
lifecycle.provider.finish(id, combined.aborted ? "cancelled" : "failed", presence, false);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function assertAsyncAdmission(): void {
|
|
80
|
+
const invocation = invocations.getStore();
|
|
81
|
+
if (invocation && (invocation.controller.signal.aborted || !invocation.lifecycle.provider.accepting))
|
|
82
|
+
throw new Error("Subagent was aborted");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function completeAsyncInvocation(status: string): void {
|
|
86
|
+
const invocation = invocations.getStore();
|
|
87
|
+
if (!invocation || invocation.result) return;
|
|
88
|
+
invocation.result =
|
|
89
|
+
invocation.controller.signal.aborted || status === "aborted"
|
|
90
|
+
? "cancelled"
|
|
91
|
+
: status === "completed" || status === "done"
|
|
92
|
+
? "succeeded"
|
|
93
|
+
: "failed";
|
|
94
|
+
invocation.lifecycle.finish(invocation);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function discardRemovedAsyncInvocation(content: string): void {
|
|
98
|
+
const root = invocations.getStore();
|
|
99
|
+
if (!root) return;
|
|
100
|
+
completeAsyncInvocation("aborted");
|
|
101
|
+
root.lifecycle.retain(root.id, { content });
|
|
102
|
+
root.lifecycle.discardPending(root.id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Only a host-cancelled invocation may omit its redundant abort notice. */
|
|
106
|
+
export function isExpectedTrackedCancellation(): boolean {
|
|
107
|
+
return invocations.getStore()?.controller.signal.aborted === true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function asyncInvocationDetails(): Record<string, string> {
|
|
111
|
+
const invocation = invocations.getStore();
|
|
112
|
+
return invocation ? { asyncTaskRootId: invocation.id } : {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function retainAsyncCompletion<T extends { content: unknown; details?: unknown }>(message: T): T {
|
|
116
|
+
const root = invocations.getStore();
|
|
117
|
+
if (root) root.lifecycle.retain(root.id, message);
|
|
118
|
+
return message;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class SubagentAsyncTasks {
|
|
122
|
+
readonly provider: AsyncTaskProvider;
|
|
123
|
+
private boundPiSessionId?: string;
|
|
124
|
+
private readonly roots = new Map<string, Invocation>();
|
|
125
|
+
private readonly outputs = new Map<string, string>();
|
|
126
|
+
private readonly pendingPayloads = new Map<string, unknown>();
|
|
127
|
+
private readonly wrapped = new WeakSet<ExtensionAPI>();
|
|
128
|
+
|
|
129
|
+
constructor(pi: ExtensionAPI) {
|
|
130
|
+
this.provider = new AsyncTaskProvider(pi.events, "subagent", "0.5.7", {
|
|
131
|
+
cancel: (id) => {
|
|
132
|
+
const root = this.roots.get(id);
|
|
133
|
+
if (!root) throw new Error("Only group-root cancellation is supported");
|
|
134
|
+
root.controller.abort();
|
|
135
|
+
},
|
|
136
|
+
detail: (id) => this.outputs.get(id),
|
|
137
|
+
observed: (ids) => {
|
|
138
|
+
for (const id of ids) this.pendingPayloads.delete(id);
|
|
139
|
+
},
|
|
140
|
+
close: () => {
|
|
141
|
+
this.pendingPayloads.clear();
|
|
142
|
+
// Abort also fences retry and chain admission, not merely the current process.
|
|
143
|
+
for (const root of this.roots.values()) root.controller.abort();
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
bind(piSessionId: string): void {
|
|
149
|
+
this.provider.bind(piSessionId);
|
|
150
|
+
if (this.boundPiSessionId !== piSessionId) {
|
|
151
|
+
this.roots.clear();
|
|
152
|
+
this.outputs.clear();
|
|
153
|
+
this.pendingPayloads.clear();
|
|
154
|
+
this.boundPiSessionId = piSessionId;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async invoke<T>(
|
|
159
|
+
title: string,
|
|
160
|
+
invocationId: string | undefined,
|
|
161
|
+
run: () => Promise<T>,
|
|
162
|
+
hidden = false,
|
|
163
|
+
signal?: AbortSignal,
|
|
164
|
+
): Promise<T> {
|
|
165
|
+
const binding = this.provider.bindingToken;
|
|
166
|
+
await this.provider.whenDiscovered();
|
|
167
|
+
if (this.provider.bindingToken !== binding) throw new Error("Subagent session changed before invocation");
|
|
168
|
+
if (invocations.getStore() || !this.provider.supported) return run();
|
|
169
|
+
const id = await this.provider.reserve({ title, kind: "subagent", invocationId }, signal);
|
|
170
|
+
if (!id) throw new Error("Subagent registration unavailable");
|
|
171
|
+
if (signal?.aborted || !this.provider.start(id)) {
|
|
172
|
+
await this.provider.abandon(id);
|
|
173
|
+
throw new Error("Subagent admission closed before invocation");
|
|
174
|
+
}
|
|
175
|
+
const root: Invocation = { id, lifecycle: this, controller: new AbortController(), hidden, children: new Map() };
|
|
176
|
+
this.roots.set(id, root);
|
|
177
|
+
return invocations.run(root, async () => {
|
|
178
|
+
try {
|
|
179
|
+
const result = await run();
|
|
180
|
+
// Validation can reject before a runner starts. Do not leave a phantom root.
|
|
181
|
+
if (
|
|
182
|
+
result &&
|
|
183
|
+
typeof result === "object" &&
|
|
184
|
+
"isError" in result &&
|
|
185
|
+
result.isError === true &&
|
|
186
|
+
root.children.size === 0
|
|
187
|
+
) {
|
|
188
|
+
root.result = "failed";
|
|
189
|
+
this.provider.finish(id, "failed", "settled", false);
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
root.result = root.controller.signal.aborted ? "cancelled" : "failed";
|
|
194
|
+
this.provider.finish(id, root.result, this.presence(root), false);
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private presence(root: Invocation): Presence {
|
|
201
|
+
const children = [...root.children.values()];
|
|
202
|
+
if (children.includes("active")) return "active";
|
|
203
|
+
return children.includes("unknown") ? "unknown" : root.result ? "settled" : "active";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
updatePresence(root: Invocation): void {
|
|
207
|
+
this.provider.resource(root.id, this.presence(root));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
finish(root: Invocation): void {
|
|
211
|
+
if (root.result) this.provider.finish(root.id, root.result, this.presence(root), !root.hidden);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
shutdown(): void {
|
|
215
|
+
this.provider.shutdown();
|
|
216
|
+
for (const root of this.roots.values()) {
|
|
217
|
+
if (!root.result) root.result = "interrupted";
|
|
218
|
+
this.finish(root);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
retain(id: string, message: { content: unknown }): void {
|
|
223
|
+
if (!this.roots.get(id)?.hidden) this.pendingPayloads.set(id, structuredClone(message));
|
|
224
|
+
this.outputs.set(
|
|
225
|
+
id,
|
|
226
|
+
(typeof message.content === "string" ? message.content : JSON.stringify(message.content)).slice(0, 4096),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
discardPending(id: string): void {
|
|
231
|
+
this.provider.discardPending(id);
|
|
232
|
+
this.pendingPayloads.delete(id);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
expirePending(message: { details?: unknown }): void {
|
|
236
|
+
const details = message.details as Record<string, unknown> | undefined;
|
|
237
|
+
if (typeof details?.asyncTaskRootId === "string" && this.roots.has(details.asyncTaskRootId)) {
|
|
238
|
+
this.provider.failPending(
|
|
239
|
+
details.asyncTaskRootId,
|
|
240
|
+
"Automatic delivery expired while the origin session was inactive; result is retained for inspection.",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
wrap(pi: ExtensionAPI): ExtensionAPI {
|
|
246
|
+
if (this.wrapped.has(pi)) return pi;
|
|
247
|
+
const proxy = new Proxy(pi, {
|
|
248
|
+
get: (target, property, receiver) => {
|
|
249
|
+
if (property !== "sendMessage") return Reflect.get(target, property, receiver);
|
|
250
|
+
const send: ExtensionAPI["sendMessage"] = (message, options) => {
|
|
251
|
+
const details =
|
|
252
|
+
message.details && typeof message.details === "object" ? (message.details as Record<string, unknown>) : {};
|
|
253
|
+
const rootId =
|
|
254
|
+
typeof details.asyncTaskRootId === "string" ? details.asyncTaskRootId : invocations.getStore()?.id;
|
|
255
|
+
const root = rootId ? this.roots.get(rootId) : undefined;
|
|
256
|
+
// A persisted completion from another provider instance must never auto-replay.
|
|
257
|
+
if (typeof details.asyncTaskRootId === "string" && !root) return;
|
|
258
|
+
if (!root || options?.triggerTurn === false || (!root.result && options?.triggerTurn !== true)) {
|
|
259
|
+
outsideInvocation(() => target.sendMessage(message, options));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (root.hidden) return;
|
|
263
|
+
if (!root.result) {
|
|
264
|
+
root.result = "succeeded";
|
|
265
|
+
this.finish(root);
|
|
266
|
+
}
|
|
267
|
+
this.outputs.set(
|
|
268
|
+
root.id,
|
|
269
|
+
(typeof message.content === "string" ? message.content : JSON.stringify(message.content)).slice(0, 4096),
|
|
270
|
+
);
|
|
271
|
+
this.provider.deliver([root.id], message, (annotated) =>
|
|
272
|
+
outsideInvocation(() => target.sendMessage(annotated, options)),
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
return send;
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
this.wrapped.add(proxy);
|
|
279
|
+
return proxy;
|
|
280
|
+
}
|
|
281
|
+
}
|