@llblab/pi-actors 0.22.4 → 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 +23 -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
package/lib/async-runs.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { spawn, spawnSync } from "node:child_process";
8
- import { randomUUID } from "node:crypto";
8
+ import { createHash, randomUUID } from "node:crypto";
9
9
  import {
10
10
  closeSync,
11
11
  constants,
@@ -14,7 +14,9 @@ import {
14
14
  openSync,
15
15
  readdirSync,
16
16
  readFileSync,
17
+ cpSync,
17
18
  readlinkSync,
19
+ renameSync,
18
20
  rmSync,
19
21
  statSync,
20
22
  writeFileSync,
@@ -65,7 +67,7 @@ export interface AsyncRunStartParams {
65
67
  timeout?: number | string;
66
68
  delay?: number | string;
67
69
  output?: string;
68
- artifacts?: Record<string, string>;
70
+ artifacts?: Record<string, RunArtifactDeclaration>;
69
71
  mailbox?: RecipeReferences.TemplateRecipeMailbox;
70
72
  retire_when?: "children_terminal";
71
73
  retry?: number | string;
@@ -121,7 +123,7 @@ export interface AsyncRunMeta {
121
123
  tool?: string;
122
124
  template: CommandTemplateValue;
123
125
  values: Record<string, unknown>;
124
- artifacts?: Record<string, string>;
126
+ artifacts?: Record<string, RunArtifactDeclaration>;
125
127
  control?: AsyncRunControlEndpoint;
126
128
  mailbox?: RecipeReferences.TemplateRecipeMailbox;
127
129
  recipe_context_records?: RecipeReferences.TemplateRecipeContextRecord[];
@@ -160,23 +162,93 @@ function safeRunId(value: string | undefined): string {
160
162
  return run;
161
163
  }
162
164
 
165
+ export type RunArtifactDeclaration =
166
+ | string
167
+ | { path: string; kind?: string; media_type?: string; required?: boolean };
168
+
169
+ export interface RunArtifactManifestEntry {
170
+ exists: boolean;
171
+ kind?: string;
172
+ media_type?: string;
173
+ path: string;
174
+ required?: boolean;
175
+ sha256?: string;
176
+ size?: number;
177
+ }
178
+
163
179
  function resolveArtifactPaths(
164
- artifacts: Record<string, string> | undefined,
180
+ artifacts: Record<string, RunArtifactDeclaration> | undefined,
165
181
  values: Record<string, unknown>,
166
- ): Record<string, string> | undefined {
182
+ ): Record<string, RunArtifactDeclaration> | undefined {
167
183
  if (!artifacts) return undefined;
168
- const resolved: Record<string, string> = {};
184
+ const resolved: Record<string, RunArtifactDeclaration> = {};
169
185
  for (const [key, value] of Object.entries(artifacts)) {
170
186
  if (!key.trim()) continue;
171
- resolved[key] = substituteCommandTemplateToken(
172
- value,
173
- values,
174
- `recipe artifacts.${key}`,
175
- );
187
+ if (typeof value === "string") {
188
+ resolved[key] = substituteCommandTemplateToken(
189
+ value,
190
+ values,
191
+ `recipe artifacts.${key}`,
192
+ );
193
+ } else if (
194
+ value &&
195
+ typeof value === "object" &&
196
+ typeof value.path === "string"
197
+ ) {
198
+ resolved[key] = {
199
+ ...value,
200
+ path: substituteCommandTemplateToken(
201
+ value.path,
202
+ values,
203
+ `recipe artifacts.${key}.path`,
204
+ ),
205
+ };
206
+ }
176
207
  }
177
208
  return Object.keys(resolved).length > 0 ? resolved : undefined;
178
209
  }
179
210
 
211
+ export function resolveArtifactManifest(
212
+ artifacts: Record<string, RunArtifactDeclaration> | undefined,
213
+ ): Record<string, RunArtifactManifestEntry> | undefined {
214
+ if (!artifacts) return undefined;
215
+ const manifest: Record<string, RunArtifactManifestEntry> = {};
216
+ for (const [name, artifact] of Object.entries(artifacts)) {
217
+ const declaration =
218
+ typeof artifact === "string" ? { path: artifact } : artifact;
219
+ if (!declaration?.path) continue;
220
+ try {
221
+ const content = readFileSync(declaration.path);
222
+ manifest[name] = {
223
+ exists: true,
224
+ ...(declaration.kind ? { kind: declaration.kind } : {}),
225
+ ...(declaration.media_type
226
+ ? { media_type: declaration.media_type }
227
+ : {}),
228
+ path: declaration.path,
229
+ ...(declaration.required !== undefined
230
+ ? { required: declaration.required }
231
+ : {}),
232
+ sha256: createHash("sha256").update(content).digest("hex"),
233
+ size: content.byteLength,
234
+ };
235
+ } catch {
236
+ manifest[name] = {
237
+ exists: false,
238
+ ...(declaration.kind ? { kind: declaration.kind } : {}),
239
+ ...(declaration.media_type
240
+ ? { media_type: declaration.media_type }
241
+ : {}),
242
+ path: declaration.path,
243
+ ...(declaration.required !== undefined
244
+ ? { required: declaration.required }
245
+ : {}),
246
+ };
247
+ }
248
+ }
249
+ return Object.keys(manifest).length > 0 ? manifest : undefined;
250
+ }
251
+
180
252
  function resolveRunTemplate(params: AsyncRunStartParams): {
181
253
  template: CommandTemplateValue;
182
254
  } {
@@ -426,7 +498,11 @@ export function startRun(
426
498
  ? RecipeReferences.buildRecipeContextRecords(recipeFile)
427
499
  : undefined;
428
500
  if (recipeFile && isMutableUsageRecipeFile(recipeFile)) {
429
- RecipeUsage.recordRecipeLaunch(recipeFile);
501
+ RecipeUsage.recordRecipeLaunch(
502
+ recipeFile,
503
+ new Date(),
504
+ startParams.launch_source === "tool" ? "tool" : "spawn",
505
+ );
430
506
  }
431
507
  const outFd = openSync(stdout, "a");
432
508
  const errFd = openSync(stderr, "a");
@@ -611,6 +687,16 @@ export function getRunStatus(runOrDir: string): Record<string, unknown> {
611
687
  };
612
688
  }
613
689
 
690
+ export interface RunStateIndexEntry {
691
+ ownerId?: string;
692
+ recipe?: string;
693
+ run: string;
694
+ state_dir: string;
695
+ status: AsyncRunStatus;
696
+ tool?: string;
697
+ updated_at?: string;
698
+ }
699
+
614
700
  function matchesStatusFilter(
615
701
  status: unknown,
616
702
  filter: string | undefined,
@@ -621,30 +707,125 @@ function matchesStatusFilter(
621
707
  return status === filter;
622
708
  }
623
709
 
710
+ export function listRunStateDirs(
711
+ stateRoot = DEFAULT_STATE_ROOT,
712
+ depth = 0,
713
+ seen = new Set<string>(),
714
+ ): string[] {
715
+ if (!existsSync(stateRoot) || seen.has(resolve(stateRoot))) return [];
716
+ seen.add(resolve(stateRoot));
717
+ let entries: import("node:fs").Dirent[];
718
+ try {
719
+ entries = readdirSync(stateRoot, { withFileTypes: true });
720
+ } catch {
721
+ return [];
722
+ }
723
+ const result: string[] = [];
724
+ for (const entry of entries) {
725
+ if (!entry.isDirectory()) continue;
726
+ const child = join(stateRoot, entry.name);
727
+ if (existsSync(join(child, "run.json"))) result.push(child);
728
+ if (depth + 1 < 8) result.push(...listRunStateDirs(child, depth + 1, seen));
729
+ }
730
+ return result;
731
+ }
732
+
733
+ function runIndexPath(stateRoot: string): string {
734
+ return join(stateRoot, "index.json");
735
+ }
736
+
737
+ function indexEntryFromStatus(
738
+ status: Record<string, unknown>,
739
+ ): RunStateIndexEntry {
740
+ const progress =
741
+ status.progress && typeof status.progress === "object"
742
+ ? (status.progress as Record<string, unknown>)
743
+ : {};
744
+ return {
745
+ ...(typeof status.ownerId === "string" ? { ownerId: status.ownerId } : {}),
746
+ ...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
747
+ run: String(status.run),
748
+ state_dir: String(status.state_dir),
749
+ status: status.status as AsyncRunStatus,
750
+ ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
751
+ updated_at:
752
+ typeof progress.updatedAt === "string"
753
+ ? progress.updatedAt
754
+ : typeof status.createdAt === "string"
755
+ ? status.createdAt
756
+ : new Date(0).toISOString(),
757
+ };
758
+ }
759
+
760
+ export function rebuildRunStateIndex(
761
+ stateRoot = DEFAULT_STATE_ROOT,
762
+ ): RunStateIndexEntry[] {
763
+ mkdirSync(stateRoot, { recursive: true });
764
+ const entries = listRunStateDirs(stateRoot)
765
+ .flatMap((stateDir) => {
766
+ try {
767
+ return [indexEntryFromStatus(getRunStatus(stateDir))];
768
+ } catch {
769
+ return [];
770
+ }
771
+ })
772
+ .sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
773
+ writeJsonAtomic(runIndexPath(stateRoot), {
774
+ entries,
775
+ rebuilt_at: new Date().toISOString(),
776
+ });
777
+ return entries;
778
+ }
779
+
780
+ export function readRunStateIndex(
781
+ stateRoot = DEFAULT_STATE_ROOT,
782
+ ): RunStateIndexEntry[] | undefined {
783
+ const index = readJson(runIndexPath(stateRoot));
784
+ if (!index || typeof index !== "object") return undefined;
785
+ const entries = (index as Record<string, unknown>).entries;
786
+ if (!Array.isArray(entries)) return undefined;
787
+ const valid = entries.filter((entry): entry is RunStateIndexEntry => {
788
+ const record =
789
+ entry && typeof entry === "object"
790
+ ? (entry as Record<string, unknown>)
791
+ : {};
792
+ return (
793
+ typeof record.run === "string" &&
794
+ typeof record.state_dir === "string" &&
795
+ typeof record.status === "string"
796
+ );
797
+ });
798
+ if (valid.some((entry) => !existsSync(join(entry.state_dir, "run.json"))))
799
+ return undefined;
800
+ return valid;
801
+ }
802
+
624
803
  export function listRuns(
625
804
  stateRoot = DEFAULT_STATE_ROOT,
626
805
  statusFilter?: string,
627
806
  ): Array<Record<string, unknown>> {
628
807
  if (!existsSync(stateRoot)) return [];
629
- const runs: Array<Record<string, unknown>> = [];
630
- for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
631
- if (!entry.isDirectory()) continue;
632
- try {
633
- const stateDir = join(stateRoot, entry.name);
634
- const status = getRunStatus(stateDir);
635
- if (!matchesStatusFilter(status.status, statusFilter)) continue;
636
- runs.push({
637
- run: status.run,
638
- state_dir: stateDir,
639
- status: status.status,
640
- ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
641
- ...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
642
- });
643
- } catch {
644
- // Ignore malformed run dirs.
645
- }
808
+ const indexed = readRunStateIndex(stateRoot);
809
+ if (indexed) {
810
+ return indexed
811
+ .filter((entry) => matchesStatusFilter(entry.status, statusFilter))
812
+ .map((entry) => ({
813
+ run: entry.run,
814
+ state_dir: entry.state_dir,
815
+ status: entry.status,
816
+ ...(entry.tool ? { tool: entry.tool } : {}),
817
+ ...(entry.recipe ? { recipe: entry.recipe } : {}),
818
+ }));
646
819
  }
647
- return runs;
820
+ return rebuildRunStateIndex(stateRoot)
821
+ .filter((entry) => matchesStatusFilter(entry.status, statusFilter))
822
+ .map((entry) => ({
823
+ run: entry.run,
824
+ state_dir: entry.state_dir,
825
+ status: entry.status,
826
+ ...(entry.tool ? { tool: entry.tool } : {}),
827
+ ...(entry.recipe ? { recipe: entry.recipe } : {}),
828
+ }));
648
829
  }
649
830
 
650
831
  export function tailRun(runOrDir: string, lines = 40): string {
@@ -879,12 +1060,19 @@ export function appendRunOutboxEvent(
879
1060
  const run = String(status.run ?? runOrDir);
880
1061
  const type = event.type || event.event || "run.message";
881
1062
  const to = event.to || "coordinator";
1063
+ const metadata = event.metadata ?? {};
1064
+ const requiresResponse = metadata.requires_response === true;
882
1065
  const payload = {
883
1066
  ...(event.body !== undefined ? { body: event.body } : {}),
884
1067
  ...(event.correlation_id ? { correlation_id: event.correlation_id } : {}),
885
1068
  ...(event.data !== undefined ? { data: event.data } : {}),
886
1069
  delivery: normalizeRunOutboxDelivery(
887
- event.delivery ?? (to === "coordinator" ? "followup" : "log"),
1070
+ event.delivery ??
1071
+ (requiresResponse
1072
+ ? "followup"
1073
+ : to === "coordinator"
1074
+ ? "notify"
1075
+ : "log"),
888
1076
  ),
889
1077
  event: type,
890
1078
  from: event.from || `run:${run}`,
@@ -1242,6 +1430,77 @@ export function cancelRun(runOrDir: string): Record<string, unknown> {
1242
1430
  : result;
1243
1431
  }
1244
1432
 
1433
+ function assertTerminalRun(runOrDir: string): Record<string, unknown> {
1434
+ const status = getRunStatus(runOrDir);
1435
+ if (status.status === "running") {
1436
+ throw new Error("Only terminal runs can be archived or pruned.");
1437
+ }
1438
+ return status;
1439
+ }
1440
+
1441
+ function archivePathFor(run: string, stateDir: string): string {
1442
+ const archiveRoot = join(dirname(stateDir), "archived");
1443
+ mkdirSync(archiveRoot, { recursive: true });
1444
+ return join(
1445
+ archiveRoot,
1446
+ `${safeRunId(run)}-${new Date().toISOString().replace(/[:.]/g, "-")}`,
1447
+ );
1448
+ }
1449
+
1450
+ export function archiveRun(runOrDir: string): Record<string, unknown> {
1451
+ const status = assertTerminalRun(runOrDir);
1452
+ const stateDir = String(status.state_dir);
1453
+ const run = String(status.run ?? basename(stateDir));
1454
+ const archiveDir = archivePathFor(run, stateDir);
1455
+ renameSync(stateDir, archiveDir);
1456
+ mkdirSync(stateDir, { recursive: true });
1457
+ const tombstone = {
1458
+ archived: true,
1459
+ archive_dir: archiveDir,
1460
+ original_state_dir: stateDir,
1461
+ run,
1462
+ status: status.status,
1463
+ ts: new Date().toISOString(),
1464
+ };
1465
+ writeJsonAtomic(join(stateDir, "archive-tombstone.json"), tombstone);
1466
+ return tombstone;
1467
+ }
1468
+
1469
+ export function pruneRun(
1470
+ runOrDir: string,
1471
+ options: { preserveArtifacts?: boolean } = {},
1472
+ ): Record<string, unknown> {
1473
+ const status = assertTerminalRun(runOrDir);
1474
+ const stateDir = String(status.state_dir);
1475
+ const run = String(status.run ?? basename(stateDir));
1476
+ const manifest = resolveArtifactManifest(
1477
+ status.artifacts as Record<string, RunArtifactDeclaration> | undefined,
1478
+ );
1479
+ const preserved: Record<string, string> = {};
1480
+ if (options.preserveArtifacts && manifest) {
1481
+ const preserveRoot = join(
1482
+ dirname(stateDir),
1483
+ "preserved-artifacts",
1484
+ safeRunId(run),
1485
+ );
1486
+ mkdirSync(preserveRoot, { recursive: true });
1487
+ for (const [name, artifact] of Object.entries(manifest)) {
1488
+ if (!artifact.exists) continue;
1489
+ const target = join(preserveRoot, basename(artifact.path));
1490
+ cpSync(artifact.path, target, { force: true });
1491
+ preserved[name] = target;
1492
+ }
1493
+ }
1494
+ rmSync(stateDir, { recursive: true, force: true });
1495
+ return {
1496
+ pruned: true,
1497
+ preserved_artifacts: preserved,
1498
+ run,
1499
+ state_dir: stateDir,
1500
+ ts: new Date().toISOString(),
1501
+ };
1502
+ }
1503
+
1245
1504
  export function killRun(runOrDir: string): Record<string, unknown> {
1246
1505
  const result = stopRun(runOrDir, "SIGKILL", "run.kill");
1247
1506
  return Object.hasOwn(result, "stopped")
@@ -193,7 +193,7 @@ function getLeafCommandTemplateWarnings(
193
193
  ? "shell command strings"
194
194
  : "shell scripts";
195
195
  warnings.push(
196
- `${config.label ?? command}: invokes ${command}; ${shellContent} are trusted executable content and are not sandboxed by command-template argv splitting.`,
196
+ `${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.`,
197
197
  );
198
198
  }
199
199
  if (
@@ -201,7 +201,7 @@ function getLeafCommandTemplateWarnings(
201
201
  hasAnyFlag(args, ["-e", "--eval"])
202
202
  ) {
203
203
  warnings.push(
204
- `${config.label ?? command}: invokes ${command} eval mode; code strings are trusted executable content and are not sandboxed.`,
204
+ `${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.`,
205
205
  );
206
206
  }
207
207
  if (
@@ -209,7 +209,7 @@ function getLeafCommandTemplateWarnings(
209
209
  hasAnyFlag(args, ["-c", "-e"])
210
210
  ) {
211
211
  warnings.push(
212
- `${config.label ?? command}: invokes ${command} code-eval mode; code strings are trusted executable content and are not sandboxed.`,
212
+ `${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.`,
213
213
  );
214
214
  }
215
215
  if (
@@ -218,12 +218,12 @@ function getLeafCommandTemplateWarnings(
218
218
  hasRiskyPathArg(args))
219
219
  ) {
220
220
  warnings.push(
221
- `${config.label ?? command}: removes filesystem paths; verify placeholders and paths before running trusted destructive commands.`,
221
+ `${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.`,
222
222
  );
223
223
  }
224
224
  if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
225
225
  warnings.push(
226
- `${config.label ?? command}: mutates broad filesystem paths; verify placeholders and paths before running trusted commands.`,
226
+ `${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.`,
227
227
  );
228
228
  }
229
229
  return warnings;
package/lib/limits.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared output and preview size limits.
3
+ * Zones: output governance, actor-message previews, inspect defaults
4
+ */
5
+
6
+ export const DEFAULT_INSPECT_LINES = 40;
7
+ export const TOOL_OUTPUT_MAX_BYTES = 50 * 1024;
8
+ export const TOOL_OUTPUT_MAX_LINES = 2_000;
9
+ export const COMPACT_PREVIEW_CHARS = 160;
10
+ export const INSPECTOR_BODY_PREVIEW_CHARS = 320;
11
+ export const ROOM_MESSAGE_PREVIEW_CHARS = 320;
12
+ export const DOCTOR_ACTION_PREVIEW_CHARS = 72;
@@ -144,7 +144,7 @@ function getUpdatedAt(status: Record<string, unknown>): string | undefined {
144
144
  : undefined;
145
145
  }
146
146
 
147
- function listRunStateDirs(
147
+ function scanRunStateDirs(
148
148
  stateRoot: string,
149
149
  depth = 0,
150
150
  seen = new Set<string>(),
@@ -163,7 +163,7 @@ function listRunStateDirs(
163
163
  const child = join(stateRoot, entry.name);
164
164
  if (existsSync(join(child, "run.json"))) result.push(child);
165
165
  if (depth + 1 < RUN_STATE_DISCOVERY_MAX_DEPTH)
166
- result.push(...listRunStateDirs(child, depth + 1, seen));
166
+ result.push(...scanRunStateDirs(child, depth + 1, seen));
167
167
  }
168
168
  return result;
169
169
  }
@@ -226,7 +226,10 @@ export function summarizeRuns(
226
226
  total: 0,
227
227
  };
228
228
  }
229
- const runs = listRunStateDirs(stateRoot)
229
+ const runs = (
230
+ AsyncRuns.readRunStateIndex(stateRoot)?.map((entry) => entry.state_dir) ??
231
+ scanRunStateDirs(stateRoot)
232
+ )
230
233
  .map((stateDir) => observeRun(stateDir))
231
234
  .filter((run): run is RunObservation => Boolean(run))
232
235
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
@@ -331,7 +334,10 @@ function summarizeRunsWithoutSubagents(
331
334
  runs: [],
332
335
  total: 0,
333
336
  };
334
- const runs = listRunStateDirs(stateRoot)
337
+ const runs = (
338
+ AsyncRuns.readRunStateIndex(stateRoot)?.map((entry) => entry.state_dir) ??
339
+ scanRunStateDirs(stateRoot)
340
+ )
335
341
  .map((stateDir) => observeRun(stateDir))
336
342
  .filter((run): run is RunObservation => Boolean(run))
337
343
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
package/lib/output.ts CHANGED
@@ -8,6 +8,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
 
10
10
  import { sanitizeFilePart } from "./identity.ts";
11
+ import * as Limits from "./limits.ts";
11
12
  import * as Paths from "./paths.ts";
12
13
 
13
14
  export interface FormattedOutput {
@@ -16,9 +17,6 @@ export interface FormattedOutput {
16
17
  fullOutputPath?: string;
17
18
  }
18
19
 
19
- const MAX_OUTPUT_BYTES = 50 * 1024;
20
- const MAX_OUTPUT_LINES = 2_000;
21
-
22
20
  export function writeFullOutput(
23
21
  toolName: string,
24
22
  stream: string,
@@ -70,10 +68,10 @@ export function truncateTailContent(content: string): {
70
68
  const lines = content.split("\n");
71
69
  const totalLines = lines.length;
72
70
  let output =
73
- totalLines > MAX_OUTPUT_LINES
74
- ? lines.slice(-MAX_OUTPUT_LINES).join("\n")
71
+ totalLines > Limits.TOOL_OUTPUT_MAX_LINES
72
+ ? lines.slice(-Limits.TOOL_OUTPUT_MAX_LINES).join("\n")
75
73
  : content;
76
- output = trimToTailBytes(output, MAX_OUTPUT_BYTES);
74
+ output = trimToTailBytes(output, Limits.TOOL_OUTPUT_MAX_BYTES);
77
75
  const outputBytes = byteLength(output);
78
76
  const outputLines = output ? output.split("\n").length : 0;
79
77
  return {
@@ -493,6 +493,7 @@ export function summarizeDiscovery(
493
493
  disabled: entry.disabled,
494
494
  invalid: entry.invalid,
495
495
  shadows: entry.shadows,
496
+ ...(entry.config?.imports ? { imports: entry.config.imports } : {}),
496
497
  ...(recipeUsage(entry.config)
497
498
  ? { usage: recipeUsage(entry.config) }
498
499
  : {}),
@@ -26,9 +26,21 @@ export interface TemplateRecipeImportBinding {
26
26
 
27
27
  export type TemplateRecipeImport = string | TemplateRecipeImportBinding;
28
28
 
29
+ export type TemplateRecipeMailboxEntry =
30
+ | string
31
+ | {
32
+ type: string;
33
+ body_schema?: unknown;
34
+ ack?: boolean | string;
35
+ idempotency?: string;
36
+ requires_response?: boolean;
37
+ level?: string;
38
+ summary?: string;
39
+ };
40
+
29
41
  export interface TemplateRecipeMailbox {
30
- accepts?: string[];
31
- emits?: string[];
42
+ accepts?: TemplateRecipeMailboxEntry[];
43
+ emits?: TemplateRecipeMailboxEntry[];
32
44
  }
33
45
 
34
46
  export interface TemplateRecipeDefinition {
@@ -834,14 +846,18 @@ export function readResolvedRecipeConfig(
834
846
  ...(Array.isArray(substituted.mailbox.accepts)
835
847
  ? {
836
848
  accepts: substituted.mailbox.accepts.filter(
837
- (value): value is string => typeof value === "string",
849
+ (value): value is TemplateRecipeMailboxEntry =>
850
+ typeof value === "string" ||
851
+ (isRecord(value) && typeof value.type === "string"),
838
852
  ),
839
853
  }
840
854
  : {}),
841
855
  ...(Array.isArray(substituted.mailbox.emits)
842
856
  ? {
843
857
  emits: substituted.mailbox.emits.filter(
844
- (value): value is string => typeof value === "string",
858
+ (value): value is TemplateRecipeMailboxEntry =>
859
+ typeof value === "string" ||
860
+ (isRecord(value) && typeof value.type === "string"),
845
861
  ),
846
862
  }
847
863
  : {}),
@@ -11,9 +11,14 @@ import { writeJsonAtomic } from "./file-state.ts";
11
11
 
12
12
  interface RecipeUsageRecord {
13
13
  calls?: number;
14
- last_called?: string;
14
+ direct_calls?: number;
15
15
  fingerprint?: string;
16
+ last_called?: string;
17
+ launch_kind?: string;
16
18
  reset_at?: string;
19
+ reset_reason?: string;
20
+ spawn_calls?: number;
21
+ tool_calls?: number;
17
22
  }
18
23
 
19
24
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -40,23 +45,45 @@ function getRecipeFingerprint(raw: Record<string, unknown>): string {
40
45
  return createHash("sha256").update(stableStringify(content)).digest("hex");
41
46
  }
42
47
 
43
- export function recordRecipeLaunch(path: string, now = new Date()): boolean {
48
+ export type RecipeLaunchKind = "direct" | "spawn" | "tool";
49
+
50
+ function launchCounterKey(
51
+ kind: RecipeLaunchKind,
52
+ ): "direct_calls" | "spawn_calls" | "tool_calls" {
53
+ return `${kind}_calls` as "direct_calls" | "spawn_calls" | "tool_calls";
54
+ }
55
+
56
+ export function recordRecipeLaunch(
57
+ path: string,
58
+ now = new Date(),
59
+ kind: RecipeLaunchKind = "direct",
60
+ ): boolean {
44
61
  if (!existsSync(path)) return false;
45
62
  try {
46
63
  const raw = JSON.parse(readFileSync(path, "utf8"));
47
64
  if (!isRecord(raw)) return false;
48
65
  const usage: RecipeUsageRecord = isRecord(raw.usage) ? raw.usage : {};
49
66
  const fingerprint = getRecipeFingerprint(raw);
50
- const changed = typeof usage.fingerprint === "string" && usage.fingerprint !== fingerprint;
67
+ const changed =
68
+ typeof usage.fingerprint === "string" &&
69
+ usage.fingerprint !== fingerprint;
51
70
  const nowIso = now.toISOString();
71
+ const counterKey = launchCounterKey(kind);
52
72
  writeJsonAtomic(path, {
53
73
  ...raw,
54
74
  usage: {
55
75
  ...usage,
56
76
  calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
77
+ [counterKey]: (changed ? 0 : normalizeCalls(usage[counterKey])) + 1,
57
78
  last_called: nowIso,
79
+ launch_kind: kind,
58
80
  fingerprint,
59
- ...(changed ? { reset_at: nowIso } : {}),
81
+ ...(changed
82
+ ? {
83
+ reset_at: nowIso,
84
+ reset_reason: "recipe content fingerprint changed",
85
+ }
86
+ : {}),
60
87
  },
61
88
  });
62
89
  return true;