@llblab/pi-actors 0.12.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 (86) hide show
  1. package/AGENTS.md +72 -0
  2. package/BACKLOG.md +38 -0
  3. package/CHANGELOG.md +179 -0
  4. package/README.md +338 -0
  5. package/docs/README.md +21 -0
  6. package/docs/actor-messages.md +149 -0
  7. package/docs/async-runs.md +335 -0
  8. package/docs/command-templates.md +424 -0
  9. package/docs/component-recipes.md +148 -0
  10. package/docs/recipe-library.md +176 -0
  11. package/docs/task-first-recipes.md +233 -0
  12. package/docs/template-recipes.md +285 -0
  13. package/docs/tool-registry.md +142 -0
  14. package/index.ts +198 -0
  15. package/lib/actor-messages.ts +120 -0
  16. package/lib/async-runs.ts +688 -0
  17. package/lib/command-templates.ts +795 -0
  18. package/lib/config.ts +266 -0
  19. package/lib/execution.ts +720 -0
  20. package/lib/file-state.ts +24 -0
  21. package/lib/identity.ts +29 -0
  22. package/lib/observability.ts +525 -0
  23. package/lib/output.ts +123 -0
  24. package/lib/paths.ts +35 -0
  25. package/lib/prompts.ts +75 -0
  26. package/lib/recipe-references.ts +586 -0
  27. package/lib/registry.ts +302 -0
  28. package/lib/runtime.ts +101 -0
  29. package/lib/schema.ts +402 -0
  30. package/lib/temp.ts +44 -0
  31. package/lib/tools.ts +651 -0
  32. package/package.json +52 -0
  33. package/recipes/music-player.json +25 -0
  34. package/recipes/pipeline-architect-coordinator.json +88 -0
  35. package/recipes/pipeline-artifact-report.json +52 -0
  36. package/recipes/pipeline-artifact-write.json +66 -0
  37. package/recipes/pipeline-async-run-ops.json +67 -0
  38. package/recipes/pipeline-checkpoint-continuation.json +57 -0
  39. package/recipes/pipeline-development-tasking.json +73 -0
  40. package/recipes/pipeline-docs-maintenance.json +72 -0
  41. package/recipes/pipeline-media-library.json +51 -0
  42. package/recipes/pipeline-quorum-review.json +72 -0
  43. package/recipes/pipeline-release-readiness.json +83 -0
  44. package/recipes/pipeline-repo-health.json +81 -0
  45. package/recipes/pipeline-research-synthesis.json +87 -0
  46. package/recipes/pipeline-review-readiness.json +49 -0
  47. package/recipes/subagent-artifact.json +26 -0
  48. package/recipes/subagent-checkpoint.json +27 -0
  49. package/recipes/subagent-conflict-report.json +25 -0
  50. package/recipes/subagent-contradiction-map.json +26 -0
  51. package/recipes/subagent-critic.json +28 -0
  52. package/recipes/subagent-evidence-map.json +26 -0
  53. package/recipes/subagent-followup.json +27 -0
  54. package/recipes/subagent-judge.json +26 -0
  55. package/recipes/subagent-merge.json +26 -0
  56. package/recipes/subagent-message.json +29 -0
  57. package/recipes/subagent-normalize.json +24 -0
  58. package/recipes/subagent-plan.json +26 -0
  59. package/recipes/subagent-prompt.json +22 -0
  60. package/recipes/subagent-quorum.json +41 -0
  61. package/recipes/subagent-review-coordinator.json +107 -0
  62. package/recipes/subagent-review.json +30 -0
  63. package/recipes/subagent-task-card.json +28 -0
  64. package/recipes/subagent-tools.json +17 -0
  65. package/recipes/subagent-verify.json +27 -0
  66. package/recipes/subagents-prompts.json +32 -0
  67. package/recipes/utility-actor-message.json +24 -0
  68. package/recipes/utility-artifact-manifest.json +17 -0
  69. package/recipes/utility-artifact-write.json +17 -0
  70. package/recipes/utility-changelog-head.json +12 -0
  71. package/recipes/utility-changelog-section.json +14 -0
  72. package/recipes/utility-git-log.json +12 -0
  73. package/recipes/utility-git-status.json +10 -0
  74. package/recipes/utility-jsonl-tail.json +11 -0
  75. package/recipes/utility-markdown-index.json +15 -0
  76. package/recipes/utility-package-summary.json +12 -0
  77. package/recipes/utility-playlist-build.json +18 -0
  78. package/recipes/utility-playlist-scan.json +12 -0
  79. package/recipes/utility-run-state-files.json +14 -0
  80. package/recipes/utility-run-summary.json +12 -0
  81. package/recipes/utility-validate-recipe.json +14 -0
  82. package/recipes/utility-validation-wrapper.json +14 -0
  83. package/scripts/async-runner.mjs +170 -0
  84. package/scripts/music-player.mjs +637 -0
  85. package/scripts/recipe-utils.mjs +273 -0
  86. package/scripts/validate-recipe.mjs +89 -0
@@ -0,0 +1,24 @@
1
+ /**
2
+ * File state persistence helpers
3
+ * Zones: file persistence, atomic writes, runtime state support
4
+ * Owns generic durable JSON file writes shared by registry config and async run state.
5
+ */
6
+
7
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+
10
+ export function writeJsonAtomic(path: string, value: unknown): void {
11
+ mkdirSync(dirname(path), { recursive: true });
12
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
13
+ try {
14
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
15
+ renameSync(tempPath, path);
16
+ } catch (error) {
17
+ try {
18
+ unlinkSync(tempPath);
19
+ } catch {
20
+ /* best effort */
21
+ }
22
+ throw error;
23
+ }
24
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Identifier normalization helpers
3
+ * Zones: registry identity, shared validation
4
+ * Owns stable tool, argument, and file-label identifier normalization
5
+ */
6
+
7
+ export function normalizeIdentifier(value: string, prefix: string): string {
8
+ let name = value
9
+ .trim()
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9_]/g, "_")
12
+ .replace(/_+/g, "_")
13
+ .replace(/^_+|_+$/g, "");
14
+ if (!name) return "";
15
+ if (/^[0-9]/.test(name)) name = `${prefix}_${name}`;
16
+ return name.slice(0, 64).replace(/_+$/g, "");
17
+ }
18
+
19
+ export function normalizeToolName(value: string): string {
20
+ return normalizeIdentifier(value, "tool");
21
+ }
22
+
23
+ export function normalizeArgName(value: string): string {
24
+ return normalizeIdentifier(value, "arg");
25
+ }
26
+
27
+ export function sanitizeFilePart(value: string): string {
28
+ return normalizeIdentifier(value, "tool") || "tool";
29
+ }
@@ -0,0 +1,525 @@
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
+
7
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
8
+ import { basename, dirname, relative, join } from "node:path";
9
+
10
+ import * as AsyncRuns from "./async-runs.ts";
11
+ import * as Paths from "./paths.ts";
12
+
13
+ export type RunObservedStatus =
14
+ | "running"
15
+ | "done"
16
+ | "failed"
17
+ | "exited"
18
+ | "cancelled"
19
+ | "killed";
20
+ export type RunOutboxDelivery = "log" | "notify" | "followup";
21
+ export type RunOutboxLevel = "info" | "warning" | "error";
22
+
23
+ export interface RunObservation {
24
+ activeSubagents?: number;
25
+ completed?: number;
26
+ failures?: number;
27
+ ownerId?: string;
28
+ artifacts?: Record<string, string>;
29
+ terminalHandled?: boolean;
30
+ run: string;
31
+ stateDir?: string;
32
+ status: RunObservedStatus;
33
+ updatedAt?: string;
34
+ }
35
+
36
+ export interface RunSummary {
37
+ cancelled: number;
38
+ done: number;
39
+ exited: number;
40
+ failed: number;
41
+ killed: number;
42
+ running: number;
43
+ runningSubagents: number;
44
+ runs: RunObservation[];
45
+ total: number;
46
+ }
47
+
48
+ export interface RunTransition {
49
+ from: RunObservedStatus;
50
+ run: string;
51
+ stateDir?: string;
52
+ artifacts?: Record<string, string>;
53
+ terminalHandled?: boolean;
54
+ to: RunObservedStatus;
55
+ }
56
+
57
+ export interface RunOutboxEvent {
58
+ data?: unknown;
59
+ delivery: RunOutboxDelivery;
60
+ event: string;
61
+ id: string;
62
+ level: RunOutboxLevel;
63
+ run: string;
64
+ stateDir: string;
65
+ summary: string;
66
+ ts: string;
67
+ }
68
+
69
+ export type RunTransitionNotificationType = "info" | "warning" | "error";
70
+
71
+ const TERMINAL = new Set<RunObservedStatus>([
72
+ "done",
73
+ "failed",
74
+ "exited",
75
+ "cancelled",
76
+ "killed",
77
+ ]);
78
+
79
+ function toNumber(value: unknown): number | undefined {
80
+ const number = Number(value);
81
+ return Number.isFinite(number) ? number : undefined;
82
+ }
83
+
84
+ function getProgress(status: Record<string, unknown>): Record<string, unknown> {
85
+ const progress = status.progress;
86
+ return progress && typeof progress === "object"
87
+ ? (progress as Record<string, unknown>)
88
+ : {};
89
+ }
90
+
91
+ function getUpdatedAt(status: Record<string, unknown>): string | undefined {
92
+ const progress = getProgress(status);
93
+ return typeof progress.updatedAt === "string"
94
+ ? progress.updatedAt
95
+ : typeof status.createdAt === "string"
96
+ ? status.createdAt
97
+ : undefined;
98
+ }
99
+
100
+ function observeRun(stateDir: string): RunObservation | undefined {
101
+ try {
102
+ const status = AsyncRuns.getRunStatus(stateDir);
103
+ const progress = getProgress(status);
104
+ const run = typeof status.run === "string" ? status.run : undefined;
105
+ if (!run) return undefined;
106
+ return {
107
+ activeSubagents: toNumber(progress.activeSubagents),
108
+ completed: toNumber(progress.completed),
109
+ failures: Array.isArray(progress.failures)
110
+ ? progress.failures.length
111
+ : undefined,
112
+ ...(typeof status.ownerId === "string"
113
+ ? { ownerId: status.ownerId }
114
+ : {}),
115
+ ...(status.artifacts && typeof status.artifacts === "object" && !Array.isArray(status.artifacts)
116
+ ? { artifacts: status.artifacts as Record<string, string> }
117
+ : {}),
118
+ ...(status.terminal_handled ? { terminalHandled: true } : {}),
119
+ run,
120
+ stateDir,
121
+ status: status.status as RunObservedStatus,
122
+ updatedAt: getUpdatedAt(status),
123
+ };
124
+ } catch {
125
+ return undefined;
126
+ }
127
+ }
128
+
129
+ export function summarizeRuns(
130
+ stateRoot = Paths.getRunStateRoot(),
131
+ ownerId?: string,
132
+ ): RunSummary {
133
+ if (!existsSync(stateRoot)) {
134
+ return {
135
+ cancelled: 0,
136
+ done: 0,
137
+ exited: 0,
138
+ failed: 0,
139
+ killed: 0,
140
+ running: 0,
141
+ runningSubagents: 0,
142
+ runs: [],
143
+ total: 0,
144
+ };
145
+ }
146
+ const runs = readdirSync(stateRoot, { withFileTypes: true })
147
+ .filter((entry) => entry.isDirectory())
148
+ .map((entry) => observeRun(join(stateRoot, entry.name)))
149
+ .filter((run): run is RunObservation => Boolean(run))
150
+ .filter((run) => ownerId === undefined || run.ownerId === ownerId)
151
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
152
+ const runningRuns = runs.filter((run) => run.status === "running");
153
+ const running = runningRuns.length;
154
+ const done = runs.filter((run) => run.status === "done").length;
155
+ const exited = runs.filter((run) => run.status === "exited").length;
156
+ const failed = runs.filter((run) => run.status === "failed").length;
157
+ const cancelled = runs.filter((run) => run.status === "cancelled").length;
158
+ const killed = runs.filter((run) => run.status === "killed").length;
159
+ const runningSubagents = runningRuns.reduce(
160
+ (sum, run) => sum + Math.max(1, Math.floor(run.activeSubagents ?? 0)),
161
+ 0,
162
+ );
163
+ return {
164
+ cancelled,
165
+ done,
166
+ exited,
167
+ failed,
168
+ killed,
169
+ running,
170
+ runningSubagents,
171
+ runs,
172
+ total: runs.length,
173
+ };
174
+ }
175
+
176
+ function readProcFile(path: string): string | undefined {
177
+ try {
178
+ return readFileSync(path, "utf8");
179
+ } catch {
180
+ return undefined;
181
+ }
182
+ }
183
+
184
+ function getProcPpid(pid: string): string | undefined {
185
+ const stat = readProcFile(`/proc/${pid}/stat`);
186
+ if (!stat) return undefined;
187
+ const close = stat.lastIndexOf(")");
188
+ if (close === -1) return undefined;
189
+ return stat.slice(close + 2).split(" ")[1];
190
+ }
191
+
192
+ function getProcCommand(pid: string): string {
193
+ return (readProcFile(`/proc/${pid}/cmdline`) ?? "").replaceAll("\0", " ");
194
+ }
195
+
196
+ function getRunningRunPids(stateRoot: string, ownerId?: string): Set<string> {
197
+ const pids = new Set<string>();
198
+ for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
199
+ if (run.status !== "running") continue;
200
+ const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
201
+ const pid = Number(status.pid || 0);
202
+ if (pid > 0) pids.add(String(pid));
203
+ }
204
+ return pids;
205
+ }
206
+
207
+ function summarizeRunsWithoutSubagents(
208
+ stateRoot: string,
209
+ ownerId?: string,
210
+ ): Omit<RunSummary, "runningSubagents"> {
211
+ if (!existsSync(stateRoot))
212
+ return {
213
+ cancelled: 0,
214
+ done: 0,
215
+ exited: 0,
216
+ failed: 0,
217
+ killed: 0,
218
+ running: 0,
219
+ runs: [],
220
+ total: 0,
221
+ };
222
+ const runs = readdirSync(stateRoot, { withFileTypes: true })
223
+ .filter((entry) => entry.isDirectory())
224
+ .map((entry) => observeRun(join(stateRoot, entry.name)))
225
+ .filter((run): run is RunObservation => Boolean(run))
226
+ .filter((run) => ownerId === undefined || run.ownerId === ownerId)
227
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
228
+ const running = runs.filter((run) => run.status === "running").length;
229
+ const done = runs.filter((run) => run.status === "done").length;
230
+ const exited = runs.filter((run) => run.status === "exited").length;
231
+ const failed = runs.filter((run) => run.status === "failed").length;
232
+ const cancelled = runs.filter((run) => run.status === "cancelled").length;
233
+ const killed = runs.filter((run) => run.status === "killed").length;
234
+ return {
235
+ cancelled,
236
+ done,
237
+ exited,
238
+ failed,
239
+ killed,
240
+ running,
241
+ runs,
242
+ total: runs.length,
243
+ };
244
+ }
245
+
246
+ export function countRunningSubagents(
247
+ stateRoot = Paths.getRunStateRoot(),
248
+ ownerId?: string,
249
+ ): number {
250
+ const runPids = getRunningRunPids(stateRoot, ownerId);
251
+ if (runPids.size === 0 || !existsSync("/proc")) return 0;
252
+ const parentByPid = new Map<string, string>();
253
+ const commandByPid = new Map<string, string>();
254
+ for (const entry of readdirSync("/proc", { withFileTypes: true })) {
255
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
256
+ const ppid = getProcPpid(entry.name);
257
+ if (!ppid) continue;
258
+ parentByPid.set(entry.name, ppid);
259
+ commandByPid.set(entry.name, getProcCommand(entry.name));
260
+ }
261
+ const descendantOfRun = (pid: string): boolean => {
262
+ let current = parentByPid.get(pid);
263
+ const seen = new Set<string>();
264
+ while (current && !seen.has(current)) {
265
+ if (runPids.has(current)) return true;
266
+ seen.add(current);
267
+ current = parentByPid.get(current);
268
+ }
269
+ return false;
270
+ };
271
+ let count = 0;
272
+ for (const [pid, command] of commandByPid.entries()) {
273
+ if (!command.includes("pi -p") && !command.includes("pi\0-p")) continue;
274
+ if (descendantOfRun(pid)) count++;
275
+ }
276
+ return count;
277
+ }
278
+
279
+ export function renderSubagentStatus(
280
+ count: number,
281
+ frame = 0,
282
+ ): string | undefined {
283
+ if (count <= 0) return undefined;
284
+ if (count === 1) return frame % 2 === 0 ? "▶" : "▷";
285
+ const active = frame % count;
286
+ return Array.from({ length: count }, (_value, index) =>
287
+ index === active ? "▶" : "▷",
288
+ ).join(" ");
289
+ }
290
+
291
+ export function renderRunStatus(
292
+ summary: RunSummary,
293
+ frame = 0,
294
+ ): string | undefined {
295
+ return renderSubagentStatus(summary.runningSubagents, frame);
296
+ }
297
+
298
+ export function detectRunTransitions(
299
+ previous: Map<string, RunObservedStatus>,
300
+ summary: RunSummary,
301
+ ): RunTransition[] {
302
+ const transitions: RunTransition[] = [];
303
+ for (const run of summary.runs) {
304
+ const old = previous.get(run.run);
305
+ if (old && old !== run.status && TERMINAL.has(run.status)) {
306
+ transitions.push({
307
+ from: old,
308
+ run: run.run,
309
+ ...(run.stateDir ? { stateDir: run.stateDir } : {}),
310
+ ...(run.artifacts ? { artifacts: run.artifacts } : {}),
311
+ ...(run.terminalHandled ? { terminalHandled: true } : {}),
312
+ to: run.status,
313
+ });
314
+ }
315
+ previous.set(run.run, run.status);
316
+ }
317
+ return transitions;
318
+ }
319
+
320
+ function normalizeOutboxDelivery(value: unknown): RunOutboxDelivery {
321
+ return value === "notify" || value === "followup" ? value : "log";
322
+ }
323
+
324
+ function normalizeOutboxLevel(value: unknown): RunOutboxLevel {
325
+ return value === "warning" || value === "error" ? value : "info";
326
+ }
327
+
328
+ function parseOutboxLine(
329
+ line: string,
330
+ run: RunObservation,
331
+ index: number,
332
+ ): RunOutboxEvent | undefined {
333
+ if (!run.stateDir) return undefined;
334
+ try {
335
+ const raw = JSON.parse(line) as Record<string, unknown>;
336
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
337
+ const event =
338
+ typeof raw.event === "string" && raw.event.trim()
339
+ ? raw.event.trim()
340
+ : "run.event";
341
+ const summary =
342
+ typeof raw.summary === "string" && raw.summary.trim()
343
+ ? raw.summary.trim()
344
+ : event;
345
+ const ts =
346
+ typeof raw.ts === "string" && raw.ts.trim()
347
+ ? raw.ts.trim()
348
+ : new Date(0).toISOString();
349
+ const id =
350
+ typeof raw.id === "string" && raw.id.trim()
351
+ ? raw.id.trim()
352
+ : `${run.run}:${index}`;
353
+ return {
354
+ ...(raw.data !== undefined ? { data: raw.data } : {}),
355
+ delivery: normalizeOutboxDelivery(raw.delivery),
356
+ event,
357
+ id,
358
+ level: normalizeOutboxLevel(raw.level),
359
+ run: run.run,
360
+ stateDir: run.stateDir,
361
+ summary,
362
+ ts,
363
+ };
364
+ } catch {
365
+ return undefined;
366
+ }
367
+ }
368
+
369
+ function readOutboxLines(run: RunObservation): string[] {
370
+ if (!run.stateDir) return [];
371
+ const path = join(run.stateDir, "outbox.jsonl");
372
+ if (!existsSync(path)) return [];
373
+ const content = readFileSync(path, "utf8").trimEnd();
374
+ return content ? content.split("\n") : [];
375
+ }
376
+
377
+ export function detectRunOutboxEvents(
378
+ previousLineCounts: Map<string, number>,
379
+ summary: RunSummary,
380
+ ): RunOutboxEvent[] {
381
+ const events: RunOutboxEvent[] = [];
382
+ for (const run of summary.runs) {
383
+ const key = run.stateDir ?? run.run;
384
+ const lines = readOutboxLines(run);
385
+ const previousCount = previousLineCounts.get(key) ?? 0;
386
+ const start = Math.min(previousCount, lines.length);
387
+ for (let index = start; index < lines.length; index += 1) {
388
+ const event = parseOutboxLine(lines[index], run, index);
389
+ if (event) events.push(event);
390
+ }
391
+ previousLineCounts.set(key, lines.length);
392
+ }
393
+ return events;
394
+ }
395
+
396
+ export function getRunOutboxNotificationType(
397
+ event: RunOutboxEvent,
398
+ ): RunTransitionNotificationType {
399
+ return event.level;
400
+ }
401
+
402
+ export function shouldNotifyRunOutboxEvent(event: RunOutboxEvent): boolean {
403
+ return event.delivery === "notify" || event.delivery === "followup";
404
+ }
405
+
406
+ export function shouldSendRunOutboxFollowUp(event: RunOutboxEvent): boolean {
407
+ return event.delivery === "followup";
408
+ }
409
+
410
+ function commonDirectory(paths: string[]): string | undefined {
411
+ if (paths.length === 0) return undefined;
412
+ const split = (path: string): string[] => dirname(path).split("/").filter(Boolean);
413
+ const first = split(paths[0]);
414
+ let length = first.length;
415
+ for (const path of paths.slice(1)) {
416
+ const parts = split(path);
417
+ length = Math.min(length, parts.length);
418
+ for (let index = 0; index < length; index += 1) {
419
+ if (first[index] !== parts[index]) {
420
+ length = index;
421
+ break;
422
+ }
423
+ }
424
+ }
425
+ if (length === 0) return paths[0].startsWith("/") ? "/" : undefined;
426
+ return `${paths[0].startsWith("/") ? "/" : ""}${first.slice(0, length).join("/")}`;
427
+ }
428
+
429
+ function relativeName(base: string | undefined, path: string): string {
430
+ if (!base) return basename(path) || path;
431
+ const name = relative(base, path);
432
+ return name && !name.startsWith("..") ? name : basename(path) || path;
433
+ }
434
+
435
+ function formatPathGroup(label: string, paths: string[]): string {
436
+ const unique = [...new Set(paths.filter(Boolean))].slice(0, 8);
437
+ if (unique.length === 0) return "";
438
+ const base = commonDirectory(unique);
439
+ const names = unique.map((path) => `\`${relativeName(base, path)}\``).join(", ");
440
+ return `\n${label}:\n- Base: ${base ? `\`${base}\`` : "current run"}\n- Files: ${names}`;
441
+ }
442
+
443
+ function formatRunFileList(files: unknown): string {
444
+ if (!Array.isArray(files)) return "";
445
+ return formatPathGroup(
446
+ "Run files",
447
+ files.filter((file): file is string => typeof file === "string"),
448
+ );
449
+ }
450
+
451
+ function formatNamedArtifacts(artifacts: unknown): string {
452
+ if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts))
453
+ return "";
454
+ return formatPathGroup(
455
+ "Artifacts",
456
+ Object.values(artifacts as Record<string, unknown>).filter(
457
+ (path): path is string => typeof path === "string",
458
+ ),
459
+ );
460
+ }
461
+
462
+ function getOutboxField(event: RunOutboxEvent, key: string): unknown {
463
+ return event.data && typeof event.data === "object" && !Array.isArray(event.data)
464
+ ? (event.data as Record<string, unknown>)[key]
465
+ : undefined;
466
+ }
467
+
468
+ export function formatRunOutboxMessage(event: RunOutboxEvent): string {
469
+ if (event.event === "command.done") return `Run ${event.run}: ${event.summary}`;
470
+ return `Run ${event.run}: ${event.summary}${formatNamedArtifacts(getOutboxField(event, "artifacts"))}${formatRunFileList(getOutboxField(event, "run_files"))}`;
471
+ }
472
+
473
+ export function getRunTransitionNotificationType(
474
+ transition: RunTransition,
475
+ ): RunTransitionNotificationType {
476
+ if (transition.to === "done" || transition.to === "cancelled") return "info";
477
+ if (transition.to === "killed" || transition.to === "exited")
478
+ return "warning";
479
+ return "error";
480
+ }
481
+
482
+ export function shouldNotifyRunTransition(
483
+ transition: RunTransition,
484
+ ): boolean {
485
+ if (transition.terminalHandled) return false;
486
+ return (
487
+ transition.to === "done" ||
488
+ transition.to === "failed" ||
489
+ transition.to === "killed" ||
490
+ transition.to === "exited"
491
+ );
492
+ }
493
+
494
+ export function shouldSendRunTransitionFollowUp(
495
+ transition: RunTransition,
496
+ ): boolean {
497
+ return shouldNotifyRunTransition(transition);
498
+ }
499
+
500
+ function getRunArtifacts(transition: RunTransition): string[] {
501
+ if (!transition.stateDir) return [];
502
+ return [
503
+ join(transition.stateDir, "stdout.log"),
504
+ join(transition.stateDir, "stderr.log"),
505
+ join(transition.stateDir, "result.json"),
506
+ join(transition.stateDir, "events.jsonl"),
507
+ join(transition.stateDir, "outbox.jsonl"),
508
+ ];
509
+ }
510
+
511
+ export function formatRunTransitionMessage(transition: RunTransition): string {
512
+ const artifacts = formatNamedArtifacts(transition.artifacts);
513
+ const runFiles = formatRunFileList(getRunArtifacts(transition));
514
+ if (transition.to === "done")
515
+ return `Run ${transition.run} completed successfully.${artifacts}${runFiles}\nUse inspect target=run:${transition.run} view=status or view=tail if the result needs inspection.`;
516
+ if (transition.to === "failed")
517
+ return `Run ${transition.run} failed.${artifacts}${runFiles}\nUse inspect target=run:${transition.run} view=status or view=tail for details.`;
518
+ if (transition.to === "cancelled")
519
+ return `Run ${transition.run} was cancelled. Use inspect target=run:${transition.run} view=status or view=tail if analysis is needed.`;
520
+ if (transition.to === "killed")
521
+ return `Run ${transition.run} was force-killed. Use inspect target=run:${transition.run} view=status or view=tail if analysis is needed.`;
522
+ if (transition.to === "exited")
523
+ return `Run ${transition.run} exited before writing a result. Use inspect target=run:${transition.run} view=status or view=tail if analysis is needed.`;
524
+ return `Run ${transition.run} finished with status ${transition.to}. Use inspect target=run:${transition.run} view=status or view=tail if analysis is needed.`;
525
+ }
package/lib/output.ts ADDED
@@ -0,0 +1,123 @@
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
+
7
+ import { mkdtempSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { sanitizeFilePart } from "./identity.ts";
12
+
13
+ export interface FormattedOutput {
14
+ text: string;
15
+ truncated: boolean;
16
+ fullOutputPath?: string;
17
+ }
18
+
19
+ const MAX_OUTPUT_BYTES = 50 * 1024;
20
+ const MAX_OUTPUT_LINES = 2_000;
21
+
22
+ export function writeFullOutput(
23
+ toolName: string,
24
+ stream: string,
25
+ content: string,
26
+ ): string | undefined {
27
+ try {
28
+ const dir = mkdtempSync(join(tmpdir(), "pi-actors-"));
29
+ const filePath = join(
30
+ dir,
31
+ `${sanitizeFilePart(toolName)}-${sanitizeFilePart(stream)}.txt`,
32
+ );
33
+ writeFileSync(filePath, content, "utf8");
34
+ return filePath;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ export function formatSize(bytes: number): string {
41
+ if (bytes < 1024) return `${bytes}B`;
42
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
43
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
44
+ }
45
+
46
+ export function byteLength(value: string): number {
47
+ return Buffer.byteLength(value, "utf8");
48
+ }
49
+
50
+ export function trimToTailBytes(value: string, maxBytes: number): string {
51
+ const buffer = Buffer.from(value, "utf8");
52
+ if (buffer.length <= maxBytes) return value;
53
+ return buffer
54
+ .subarray(buffer.length - maxBytes)
55
+ .toString("utf8")
56
+ .replace(/^\uFFFD+/, "");
57
+ }
58
+
59
+ export function truncateTailContent(content: string): {
60
+ content: string;
61
+ outputBytes: number;
62
+ outputLines: number;
63
+ totalBytes: number;
64
+ totalLines: number;
65
+ truncated: boolean;
66
+ } {
67
+ const totalBytes = byteLength(content);
68
+ const lines = content.split("\n");
69
+ const totalLines = lines.length;
70
+ let output =
71
+ totalLines > MAX_OUTPUT_LINES
72
+ ? lines.slice(-MAX_OUTPUT_LINES).join("\n")
73
+ : content;
74
+ output = trimToTailBytes(output, MAX_OUTPUT_BYTES);
75
+ const outputBytes = byteLength(output);
76
+ const outputLines = output ? output.split("\n").length : 0;
77
+ return {
78
+ content: output,
79
+ outputBytes,
80
+ outputLines,
81
+ totalBytes,
82
+ totalLines,
83
+ truncated: outputBytes < totalBytes || outputLines < totalLines,
84
+ };
85
+ }
86
+
87
+ export function formatToolText(text: string): string {
88
+ return `\n${text.replace(/^\n+/, "")}`;
89
+ }
90
+
91
+ export function formatOutput(
92
+ toolName: string,
93
+ stream: string,
94
+ content: string,
95
+ ): FormattedOutput {
96
+ const body = content.trimEnd() || "(no output)";
97
+ const truncation = truncateTailContent(body);
98
+ if (!truncation.truncated) {
99
+ return { text: formatToolText(truncation.content), truncated: false };
100
+ }
101
+ const fullOutputPath = writeFullOutput(toolName, stream, body);
102
+ const notice = fullOutputPath
103
+ ? `[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output saved to: ${fullOutputPath}]`
104
+ : `[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output could not be saved.]`;
105
+ return {
106
+ text: formatToolText(`${truncation.content}\n\n${notice}`),
107
+ truncated: true,
108
+ fullOutputPath,
109
+ };
110
+ }
111
+
112
+ export function formatFailureOutput(
113
+ toolName: string,
114
+ code: number,
115
+ killed: boolean,
116
+ stdout: string,
117
+ stderr: string,
118
+ ): FormattedOutput {
119
+ const parts = [`Exit code ${code}${killed ? " (killed)" : ""}`];
120
+ if (stderr.trim()) parts.push(`stderr:\n${stderr.trimEnd()}`);
121
+ if (stdout.trim()) parts.push(`stdout:\n${stdout.trimEnd()}`);
122
+ return formatOutput(toolName, "error", parts.join("\n\n"));
123
+ }