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