larkin 0.4.18 → 0.4.20
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/dist/agent/context-prompt.cjs +2 -2
- package/dist/agent/context-prompt.mjs +2 -2
- package/dist/runtime/pi-inline-extensions.mjs +44 -8
- package/dist/runtime/pi-subagent-injection.mjs +103 -10
- package/dist/runtime/pi-subagent-ledger.cjs +632 -0
- package/dist/runtime/pi-subagent-ledger.mjs +594 -0
- package/dist/runtime/pi-subagent-record-watchdog-injection.mjs +43 -0
- package/dist/runtime/pi-subagent-record-watchdog.bundle.js +183 -0
- package/dist/runtime/pi-subagent-record-watchdog.mjs +46 -0
- package/dist/runtime/pi-subagents-notification.mjs +14 -0
- package/dist/runtime/pi-subagents.bundle.js +131 -58
- package/dist/runtime/pi-supervised-command-extension.mjs +72 -0
- package/dist/runtime/pi-supervised-command.bundle.js +4735 -0
- package/dist/runtime/pi-supervised-command.mjs +420 -0
- package/dist/runtime/runtime-adapters.mjs +52 -10
- package/dist/runtime/runtime-host.cjs +187 -14
- package/dist/runtime/runtime-host.mjs +187 -14
- package/package.json +1 -1
- package/patches/@tintinweb%2Fpi-subagents@0.14.3.patch +712 -16
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const DEFAULT_WAIT_SECONDS = 60;
|
|
6
|
+
export const DEFAULT_LIFE_SECONDS = 600;
|
|
7
|
+
export const LARKIN_SUPERVISED_COMMAND_CAPABILITY = "larkin-pi-supervised-command-v1";
|
|
8
|
+
const MAX_STREAM_BYTES = 64 * 1024;
|
|
9
|
+
export function supervisedWaitSeconds() {
|
|
10
|
+
const raw = Number.parseInt(process.env.LARKIN_PI_SUPERVISED_WAIT_SECONDS || "", 10);
|
|
11
|
+
if (Number.isInteger(raw) && raw > 0)
|
|
12
|
+
return Math.min(raw, DEFAULT_WAIT_SECONDS);
|
|
13
|
+
return DEFAULT_WAIT_SECONDS;
|
|
14
|
+
}
|
|
15
|
+
export function supervisedLifeSeconds() {
|
|
16
|
+
const raw = Number.parseInt(process.env.LARKIN_PI_SUPERVISED_LIFE_SECONDS || "", 10);
|
|
17
|
+
if (Number.isInteger(raw) && raw > 0)
|
|
18
|
+
return Math.min(raw, DEFAULT_LIFE_SECONDS);
|
|
19
|
+
return DEFAULT_LIFE_SECONDS;
|
|
20
|
+
}
|
|
21
|
+
export function resolveSupervisedCwd(root, requested) {
|
|
22
|
+
if (typeof root !== "string" || root.length === 0)
|
|
23
|
+
throw new Error("supervised cwd root is required");
|
|
24
|
+
if (fs.lstatSync(root).isSymbolicLink())
|
|
25
|
+
throw new Error("supervised cwd must not be a symlink");
|
|
26
|
+
const base = fs.realpathSync(root);
|
|
27
|
+
const target = requested && requested.length > 0 ? path.resolve(base, requested) : base;
|
|
28
|
+
const relative = path.relative(base, target);
|
|
29
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
30
|
+
throw new Error("supervised cwd escapes the session root");
|
|
31
|
+
let current = base;
|
|
32
|
+
if (relative && relative !== ".") {
|
|
33
|
+
for (const part of relative.split(path.sep)) {
|
|
34
|
+
if (!part || part === ".")
|
|
35
|
+
continue;
|
|
36
|
+
current = path.join(current, part);
|
|
37
|
+
if (fs.lstatSync(current).isSymbolicLink())
|
|
38
|
+
throw new Error("supervised cwd must not be a symlink");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const real = fs.realpathSync(current);
|
|
42
|
+
const prefix = base.endsWith(path.sep) ? base : base + path.sep;
|
|
43
|
+
if (real !== base && !real.startsWith(prefix))
|
|
44
|
+
throw new Error("supervised cwd escapes the session root");
|
|
45
|
+
if (!fs.statSync(real).isDirectory())
|
|
46
|
+
throw new Error("supervised cwd is not a directory");
|
|
47
|
+
return real;
|
|
48
|
+
}
|
|
49
|
+
function pidAlive(pid) {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function listDescendantPids(root) {
|
|
59
|
+
if (process.platform === "win32")
|
|
60
|
+
return [];
|
|
61
|
+
const found = new Set();
|
|
62
|
+
const queue = [root];
|
|
63
|
+
while (queue.length > 0) {
|
|
64
|
+
const current = queue.pop();
|
|
65
|
+
if (current === undefined)
|
|
66
|
+
break;
|
|
67
|
+
const listed = spawnSync("pgrep", ["-P", String(current)], { encoding: "utf8" });
|
|
68
|
+
if (listed.status !== 0 || !listed.stdout)
|
|
69
|
+
continue;
|
|
70
|
+
for (const line of listed.stdout.split(/\s+/)) {
|
|
71
|
+
const child = Number.parseInt(line, 10);
|
|
72
|
+
if (!Number.isInteger(child) || child <= 0 || found.has(child) || child === root)
|
|
73
|
+
continue;
|
|
74
|
+
found.add(child);
|
|
75
|
+
queue.push(child);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [...found];
|
|
79
|
+
}
|
|
80
|
+
function refreshTreePids(pid, known) {
|
|
81
|
+
known.add(pid);
|
|
82
|
+
for (const child of listDescendantPids(pid))
|
|
83
|
+
known.add(child);
|
|
84
|
+
return [...known];
|
|
85
|
+
}
|
|
86
|
+
async function killProcessTree(pid, known = new Set([pid])) {
|
|
87
|
+
const descendants = refreshTreePids(pid, known).filter((child) => child !== pid);
|
|
88
|
+
if (process.platform === "win32") {
|
|
89
|
+
const result = await new Promise((resolve) => {
|
|
90
|
+
const child = spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", windowsHide: true });
|
|
91
|
+
child.once("error", (error) => resolve({ status: null, error }));
|
|
92
|
+
child.once("exit", (status) => resolve({ status }));
|
|
93
|
+
});
|
|
94
|
+
if (result.error && pidAlive(pid))
|
|
95
|
+
throw result.error;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
for (const child of [...descendants].reverse()) {
|
|
99
|
+
try {
|
|
100
|
+
process.kill(child, "SIGKILL");
|
|
101
|
+
}
|
|
102
|
+
catch { /* already dead */ }
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
process.kill(-pid, "SIGKILL");
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, "SIGKILL");
|
|
110
|
+
}
|
|
111
|
+
catch { /* already dead */ }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const started = Date.now();
|
|
115
|
+
const pending = () => pidAlive(pid) || descendants.some((child) => pidAlive(child));
|
|
116
|
+
while (pending() && Date.now() - started < 2000) {
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
118
|
+
if (process.platform !== "win32") {
|
|
119
|
+
for (const child of descendants) {
|
|
120
|
+
try {
|
|
121
|
+
process.kill(child, "SIGKILL");
|
|
122
|
+
}
|
|
123
|
+
catch { /* already dead */ }
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
process.kill(pid, "SIGKILL");
|
|
127
|
+
}
|
|
128
|
+
catch { /* already dead */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (pending())
|
|
132
|
+
throw new Error(`supervised process tree ${pid} still alive`);
|
|
133
|
+
}
|
|
134
|
+
class ByteCursor {
|
|
135
|
+
chunks = [];
|
|
136
|
+
dropped = 0;
|
|
137
|
+
write(chunk) {
|
|
138
|
+
let data = chunk;
|
|
139
|
+
if (chunk.length > MAX_STREAM_BYTES) {
|
|
140
|
+
this.dropped += chunk.length - MAX_STREAM_BYTES;
|
|
141
|
+
data = chunk.subarray(chunk.length - MAX_STREAM_BYTES);
|
|
142
|
+
}
|
|
143
|
+
this.chunks.push(data);
|
|
144
|
+
let total = this.chunks.reduce((n, c) => n + c.length, 0);
|
|
145
|
+
while (total > MAX_STREAM_BYTES && this.chunks.length) {
|
|
146
|
+
const overflow = total - MAX_STREAM_BYTES;
|
|
147
|
+
const head = this.chunks[0];
|
|
148
|
+
if (head.length <= overflow) {
|
|
149
|
+
this.chunks.shift();
|
|
150
|
+
this.dropped += head.length;
|
|
151
|
+
total -= head.length;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
this.chunks[0] = head.subarray(overflow);
|
|
155
|
+
this.dropped += overflow;
|
|
156
|
+
total -= overflow;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
snapshot(cursor) {
|
|
161
|
+
const joined = Buffer.concat(this.chunks);
|
|
162
|
+
let start = Math.max(0, cursor - this.dropped);
|
|
163
|
+
if (start < joined.length) {
|
|
164
|
+
while (start < joined.length && (joined[start] & 0b1100_0000) === 0b1000_0000)
|
|
165
|
+
start++;
|
|
166
|
+
}
|
|
167
|
+
let end = joined.length;
|
|
168
|
+
if (end > start) {
|
|
169
|
+
let cont = 0;
|
|
170
|
+
let i = end;
|
|
171
|
+
while (i > start && (joined[i - 1] & 0b1100_0000) === 0b1000_0000) {
|
|
172
|
+
i--;
|
|
173
|
+
cont++;
|
|
174
|
+
}
|
|
175
|
+
if (i > start) {
|
|
176
|
+
const lead = joined[i - 1];
|
|
177
|
+
const need = lead < 0x80 ? 1 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : 4;
|
|
178
|
+
if (cont + 1 < need)
|
|
179
|
+
end = i - 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const slice = joined.subarray(Math.min(start, end), end);
|
|
183
|
+
return {
|
|
184
|
+
text: slice.toString("utf8"),
|
|
185
|
+
cursor: this.dropped + end,
|
|
186
|
+
truncated: this.dropped > cursor || start > Math.max(0, cursor - this.dropped),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const handles = new Map();
|
|
191
|
+
const terminals = new Map();
|
|
192
|
+
const MAX_TERMINALS = 32;
|
|
193
|
+
function rememberTerminal(id, owner, snapshot) {
|
|
194
|
+
terminals.set(id, { owner, snapshot });
|
|
195
|
+
while (terminals.size > MAX_TERMINALS) {
|
|
196
|
+
const oldest = terminals.keys().next().value;
|
|
197
|
+
if (!oldest)
|
|
198
|
+
break;
|
|
199
|
+
terminals.delete(oldest);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function notify(record) {
|
|
203
|
+
const waiters = record.exitWaiters.splice(0);
|
|
204
|
+
for (const wake of waiters)
|
|
205
|
+
wake();
|
|
206
|
+
}
|
|
207
|
+
function finish(record, status, exitCode) {
|
|
208
|
+
if (record.lifetimeTimer) {
|
|
209
|
+
clearTimeout(record.lifetimeTimer);
|
|
210
|
+
record.lifetimeTimer = undefined;
|
|
211
|
+
}
|
|
212
|
+
if (record.treePoll) {
|
|
213
|
+
clearInterval(record.treePoll);
|
|
214
|
+
record.treePoll = undefined;
|
|
215
|
+
}
|
|
216
|
+
if (record.status === "running") {
|
|
217
|
+
record.status = status;
|
|
218
|
+
record.exitCode = exitCode;
|
|
219
|
+
}
|
|
220
|
+
notify(record);
|
|
221
|
+
}
|
|
222
|
+
export function startSupervisedCommand(input) {
|
|
223
|
+
if (typeof input.executable !== "string" || input.executable.length === 0) {
|
|
224
|
+
throw new Error("executable must be a non-empty string");
|
|
225
|
+
}
|
|
226
|
+
if (!Array.isArray(input.args) || input.args.some((arg) => typeof arg !== "string")) {
|
|
227
|
+
throw new Error("args must be a string array");
|
|
228
|
+
}
|
|
229
|
+
const cwd = resolveSupervisedCwd(input.cwd);
|
|
230
|
+
const child = spawn(input.executable, input.args, {
|
|
231
|
+
cwd,
|
|
232
|
+
shell: false,
|
|
233
|
+
detached: process.platform !== "win32",
|
|
234
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
235
|
+
windowsHide: true,
|
|
236
|
+
});
|
|
237
|
+
let record;
|
|
238
|
+
child.on("error", (error) => {
|
|
239
|
+
if (!record)
|
|
240
|
+
return;
|
|
241
|
+
const current = record;
|
|
242
|
+
void current.chain.then(async () => {
|
|
243
|
+
await killProcessTree(current.pid, current.treePids);
|
|
244
|
+
finish(current, "killed", null);
|
|
245
|
+
});
|
|
246
|
+
void error;
|
|
247
|
+
});
|
|
248
|
+
if (!child.pid)
|
|
249
|
+
throw new Error("failed to spawn supervised command");
|
|
250
|
+
const id = randomBytes(16).toString("hex");
|
|
251
|
+
record = {
|
|
252
|
+
id,
|
|
253
|
+
owner: input.owner,
|
|
254
|
+
child,
|
|
255
|
+
pid: child.pid,
|
|
256
|
+
startedAt: Date.now(),
|
|
257
|
+
deadlineAt: Date.now() + supervisedLifeSeconds() * 1000,
|
|
258
|
+
stdout: new ByteCursor(),
|
|
259
|
+
stderr: new ByteCursor(),
|
|
260
|
+
stdoutCursor: 0,
|
|
261
|
+
stderrCursor: 0,
|
|
262
|
+
status: "running",
|
|
263
|
+
exitCode: null,
|
|
264
|
+
terminalConsumed: false,
|
|
265
|
+
cancelRequested: false,
|
|
266
|
+
exitWaiters: [],
|
|
267
|
+
treePids: new Set([child.pid]),
|
|
268
|
+
chain: Promise.resolve(),
|
|
269
|
+
closed: Promise.resolve(),
|
|
270
|
+
};
|
|
271
|
+
const started = record;
|
|
272
|
+
let markClosed = () => { };
|
|
273
|
+
started.closed = new Promise((resolve) => { markClosed = resolve; });
|
|
274
|
+
child.stdout?.on("data", (chunk) => started.stdout.write(Buffer.from(chunk)));
|
|
275
|
+
child.stderr?.on("data", (chunk) => started.stderr.write(Buffer.from(chunk)));
|
|
276
|
+
started.treePoll = setInterval(() => {
|
|
277
|
+
refreshTreePids(started.pid, started.treePids);
|
|
278
|
+
}, 50);
|
|
279
|
+
started.treePoll.unref?.();
|
|
280
|
+
child.once("close", (code) => {
|
|
281
|
+
markClosed();
|
|
282
|
+
void started.chain.then(async () => {
|
|
283
|
+
await killProcessTree(started.pid, started.treePids);
|
|
284
|
+
finish(started, "exited", code);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
started.lifetimeTimer = setTimeout(() => {
|
|
288
|
+
void started.chain.then(async () => {
|
|
289
|
+
if (started.status !== "running")
|
|
290
|
+
return;
|
|
291
|
+
await killProcessTree(started.pid, started.treePids);
|
|
292
|
+
finish(started, "lifetime_exceeded", null);
|
|
293
|
+
});
|
|
294
|
+
}, supervisedLifeSeconds() * 1000);
|
|
295
|
+
started.lifetimeTimer.unref?.();
|
|
296
|
+
handles.set(id, started);
|
|
297
|
+
return { handle: id, pid: started.pid };
|
|
298
|
+
}
|
|
299
|
+
function snapshot(record) {
|
|
300
|
+
const out = record.stdout.snapshot(record.stdoutCursor);
|
|
301
|
+
const err = record.stderr.snapshot(record.stderrCursor);
|
|
302
|
+
record.stdoutCursor = out.cursor;
|
|
303
|
+
record.stderrCursor = err.cursor;
|
|
304
|
+
return {
|
|
305
|
+
handle: record.id,
|
|
306
|
+
pid: record.pid,
|
|
307
|
+
status: record.status,
|
|
308
|
+
exitCode: record.exitCode,
|
|
309
|
+
stdout: out.text,
|
|
310
|
+
stderr: err.text,
|
|
311
|
+
stdoutCursor: out.cursor,
|
|
312
|
+
stderrCursor: err.cursor,
|
|
313
|
+
truncated: out.truncated || err.truncated,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function requireHandle(owner, handle) {
|
|
317
|
+
const record = handles.get(handle);
|
|
318
|
+
if (!record || record.owner !== owner)
|
|
319
|
+
throw new Error("supervised handle not found");
|
|
320
|
+
return record;
|
|
321
|
+
}
|
|
322
|
+
function enqueue(record, work) {
|
|
323
|
+
const next = record.chain.then(work, work);
|
|
324
|
+
record.chain = next.then(() => undefined, () => undefined);
|
|
325
|
+
return next;
|
|
326
|
+
}
|
|
327
|
+
export async function waitSupervisedCommand(owner, handle, timeoutSeconds) {
|
|
328
|
+
const remembered = terminals.get(handle);
|
|
329
|
+
if (remembered && remembered.owner === owner && !handles.has(handle))
|
|
330
|
+
return remembered.snapshot;
|
|
331
|
+
const record = requireHandle(owner, handle);
|
|
332
|
+
return enqueue(record, async () => {
|
|
333
|
+
const cap = supervisedWaitSeconds();
|
|
334
|
+
const requested = typeof timeoutSeconds === "number" && timeoutSeconds > 0 ? timeoutSeconds : cap;
|
|
335
|
+
if (requested > cap)
|
|
336
|
+
throw new Error(`wait timeout:${requested} exceeds the ${cap}s supervised wait limit`);
|
|
337
|
+
const remainingLife = Math.max(0, record.deadlineAt - Date.now());
|
|
338
|
+
const waitMs = Math.min(requested * 1000, remainingLife, cap * 1000);
|
|
339
|
+
if (record.status === "running" && waitMs > 0) {
|
|
340
|
+
await new Promise((resolve) => {
|
|
341
|
+
let done = false;
|
|
342
|
+
const finishWait = () => {
|
|
343
|
+
if (done)
|
|
344
|
+
return;
|
|
345
|
+
done = true;
|
|
346
|
+
record.exitWaiters = record.exitWaiters.filter((item) => item !== finishWait);
|
|
347
|
+
resolve();
|
|
348
|
+
};
|
|
349
|
+
const timer = setTimeout(finishWait, waitMs);
|
|
350
|
+
record.exitWaiters.push(() => {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
finishWait();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (record.status !== "running" && record.terminalConsumed) {
|
|
357
|
+
throw new Error("supervised terminal result already consumed");
|
|
358
|
+
}
|
|
359
|
+
const result = snapshot(record);
|
|
360
|
+
if (result.status !== "running") {
|
|
361
|
+
record.terminalConsumed = true;
|
|
362
|
+
rememberTerminal(record.id, owner, result);
|
|
363
|
+
handles.delete(record.id);
|
|
364
|
+
}
|
|
365
|
+
return result;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
export async function cancelSupervisedCommand(owner, handle) {
|
|
369
|
+
const remembered = terminals.get(handle);
|
|
370
|
+
if (remembered && remembered.owner === owner && !handles.has(handle))
|
|
371
|
+
return remembered.snapshot;
|
|
372
|
+
const record = requireHandle(owner, handle);
|
|
373
|
+
return enqueue(record, async () => {
|
|
374
|
+
record.cancelRequested = true;
|
|
375
|
+
notify(record);
|
|
376
|
+
if (record.status === "running") {
|
|
377
|
+
await killProcessTree(record.pid, record.treePids);
|
|
378
|
+
record.status = "killed";
|
|
379
|
+
}
|
|
380
|
+
if (record.lifetimeTimer) {
|
|
381
|
+
clearTimeout(record.lifetimeTimer);
|
|
382
|
+
record.lifetimeTimer = undefined;
|
|
383
|
+
}
|
|
384
|
+
await record.closed;
|
|
385
|
+
const result = record.terminalConsumed && remembered?.owner === owner
|
|
386
|
+
? remembered.snapshot
|
|
387
|
+
: snapshot(record);
|
|
388
|
+
record.terminalConsumed = true;
|
|
389
|
+
rememberTerminal(record.id, owner, result);
|
|
390
|
+
handles.delete(record.id);
|
|
391
|
+
return result;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
export async function reapSupervisedCommands(owner) {
|
|
395
|
+
for (const [id, remembered] of terminals) {
|
|
396
|
+
if (remembered.owner === owner)
|
|
397
|
+
terminals.delete(id);
|
|
398
|
+
}
|
|
399
|
+
const owned = [...handles.values()].filter((record) => record.owner === owner);
|
|
400
|
+
const errors = [];
|
|
401
|
+
await Promise.all(owned.map(async (record) => {
|
|
402
|
+
try {
|
|
403
|
+
await cancelSupervisedCommand(owner, record.id);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
errors.push(error);
|
|
407
|
+
}
|
|
408
|
+
if (record.lifetimeTimer)
|
|
409
|
+
clearTimeout(record.lifetimeTimer);
|
|
410
|
+
try {
|
|
411
|
+
await killProcessTree(record.pid, record.treePids);
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
errors.push(error);
|
|
415
|
+
}
|
|
416
|
+
handles.delete(record.id);
|
|
417
|
+
}));
|
|
418
|
+
if (errors.length > 0)
|
|
419
|
+
throw errors[0];
|
|
420
|
+
}
|
|
@@ -11,7 +11,9 @@ import { BUNDLED_PI_VERSION, piAgentDirectory } from "./pi-provider-config.mjs";
|
|
|
11
11
|
import { recordPiRuntimeArtifactProvenance } from "./pi-artifact-provenance.mjs";
|
|
12
12
|
import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
|
|
13
13
|
import { resolvePiSubagentExtensionArg } from "./pi-subagent-injection.mjs";
|
|
14
|
-
import {
|
|
14
|
+
import { extractCanonicalPiSubagentNotification, ledgerStatusFromPiNotificationStatus, } from "./pi-subagents-notification.mjs";
|
|
15
|
+
import { resolvePiSubagentRecordWatchdogExtensionArg } from "./pi-subagent-record-watchdog-injection.mjs";
|
|
16
|
+
import { effectivePiStateDir, extractBackgroundPiSubagentDispatch } from "./pi-subagent-ledger.mjs";
|
|
15
17
|
import { resolvePiBashTimeoutExtensionArg } from "./pi-bash-timeout-injection.mjs";
|
|
16
18
|
import { classifyRuntimePrerequisite, probeNativeRuntimeReadiness, providerAuthenticationFailureReadiness, RuntimePrerequisiteError, } from "./runtime-readiness.mjs";
|
|
17
19
|
import { buildStrictProviderErrorInput, classifyStrictProviderError, INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON, } from "./provider-error-classifier.mjs";
|
|
@@ -455,6 +457,7 @@ class PiSession extends EventSession {
|
|
|
455
457
|
observedCompletedEpochs = new Set();
|
|
456
458
|
observedBackgroundCompletionKeys = new Set();
|
|
457
459
|
pendingUnownedCompletionKeys = new Set();
|
|
460
|
+
pendingUnownedCompletionStatuses = new Map();
|
|
458
461
|
observedAgentEndEpochs = new Set();
|
|
459
462
|
firstOutputObserved = false;
|
|
460
463
|
toolCallOpen = false;
|
|
@@ -557,6 +560,7 @@ class PiSession extends EventSession {
|
|
|
557
560
|
this.observedCompletedEpochs.clear();
|
|
558
561
|
this.observedBackgroundCompletionKeys.clear();
|
|
559
562
|
this.pendingUnownedCompletionKeys.clear();
|
|
563
|
+
this.pendingUnownedCompletionStatuses.clear();
|
|
560
564
|
this.observedAgentEndEpochs.clear();
|
|
561
565
|
this.activeEpoch = null;
|
|
562
566
|
this.settleArmedEpoch = null;
|
|
@@ -606,13 +610,34 @@ class PiSession extends EventSession {
|
|
|
606
610
|
this.observedCompletedEpochs.add(this.activeEpoch);
|
|
607
611
|
this.emitObservation("completed");
|
|
608
612
|
}
|
|
609
|
-
const
|
|
610
|
-
if (
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
613
|
+
const completionNotification = extractCanonicalPiSubagentNotification(event.messages);
|
|
614
|
+
if (completionNotification && !this.observedBackgroundCompletionKeys.has(completionNotification.key)) {
|
|
615
|
+
const completionStatuses = Object.fromEntries(completionNotification.notifications.map((notification) => [
|
|
616
|
+
notification.taskId,
|
|
617
|
+
ledgerStatusFromPiNotificationStatus(notification.status),
|
|
618
|
+
]));
|
|
619
|
+
const owningTurnFailed = event.willRetry === true
|
|
620
|
+
|| this.finalAssistantStopReason === "error"
|
|
621
|
+
|| this.finalAssistantStopReason === "aborted";
|
|
622
|
+
if (this.activeEpoch === null || owningTurnFailed) {
|
|
623
|
+
// Unowned agent_end still has an active Pi session, so prompting
|
|
624
|
+
// before settle is rejected as "Agent is already processing".
|
|
625
|
+
// A failed or retrying owned turn also did not process the
|
|
626
|
+
// notification; do not emit handledInTurn so RuntimeHost can still
|
|
627
|
+
// wake after input-error, retry, or restart.
|
|
628
|
+
this.pendingUnownedCompletionKeys.add(completionNotification.key);
|
|
629
|
+
this.pendingUnownedCompletionStatuses.set(completionNotification.key, completionStatuses);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
// Already visible in the owned turn. Persist as acknowledged; do not
|
|
633
|
+
// schedule another wake after the parent turn settles.
|
|
634
|
+
this.observedBackgroundCompletionKeys.add(completionNotification.key);
|
|
635
|
+
this.emitObservation("completed", {
|
|
636
|
+
completionKey: completionNotification.key,
|
|
637
|
+
completionStatuses,
|
|
638
|
+
handledInTurn: true,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
616
641
|
}
|
|
617
642
|
}
|
|
618
643
|
else if (event?.type === "agent_settled") {
|
|
@@ -683,6 +708,13 @@ class PiSession extends EventSession {
|
|
|
683
708
|
this.toolCallOpen = false;
|
|
684
709
|
this.emit({ type: "runtime-observation", runtime: "pi", distribution: this.distribution, phase: "tool_result" });
|
|
685
710
|
}
|
|
711
|
+
const dispatched = extractBackgroundPiSubagentDispatch(event);
|
|
712
|
+
if (dispatched) {
|
|
713
|
+
this.emitObservation("background_dispatched", {
|
|
714
|
+
taskId: dispatched.taskId,
|
|
715
|
+
...(dispatched.outputFile ? { outputFile: dispatched.outputFile } : {}),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
686
718
|
}
|
|
687
719
|
else if (event?.type === "message_update" && event.assistantMessageEvent?.delta) {
|
|
688
720
|
if (!this.firstOutputObserved) {
|
|
@@ -698,9 +730,14 @@ class PiSession extends EventSession {
|
|
|
698
730
|
if (this.observedBackgroundCompletionKeys.has(completionKey))
|
|
699
731
|
continue;
|
|
700
732
|
this.observedBackgroundCompletionKeys.add(completionKey);
|
|
701
|
-
this.
|
|
733
|
+
const completionStatuses = this.pendingUnownedCompletionStatuses.get(completionKey);
|
|
734
|
+
this.emitObservation("completed", {
|
|
735
|
+
completionKey,
|
|
736
|
+
...(completionStatuses ? { completionStatuses } : {}),
|
|
737
|
+
});
|
|
702
738
|
}
|
|
703
739
|
this.pendingUnownedCompletionKeys.clear();
|
|
740
|
+
this.pendingUnownedCompletionStatuses.clear();
|
|
704
741
|
}
|
|
705
742
|
emitObservation(phase, fields = {}) {
|
|
706
743
|
const inputId = this.oldestOwnedInput();
|
|
@@ -938,6 +975,11 @@ export function resolvePiProcessExtensionArgs(input, resolvers = {}) {
|
|
|
938
975
|
return [];
|
|
939
976
|
const resolverInput = { distribution: "external", piCommand: input.piCommand, env: input.env };
|
|
940
977
|
const args = [];
|
|
978
|
+
// Watchdog must load before the subagent extension so session_shutdown still
|
|
979
|
+
// sees AgentManager.getRecord and can bridge consumed or terminal state.
|
|
980
|
+
const recordWatchdog = (resolvers.recordWatchdog ?? resolvePiSubagentRecordWatchdogExtensionArg)(resolverInput);
|
|
981
|
+
if (recordWatchdog)
|
|
982
|
+
args.push("-e", recordWatchdog);
|
|
941
983
|
const subagents = (resolvers.subagents ?? resolvePiSubagentExtensionArg)(resolverInput);
|
|
942
984
|
if (subagents)
|
|
943
985
|
args.push("-e", subagents);
|
|
@@ -947,7 +989,7 @@ export function resolvePiProcessExtensionArgs(input, resolvers = {}) {
|
|
|
947
989
|
return args;
|
|
948
990
|
}
|
|
949
991
|
async function createPiRpcBackend(input, dependencies, spawn, productionSpawn = true) {
|
|
950
|
-
const stateRoot =
|
|
992
|
+
const stateRoot = effectivePiStateDir(input);
|
|
951
993
|
const mergedEnv = { ...globalThis.process.env, ...dependencies.env, ...input.env, NO_COLOR: "1" };
|
|
952
994
|
assertNoProjectPiCompactionOverride(input.workspaceDir);
|
|
953
995
|
const ownedPiDirectory = mergedEnv.LARKIN_CONFIG_DIR
|