@llblab/pi-actors 0.22.5 → 0.23.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 (49) hide show
  1. package/AGENTS.md +1 -0
  2. package/BACKLOG.md +0 -220
  3. package/CHANGELOG.md +18 -1
  4. package/README.md +1 -1
  5. package/dist/index.js +15 -0
  6. package/dist/lib/actor-inspector-tui.d.ts +4 -0
  7. package/dist/lib/actor-inspector-tui.js +53 -12
  8. package/dist/lib/actor-rooms.d.ts +8 -0
  9. package/dist/lib/actor-rooms.js +61 -10
  10. package/dist/lib/async-runs.d.ts +34 -2
  11. package/dist/lib/async-runs.js +215 -22
  12. package/dist/lib/command-templates.js +5 -5
  13. package/dist/lib/limits.d.ts +11 -0
  14. package/dist/lib/limits.js +11 -0
  15. package/dist/lib/observability.js +6 -4
  16. package/dist/lib/output.js +4 -5
  17. package/dist/lib/recipe-discovery.js +1 -0
  18. package/dist/lib/recipe-references.d.ts +11 -2
  19. package/dist/lib/recipe-references.js +4 -2
  20. package/dist/lib/recipe-usage.d.ts +2 -1
  21. package/dist/lib/recipe-usage.js +15 -3
  22. package/dist/lib/runtime.js +12 -2
  23. package/dist/lib/tools.js +197 -38
  24. package/docs/async-runs.md +2 -1
  25. package/docs/template-recipes.md +1 -1
  26. package/docs/tool-registry.md +0 -1
  27. package/index.ts +18 -0
  28. package/lib/actor-inspector-tui.ts +131 -29
  29. package/lib/actor-rooms.ts +155 -44
  30. package/lib/async-runs.ts +290 -31
  31. package/lib/command-templates.ts +5 -5
  32. package/lib/limits.ts +12 -0
  33. package/lib/observability.ts +10 -4
  34. package/lib/output.ts +4 -6
  35. package/lib/recipe-discovery.ts +1 -0
  36. package/lib/recipe-references.ts +20 -4
  37. package/lib/recipe-usage.ts +31 -4
  38. package/lib/runtime.ts +31 -7
  39. package/lib/tools.ts +296 -57
  40. package/package.json +2 -1
  41. package/scripts/async-runner.mjs +6 -0
  42. package/scripts/conformance.mjs +47 -0
  43. package/scripts/coordinator.mjs +13 -0
  44. package/scripts/locker.mjs +13 -0
  45. package/scripts/music-player.mjs +21 -2
  46. package/scripts/recipe-utils.mjs +13 -0
  47. package/scripts/validate-recipe.mjs +13 -0
  48. package/skills/actors/SKILL.md +3 -3
  49. package/skills/swarm/SKILL.md +1 -1
@@ -6,6 +6,7 @@
6
6
  import * as fs from "node:fs";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import * as path from "node:path";
9
+ import * as Limits from "./limits.js";
9
10
  import { notifyRuntimeWake } from "./runtime-notifier.js";
10
11
  const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
11
12
  const STATE_LOCK_TIMEOUT_MS = 5000;
@@ -128,8 +129,23 @@ function compactRoomMessages(stateDir, room) {
128
129
  return;
129
130
  const kept = lines.slice(-maxMessages);
130
131
  fs.writeFileSync(file, `${kept.join("\n")}\n`);
132
+ const keptMessages = kept.flatMap((line) => {
133
+ try {
134
+ return [JSON.parse(line)];
135
+ }
136
+ catch {
137
+ return [];
138
+ }
139
+ });
131
140
  writeJsonFile(path.join(roomDir(stateDir, room), "compaction.json"), {
132
141
  compacted_at: new Date().toISOString(),
142
+ dropped_count: lines.length - kept.length,
143
+ ...(keptMessages[0]?.received_at
144
+ ? { first_kept_at: keptMessages[0].received_at }
145
+ : {}),
146
+ ...(keptMessages.at(-1)?.received_at
147
+ ? { last_kept_at: keptMessages.at(-1)?.received_at }
148
+ : {}),
133
149
  max_messages: maxMessages,
134
150
  });
135
151
  }
@@ -250,20 +266,44 @@ function updateRosterForMessage(stateDir, room, message, receivedAt) {
250
266
  last_seen: receivedAt,
251
267
  ...(current?.caps !== undefined ? { caps: current.caps } : {}),
252
268
  ...(current?.claim !== undefined ? { claim: current.claim } : {}),
253
- ...(current?.display !== undefined ? { display: current.display } : {}),
269
+ ...(current?.display !== undefined
270
+ ? { display: current.display }
271
+ : {}),
254
272
  ...(current?.parent !== undefined ? { parent: current.parent } : {}),
255
- ...(current?.role !== undefined ? { role: current.role } : { role: "actor" }),
273
+ ...(current?.role !== undefined
274
+ ? { role: current.role }
275
+ : { role: "actor" }),
256
276
  status: String(body.status ?? "left"),
257
277
  }
258
278
  : {
259
279
  address: message.from,
260
280
  joined_at: current?.joined_at ?? receivedAt,
261
281
  last_seen: receivedAt,
262
- ...(body.caps !== undefined ? { caps: body.caps } : current?.caps !== undefined ? { caps: current.caps } : {}),
263
- ...(body.claim !== undefined ? { claim: body.claim } : current?.claim !== undefined ? { claim: current.claim } : {}),
264
- ...(body.display !== undefined ? { display: body.display } : current?.display !== undefined ? { display: current.display } : {}),
265
- ...(body.parent !== undefined ? { parent: body.parent } : current?.parent !== undefined ? { parent: current.parent } : {}),
266
- ...(body.role !== undefined ? { role: body.role } : current?.role !== undefined ? { role: current.role } : { role: "actor" }),
282
+ ...(body.caps !== undefined
283
+ ? { caps: body.caps }
284
+ : current?.caps !== undefined
285
+ ? { caps: current.caps }
286
+ : {}),
287
+ ...(body.claim !== undefined
288
+ ? { claim: body.claim }
289
+ : current?.claim !== undefined
290
+ ? { claim: current.claim }
291
+ : {}),
292
+ ...(body.display !== undefined
293
+ ? { display: body.display }
294
+ : current?.display !== undefined
295
+ ? { display: current.display }
296
+ : {}),
297
+ ...(body.parent !== undefined
298
+ ? { parent: body.parent }
299
+ : current?.parent !== undefined
300
+ ? { parent: current.parent }
301
+ : {}),
302
+ ...(body.role !== undefined
303
+ ? { role: body.role }
304
+ : current?.role !== undefined
305
+ ? { role: current.role }
306
+ : { role: "actor" }),
267
307
  status: String(body.status ?? current?.status ?? "present"),
268
308
  };
269
309
  roster[message.from] = next;
@@ -368,7 +408,14 @@ export function updateBranchInboxMessageStatus(stateDir, run, address, id, statu
368
408
  if (!("message" in record) || record.message.id !== id)
369
409
  return record;
370
410
  changed = true;
371
- return { message: { ...record.message, ...metadata, [timestampKey]: new Date().toISOString(), status } };
411
+ return {
412
+ message: {
413
+ ...record.message,
414
+ ...metadata,
415
+ [timestampKey]: new Date().toISOString(),
416
+ status,
417
+ },
418
+ };
372
419
  });
373
420
  if (!changed)
374
421
  return false;
@@ -430,7 +477,7 @@ export function readRoomMessages(stateDir, room, limit = 40) {
430
477
  throw error;
431
478
  }
432
479
  }
433
- function previewValue(value, maxLength = 120) {
480
+ function previewValue(value, maxLength = Limits.ROOM_MESSAGE_PREVIEW_CHARS) {
434
481
  if (value === undefined)
435
482
  return undefined;
436
483
  const text = typeof value === "string" ? value : JSON.stringify(value);
@@ -443,7 +490,9 @@ function previewValue(value, maxLength = 120) {
443
490
  }
444
491
  export function readRoomMessagePreviews(stateDir, room, limit = 40) {
445
492
  return readRoomMessages(stateDir, room, limit).map((message) => ({
446
- ...(previewValue(message.body) ? { body_preview: previewValue(message.body) } : {}),
493
+ ...(previewValue(message.body)
494
+ ? { body_preview: previewValue(message.body) }
495
+ : {}),
447
496
  ...(message.from ? { from: message.from } : {}),
448
497
  ...(message.summary ? { summary: message.summary } : {}),
449
498
  timestamp: message.received_at,
@@ -454,6 +503,7 @@ export function readRoomMessagePreviews(stateDir, room, limit = 40) {
454
503
  export function getRoomStatus(stateDir, room) {
455
504
  const messageCount = readRoomMessageCount(stateDir, room);
456
505
  const [last] = readRoomMessages(stateDir, room, 1);
506
+ const compaction = readJsonFile(path.join(roomDir(stateDir, room), "compaction.json"), undefined);
457
507
  return {
458
508
  ...(last
459
509
  ? {
@@ -463,6 +513,7 @@ export function getRoomStatus(stateDir, room) {
463
513
  last_message_type: last.type,
464
514
  }
465
515
  : {}),
516
+ ...(compaction ? { compaction } : {}),
466
517
  message_count: messageCount,
467
518
  room,
468
519
  roster_count: Object.keys(readRoomRoster(stateDir, room)).length,
@@ -29,7 +29,7 @@ export interface AsyncRunStartParams {
29
29
  timeout?: number | string;
30
30
  delay?: number | string;
31
31
  output?: string;
32
- artifacts?: Record<string, string>;
32
+ artifacts?: Record<string, RunArtifactDeclaration>;
33
33
  mailbox?: RecipeReferences.TemplateRecipeMailbox;
34
34
  retire_when?: "children_terminal";
35
35
  retry?: number | string;
@@ -76,15 +76,43 @@ export interface AsyncRunMeta {
76
76
  tool?: string;
77
77
  template: CommandTemplateValue;
78
78
  values: Record<string, unknown>;
79
- artifacts?: Record<string, string>;
79
+ artifacts?: Record<string, RunArtifactDeclaration>;
80
80
  control?: AsyncRunControlEndpoint;
81
81
  mailbox?: RecipeReferences.TemplateRecipeMailbox;
82
82
  recipe_context_records?: RecipeReferences.TemplateRecipeContextRecord[];
83
83
  retire_when?: "children_terminal";
84
84
  }
85
+ export type RunArtifactDeclaration = string | {
86
+ path: string;
87
+ kind?: string;
88
+ media_type?: string;
89
+ required?: boolean;
90
+ };
91
+ export interface RunArtifactManifestEntry {
92
+ exists: boolean;
93
+ kind?: string;
94
+ media_type?: string;
95
+ path: string;
96
+ required?: boolean;
97
+ sha256?: string;
98
+ size?: number;
99
+ }
100
+ export declare function resolveArtifactManifest(artifacts: Record<string, RunArtifactDeclaration> | undefined): Record<string, RunArtifactManifestEntry> | undefined;
85
101
  export declare function startRun(params: AsyncRunStartParams, cwd: string): AsyncRunMeta;
86
102
  export declare function parseRunOutboxEventLine(line: string, run: string, stateDir: string, index: number): RunOutboxEvent | undefined;
87
103
  export declare function getRunStatus(runOrDir: string): Record<string, unknown>;
104
+ export interface RunStateIndexEntry {
105
+ ownerId?: string;
106
+ recipe?: string;
107
+ run: string;
108
+ state_dir: string;
109
+ status: AsyncRunStatus;
110
+ tool?: string;
111
+ updated_at?: string;
112
+ }
113
+ export declare function listRunStateDirs(stateRoot?: string, depth?: number, seen?: Set<string>): string[];
114
+ export declare function rebuildRunStateIndex(stateRoot?: string): RunStateIndexEntry[];
115
+ export declare function readRunStateIndex(stateRoot?: string): RunStateIndexEntry[] | undefined;
88
116
  export declare function listRuns(stateRoot?: string, statusFilter?: string): Array<Record<string, unknown>>;
89
117
  export declare function tailRun(runOrDir: string, lines?: number): string;
90
118
  export declare function readRunEvents(runOrDir: string, lines?: number): RunOutboxEvent[];
@@ -132,4 +160,8 @@ export interface RunProcessSignalPlan {
132
160
  }
133
161
  export declare function getRunProcessSignalPlan(pid: number, signal: NodeJS.Signals, runtimePlatform?: NodeJS.Platform): RunProcessSignalPlan;
134
162
  export declare function cancelRun(runOrDir: string): Record<string, unknown>;
163
+ export declare function archiveRun(runOrDir: string): Record<string, unknown>;
164
+ export declare function pruneRun(runOrDir: string, options?: {
165
+ preserveArtifacts?: boolean;
166
+ }): Record<string, unknown>;
135
167
  export declare function killRun(runOrDir: string): Record<string, unknown>;
@@ -4,8 +4,8 @@
4
4
  * Owns detached run state, observation, log tailing, listing, and cancellation safety
5
5
  */
6
6
  import { spawn, spawnSync } from "node:child_process";
7
- import { randomUUID } from "node:crypto";
8
- import { closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, cpSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
9
9
  import { createConnection } from "node:net";
10
10
  import { platform } from "node:os";
11
11
  import { basename, dirname, extname, join, resolve } from "node:path";
@@ -48,10 +48,60 @@ function resolveArtifactPaths(artifacts, values) {
48
48
  for (const [key, value] of Object.entries(artifacts)) {
49
49
  if (!key.trim())
50
50
  continue;
51
- resolved[key] = substituteCommandTemplateToken(value, values, `recipe artifacts.${key}`);
51
+ if (typeof value === "string") {
52
+ resolved[key] = substituteCommandTemplateToken(value, values, `recipe artifacts.${key}`);
53
+ }
54
+ else if (value &&
55
+ typeof value === "object" &&
56
+ typeof value.path === "string") {
57
+ resolved[key] = {
58
+ ...value,
59
+ path: substituteCommandTemplateToken(value.path, values, `recipe artifacts.${key}.path`),
60
+ };
61
+ }
52
62
  }
53
63
  return Object.keys(resolved).length > 0 ? resolved : undefined;
54
64
  }
65
+ export function resolveArtifactManifest(artifacts) {
66
+ if (!artifacts)
67
+ return undefined;
68
+ const manifest = {};
69
+ for (const [name, artifact] of Object.entries(artifacts)) {
70
+ const declaration = typeof artifact === "string" ? { path: artifact } : artifact;
71
+ if (!declaration?.path)
72
+ continue;
73
+ try {
74
+ const content = readFileSync(declaration.path);
75
+ manifest[name] = {
76
+ exists: true,
77
+ ...(declaration.kind ? { kind: declaration.kind } : {}),
78
+ ...(declaration.media_type
79
+ ? { media_type: declaration.media_type }
80
+ : {}),
81
+ path: declaration.path,
82
+ ...(declaration.required !== undefined
83
+ ? { required: declaration.required }
84
+ : {}),
85
+ sha256: createHash("sha256").update(content).digest("hex"),
86
+ size: content.byteLength,
87
+ };
88
+ }
89
+ catch {
90
+ manifest[name] = {
91
+ exists: false,
92
+ ...(declaration.kind ? { kind: declaration.kind } : {}),
93
+ ...(declaration.media_type
94
+ ? { media_type: declaration.media_type }
95
+ : {}),
96
+ path: declaration.path,
97
+ ...(declaration.required !== undefined
98
+ ? { required: declaration.required }
99
+ : {}),
100
+ };
101
+ }
102
+ }
103
+ return Object.keys(manifest).length > 0 ? manifest : undefined;
104
+ }
55
105
  function resolveRunTemplate(params) {
56
106
  if (!params.template)
57
107
  throw new Error("spawn requires file or template.");
@@ -270,7 +320,7 @@ export function startRun(params, cwd) {
270
320
  ? RecipeReferences.buildRecipeContextRecords(recipeFile)
271
321
  : undefined;
272
322
  if (recipeFile && isMutableUsageRecipeFile(recipeFile)) {
273
- RecipeUsage.recordRecipeLaunch(recipeFile);
323
+ RecipeUsage.recordRecipeLaunch(recipeFile, new Date(), startParams.launch_source === "tool" ? "tool" : "spawn");
274
324
  }
275
325
  const outFd = openSync(stdout, "a");
276
326
  const errFd = openSync(stderr, "a");
@@ -440,31 +490,111 @@ function matchesStatusFilter(status, filter) {
440
490
  return status !== "running";
441
491
  return status === filter;
442
492
  }
443
- export function listRuns(stateRoot = DEFAULT_STATE_ROOT, statusFilter) {
444
- if (!existsSync(stateRoot))
493
+ export function listRunStateDirs(stateRoot = DEFAULT_STATE_ROOT, depth = 0, seen = new Set()) {
494
+ if (!existsSync(stateRoot) || seen.has(resolve(stateRoot)))
495
+ return [];
496
+ seen.add(resolve(stateRoot));
497
+ let entries;
498
+ try {
499
+ entries = readdirSync(stateRoot, { withFileTypes: true });
500
+ }
501
+ catch {
445
502
  return [];
446
- const runs = [];
447
- for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
503
+ }
504
+ const result = [];
505
+ for (const entry of entries) {
448
506
  if (!entry.isDirectory())
449
507
  continue;
508
+ const child = join(stateRoot, entry.name);
509
+ if (existsSync(join(child, "run.json")))
510
+ result.push(child);
511
+ if (depth + 1 < 8)
512
+ result.push(...listRunStateDirs(child, depth + 1, seen));
513
+ }
514
+ return result;
515
+ }
516
+ function runIndexPath(stateRoot) {
517
+ return join(stateRoot, "index.json");
518
+ }
519
+ function indexEntryFromStatus(status) {
520
+ const progress = status.progress && typeof status.progress === "object"
521
+ ? status.progress
522
+ : {};
523
+ return {
524
+ ...(typeof status.ownerId === "string" ? { ownerId: status.ownerId } : {}),
525
+ ...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
526
+ run: String(status.run),
527
+ state_dir: String(status.state_dir),
528
+ status: status.status,
529
+ ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
530
+ updated_at: typeof progress.updatedAt === "string"
531
+ ? progress.updatedAt
532
+ : typeof status.createdAt === "string"
533
+ ? status.createdAt
534
+ : new Date(0).toISOString(),
535
+ };
536
+ }
537
+ export function rebuildRunStateIndex(stateRoot = DEFAULT_STATE_ROOT) {
538
+ mkdirSync(stateRoot, { recursive: true });
539
+ const entries = listRunStateDirs(stateRoot)
540
+ .flatMap((stateDir) => {
450
541
  try {
451
- const stateDir = join(stateRoot, entry.name);
452
- const status = getRunStatus(stateDir);
453
- if (!matchesStatusFilter(status.status, statusFilter))
454
- continue;
455
- runs.push({
456
- run: status.run,
457
- state_dir: stateDir,
458
- status: status.status,
459
- ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
460
- ...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
461
- });
542
+ return [indexEntryFromStatus(getRunStatus(stateDir))];
462
543
  }
463
544
  catch {
464
- // Ignore malformed run dirs.
545
+ return [];
465
546
  }
547
+ })
548
+ .sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
549
+ writeJsonAtomic(runIndexPath(stateRoot), {
550
+ entries,
551
+ rebuilt_at: new Date().toISOString(),
552
+ });
553
+ return entries;
554
+ }
555
+ export function readRunStateIndex(stateRoot = DEFAULT_STATE_ROOT) {
556
+ const index = readJson(runIndexPath(stateRoot));
557
+ if (!index || typeof index !== "object")
558
+ return undefined;
559
+ const entries = index.entries;
560
+ if (!Array.isArray(entries))
561
+ return undefined;
562
+ const valid = entries.filter((entry) => {
563
+ const record = entry && typeof entry === "object"
564
+ ? entry
565
+ : {};
566
+ return (typeof record.run === "string" &&
567
+ typeof record.state_dir === "string" &&
568
+ typeof record.status === "string");
569
+ });
570
+ if (valid.some((entry) => !existsSync(join(entry.state_dir, "run.json"))))
571
+ return undefined;
572
+ return valid;
573
+ }
574
+ export function listRuns(stateRoot = DEFAULT_STATE_ROOT, statusFilter) {
575
+ if (!existsSync(stateRoot))
576
+ return [];
577
+ const indexed = readRunStateIndex(stateRoot);
578
+ if (indexed) {
579
+ return indexed
580
+ .filter((entry) => matchesStatusFilter(entry.status, statusFilter))
581
+ .map((entry) => ({
582
+ run: entry.run,
583
+ state_dir: entry.state_dir,
584
+ status: entry.status,
585
+ ...(entry.tool ? { tool: entry.tool } : {}),
586
+ ...(entry.recipe ? { recipe: entry.recipe } : {}),
587
+ }));
466
588
  }
467
- return runs;
589
+ return rebuildRunStateIndex(stateRoot)
590
+ .filter((entry) => matchesStatusFilter(entry.status, statusFilter))
591
+ .map((entry) => ({
592
+ run: entry.run,
593
+ state_dir: entry.state_dir,
594
+ status: entry.status,
595
+ ...(entry.tool ? { tool: entry.tool } : {}),
596
+ ...(entry.recipe ? { recipe: entry.recipe } : {}),
597
+ }));
468
598
  }
469
599
  export function tailRun(runOrDir, lines = 40) {
470
600
  const status = getRunStatus(runOrDir);
@@ -630,11 +760,18 @@ export function appendRunOutboxEvent(runOrDir, event) {
630
760
  const run = String(status.run ?? runOrDir);
631
761
  const type = event.type || event.event || "run.message";
632
762
  const to = event.to || "coordinator";
763
+ const metadata = event.metadata ?? {};
764
+ const requiresResponse = metadata.requires_response === true;
633
765
  const payload = {
634
766
  ...(event.body !== undefined ? { body: event.body } : {}),
635
767
  ...(event.correlation_id ? { correlation_id: event.correlation_id } : {}),
636
768
  ...(event.data !== undefined ? { data: event.data } : {}),
637
- delivery: normalizeRunOutboxDelivery(event.delivery ?? (to === "coordinator" ? "followup" : "log")),
769
+ delivery: normalizeRunOutboxDelivery(event.delivery ??
770
+ (requiresResponse
771
+ ? "followup"
772
+ : to === "coordinator"
773
+ ? "notify"
774
+ : "log")),
638
775
  event: type,
639
776
  from: event.from || `run:${run}`,
640
777
  level: normalizeRunOutboxLevel(event.level),
@@ -905,6 +1042,62 @@ export function cancelRun(runOrDir) {
905
1042
  ? { cancelled: result.stopped, ...result }
906
1043
  : result;
907
1044
  }
1045
+ function assertTerminalRun(runOrDir) {
1046
+ const status = getRunStatus(runOrDir);
1047
+ if (status.status === "running") {
1048
+ throw new Error("Only terminal runs can be archived or pruned.");
1049
+ }
1050
+ return status;
1051
+ }
1052
+ function archivePathFor(run, stateDir) {
1053
+ const archiveRoot = join(dirname(stateDir), "archived");
1054
+ mkdirSync(archiveRoot, { recursive: true });
1055
+ return join(archiveRoot, `${safeRunId(run)}-${new Date().toISOString().replace(/[:.]/g, "-")}`);
1056
+ }
1057
+ export function archiveRun(runOrDir) {
1058
+ const status = assertTerminalRun(runOrDir);
1059
+ const stateDir = String(status.state_dir);
1060
+ const run = String(status.run ?? basename(stateDir));
1061
+ const archiveDir = archivePathFor(run, stateDir);
1062
+ renameSync(stateDir, archiveDir);
1063
+ mkdirSync(stateDir, { recursive: true });
1064
+ const tombstone = {
1065
+ archived: true,
1066
+ archive_dir: archiveDir,
1067
+ original_state_dir: stateDir,
1068
+ run,
1069
+ status: status.status,
1070
+ ts: new Date().toISOString(),
1071
+ };
1072
+ writeJsonAtomic(join(stateDir, "archive-tombstone.json"), tombstone);
1073
+ return tombstone;
1074
+ }
1075
+ export function pruneRun(runOrDir, options = {}) {
1076
+ const status = assertTerminalRun(runOrDir);
1077
+ const stateDir = String(status.state_dir);
1078
+ const run = String(status.run ?? basename(stateDir));
1079
+ const manifest = resolveArtifactManifest(status.artifacts);
1080
+ const preserved = {};
1081
+ if (options.preserveArtifacts && manifest) {
1082
+ const preserveRoot = join(dirname(stateDir), "preserved-artifacts", safeRunId(run));
1083
+ mkdirSync(preserveRoot, { recursive: true });
1084
+ for (const [name, artifact] of Object.entries(manifest)) {
1085
+ if (!artifact.exists)
1086
+ continue;
1087
+ const target = join(preserveRoot, basename(artifact.path));
1088
+ cpSync(artifact.path, target, { force: true });
1089
+ preserved[name] = target;
1090
+ }
1091
+ }
1092
+ rmSync(stateDir, { recursive: true, force: true });
1093
+ return {
1094
+ pruned: true,
1095
+ preserved_artifacts: preserved,
1096
+ run,
1097
+ state_dir: stateDir,
1098
+ ts: new Date().toISOString(),
1099
+ };
1100
+ }
908
1101
  export function killRun(runOrDir) {
909
1102
  const result = stopRun(runOrDir, "SIGKILL", "run.kill");
910
1103
  return Object.hasOwn(result, "stopped")
@@ -102,23 +102,23 @@ function getLeafCommandTemplateWarnings(config) {
102
102
  const shellContent = hasAnyFlag(args, ["-c"])
103
103
  ? "shell command strings"
104
104
  : "shell scripts";
105
- warnings.push(`${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting.`);
105
+ warnings.push(`${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting. Mitigation: keep scripts local, reviewed, and parameterized with explicit placeholders.`);
106
106
  }
107
107
  if (["node", "deno", "bun"].includes(command) &&
108
108
  hasAnyFlag(args, ["-e", "--eval"])) {
109
- warnings.push(`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed.`);
109
+ warnings.push(`${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`);
110
110
  }
111
111
  if (["python", "python3", "perl", "ruby"].includes(command) &&
112
112
  hasAnyFlag(args, ["-c", "-e"])) {
113
- warnings.push(`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed.`);
113
+ warnings.push(`${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed. Mitigation: prefer a checked-in script file or keep eval input fixed and reviewed.`);
114
114
  }
115
115
  if (command === "rm" &&
116
116
  (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
117
117
  hasRiskyPathArg(args))) {
118
- warnings.push(`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands.`);
118
+ warnings.push(`${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands. Mitigation: constrain path placeholders and consider dry-run or explicit confirmation.`);
119
119
  }
120
120
  if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
121
- warnings.push(`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands.`);
121
+ warnings.push(`${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands. Mitigation: constrain path placeholders and prefer narrow source/destination paths.`);
122
122
  }
123
123
  return warnings;
124
124
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared output and preview size limits.
3
+ * Zones: output governance, actor-message previews, inspect defaults
4
+ */
5
+ export declare const DEFAULT_INSPECT_LINES = 40;
6
+ export declare const TOOL_OUTPUT_MAX_BYTES: number;
7
+ export declare const TOOL_OUTPUT_MAX_LINES = 2000;
8
+ export declare const COMPACT_PREVIEW_CHARS = 160;
9
+ export declare const INSPECTOR_BODY_PREVIEW_CHARS = 320;
10
+ export declare const ROOM_MESSAGE_PREVIEW_CHARS = 320;
11
+ export declare const DOCTOR_ACTION_PREVIEW_CHARS = 72;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared output and preview size limits.
3
+ * Zones: output governance, actor-message previews, inspect defaults
4
+ */
5
+ export const DEFAULT_INSPECT_LINES = 40;
6
+ export const TOOL_OUTPUT_MAX_BYTES = 50 * 1024;
7
+ export const TOOL_OUTPUT_MAX_LINES = 2_000;
8
+ export const COMPACT_PREVIEW_CHARS = 160;
9
+ export const INSPECTOR_BODY_PREVIEW_CHARS = 320;
10
+ export const ROOM_MESSAGE_PREVIEW_CHARS = 320;
11
+ export const DOCTOR_ACTION_PREVIEW_CHARS = 72;
@@ -35,7 +35,7 @@ function getUpdatedAt(status) {
35
35
  ? status.createdAt
36
36
  : undefined;
37
37
  }
38
- function listRunStateDirs(stateRoot, depth = 0, seen = new Set()) {
38
+ function scanRunStateDirs(stateRoot, depth = 0, seen = new Set()) {
39
39
  if (!existsSync(stateRoot) || seen.has(stateRoot))
40
40
  return [];
41
41
  seen.add(stateRoot);
@@ -54,7 +54,7 @@ function listRunStateDirs(stateRoot, depth = 0, seen = new Set()) {
54
54
  if (existsSync(join(child, "run.json")))
55
55
  result.push(child);
56
56
  if (depth + 1 < RUN_STATE_DISCOVERY_MAX_DEPTH)
57
- result.push(...listRunStateDirs(child, depth + 1, seen));
57
+ result.push(...scanRunStateDirs(child, depth + 1, seen));
58
58
  }
59
59
  return result;
60
60
  }
@@ -114,7 +114,8 @@ export function summarizeRuns(stateRoot = Paths.getRunStateRoot(), ownerId) {
114
114
  total: 0,
115
115
  };
116
116
  }
117
- const runs = listRunStateDirs(stateRoot)
117
+ const runs = (AsyncRuns.readRunStateIndex(stateRoot)?.map((entry) => entry.state_dir) ??
118
+ scanRunStateDirs(stateRoot))
118
119
  .map((stateDir) => observeRun(stateDir))
119
120
  .filter((run) => Boolean(run))
120
121
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
@@ -190,7 +191,8 @@ function summarizeRunsWithoutSubagents(stateRoot, ownerId) {
190
191
  runs: [],
191
192
  total: 0,
192
193
  };
193
- const runs = listRunStateDirs(stateRoot)
194
+ const runs = (AsyncRuns.readRunStateIndex(stateRoot)?.map((entry) => entry.state_dir) ??
195
+ scanRunStateDirs(stateRoot))
194
196
  .map((stateDir) => observeRun(stateDir))
195
197
  .filter((run) => Boolean(run))
196
198
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
@@ -6,9 +6,8 @@
6
6
  import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { sanitizeFilePart } from "./identity.js";
9
+ import * as Limits from "./limits.js";
9
10
  import * as Paths from "./paths.js";
10
- const MAX_OUTPUT_BYTES = 50 * 1024;
11
- const MAX_OUTPUT_LINES = 2_000;
12
11
  export function writeFullOutput(toolName, stream, content) {
13
12
  try {
14
13
  const outputRoot = join(Paths.getExtensionTmpDir(), "outputs");
@@ -45,10 +44,10 @@ export function truncateTailContent(content) {
45
44
  const totalBytes = byteLength(content);
46
45
  const lines = content.split("\n");
47
46
  const totalLines = lines.length;
48
- let output = totalLines > MAX_OUTPUT_LINES
49
- ? lines.slice(-MAX_OUTPUT_LINES).join("\n")
47
+ let output = totalLines > Limits.TOOL_OUTPUT_MAX_LINES
48
+ ? lines.slice(-Limits.TOOL_OUTPUT_MAX_LINES).join("\n")
50
49
  : content;
51
- output = trimToTailBytes(output, MAX_OUTPUT_BYTES);
50
+ output = trimToTailBytes(output, Limits.TOOL_OUTPUT_MAX_BYTES);
52
51
  const outputBytes = byteLength(output);
53
52
  const outputLines = output ? output.split("\n").length : 0;
54
53
  return {
@@ -351,6 +351,7 @@ export function summarizeDiscovery(result) {
351
351
  disabled: entry.disabled,
352
352
  invalid: entry.invalid,
353
353
  shadows: entry.shadows,
354
+ ...(entry.config?.imports ? { imports: entry.config.imports } : {}),
354
355
  ...(recipeUsage(entry.config)
355
356
  ? { usage: recipeUsage(entry.config) }
356
357
  : {}),
@@ -11,9 +11,18 @@ export interface TemplateRecipeImportBinding {
11
11
  values?: Record<string, unknown>;
12
12
  }
13
13
  export type TemplateRecipeImport = string | TemplateRecipeImportBinding;
14
+ export type TemplateRecipeMailboxEntry = string | {
15
+ type: string;
16
+ body_schema?: unknown;
17
+ ack?: boolean | string;
18
+ idempotency?: string;
19
+ requires_response?: boolean;
20
+ level?: string;
21
+ summary?: string;
22
+ };
14
23
  export interface TemplateRecipeMailbox {
15
- accepts?: string[];
16
- emits?: string[];
24
+ accepts?: TemplateRecipeMailboxEntry[];
25
+ emits?: TemplateRecipeMailboxEntry[];
17
26
  }
18
27
  export interface TemplateRecipeDefinition {
19
28
  name?: string;
@@ -630,12 +630,14 @@ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
630
630
  mailbox: {
631
631
  ...(Array.isArray(substituted.mailbox.accepts)
632
632
  ? {
633
- accepts: substituted.mailbox.accepts.filter((value) => typeof value === "string"),
633
+ accepts: substituted.mailbox.accepts.filter((value) => typeof value === "string" ||
634
+ (isRecord(value) && typeof value.type === "string")),
634
635
  }
635
636
  : {}),
636
637
  ...(Array.isArray(substituted.mailbox.emits)
637
638
  ? {
638
- emits: substituted.mailbox.emits.filter((value) => typeof value === "string"),
639
+ emits: substituted.mailbox.emits.filter((value) => typeof value === "string" ||
640
+ (isRecord(value) && typeof value.type === "string")),
639
641
  }
640
642
  : {}),
641
643
  },
@@ -3,4 +3,5 @@
3
3
  * Zones: recipe telemetry, muscle-memory cleanup evidence
4
4
  * Owns lightweight launch counters for user-owned recipe files
5
5
  */
6
- export declare function recordRecipeLaunch(path: string, now?: Date): boolean;
6
+ export type RecipeLaunchKind = "direct" | "spawn" | "tool";
7
+ export declare function recordRecipeLaunch(path: string, now?: Date, kind?: RecipeLaunchKind): boolean;