larkin 0.4.18 → 0.4.19
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/runtime/pi-inline-extensions.mjs +5 -0
- 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/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
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
export const PI_SUBAGENT_LEDGER_FILENAME = "pi-subagent-ledger.json";
|
|
5
|
+
export const PI_SUBAGENT_RECORD_DIRNAME = "pi-subagent-records";
|
|
6
|
+
export const PI_SUBAGENT_LEDGER_VERSION = 1;
|
|
7
|
+
export const PI_SUBAGENT_SESSION_OWNER_ENV = "LARKIN_PI_SESSION_OWNER";
|
|
8
|
+
export const PI_SUBAGENT_TERMINAL_NOTIFICATION_GRACE_MS = 30_000;
|
|
9
|
+
const TERMINAL_STATUSES = new Set([
|
|
10
|
+
"completed",
|
|
11
|
+
"failed",
|
|
12
|
+
"cancelled",
|
|
13
|
+
"timed_out",
|
|
14
|
+
"orphaned",
|
|
15
|
+
]);
|
|
16
|
+
const MAX_LEDGER_TASKS = 2048;
|
|
17
|
+
export function emptyDispatchedSubagentLedger() {
|
|
18
|
+
return { version: PI_SUBAGENT_LEDGER_VERSION, tasks: [] };
|
|
19
|
+
}
|
|
20
|
+
export function isTerminalDispatchedSubagentStatus(status) {
|
|
21
|
+
return TERMINAL_STATUSES.has(status);
|
|
22
|
+
}
|
|
23
|
+
export function taskIdsFromCompletionKey(completionKey) {
|
|
24
|
+
return completionKey.split("|").map((part) => part.trim()).filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
export function getDispatchedSubagent(ledger, taskId) {
|
|
27
|
+
return ledger.tasks.find((task) => task.taskId === taskId) ?? null;
|
|
28
|
+
}
|
|
29
|
+
export function noteDispatchedSubagent(ledger, input) {
|
|
30
|
+
const now = input.now ?? Date.now();
|
|
31
|
+
const existing = getDispatchedSubagent(ledger, input.taskId);
|
|
32
|
+
if (existing) {
|
|
33
|
+
if (isTerminalDispatchedSubagentStatus(existing.status))
|
|
34
|
+
return ledger;
|
|
35
|
+
return replaceTask(ledger, {
|
|
36
|
+
...existing,
|
|
37
|
+
lastActivityAt: now,
|
|
38
|
+
...(input.outputFile ? { outputFile: input.outputFile } : {}),
|
|
39
|
+
...(input.recordFile ? { recordFile: input.recordFile } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return appendTask(ledger, {
|
|
43
|
+
taskId: input.taskId,
|
|
44
|
+
status: "dispatched",
|
|
45
|
+
dispatchedAt: now,
|
|
46
|
+
lastActivityAt: now,
|
|
47
|
+
...(input.outputFile ? { outputFile: input.outputFile } : {}),
|
|
48
|
+
...(input.recordFile ? { recordFile: input.recordFile } : {}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function noteDispatchedSubagentTerminal(ledger, input) {
|
|
52
|
+
const now = input.now ?? Date.now();
|
|
53
|
+
const status = input.status ?? "completed";
|
|
54
|
+
const existing = getDispatchedSubagent(ledger, input.taskId);
|
|
55
|
+
if (existing && isTerminalDispatchedSubagentStatus(existing.status)) {
|
|
56
|
+
return replaceTask(ledger, {
|
|
57
|
+
...existing,
|
|
58
|
+
lastActivityAt: now,
|
|
59
|
+
...(input.outputFile && !existing.outputFile ? { outputFile: input.outputFile } : {}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const next = {
|
|
63
|
+
taskId: input.taskId,
|
|
64
|
+
status,
|
|
65
|
+
dispatchedAt: existing?.dispatchedAt ?? now,
|
|
66
|
+
lastActivityAt: now,
|
|
67
|
+
wakeState: existing?.wakeState ?? "pending",
|
|
68
|
+
...(existing?.outputFile || input.outputFile
|
|
69
|
+
? { outputFile: input.outputFile ?? existing?.outputFile } : {}),
|
|
70
|
+
...(existing?.recordFile ? { recordFile: existing.recordFile } : {}),
|
|
71
|
+
...(input.reason ? { reason: input.reason } : existing?.reason ? { reason: existing.reason } : {}),
|
|
72
|
+
...(input.wakeKey ? { wakeKey: input.wakeKey } : existing?.wakeKey ? { wakeKey: existing.wakeKey } : {}),
|
|
73
|
+
};
|
|
74
|
+
return existing ? replaceTask(ledger, next) : appendTask(ledger, next);
|
|
75
|
+
}
|
|
76
|
+
export function noteDispatchedSubagentActivity(ledger, input) {
|
|
77
|
+
const existing = getDispatchedSubagent(ledger, input.taskId);
|
|
78
|
+
if (!existing || isTerminalDispatchedSubagentStatus(existing.status))
|
|
79
|
+
return ledger;
|
|
80
|
+
return replaceTask(ledger, {
|
|
81
|
+
...existing,
|
|
82
|
+
lastActivityAt: input.now ?? Date.now(),
|
|
83
|
+
...(input.outputFile ? { outputFile: input.outputFile } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export function markDispatchedSubagentOrphaned(ledger, input) {
|
|
87
|
+
const existing = getDispatchedSubagent(ledger, input.taskId);
|
|
88
|
+
if (existing && isTerminalDispatchedSubagentStatus(existing.status)) {
|
|
89
|
+
return { ledger, record: null };
|
|
90
|
+
}
|
|
91
|
+
const now = input.now ?? Date.now();
|
|
92
|
+
const record = {
|
|
93
|
+
taskId: input.taskId,
|
|
94
|
+
status: "orphaned",
|
|
95
|
+
dispatchedAt: existing?.dispatchedAt ?? now,
|
|
96
|
+
lastActivityAt: existing?.lastActivityAt ?? now,
|
|
97
|
+
reason: input.reason,
|
|
98
|
+
wakeKey: input.taskId,
|
|
99
|
+
wakeState: "pending",
|
|
100
|
+
...(input.outputFile || existing?.outputFile
|
|
101
|
+
? { outputFile: input.outputFile ?? existing?.outputFile } : {}),
|
|
102
|
+
...(existing?.recordFile ? { recordFile: existing.recordFile } : {}),
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
ledger: existing ? replaceTask(ledger, record) : appendTask(ledger, record),
|
|
106
|
+
record,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function reconcileDispatchedSubagents(ledger, input = {}) {
|
|
110
|
+
const now = input.now ?? Date.now();
|
|
111
|
+
const orphaned = [];
|
|
112
|
+
const terminals = [];
|
|
113
|
+
let next = ledger;
|
|
114
|
+
for (const task of ledger.tasks) {
|
|
115
|
+
if (isTerminalDispatchedSubagentStatus(task.status))
|
|
116
|
+
continue;
|
|
117
|
+
const current = getDispatchedSubagent(next, task.taskId) ?? task;
|
|
118
|
+
const refreshed = refreshActivityFromOutput(current, now);
|
|
119
|
+
if (refreshed.lastActivityAt !== current.lastActivityAt) {
|
|
120
|
+
next = noteDispatchedSubagentActivity(next, {
|
|
121
|
+
taskId: current.taskId,
|
|
122
|
+
outputFile: refreshed.outputFile,
|
|
123
|
+
now: refreshed.lastActivityAt,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const probed = getDispatchedSubagent(next, current.taskId) ?? refreshed;
|
|
127
|
+
const probedPresence = input.probe?.(probed);
|
|
128
|
+
const presence = input.forceMissing
|
|
129
|
+
? (probedPresence === "consumed" || probedPresence === "terminal" ? probedPresence : "absent")
|
|
130
|
+
: (probedPresence ?? "present");
|
|
131
|
+
if (presence === "consumed") {
|
|
132
|
+
next = noteConsumedDispatchedSubagent(next, {
|
|
133
|
+
taskId: current.taskId,
|
|
134
|
+
status: readConsumedPiSubagentTerminal(current.recordFile)?.status ?? "completed",
|
|
135
|
+
outputFile: current.outputFile,
|
|
136
|
+
now,
|
|
137
|
+
});
|
|
138
|
+
retireConsumedPiSubagentRecord(current.recordFile);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (presence === "terminal") {
|
|
142
|
+
if (!input.forceMissing && !hasTerminalNotificationGraceElapsed(current.recordFile, now))
|
|
143
|
+
continue;
|
|
144
|
+
next = noteDispatchedSubagentTerminal(next, {
|
|
145
|
+
taskId: current.taskId,
|
|
146
|
+
status: readPersistedPiSubagentTerminal(current.recordFile)?.status ?? "completed",
|
|
147
|
+
outputFile: current.outputFile,
|
|
148
|
+
wakeKey: current.taskId,
|
|
149
|
+
now,
|
|
150
|
+
});
|
|
151
|
+
const advanced = getDispatchedSubagent(next, current.taskId);
|
|
152
|
+
if (advanced)
|
|
153
|
+
terminals.push(advanced);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (presence !== "absent")
|
|
157
|
+
continue;
|
|
158
|
+
const marked = markDispatchedSubagentOrphaned(next, {
|
|
159
|
+
taskId: current.taskId,
|
|
160
|
+
reason: input.missingReason ?? (input.forceMissing ? "pi session gone" : "pi record missing"),
|
|
161
|
+
outputFile: current.outputFile,
|
|
162
|
+
now,
|
|
163
|
+
});
|
|
164
|
+
next = marked.ledger;
|
|
165
|
+
if (marked.record)
|
|
166
|
+
orphaned.push(marked.record);
|
|
167
|
+
}
|
|
168
|
+
return { ledger: next, orphaned, terminals };
|
|
169
|
+
}
|
|
170
|
+
export function noteDispatchedSubagentWakeAcknowledged(ledger, input) {
|
|
171
|
+
const now = input.now ?? Date.now();
|
|
172
|
+
let next = ledger;
|
|
173
|
+
for (const taskId of taskIdsFromCompletionKey(input.completionKey)) {
|
|
174
|
+
const existing = getDispatchedSubagent(next, taskId);
|
|
175
|
+
if (!existing || !isTerminalDispatchedSubagentStatus(existing.status))
|
|
176
|
+
continue;
|
|
177
|
+
if (existing.wakeState === "acknowledged")
|
|
178
|
+
continue;
|
|
179
|
+
next = replaceTask(next, { ...existing, wakeState: "acknowledged", notifiedAt: now });
|
|
180
|
+
}
|
|
181
|
+
return next;
|
|
182
|
+
}
|
|
183
|
+
export function undeliveredTerminalWakeKeys(ledger) {
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
const keys = [];
|
|
186
|
+
for (const task of ledger.tasks) {
|
|
187
|
+
if (!needsTerminalWake(task))
|
|
188
|
+
continue;
|
|
189
|
+
const key = task.wakeKey ?? task.taskId;
|
|
190
|
+
if (seen.has(key))
|
|
191
|
+
continue;
|
|
192
|
+
seen.add(key);
|
|
193
|
+
keys.push(key);
|
|
194
|
+
}
|
|
195
|
+
return keys;
|
|
196
|
+
}
|
|
197
|
+
export function noteConsumedDispatchedSubagent(ledger, input) {
|
|
198
|
+
const now = input.now ?? Date.now();
|
|
199
|
+
const next = noteDispatchedSubagentTerminal(ledger, {
|
|
200
|
+
taskId: input.taskId,
|
|
201
|
+
status: input.status ?? "completed",
|
|
202
|
+
outputFile: input.outputFile,
|
|
203
|
+
wakeKey: input.taskId,
|
|
204
|
+
now,
|
|
205
|
+
});
|
|
206
|
+
return noteDispatchedSubagentWakeAcknowledged(next, { completionKey: input.taskId, now });
|
|
207
|
+
}
|
|
208
|
+
export function isTerminalPiSubagentRecord(record) {
|
|
209
|
+
if (!record || typeof record !== "object")
|
|
210
|
+
return false;
|
|
211
|
+
const status = record.status;
|
|
212
|
+
return typeof status === "string" && status.length > 0
|
|
213
|
+
&& status !== "running" && status !== "queued";
|
|
214
|
+
}
|
|
215
|
+
export function isConsumedTerminalPiSubagentRecord(record) {
|
|
216
|
+
if (!isTerminalPiSubagentRecord(record))
|
|
217
|
+
return false;
|
|
218
|
+
return record.resultConsumed === true;
|
|
219
|
+
}
|
|
220
|
+
export function ledgerStatusFromPiSubagentRecord(record) {
|
|
221
|
+
const status = typeof record === "object" && record && "status" in record
|
|
222
|
+
? String(record.status || "").trim().toLowerCase()
|
|
223
|
+
: "";
|
|
224
|
+
if (status === "error")
|
|
225
|
+
return "failed";
|
|
226
|
+
if (status === "aborted")
|
|
227
|
+
return "timed_out";
|
|
228
|
+
if (status === "stopped")
|
|
229
|
+
return "cancelled";
|
|
230
|
+
if (status === "steered")
|
|
231
|
+
return "timed_out";
|
|
232
|
+
return "completed";
|
|
233
|
+
}
|
|
234
|
+
export function readPersistedPiSubagentTerminal(file) {
|
|
235
|
+
return readPiSubagentTerminalFile(file, false);
|
|
236
|
+
}
|
|
237
|
+
export function readConsumedPiSubagentTerminal(file) {
|
|
238
|
+
return readPiSubagentTerminalFile(file, true);
|
|
239
|
+
}
|
|
240
|
+
export function writePersistedPiSubagentTerminal(file, input, options) {
|
|
241
|
+
writePiSubagentTerminalFile(file, input, options?.resultConsumed === true);
|
|
242
|
+
}
|
|
243
|
+
export function writeConsumedPiSubagentTerminal(file, input) {
|
|
244
|
+
writePersistedPiSubagentTerminal(file, input, { resultConsumed: true });
|
|
245
|
+
}
|
|
246
|
+
export function retirePiSubagentRecord(file) {
|
|
247
|
+
if (!file)
|
|
248
|
+
return false;
|
|
249
|
+
try {
|
|
250
|
+
const stat = fs.lstatSync(file);
|
|
251
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
252
|
+
return false;
|
|
253
|
+
fs.unlinkSync(file);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
export function retireConsumedPiSubagentRecord(file) {
|
|
261
|
+
if (!file || !readConsumedPiSubagentTerminal(file))
|
|
262
|
+
return false;
|
|
263
|
+
return retirePiSubagentRecord(file);
|
|
264
|
+
}
|
|
265
|
+
/** Probe the Pi task record sidecar. A leftover transcript is never presence. */
|
|
266
|
+
export function probePiSubagentRecord(record) {
|
|
267
|
+
if (!record.recordFile)
|
|
268
|
+
return "absent";
|
|
269
|
+
try {
|
|
270
|
+
const stat = fs.lstatSync(record.recordFile);
|
|
271
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
272
|
+
return "absent";
|
|
273
|
+
if (readConsumedPiSubagentTerminal(record.recordFile))
|
|
274
|
+
return "consumed";
|
|
275
|
+
if (readPersistedPiSubagentTerminal(record.recordFile))
|
|
276
|
+
return "terminal";
|
|
277
|
+
return "present";
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return "absent";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** @deprecated Use probePiSubagentRecord. Transcript leftovers are not presence. */
|
|
284
|
+
export function probePiSubagentOutputRecord(record) {
|
|
285
|
+
return probePiSubagentRecord(record);
|
|
286
|
+
}
|
|
287
|
+
/** Same implicit root the Pi adapter uses when `stateDir` is omitted. */
|
|
288
|
+
export function effectivePiStateDir(input) {
|
|
289
|
+
return input.stateDir ?? path.join(input.workspaceDir, ".larkin");
|
|
290
|
+
}
|
|
291
|
+
export function dispatchedSubagentRecordDir(stateDir) {
|
|
292
|
+
return path.join(stateDir, PI_SUBAGENT_RECORD_DIRNAME);
|
|
293
|
+
}
|
|
294
|
+
export function dispatchedSubagentRecordFile(stateDir, taskId) {
|
|
295
|
+
return path.join(dispatchedSubagentRecordDir(stateDir), taskId);
|
|
296
|
+
}
|
|
297
|
+
export function writeDispatchedSubagentRecordFile(stateDir, taskId, owner) {
|
|
298
|
+
if (!stateDir || !taskId)
|
|
299
|
+
return undefined;
|
|
300
|
+
const dir = dispatchedSubagentRecordDir(stateDir);
|
|
301
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
302
|
+
const file = dispatchedSubagentRecordFile(stateDir, taskId);
|
|
303
|
+
const existing = (() => { try {
|
|
304
|
+
return fs.lstatSync(file);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return null;
|
|
308
|
+
} })();
|
|
309
|
+
if (existing?.isSymbolicLink())
|
|
310
|
+
throw new Error("subagent record file must not be a symlink");
|
|
311
|
+
const normalizedOwner = typeof owner === "string" && owner.trim() ? owner.trim() : undefined;
|
|
312
|
+
fs.writeFileSync(file, `${JSON.stringify({
|
|
313
|
+
taskId,
|
|
314
|
+
...(normalizedOwner ? { owner: normalizedOwner } : {}),
|
|
315
|
+
})}\n`, { mode: 0o600 });
|
|
316
|
+
fs.chmodSync(file, 0o600);
|
|
317
|
+
return file;
|
|
318
|
+
}
|
|
319
|
+
export function sweepAbsentPiSubagentRecordFiles(recordDir, getRecord, options) {
|
|
320
|
+
let names;
|
|
321
|
+
try {
|
|
322
|
+
names = fs.readdirSync(recordDir);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (error.code === "ENOENT")
|
|
326
|
+
return [];
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
const removed = [];
|
|
330
|
+
for (const name of names) {
|
|
331
|
+
if (!name || name.startsWith("."))
|
|
332
|
+
continue;
|
|
333
|
+
const file = path.join(recordDir, name);
|
|
334
|
+
try {
|
|
335
|
+
const stat = fs.lstatSync(file);
|
|
336
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const owner = typeof options?.owner === "string" && options.owner.trim() ? options.owner.trim() : undefined;
|
|
343
|
+
if (owner && readPiSubagentRecordOwner(file) !== owner)
|
|
344
|
+
continue;
|
|
345
|
+
const record = getRecord(name);
|
|
346
|
+
if (isTerminalPiSubagentRecord(record)) {
|
|
347
|
+
try {
|
|
348
|
+
// Unchanged terminal payloads must not be rewritten: mtime is the
|
|
349
|
+
// 30s lost-notification grace clock while Pi still holds the record.
|
|
350
|
+
writePersistedPiSubagentTerminal(file, {
|
|
351
|
+
taskId: name,
|
|
352
|
+
status: ledgerStatusFromPiSubagentRecord(record),
|
|
353
|
+
}, { resultConsumed: isConsumedTerminalPiSubagentRecord(record) });
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Keep the sidecar; the next sweep retries the terminal bridge.
|
|
357
|
+
}
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (record != null)
|
|
361
|
+
continue;
|
|
362
|
+
if (readPersistedPiSubagentTerminal(file))
|
|
363
|
+
continue;
|
|
364
|
+
try {
|
|
365
|
+
fs.unlinkSync(file);
|
|
366
|
+
removed.push(name);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// Best-effort: the next sweep retries.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return removed;
|
|
373
|
+
}
|
|
374
|
+
export function ledgerFilePath(stateDir) {
|
|
375
|
+
return stateDir ? path.join(stateDir, PI_SUBAGENT_LEDGER_FILENAME) : null;
|
|
376
|
+
}
|
|
377
|
+
export function readDispatchedSubagentLedger(file) {
|
|
378
|
+
if (!file)
|
|
379
|
+
return emptyDispatchedSubagentLedger();
|
|
380
|
+
try {
|
|
381
|
+
const stat = fs.lstatSync(file);
|
|
382
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
383
|
+
throw new Error("subagent ledger is not a regular file");
|
|
384
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
385
|
+
return normalizeLedger(parsed);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
if (error.code === "ENOENT")
|
|
389
|
+
return emptyDispatchedSubagentLedger();
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
export function writeDispatchedSubagentLedger(file, ledger) {
|
|
394
|
+
if (!file)
|
|
395
|
+
return;
|
|
396
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
397
|
+
const existing = (() => { try {
|
|
398
|
+
return fs.lstatSync(file);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return null;
|
|
402
|
+
} })();
|
|
403
|
+
if (existing?.isSymbolicLink())
|
|
404
|
+
throw new Error("subagent ledger must not be a symlink");
|
|
405
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
406
|
+
fs.writeFileSync(temporary, `${JSON.stringify(normalizeLedger(ledger), null, 2)}\n`, { mode: 0o600 });
|
|
407
|
+
fs.renameSync(temporary, file);
|
|
408
|
+
fs.chmodSync(file, 0o600);
|
|
409
|
+
}
|
|
410
|
+
export function extractBackgroundPiSubagentDispatch(event) {
|
|
411
|
+
if (event.isError === true)
|
|
412
|
+
return null;
|
|
413
|
+
if (String(event.toolName || "") !== "Agent")
|
|
414
|
+
return null;
|
|
415
|
+
const args = asRecord(event.args);
|
|
416
|
+
const resultText = collectResultText(event.result);
|
|
417
|
+
const details = asRecord(asRecord(event.result)?.details);
|
|
418
|
+
const backgroundByArgs = args?.run_in_background === true;
|
|
419
|
+
const backgroundByResult = /Agent (?:started|queued) in background/i.test(resultText);
|
|
420
|
+
if (!backgroundByArgs && !backgroundByResult)
|
|
421
|
+
return null;
|
|
422
|
+
const taskId = firstNonEmpty(stringValue(details?.agentId), stringValue(details?.id), matchCapture(resultText, /Agent ID:\s*(\S+)/i));
|
|
423
|
+
if (!taskId)
|
|
424
|
+
return null;
|
|
425
|
+
const outputFile = firstNonEmpty(stringValue(details?.outputFile), matchCapture(resultText, /Output file:\s*(.+)/i));
|
|
426
|
+
return outputFile ? { taskId, outputFile } : { taskId };
|
|
427
|
+
}
|
|
428
|
+
function readPiSubagentTerminalFile(file, requireConsumed) {
|
|
429
|
+
if (!file)
|
|
430
|
+
return null;
|
|
431
|
+
try {
|
|
432
|
+
const stat = fs.lstatSync(file);
|
|
433
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
434
|
+
return null;
|
|
435
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
436
|
+
if (!parsed || typeof parsed !== "object")
|
|
437
|
+
return null;
|
|
438
|
+
if (requireConsumed && parsed.resultConsumed !== true && parsed.consumed !== true)
|
|
439
|
+
return null;
|
|
440
|
+
if (typeof parsed.taskId !== "string" || parsed.taskId.length === 0)
|
|
441
|
+
return null;
|
|
442
|
+
if (parsed.status !== "completed" && parsed.status !== "failed"
|
|
443
|
+
&& parsed.status !== "cancelled" && parsed.status !== "timed_out") {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
return { taskId: parsed.taskId, status: parsed.status };
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function writePiSubagentTerminalFile(file, input, resultConsumed) {
|
|
453
|
+
const existing = (() => { try {
|
|
454
|
+
return fs.lstatSync(file);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return null;
|
|
458
|
+
} })();
|
|
459
|
+
if (existing?.isSymbolicLink())
|
|
460
|
+
throw new Error("subagent record file must not be a symlink");
|
|
461
|
+
const owner = readPiSubagentRecordOwner(file);
|
|
462
|
+
const payload = `${JSON.stringify({
|
|
463
|
+
taskId: input.taskId,
|
|
464
|
+
...(resultConsumed ? { resultConsumed: true } : {}),
|
|
465
|
+
status: input.status,
|
|
466
|
+
...(owner ? { owner } : {}),
|
|
467
|
+
})}\n`;
|
|
468
|
+
if (existing?.isFile()) {
|
|
469
|
+
try {
|
|
470
|
+
if (fs.readFileSync(file, "utf8") === payload)
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// Rewrite if the existing sidecar cannot be compared.
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
fs.writeFileSync(file, payload, { mode: 0o600 });
|
|
478
|
+
fs.chmodSync(file, 0o600);
|
|
479
|
+
}
|
|
480
|
+
function hasTerminalNotificationGraceElapsed(file, now) {
|
|
481
|
+
if (!file)
|
|
482
|
+
return true;
|
|
483
|
+
try {
|
|
484
|
+
const mtimeMs = fs.statSync(file).mtimeMs;
|
|
485
|
+
return Number.isFinite(mtimeMs) && now - mtimeMs >= PI_SUBAGENT_TERMINAL_NOTIFICATION_GRACE_MS;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function refreshActivityFromOutput(task, now) {
|
|
492
|
+
if (!task.outputFile)
|
|
493
|
+
return task;
|
|
494
|
+
try {
|
|
495
|
+
const mtimeMs = fs.statSync(task.outputFile).mtimeMs;
|
|
496
|
+
if (Number.isFinite(mtimeMs) && mtimeMs > task.lastActivityAt) {
|
|
497
|
+
return { ...task, lastActivityAt: Math.min(now, Math.floor(mtimeMs)) };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
// Missing output files are handled by the probe, not activity refresh.
|
|
502
|
+
}
|
|
503
|
+
return task;
|
|
504
|
+
}
|
|
505
|
+
function normalizeLedger(value) {
|
|
506
|
+
const tasks = Array.isArray(value?.tasks) ? value.tasks.filter(isPersistedTask) : [];
|
|
507
|
+
return { version: PI_SUBAGENT_LEDGER_VERSION, tasks: capLedgerTasks(tasks) };
|
|
508
|
+
}
|
|
509
|
+
function isAcknowledgedTerminalTask(task) {
|
|
510
|
+
return isTerminalDispatchedSubagentStatus(task.status) && task.wakeState === "acknowledged";
|
|
511
|
+
}
|
|
512
|
+
function capLedgerTasks(tasks) {
|
|
513
|
+
if (tasks.length <= MAX_LEDGER_TASKS)
|
|
514
|
+
return tasks;
|
|
515
|
+
const overflow = tasks.length - MAX_LEDGER_TASKS;
|
|
516
|
+
const evict = new Set();
|
|
517
|
+
for (let index = 0; index < tasks.length && evict.size < overflow; index += 1) {
|
|
518
|
+
if (isAcknowledgedTerminalTask(tasks[index]))
|
|
519
|
+
evict.add(index);
|
|
520
|
+
}
|
|
521
|
+
if (evict.size === 0)
|
|
522
|
+
return tasks;
|
|
523
|
+
return tasks.filter((_, index) => !evict.has(index));
|
|
524
|
+
}
|
|
525
|
+
function readPiSubagentRecordOwner(file) {
|
|
526
|
+
try {
|
|
527
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
528
|
+
return typeof parsed?.owner === "string" && parsed.owner.trim() ? parsed.owner.trim() : undefined;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function isPersistedTask(value) {
|
|
535
|
+
if (!value || typeof value !== "object")
|
|
536
|
+
return false;
|
|
537
|
+
const record = value;
|
|
538
|
+
return typeof record.taskId === "string" && record.taskId.length > 0
|
|
539
|
+
&& typeof record.status === "string"
|
|
540
|
+
&& ["dispatched", "completed", "failed", "cancelled", "timed_out", "orphaned"].includes(record.status)
|
|
541
|
+
&& Number.isFinite(record.dispatchedAt)
|
|
542
|
+
&& Number.isFinite(record.lastActivityAt)
|
|
543
|
+
&& (record.recordFile === undefined || (typeof record.recordFile === "string" && record.recordFile.length > 0))
|
|
544
|
+
&& (record.wakeState === undefined || record.wakeState === "pending" || record.wakeState === "acknowledged");
|
|
545
|
+
}
|
|
546
|
+
function needsTerminalWake(task) {
|
|
547
|
+
if (!isTerminalDispatchedSubagentStatus(task.status))
|
|
548
|
+
return false;
|
|
549
|
+
if (task.wakeState === "acknowledged")
|
|
550
|
+
return false;
|
|
551
|
+
if (task.wakeState === "pending")
|
|
552
|
+
return true;
|
|
553
|
+
return task.notifiedAt == null;
|
|
554
|
+
}
|
|
555
|
+
function appendTask(ledger, record) {
|
|
556
|
+
return normalizeLedger({ version: PI_SUBAGENT_LEDGER_VERSION, tasks: [...ledger.tasks, record] });
|
|
557
|
+
}
|
|
558
|
+
function replaceTask(ledger, record) {
|
|
559
|
+
return {
|
|
560
|
+
version: PI_SUBAGENT_LEDGER_VERSION,
|
|
561
|
+
tasks: ledger.tasks.map((task) => task.taskId === record.taskId ? record : task),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function asRecord(value) {
|
|
565
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
566
|
+
}
|
|
567
|
+
function stringValue(value) {
|
|
568
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
569
|
+
}
|
|
570
|
+
function firstNonEmpty(...values) {
|
|
571
|
+
return values.find((value) => Boolean(value)) ?? null;
|
|
572
|
+
}
|
|
573
|
+
function matchCapture(text, pattern) {
|
|
574
|
+
const match = pattern.exec(text);
|
|
575
|
+
return match?.[1] ? match[1].trim() : null;
|
|
576
|
+
}
|
|
577
|
+
function collectResultText(result) {
|
|
578
|
+
if (typeof result === "string")
|
|
579
|
+
return result;
|
|
580
|
+
const record = asRecord(result);
|
|
581
|
+
if (!record)
|
|
582
|
+
return "";
|
|
583
|
+
if (typeof record.content === "string")
|
|
584
|
+
return record.content;
|
|
585
|
+
if (Array.isArray(record.content)) {
|
|
586
|
+
return record.content.map((part) => {
|
|
587
|
+
if (typeof part === "string")
|
|
588
|
+
return part;
|
|
589
|
+
const item = asRecord(part);
|
|
590
|
+
return typeof item?.text === "string" ? item.text : "";
|
|
591
|
+
}).join("\n");
|
|
592
|
+
}
|
|
593
|
+
return typeof record.text === "string" ? record.text : "";
|
|
594
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { piVersionSupportsSubagents, probeExternalPiVersion } from "./pi-subagent-injection.mjs";
|
|
5
|
+
export function materializeEmbeddedPiSubagentRecordWatchdogBundle(configDir) {
|
|
6
|
+
const embedded = globalThis.__LARKIN_EMBEDDED_PI_SUBAGENT_RECORD_WATCHDOG_BUNDLE__;
|
|
7
|
+
if (!embedded || !configDir)
|
|
8
|
+
return null;
|
|
9
|
+
const dir = path.join(path.resolve(configDir), "providers", "pi", "extensions");
|
|
10
|
+
const target = path.join(dir, "pi-subagent-record-watchdog.bundle.js");
|
|
11
|
+
try {
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
fs.chmodSync(dir, 0o700);
|
|
14
|
+
if (!fs.existsSync(target) || fs.readFileSync(target, "utf8") !== embedded) {
|
|
15
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
16
|
+
fs.writeFileSync(temporary, embedded, { mode: 0o600, flag: "wx" });
|
|
17
|
+
fs.renameSync(temporary, target);
|
|
18
|
+
fs.chmodSync(target, 0o600);
|
|
19
|
+
}
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function bundledPiSubagentRecordWatchdogExtensionPath(configDir) {
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL("./pi-subagent-record-watchdog.bundle.js", import.meta.url);
|
|
29
|
+
const resolved = fileURLToPath(url);
|
|
30
|
+
if (fs.existsSync(resolved))
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* fall through to embedded */
|
|
35
|
+
}
|
|
36
|
+
return materializeEmbeddedPiSubagentRecordWatchdogBundle(configDir);
|
|
37
|
+
}
|
|
38
|
+
export function resolvePiSubagentRecordWatchdogExtensionArg(input, probeVersion = () => probeExternalPiVersion(input.piCommand, input.env), resolveBundle = () => bundledPiSubagentRecordWatchdogExtensionPath(input.env.LARKIN_CONFIG_DIR)) {
|
|
39
|
+
const bundle = resolveBundle();
|
|
40
|
+
if (!bundle)
|
|
41
|
+
return null;
|
|
42
|
+
return piVersionSupportsSubagents(probeVersion()) ? bundle : null;
|
|
43
|
+
}
|