@llblab/pi-actors 0.19.11 → 0.20.0

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.
Files changed (54) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/dist/lib/actor-inspector-tui.d.ts +55 -0
  4. package/dist/lib/actor-inspector-tui.js +559 -0
  5. package/dist/lib/actor-messages.d.ts +25 -0
  6. package/dist/lib/actor-messages.js +122 -0
  7. package/dist/lib/actor-recipe-context.d.ts +14 -0
  8. package/dist/lib/actor-recipe-context.js +79 -0
  9. package/dist/lib/actor-rooms.d.ts +81 -0
  10. package/dist/lib/actor-rooms.js +468 -0
  11. package/dist/lib/async-runs.d.ts +101 -0
  12. package/dist/lib/async-runs.js +612 -0
  13. package/dist/lib/command-templates.d.ts +70 -0
  14. package/dist/lib/command-templates.js +592 -0
  15. package/dist/lib/config.d.ts +34 -0
  16. package/dist/lib/config.js +226 -0
  17. package/dist/lib/execution.d.ts +63 -0
  18. package/dist/lib/execution.js +450 -0
  19. package/dist/lib/file-state.d.ts +6 -0
  20. package/dist/lib/file-state.js +25 -0
  21. package/dist/lib/identity.d.ts +9 -0
  22. package/dist/lib/identity.js +27 -0
  23. package/dist/lib/observability.d.ts +86 -0
  24. package/dist/lib/observability.js +534 -0
  25. package/dist/lib/output.d.ts +25 -0
  26. package/dist/lib/output.js +89 -0
  27. package/dist/lib/paths.d.ts +11 -0
  28. package/dist/lib/paths.js +28 -0
  29. package/dist/lib/prompts.d.ts +23 -0
  30. package/dist/lib/prompts.js +50 -0
  31. package/dist/lib/recipe-discovery.d.ts +50 -0
  32. package/dist/lib/recipe-discovery.js +317 -0
  33. package/dist/lib/recipe-migration.d.ts +21 -0
  34. package/dist/lib/recipe-migration.js +90 -0
  35. package/dist/lib/recipe-references.d.ts +67 -0
  36. package/dist/lib/recipe-references.js +542 -0
  37. package/dist/lib/recipe-usage.d.ts +6 -0
  38. package/dist/lib/recipe-usage.js +57 -0
  39. package/dist/lib/registry.d.ts +47 -0
  40. package/dist/lib/registry.js +222 -0
  41. package/dist/lib/runtime.d.ts +36 -0
  42. package/dist/lib/runtime.js +126 -0
  43. package/dist/lib/schema.d.ts +48 -0
  44. package/dist/lib/schema.js +355 -0
  45. package/dist/lib/temp.d.ts +10 -0
  46. package/dist/lib/temp.js +90 -0
  47. package/dist/lib/tools.d.ts +39 -0
  48. package/dist/lib/tools.js +982 -0
  49. package/lib/async-runs.ts +20 -4
  50. package/package.json +5 -2
  51. package/scripts/async-runner.mjs +8 -12
  52. package/scripts/validate-recipe.mjs +9 -13
  53. package/skills/actors/SKILL.md +1 -1
  54. package/skills/swarm/SKILL.md +1 -1
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Async run observability helpers
3
+ * Zones: async runtime, ambient UI, diagnostics
4
+ * Owns ambient summaries, terminal events, and run outbox delivery for detached command-template runs
5
+ */
6
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import { basename, dirname, join, relative, resolve } from "node:path";
8
+ import * as AsyncRuns from "./async-runs.js";
9
+ import * as Paths from "./paths.js";
10
+ const TERMINAL = new Set([
11
+ "done",
12
+ "failed",
13
+ "exited",
14
+ "cancelled",
15
+ "killed",
16
+ ]);
17
+ const PROC_DESCENDANT_SCAN_TTL_MS = 1000;
18
+ const procDescendantScanCache = new Map();
19
+ function toNumber(value) {
20
+ const number = Number(value);
21
+ return Number.isFinite(number) ? number : undefined;
22
+ }
23
+ function getProgress(status) {
24
+ const progress = status.progress;
25
+ return progress && typeof progress === "object"
26
+ ? progress
27
+ : {};
28
+ }
29
+ function getUpdatedAt(status) {
30
+ const progress = getProgress(status);
31
+ return typeof progress.updatedAt === "string"
32
+ ? progress.updatedAt
33
+ : typeof status.createdAt === "string"
34
+ ? status.createdAt
35
+ : undefined;
36
+ }
37
+ function observeRun(stateDir) {
38
+ try {
39
+ const status = AsyncRuns.getRunStatus(stateDir);
40
+ const progress = getProgress(status);
41
+ const run = typeof status.run === "string" ? status.run : undefined;
42
+ if (!run)
43
+ return undefined;
44
+ return {
45
+ activeSubagents: toNumber(progress.activeSubagents),
46
+ completed: toNumber(progress.completed),
47
+ failures: Array.isArray(progress.failures)
48
+ ? progress.failures.length
49
+ : undefined,
50
+ ...(typeof status.ownerId === "string"
51
+ ? { ownerId: status.ownerId }
52
+ : {}),
53
+ ...(status.artifacts &&
54
+ typeof status.artifacts === "object" &&
55
+ !Array.isArray(status.artifacts)
56
+ ? { artifacts: status.artifacts }
57
+ : {}),
58
+ ...(status.launch_source === "spawn" || status.launch_source === "tool"
59
+ ? { launchSource: status.launch_source }
60
+ : {}),
61
+ ...(typeof status.recipe_file === "string"
62
+ ? { recipeFile: status.recipe_file }
63
+ : {}),
64
+ ...(status.terminal_handled ? { terminalHandled: true } : {}),
65
+ ...(typeof status.retire_when === "string"
66
+ ? { retireWhen: status.retire_when }
67
+ : {}),
68
+ run,
69
+ stateDir,
70
+ status: status.status,
71
+ ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
72
+ updatedAt: getUpdatedAt(status),
73
+ };
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ export function summarizeRuns(stateRoot = Paths.getRunStateRoot(), ownerId) {
80
+ if (!existsSync(stateRoot)) {
81
+ return {
82
+ cancelled: 0,
83
+ done: 0,
84
+ exited: 0,
85
+ failed: 0,
86
+ killed: 0,
87
+ running: 0,
88
+ runningSubagents: 0,
89
+ runs: [],
90
+ total: 0,
91
+ };
92
+ }
93
+ const runs = readdirSync(stateRoot, { withFileTypes: true })
94
+ .filter((entry) => entry.isDirectory())
95
+ .map((entry) => observeRun(join(stateRoot, entry.name)))
96
+ .filter((run) => Boolean(run))
97
+ .filter((run) => ownerId === undefined || run.ownerId === ownerId)
98
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
99
+ const processSubagentsByRun = countRunningSubagentsByRun(stateRoot, ownerId);
100
+ const runsWithDescendants = runs.map((run) => {
101
+ const descendantSubagents = processSubagentsByRun.get(run.run) ?? 0;
102
+ return descendantSubagents > 0 ? { ...run, descendantSubagents } : run;
103
+ });
104
+ const runningRuns = runsWithDescendants.filter((run) => run.status === "running");
105
+ const running = runningRuns.length;
106
+ const done = runsWithDescendants.filter((run) => run.status === "done").length;
107
+ const exited = runsWithDescendants.filter((run) => run.status === "exited").length;
108
+ const failed = runsWithDescendants.filter((run) => run.status === "failed").length;
109
+ const cancelled = runsWithDescendants.filter((run) => run.status === "cancelled").length;
110
+ const killed = runsWithDescendants.filter((run) => run.status === "killed").length;
111
+ const progressSubagents = runningRuns.reduce((sum, run) => sum + Math.max(1, Math.floor(run.activeSubagents ?? 0)), 0);
112
+ const processSubagents = [...processSubagentsByRun.values()].reduce((sum, count) => sum + count, 0);
113
+ const runningSubagents = Math.max(progressSubagents, running + processSubagents);
114
+ return {
115
+ cancelled,
116
+ done,
117
+ exited,
118
+ failed,
119
+ killed,
120
+ running,
121
+ runningSubagents,
122
+ runs: runsWithDescendants,
123
+ total: runsWithDescendants.length,
124
+ };
125
+ }
126
+ function readProcFile(path) {
127
+ try {
128
+ return readFileSync(path, "utf8");
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ function getProcPpid(pid) {
135
+ const stat = readProcFile(`/proc/${pid}/stat`);
136
+ if (!stat)
137
+ return undefined;
138
+ const close = stat.lastIndexOf(")");
139
+ if (close === -1)
140
+ return undefined;
141
+ return stat.slice(close + 2).split(" ")[1];
142
+ }
143
+ function getProcCommand(pid) {
144
+ return (readProcFile(`/proc/${pid}/cmdline`) ?? "").replaceAll("\0", " ");
145
+ }
146
+ function getRunningRunPidMap(stateRoot, ownerId) {
147
+ const pids = new Map();
148
+ for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
149
+ if (run.status !== "running")
150
+ continue;
151
+ const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
152
+ const pid = Number(status.pid || 0);
153
+ if (pid > 0)
154
+ pids.set(String(pid), run.run);
155
+ }
156
+ return pids;
157
+ }
158
+ function summarizeRunsWithoutSubagents(stateRoot, ownerId) {
159
+ if (!existsSync(stateRoot))
160
+ return {
161
+ cancelled: 0,
162
+ done: 0,
163
+ exited: 0,
164
+ failed: 0,
165
+ killed: 0,
166
+ running: 0,
167
+ runs: [],
168
+ total: 0,
169
+ };
170
+ const runs = readdirSync(stateRoot, { withFileTypes: true })
171
+ .filter((entry) => entry.isDirectory())
172
+ .map((entry) => observeRun(join(stateRoot, entry.name)))
173
+ .filter((run) => Boolean(run))
174
+ .filter((run) => ownerId === undefined || run.ownerId === ownerId)
175
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
176
+ const running = runs.filter((run) => run.status === "running").length;
177
+ const done = runs.filter((run) => run.status === "done").length;
178
+ const exited = runs.filter((run) => run.status === "exited").length;
179
+ const failed = runs.filter((run) => run.status === "failed").length;
180
+ const cancelled = runs.filter((run) => run.status === "cancelled").length;
181
+ const killed = runs.filter((run) => run.status === "killed").length;
182
+ return {
183
+ cancelled,
184
+ done,
185
+ exited,
186
+ failed,
187
+ killed,
188
+ running,
189
+ runs,
190
+ total: runs.length,
191
+ };
192
+ }
193
+ export function countRunningSubagentsByRun(stateRoot = Paths.getRunStateRoot(), ownerId) {
194
+ const runPidMap = getRunningRunPidMap(stateRoot, ownerId);
195
+ if (runPidMap.size === 0 || !existsSync("/proc"))
196
+ return new Map();
197
+ const signature = [...runPidMap.keys()].sort().join(",");
198
+ const cacheKey = `${stateRoot}\0${ownerId ?? ""}`;
199
+ const cached = procDescendantScanCache.get(cacheKey);
200
+ const now = Date.now();
201
+ if (cached && cached.signature === signature && cached.expiresAt > now) {
202
+ return new Map(cached.counts);
203
+ }
204
+ const parentByPid = new Map();
205
+ const commandByPid = new Map();
206
+ let procEntries;
207
+ try {
208
+ procEntries = readdirSync("/proc", { withFileTypes: true });
209
+ }
210
+ catch {
211
+ return new Map();
212
+ }
213
+ for (const entry of procEntries) {
214
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name))
215
+ continue;
216
+ const ppid = getProcPpid(entry.name);
217
+ if (!ppid)
218
+ continue;
219
+ parentByPid.set(entry.name, ppid);
220
+ commandByPid.set(entry.name, getProcCommand(entry.name));
221
+ }
222
+ const runForDescendant = (pid) => {
223
+ let current = parentByPid.get(pid);
224
+ const seen = new Set();
225
+ while (current && !seen.has(current)) {
226
+ const run = runPidMap.get(current);
227
+ if (run)
228
+ return run;
229
+ seen.add(current);
230
+ current = parentByPid.get(current);
231
+ }
232
+ return undefined;
233
+ };
234
+ const counts = new Map();
235
+ for (const [pid, command] of commandByPid.entries()) {
236
+ if (!command.includes("pi -p") && !command.includes("pi\0-p"))
237
+ continue;
238
+ const run = runForDescendant(pid);
239
+ if (run)
240
+ counts.set(run, (counts.get(run) ?? 0) + 1);
241
+ }
242
+ procDescendantScanCache.set(cacheKey, {
243
+ counts,
244
+ expiresAt: now + PROC_DESCENDANT_SCAN_TTL_MS,
245
+ signature,
246
+ });
247
+ return new Map(counts);
248
+ }
249
+ export function countRunningSubagents(stateRoot = Paths.getRunStateRoot(), ownerId) {
250
+ return [...countRunningSubagentsByRun(stateRoot, ownerId).values()].reduce((sum, count) => sum + count, 0);
251
+ }
252
+ export function renderSubagentStatus(count, frame = 0) {
253
+ if (count <= 0)
254
+ return undefined;
255
+ if (count === 1)
256
+ return frame % 2 === 0 ? "▶" : "▷";
257
+ const active = frame % count;
258
+ return Array.from({ length: count }, (_value, index) => index === active ? "▶" : "▷").join(" ");
259
+ }
260
+ export function renderRunStatus(summary, frame = 0) {
261
+ return renderSubagentStatus(summary.runningSubagents, frame);
262
+ }
263
+ export function findRunRetirementCandidates(summary) {
264
+ return summary.runs
265
+ .filter((run) => {
266
+ const activeSubagents = Math.max(0, Math.floor(run.activeSubagents ?? 0));
267
+ const descendantSubagents = Math.max(0, Math.floor(run.descendantSubagents ?? 0));
268
+ return (run.status === "running" &&
269
+ run.retireWhen === "children_terminal" &&
270
+ run.stateDir &&
271
+ activeSubagents + descendantSubagents <= 0);
272
+ })
273
+ .map((run) => ({
274
+ activeSubagents: Math.max(0, Math.floor(run.activeSubagents ?? 0)),
275
+ descendantSubagents: Math.max(0, Math.floor(run.descendantSubagents ?? 0)),
276
+ run: run.run,
277
+ stateDir: run.stateDir,
278
+ }));
279
+ }
280
+ export function detectRunTransitions(previous, summary) {
281
+ const transitions = [];
282
+ for (const run of summary.runs) {
283
+ const old = previous.get(run.run);
284
+ if (old && old !== run.status && TERMINAL.has(run.status)) {
285
+ transitions.push({
286
+ from: old,
287
+ run: run.run,
288
+ ...(run.stateDir ? { stateDir: run.stateDir } : {}),
289
+ ...(run.artifacts ? { artifacts: run.artifacts } : {}),
290
+ ...(run.launchSource ? { launchSource: run.launchSource } : {}),
291
+ ...(run.recipeFile ? { recipeFile: run.recipeFile } : {}),
292
+ ...(run.terminalHandled ? { terminalHandled: true } : {}),
293
+ to: run.status,
294
+ ...(run.tool ? { tool: run.tool } : {}),
295
+ });
296
+ }
297
+ previous.set(run.run, run.status);
298
+ }
299
+ return transitions;
300
+ }
301
+ function normalizeOutboxDelivery(value) {
302
+ return value === "notify" || value === "followup" ? value : "log";
303
+ }
304
+ function normalizeOutboxLevel(value) {
305
+ return value === "warning" || value === "error" ? value : "info";
306
+ }
307
+ function parseOutboxLine(line, run, index) {
308
+ if (!run.stateDir)
309
+ return undefined;
310
+ try {
311
+ const raw = JSON.parse(line);
312
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
313
+ return undefined;
314
+ const event = typeof raw.event === "string" && raw.event.trim()
315
+ ? raw.event.trim()
316
+ : "run.event";
317
+ const summary = typeof raw.summary === "string" && raw.summary.trim()
318
+ ? raw.summary.trim()
319
+ : event;
320
+ const ts = typeof raw.ts === "string" && raw.ts.trim()
321
+ ? raw.ts.trim()
322
+ : new Date(0).toISOString();
323
+ const id = typeof raw.id === "string" && raw.id.trim()
324
+ ? raw.id.trim()
325
+ : `${run.run}:${index}`;
326
+ return {
327
+ ...(raw.body !== undefined ? { body: raw.body } : {}),
328
+ ...(raw.data !== undefined ? { data: raw.data } : {}),
329
+ delivery: normalizeOutboxDelivery(raw.delivery),
330
+ event,
331
+ id,
332
+ level: normalizeOutboxLevel(raw.level),
333
+ ...(raw.metadata &&
334
+ typeof raw.metadata === "object" &&
335
+ !Array.isArray(raw.metadata)
336
+ ? { metadata: raw.metadata }
337
+ : {}),
338
+ run: run.run,
339
+ stateDir: run.stateDir,
340
+ summary,
341
+ ts,
342
+ };
343
+ }
344
+ catch {
345
+ return undefined;
346
+ }
347
+ }
348
+ function readOutboxLines(run) {
349
+ if (!run.stateDir)
350
+ return [];
351
+ const path = join(run.stateDir, "outbox.jsonl");
352
+ if (!existsSync(path))
353
+ return [];
354
+ const content = readFileSync(path, "utf8").trimEnd();
355
+ return content ? content.split("\n") : [];
356
+ }
357
+ export function pruneRunObservationState(previousStatuses, previousLineCounts, summary, terminalRuns = []) {
358
+ const activeRuns = new Set(summary.runs.map((run) => run.run));
359
+ const terminalRunSet = new Set(terminalRuns);
360
+ const terminalLineKeys = new Set(summary.runs
361
+ .filter((run) => terminalRunSet.has(run.run))
362
+ .map((run) => run.stateDir ?? run.run));
363
+ const activeLineKeys = new Set(summary.runs.map((run) => run.stateDir ?? run.run));
364
+ for (const run of terminalRunSet)
365
+ previousStatuses.delete(run);
366
+ for (const run of previousStatuses.keys()) {
367
+ if (!activeRuns.has(run))
368
+ previousStatuses.delete(run);
369
+ }
370
+ for (const key of previousLineCounts.keys()) {
371
+ if (terminalLineKeys.has(key) || !activeLineKeys.has(key)) {
372
+ previousLineCounts.delete(key);
373
+ }
374
+ }
375
+ }
376
+ export function detectRunOutboxEvents(previousLineCounts, summary) {
377
+ const events = [];
378
+ for (const run of summary.runs) {
379
+ const key = run.stateDir ?? run.run;
380
+ const lines = readOutboxLines(run);
381
+ const previousCount = previousLineCounts.get(key) ?? 0;
382
+ const start = Math.min(previousCount, lines.length);
383
+ for (let index = start; index < lines.length; index += 1) {
384
+ const event = parseOutboxLine(lines[index], run, index);
385
+ if (event)
386
+ events.push(event);
387
+ }
388
+ previousLineCounts.set(key, lines.length);
389
+ }
390
+ return events;
391
+ }
392
+ export function getRunOutboxNotificationType(event) {
393
+ return event.level;
394
+ }
395
+ export function shouldNotifyRunOutboxEvent(event) {
396
+ return event.delivery === "notify" || event.delivery === "followup";
397
+ }
398
+ export function shouldSendRunOutboxFollowUp(event) {
399
+ return event.delivery === "followup";
400
+ }
401
+ function commonDirectory(paths) {
402
+ if (paths.length === 0)
403
+ return undefined;
404
+ const split = (path) => dirname(path).split("/").filter(Boolean);
405
+ const first = split(paths[0]);
406
+ let length = first.length;
407
+ for (const path of paths.slice(1)) {
408
+ const parts = split(path);
409
+ length = Math.min(length, parts.length);
410
+ for (let index = 0; index < length; index += 1) {
411
+ if (first[index] !== parts[index]) {
412
+ length = index;
413
+ break;
414
+ }
415
+ }
416
+ }
417
+ if (length === 0)
418
+ return paths[0].startsWith("/") ? "/" : undefined;
419
+ return `${paths[0].startsWith("/") ? "/" : ""}${first.slice(0, length).join("/")}`;
420
+ }
421
+ function relativeName(base, path) {
422
+ if (!base)
423
+ return basename(path) || path;
424
+ const name = relative(base, path);
425
+ return name && !name.startsWith("..") ? name : basename(path) || path;
426
+ }
427
+ function formatPathGroup(label, paths) {
428
+ const unique = [...new Set(paths.filter(Boolean))].slice(0, 8);
429
+ if (unique.length === 0)
430
+ return "";
431
+ const base = commonDirectory(unique);
432
+ const names = unique
433
+ .map((path) => `\`${relativeName(base, path)}\``)
434
+ .join(", ");
435
+ return `\n${label}:\n- Base: ${base ? `\`${base}\`` : "current run"}\n- Files: ${names}`;
436
+ }
437
+ function formatRunFileList(files) {
438
+ if (!Array.isArray(files))
439
+ return "";
440
+ return formatPathGroup("Run files", files.filter((file) => typeof file === "string"));
441
+ }
442
+ function formatNamedArtifacts(artifacts) {
443
+ if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts))
444
+ return "";
445
+ return formatPathGroup("Artifacts", Object.values(artifacts).filter((path) => typeof path === "string"));
446
+ }
447
+ function getOutboxField(event, key) {
448
+ return event.data &&
449
+ typeof event.data === "object" &&
450
+ !Array.isArray(event.data)
451
+ ? event.data[key]
452
+ : undefined;
453
+ }
454
+ function formatBodyPreview(body) {
455
+ if (body === undefined)
456
+ return "";
457
+ const rendered = typeof body === "string" ? body : JSON.stringify(body);
458
+ const compact = rendered.replaceAll(/\s+/g, " ").trim();
459
+ if (!compact)
460
+ return "";
461
+ return `\nBody: ${compact.length > 500 ? `${compact.slice(0, 500)}…` : compact}`;
462
+ }
463
+ export function formatRunOutboxMessage(event) {
464
+ if (event.event === "command.done")
465
+ return `Run ${event.run}: ${event.summary}`;
466
+ return `Run ${event.run}: ${event.summary}${formatBodyPreview(event.body)}${formatNamedArtifacts(getOutboxField(event, "artifacts"))}${formatRunFileList(getOutboxField(event, "run_files"))}`;
467
+ }
468
+ export function getRunTransitionNotificationType(transition) {
469
+ if (transition.to === "done" || transition.to === "cancelled")
470
+ return "info";
471
+ if (transition.to === "killed" || transition.to === "exited")
472
+ return "warning";
473
+ return "error";
474
+ }
475
+ export function shouldNotifyRunTransition(transition) {
476
+ if (transition.terminalHandled)
477
+ return false;
478
+ return (transition.to === "done" ||
479
+ transition.to === "failed" ||
480
+ transition.to === "killed" ||
481
+ transition.to === "exited");
482
+ }
483
+ export function shouldSendRunTransitionFollowUp(transition) {
484
+ return shouldNotifyRunTransition(transition);
485
+ }
486
+ function getRunArtifacts(transition) {
487
+ if (!transition.stateDir)
488
+ return [];
489
+ return [
490
+ join(transition.stateDir, "stdout.log"),
491
+ join(transition.stateDir, "stderr.log"),
492
+ join(transition.stateDir, "result.json"),
493
+ join(transition.stateDir, "events.jsonl"),
494
+ join(transition.stateDir, "outbox.jsonl"),
495
+ ];
496
+ }
497
+ function isUserRecipeFile(file) {
498
+ if (!file)
499
+ return false;
500
+ const recipeRoot = resolve(Paths.getRecipeRoot());
501
+ const path = resolve(file);
502
+ return path === recipeRoot || path.startsWith(`${recipeRoot}/`);
503
+ }
504
+ export function shouldSuggestRecipePersistence(transition) {
505
+ if (transition.to !== "done")
506
+ return false;
507
+ if (isUserRecipeFile(transition.recipeFile))
508
+ return false;
509
+ return Boolean(transition.recipeFile) || transition.launchSource === "spawn";
510
+ }
511
+ function formatRecipePersistenceSuggestion(transition) {
512
+ if (!shouldSuggestRecipePersistence(transition))
513
+ return "";
514
+ if (transition.recipeFile) {
515
+ return `\nAgent note: this actor completed successfully from recipe ${transition.recipeFile}. If this recipe fits this machine's recurring workflow, ask the operator whether to copy or register it as a durable tool recipe under ~/.pi/agent/recipes. Do not auto-save without confirmation.`;
516
+ }
517
+ return `\nAgent note: this actor was spawned directly and completed successfully. If this pattern fits this machine's recurring workflow, ask the operator whether to save it as a durable recipe/tool under ~/.pi/agent/recipes with register_tool. Do not auto-save without confirmation.`;
518
+ }
519
+ export function formatRunTransitionMessage(transition) {
520
+ const artifacts = formatNamedArtifacts(transition.artifacts);
521
+ const runFiles = formatRunFileList(getRunArtifacts(transition));
522
+ const persistenceSuggestion = formatRecipePersistenceSuggestion(transition);
523
+ if (transition.to === "done")
524
+ return `Run ${transition.run} completed successfully.${artifacts}${runFiles}\nUse inspect target=run:${transition.run} view=status or view=tail if the result needs inspection.${persistenceSuggestion}`;
525
+ if (transition.to === "failed")
526
+ return `Run ${transition.run} failed.${artifacts}${runFiles}\nUse inspect target=run:${transition.run} view=status or view=tail for details.`;
527
+ if (transition.to === "cancelled")
528
+ return `Run ${transition.run} was cancelled. Use inspect target=run:${transition.run} view=status or view=tail if analysis is needed.`;
529
+ if (transition.to === "killed")
530
+ return `Run ${transition.run} was force-killed. Use inspect target=run:${transition.run} view=status or view=tail if analysis is needed.`;
531
+ if (transition.to === "exited")
532
+ return `Run ${transition.run} exited before writing a result. Use inspect target=run:${transition.run} view=status or view=tail if analysis is needed.`;
533
+ return `Run ${transition.run} finished with status ${transition.to}. Use inspect target=run:${transition.run} view=status or view=tail if analysis is needed.`;
534
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Tool output formatting helpers
3
+ * Zones: tool execution, output formatting, temp artifacts
4
+ * Owns stdout/stderr failure formatting, tail truncation, and full-output temp-file persistence
5
+ */
6
+ export interface FormattedOutput {
7
+ text: string;
8
+ truncated: boolean;
9
+ fullOutputPath?: string;
10
+ }
11
+ export declare function writeFullOutput(toolName: string, stream: string, content: string): string | undefined;
12
+ export declare function formatSize(bytes: number): string;
13
+ export declare function byteLength(value: string): number;
14
+ export declare function trimToTailBytes(value: string, maxBytes: number): string;
15
+ export declare function truncateTailContent(content: string): {
16
+ content: string;
17
+ outputBytes: number;
18
+ outputLines: number;
19
+ totalBytes: number;
20
+ totalLines: number;
21
+ truncated: boolean;
22
+ };
23
+ export declare function formatToolText(text: string): string;
24
+ export declare function formatOutput(toolName: string, stream: string, content: string): FormattedOutput;
25
+ export declare function formatFailureOutput(toolName: string, code: number, killed: boolean, stdout: string, stderr: string): FormattedOutput;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tool output formatting helpers
3
+ * Zones: tool execution, output formatting, temp artifacts
4
+ * Owns stdout/stderr failure formatting, tail truncation, and full-output temp-file persistence
5
+ */
6
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { sanitizeFilePart } from "./identity.js";
9
+ import * as Paths from "./paths.js";
10
+ const MAX_OUTPUT_BYTES = 50 * 1024;
11
+ const MAX_OUTPUT_LINES = 2_000;
12
+ export function writeFullOutput(toolName, stream, content) {
13
+ try {
14
+ const outputRoot = join(Paths.getExtensionTmpDir(), "outputs");
15
+ mkdirSync(outputRoot, { recursive: true });
16
+ const dir = mkdtempSync(join(outputRoot, "tool-output-"));
17
+ const filePath = join(dir, `${sanitizeFilePart(toolName)}-${sanitizeFilePart(stream)}.txt`);
18
+ writeFileSync(filePath, content, "utf8");
19
+ return filePath;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ export function formatSize(bytes) {
26
+ if (bytes < 1024)
27
+ return `${bytes}B`;
28
+ if (bytes < 1024 * 1024)
29
+ return `${(bytes / 1024).toFixed(1)}KB`;
30
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
31
+ }
32
+ export function byteLength(value) {
33
+ return Buffer.byteLength(value, "utf8");
34
+ }
35
+ export function trimToTailBytes(value, maxBytes) {
36
+ const buffer = Buffer.from(value, "utf8");
37
+ if (buffer.length <= maxBytes)
38
+ return value;
39
+ return buffer
40
+ .subarray(buffer.length - maxBytes)
41
+ .toString("utf8")
42
+ .replace(/^\uFFFD+/, "");
43
+ }
44
+ export function truncateTailContent(content) {
45
+ const totalBytes = byteLength(content);
46
+ const lines = content.split("\n");
47
+ const totalLines = lines.length;
48
+ let output = totalLines > MAX_OUTPUT_LINES
49
+ ? lines.slice(-MAX_OUTPUT_LINES).join("\n")
50
+ : content;
51
+ output = trimToTailBytes(output, MAX_OUTPUT_BYTES);
52
+ const outputBytes = byteLength(output);
53
+ const outputLines = output ? output.split("\n").length : 0;
54
+ return {
55
+ content: output,
56
+ outputBytes,
57
+ outputLines,
58
+ totalBytes,
59
+ totalLines,
60
+ truncated: outputBytes < totalBytes || outputLines < totalLines,
61
+ };
62
+ }
63
+ export function formatToolText(text) {
64
+ return `\n${text.replace(/^\n+/, "")}`;
65
+ }
66
+ export function formatOutput(toolName, stream, content) {
67
+ const body = content.trimEnd() || "(no output)";
68
+ const truncation = truncateTailContent(body);
69
+ if (!truncation.truncated) {
70
+ return { text: formatToolText(truncation.content), truncated: false };
71
+ }
72
+ const fullOutputPath = writeFullOutput(toolName, stream, body);
73
+ const notice = fullOutputPath
74
+ ? `[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output saved to: ${fullOutputPath}]`
75
+ : `[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output could not be saved.]`;
76
+ return {
77
+ text: formatToolText(`${truncation.content}\n\n${notice}`),
78
+ truncated: true,
79
+ fullOutputPath,
80
+ };
81
+ }
82
+ export function formatFailureOutput(toolName, code, killed, stdout, stderr) {
83
+ const parts = [`Exit code ${code}${killed ? " (killed)" : ""}`];
84
+ if (stderr.trim())
85
+ parts.push(`stderr:\n${stderr.trimEnd()}`);
86
+ if (stdout.trim())
87
+ parts.push(`stdout:\n${stdout.trimEnd()}`);
88
+ return formatOutput(toolName, "error", parts.join("\n\n"));
89
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Registry path helpers
3
+ * Zones: paths, registry config, temp directory
4
+ * Owns agent directory, tools config, recipe root, and actor run state root resolution
5
+ */
6
+ export declare function getAgentDir(env?: Record<string, string | undefined>): string;
7
+ export declare function getConfigPath(agentDir?: string): string;
8
+ export declare function getExtensionTmpDir(agentDir?: string, extensionName?: string): string;
9
+ export declare function getRunStateRoot(agentDir?: string): string;
10
+ export declare function getRecipeRoot(agentDir?: string): string;
11
+ export declare function getPackagedRecipeRoot(): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Registry path helpers
3
+ * Zones: paths, registry config, temp directory
4
+ * Owns agent directory, tools config, recipe root, and actor run state root resolution
5
+ */
6
+ import { homedir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ export function getAgentDir(env = process.env) {
10
+ return env.PI_CODING_AGENT_DIR
11
+ ? resolve(env.PI_CODING_AGENT_DIR)
12
+ : join(homedir(), ".pi", "agent");
13
+ }
14
+ export function getConfigPath(agentDir = getAgentDir()) {
15
+ return join(agentDir, "legacy-tool-registry.json");
16
+ }
17
+ export function getExtensionTmpDir(agentDir = getAgentDir(), extensionName = "pi-actors") {
18
+ return join(agentDir, "tmp", extensionName);
19
+ }
20
+ export function getRunStateRoot(agentDir = getAgentDir()) {
21
+ return join(getExtensionTmpDir(agentDir), "runs");
22
+ }
23
+ export function getRecipeRoot(agentDir = getAgentDir()) {
24
+ return join(agentDir, "recipes");
25
+ }
26
+ export function getPackagedRecipeRoot() {
27
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "recipes");
28
+ }