@llblab/pi-actors 0.35.0 → 0.37.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 (42) hide show
  1. package/AGENTS.md +3 -1
  2. package/BACKLOG.md +4 -72
  3. package/CHANGELOG.md +17 -3
  4. package/README.md +2 -1
  5. package/dist/index.js +0 -1
  6. package/dist/lib/async-runs.js +4 -3
  7. package/dist/lib/command-templates.d.ts +2 -0
  8. package/dist/lib/command-templates.js +95 -5
  9. package/dist/lib/recipes-discovery.d.ts +2 -0
  10. package/dist/lib/recipes-discovery.js +52 -5
  11. package/dist/lib/recipes-references.d.ts +1 -0
  12. package/dist/lib/recipes-references.js +122 -35
  13. package/dist/lib/registry.d.ts +1 -1
  14. package/dist/lib/registry.js +6 -6
  15. package/dist/lib/runtime-notifier.js +8 -3
  16. package/dist/lib/runtime.d.ts +2 -6
  17. package/dist/lib/runtime.js +10 -11
  18. package/dist/lib/tools-inspect.js +172 -4
  19. package/dist/lib/tools-response.js +13 -2
  20. package/dist/lib/tools.d.ts +1 -1
  21. package/dist/lib/tools.js +1 -1
  22. package/dist/scripts/validate-recipe.mjs +164 -7
  23. package/dist/skills/actors/SKILL.md +45 -13
  24. package/dist/skills/swarm/SKILL.md +1 -1
  25. package/docs/recipe-library.md +2 -0
  26. package/docs/template-recipes.md +20 -1
  27. package/docs/tool-registry.md +15 -10
  28. package/index.ts +0 -1
  29. package/lib/async-runs.ts +4 -7
  30. package/lib/command-templates.ts +146 -7
  31. package/lib/recipes-discovery.ts +69 -6
  32. package/lib/recipes-references.ts +201 -34
  33. package/lib/registry.ts +5 -5
  34. package/lib/runtime-notifier.ts +9 -3
  35. package/lib/runtime.ts +10 -16
  36. package/lib/tools-inspect.ts +186 -4
  37. package/lib/tools-response.ts +16 -2
  38. package/lib/tools.ts +2 -2
  39. package/package.json +3 -2
  40. package/scripts/validate-recipe.mjs +164 -7
  41. package/skills/actors/SKILL.md +45 -13
  42. package/skills/swarm/SKILL.md +1 -1
@@ -27,6 +27,7 @@ export interface DiscoveredRecipe {
27
27
  tool: boolean;
28
28
  mutableUsage: boolean;
29
29
  diagnostics: string[];
30
+ riskLabels: CommandTemplates.CommandTemplateRiskLabel[];
30
31
  shadows: string[];
31
32
  }
32
33
 
@@ -110,6 +111,14 @@ function listRecipeFiles(root: string): string[] {
110
111
  );
111
112
  }
112
113
 
114
+ function getRecipeCommandTemplateConfig(
115
+ config: TemplateRecipeConfig,
116
+ ): CommandTemplates.CommandTemplateConfig {
117
+ return (typeof config.template === "object" && config.template !== null
118
+ ? config.template
119
+ : config) as CommandTemplates.CommandTemplateConfig;
120
+ }
121
+
113
122
  function getRecipeConfigDiagnostics(
114
123
  file: string,
115
124
  config: TemplateRecipeConfig | undefined,
@@ -118,15 +127,36 @@ function getRecipeConfigDiagnostics(
118
127
  const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(file);
119
128
  return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
120
129
  }
121
- const commandTemplateConfig =
122
- typeof config.template === "object" && config.template !== null
123
- ? config.template
124
- : config;
125
130
  return CommandTemplates.getCommandTemplateWarnings(
126
- commandTemplateConfig as CommandTemplates.CommandTemplateConfig,
131
+ getRecipeCommandTemplateConfig(config),
127
132
  ).map((warning) => `Recipe ${file}: ${warning}`);
128
133
  }
129
134
 
135
+ function getRecipeRiskLabels(
136
+ config: TemplateRecipeConfig | undefined,
137
+ ): CommandTemplates.CommandTemplateRiskLabel[] {
138
+ if (!config) return [];
139
+ const labels = new Set(
140
+ CommandTemplates.getCommandTemplateRiskLabels(
141
+ getRecipeCommandTemplateConfig(config),
142
+ ),
143
+ );
144
+ if (config.async === true) labels.add("risk.long_running");
145
+ return [
146
+ "risk.shell",
147
+ "risk.eval",
148
+ "risk.destructive_fs",
149
+ "risk.broad_fs_write",
150
+ "risk.external_side_effect",
151
+ "risk.secret_touching",
152
+ "risk.network",
153
+ "risk.long_running",
154
+ "risk.platform_specific",
155
+ ].filter((label): label is CommandTemplates.CommandTemplateRiskLabel =>
156
+ labels.has(label as CommandTemplates.CommandTemplateRiskLabel),
157
+ );
158
+ }
159
+
130
160
  function readDiscoveredRecipe(
131
161
  root: string,
132
162
  file: string,
@@ -152,6 +182,7 @@ function readDiscoveredRecipe(
152
182
  tool: defaultTool && !disabled && !invalid,
153
183
  mutableUsage,
154
184
  diagnostics: getRecipeConfigDiagnostics(file, config),
185
+ riskLabels: getRecipeRiskLabels(config),
155
186
  shadows: [],
156
187
  };
157
188
  } catch (error) {
@@ -169,6 +200,7 @@ function readDiscoveredRecipe(
169
200
  diagnostics: [
170
201
  `Failed to load recipe ${file}: ${error instanceof Error ? error.message : String(error)}`,
171
202
  ],
203
+ riskLabels: [],
172
204
  shadows: [],
173
205
  };
174
206
  }
@@ -392,7 +424,7 @@ function diagnosticSeverity(message: string): "info" | "warning" | "error" {
392
424
  return "error";
393
425
  }
394
426
  if (
395
- /world-writable|group-writable|invokes bash|eval|destructive|unsafe/i.test(
427
+ /world-writable|group-writable|invokes bash|eval|destructive|broad filesystem|unsafe/i.test(
396
428
  message,
397
429
  )
398
430
  ) {
@@ -435,6 +467,7 @@ function diagnosticDetails(
435
467
  seen.add(key);
436
468
  details.push({
437
469
  ...(entry ? { id: entry.id, path: entry.path } : {}),
470
+ ...(entry?.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
438
471
  action: diagnosticSuggestedAction(message),
439
472
  message,
440
473
  severity: diagnosticSeverity(message),
@@ -496,6 +529,7 @@ function remediationForEntry(
496
529
  kind: "risky_shell_boundary",
497
530
  severity: "warning",
498
531
  path: entry.path,
532
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
499
533
  reason: riskyDiagnostics[0],
500
534
  action:
501
535
  "audit trusted command boundary; keep only if the recipe is local and intentional",
@@ -544,6 +578,31 @@ function discoveryRemediations(
544
578
  );
545
579
  }
546
580
 
581
+ function summarizeRiskLabels(
582
+ entries: DiscoveredRecipe[],
583
+ ): Array<Record<string, unknown>> {
584
+ const counts = new Map<string, { count: number; ids: Set<string> }>();
585
+ for (const entry of entries) {
586
+ for (const label of entry.riskLabels) {
587
+ const current = counts.get(label) ?? { count: 0, ids: new Set<string>() };
588
+ current.count += 1;
589
+ current.ids.add(entry.id);
590
+ counts.set(label, current);
591
+ }
592
+ }
593
+ return [...counts.entries()]
594
+ .map(([label, value]) => ({
595
+ label,
596
+ count: value.count,
597
+ recipes: [...value.ids].sort(),
598
+ }))
599
+ .sort(
600
+ (a, b) =>
601
+ Number(b.count) - Number(a.count) ||
602
+ String(a.label).localeCompare(String(b.label)),
603
+ );
604
+ }
605
+
547
606
  function recommendationForEntry(
548
607
  entry: DiscoveredRecipe,
549
608
  activePath: string | undefined,
@@ -598,6 +657,7 @@ export function listDraftRecipes(root: string): Array<Record<string, unknown>> {
598
657
  /spawn run ([^\s]+)/,
599
658
  )?.[1];
600
659
  const preview = templatePreview(config?.template);
660
+ const riskLabels = getRecipeRiskLabels(resolved);
601
661
  return {
602
662
  id,
603
663
  path,
@@ -607,6 +667,7 @@ export function listDraftRecipes(root: string): Array<Record<string, unknown>> {
607
667
  modified_at: stat.mtime.toISOString(),
608
668
  valid: Boolean(resolved),
609
669
  diagnostics,
670
+ ...(riskLabels.length ? { risk_labels: riskLabels } : {}),
610
671
  ...(config?.description ? { description: config.description } : {}),
611
672
  ...(sourceRun ? { source_run: sourceRun } : {}),
612
673
  ...(config?.async !== undefined ? { async: config.async } : {}),
@@ -639,6 +700,7 @@ export function summarizeDiscovery(
639
700
  disabled: entry.disabled,
640
701
  invalid: entry.invalid,
641
702
  shadows: entry.shadows,
703
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
642
704
  ...(entry.config?.imports ? { imports: entry.config.imports } : {}),
643
705
  ...(recipeUsage(entry.config)
644
706
  ? { usage: recipeUsage(entry.config) }
@@ -665,6 +727,7 @@ export function summarizeDiscovery(
665
727
  .filter((entry) => entry.disabled)
666
728
  .map((entry) => ({ id: entry.id, path: entry.path }))
667
729
  .sort((a, b) => a.id.localeCompare(b.id)),
730
+ risk_summary: summarizeRiskLabels(result.entries),
668
731
  recommendations,
669
732
  remediations,
670
733
  top_action: remediations[0],
@@ -135,27 +135,55 @@ function recipeNameFiles(value: string): string[] {
135
135
  return [`${trimmed}.json`, `${trimmed}.md`];
136
136
  }
137
137
 
138
- function resolveRecipeImportPath(
138
+ function recipeCandidatePaths(
139
139
  value: string,
140
140
  currentRecipeRoot: string,
141
- ): string {
141
+ ): string[] {
142
142
  if (!isBareRecipeName(value))
143
- return resolveRecipePath(value, currentRecipeRoot);
143
+ return [resolveRecipePath(value, currentRecipeRoot)];
144
144
  const roots = [
145
145
  Paths.getRecipeRoot(),
146
146
  currentRecipeRoot,
147
147
  Paths.getPackagedRecipeRoot(),
148
148
  ];
149
- const candidates = [
149
+ return [
150
150
  ...new Set(
151
151
  roots.flatMap((root) =>
152
152
  recipeNameFiles(value).map((file) => resolve(root, file)),
153
153
  ),
154
154
  ),
155
155
  ];
156
+ }
157
+
158
+ function resolveRecipeImportPath(
159
+ value: string,
160
+ currentRecipeRoot: string,
161
+ ): string {
162
+ const candidates = recipeCandidatePaths(value, currentRecipeRoot);
156
163
  return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
157
164
  }
158
165
 
166
+ export function resolveRecipeReferencePath(
167
+ value: unknown,
168
+ currentRecipeRoot = Paths.getRecipeRoot(),
169
+ ): string | undefined {
170
+ if (typeof value !== "string") return undefined;
171
+ const trimmed = value.trim();
172
+ if (!trimmed || hasWhitespace(trimmed)) return undefined;
173
+ for (const path of recipeCandidatePaths(trimmed, currentRecipeRoot)) {
174
+ if (!existsSync(path)) continue;
175
+ try {
176
+ const raw = readRawRecipeConfig(path);
177
+ if (raw && typeof raw === "object" && Object.hasOwn(raw, "template"))
178
+ return path;
179
+ return path;
180
+ } catch {
181
+ return path;
182
+ }
183
+ }
184
+ return undefined;
185
+ }
186
+
159
187
  export function getRecipePath(
160
188
  value: unknown,
161
189
  recipeRoot = Paths.getRecipeRoot(),
@@ -165,18 +193,7 @@ export function getRecipePath(
165
193
  if (!trimmed || hasWhitespace(trimmed)) return undefined;
166
194
  if (trimmed.endsWith(".json") || trimmed.endsWith(".md"))
167
195
  return resolveRecipePath(trimmed, recipeRoot);
168
- const jsonPath = resolveRecipePath(trimmed, recipeRoot);
169
- const mdPath = resolveRecipePath(`${trimmed}.md`, recipeRoot);
170
- const path = existsSync(jsonPath) ? jsonPath : mdPath;
171
- if (!existsSync(path)) return undefined;
172
- try {
173
- const raw = readRawRecipeConfig(path);
174
- return raw && typeof raw === "object" && Object.hasOwn(raw, "template")
175
- ? path
176
- : undefined;
177
- } catch {
178
- return undefined;
179
- }
196
+ return resolveRecipeReferencePath(trimmed, recipeRoot);
180
197
  }
181
198
 
182
199
  function isImportNode(value: unknown): boolean {
@@ -483,7 +500,7 @@ export function getRecipeIdFromPath(file: string): string {
483
500
  }
484
501
 
485
502
  function readRecipeConfig(value: unknown): TemplateRecipeConfig | undefined {
486
- const path = getRecipePath(value);
503
+ const path = resolveRecipeReferencePath(value);
487
504
  return path ? readResolvedRecipeConfig(path) : undefined;
488
505
  }
489
506
 
@@ -647,6 +664,7 @@ function applyDefaultsToTemplate(
647
664
  ): CommandTemplateValue {
648
665
  const cleanOverrides = { ...overrides };
649
666
  delete cleanOverrides.name;
667
+ delete cleanOverrides.template;
650
668
  delete cleanOverrides.values;
651
669
  if (typeof template === "object" && !Array.isArray(template)) {
652
670
  return {
@@ -686,6 +704,119 @@ function withActorRecipeContext(
686
704
  return { actorRecipeContext: context, template: value };
687
705
  }
688
706
 
707
+ function loadDelegatedRecipe(
708
+ value: unknown,
709
+ currentRecipeFile: string,
710
+ stack: string[],
711
+ options: ReadResolvedRecipeConfigOptions,
712
+ ): TemplateRecipeConfig | undefined {
713
+ const path = resolveRecipeReferencePath(value, dirname(currentRecipeFile));
714
+ if (!path) return undefined;
715
+ const config = readResolvedRecipeConfig(
716
+ path,
717
+ [...stack, currentRecipeFile],
718
+ options,
719
+ );
720
+ if (!config) throw new Error(`Template recipe must define template: ${path}`);
721
+ if (config.disabled === true)
722
+ throw new Error(`Template recipe is disabled: ${path}`);
723
+ return config;
724
+ }
725
+
726
+ function applyDelegatedRecipeToNode(
727
+ delegated: TemplateRecipeConfig,
728
+ overrides: Record<string, unknown> = {},
729
+ ): CommandTemplateValue {
730
+ return applyDefaultsToTemplate(
731
+ delegated.template,
732
+ delegated.values,
733
+ overrides,
734
+ );
735
+ }
736
+
737
+ function expandRecipeDelegations(
738
+ value: CommandTemplateValue,
739
+ currentRecipeFile: string,
740
+ stack: string[],
741
+ options: ReadResolvedRecipeConfigOptions = {},
742
+ ): CommandTemplateValue {
743
+ if (typeof value === "string") {
744
+ const delegated = loadDelegatedRecipe(
745
+ value,
746
+ currentRecipeFile,
747
+ stack,
748
+ options,
749
+ );
750
+ return delegated ? applyDelegatedRecipeToNode(delegated) : value;
751
+ }
752
+ if (Array.isArray(value)) {
753
+ return value.map(
754
+ (item) =>
755
+ expandRecipeDelegations(
756
+ item as CommandTemplateValue,
757
+ currentRecipeFile,
758
+ stack,
759
+ options,
760
+ ) as CommandTemplateConfig,
761
+ );
762
+ }
763
+ const record = value as Record<string, unknown>;
764
+ if (typeof record.template === "string") {
765
+ const delegated = loadDelegatedRecipe(
766
+ record.template,
767
+ currentRecipeFile,
768
+ stack,
769
+ options,
770
+ );
771
+ if (delegated) return applyDelegatedRecipeToNode(delegated, record);
772
+ }
773
+ if (Array.isArray(record.template)) {
774
+ return {
775
+ ...record,
776
+ template: record.template.map(
777
+ (item) =>
778
+ expandRecipeDelegations(
779
+ item as CommandTemplateValue,
780
+ currentRecipeFile,
781
+ stack,
782
+ options,
783
+ ) as CommandTemplateConfig,
784
+ ),
785
+ } as CommandTemplates.CommandTemplateObjectConfig;
786
+ }
787
+ if (record.template && typeof record.template === "object") {
788
+ return {
789
+ ...record,
790
+ template: expandRecipeDelegations(
791
+ record.template as CommandTemplateValue,
792
+ currentRecipeFile,
793
+ stack,
794
+ options,
795
+ ),
796
+ } as CommandTemplates.CommandTemplateObjectConfig;
797
+ }
798
+ return value;
799
+ }
800
+
801
+ function getDirectDelegatedRecipe(
802
+ value: CommandTemplateValue,
803
+ currentRecipeFile: string,
804
+ stack: string[],
805
+ options: ReadResolvedRecipeConfigOptions = {},
806
+ ): TemplateRecipeConfig | undefined {
807
+ if (typeof value === "string")
808
+ return loadDelegatedRecipe(value, currentRecipeFile, stack, options);
809
+ if (!Array.isArray(value) && typeof value.template === "string") {
810
+ return loadDelegatedRecipe(
811
+ value.template,
812
+ currentRecipeFile,
813
+ stack,
814
+ options,
815
+ );
816
+ }
817
+ return undefined;
818
+ }
819
+
689
820
  function expandImportNodes(
690
821
  value: CommandTemplateValue,
691
822
  imports: Record<string, ImportedRecipe>,
@@ -807,7 +938,22 @@ export function readResolvedRecipeConfig(
807
938
  >;
808
939
  const template = getRecipeCommandTemplate(substituted);
809
940
  if (!template) return undefined;
810
- const expandedTemplate = expandImportNodes(template, imports, options);
941
+ const expandedImportsTemplate = expandImportNodes(template, imports, options);
942
+ const delegated = getDirectDelegatedRecipe(
943
+ expandedImportsTemplate,
944
+ path,
945
+ stack,
946
+ options,
947
+ );
948
+ const expandedTemplate = delegated
949
+ ? applyDelegatedRecipeToNode(
950
+ delegated,
951
+ typeof expandedImportsTemplate === "object" &&
952
+ !Array.isArray(expandedImportsTemplate)
953
+ ? (expandedImportsTemplate as Record<string, unknown>)
954
+ : {},
955
+ )
956
+ : expandRecipeDelegations(expandedImportsTemplate, path, stack, options);
811
957
  const recipeName = getRecipeIdFromPath(path);
812
958
  const templateWithContext = options.includeActorRecipeContext
813
959
  ? withActorRecipeContext(expandedTemplate, {
@@ -817,33 +963,53 @@ export function readResolvedRecipeConfig(
817
963
  role: stack.length > 0 ? "import" : "entry",
818
964
  })
819
965
  : expandedTemplate;
966
+ const mergedDefaults = mergeDefaults(
967
+ delegated?.defaults,
968
+ isRecord(substituted.defaults) ? substituted.defaults : undefined,
969
+ );
970
+ const artifactSource = isRecord(substituted.artifacts)
971
+ ? substituted.artifacts
972
+ : delegated?.artifacts;
973
+ const mailboxSource = isRecord(substituted.mailbox)
974
+ ? substituted.mailbox
975
+ : delegated?.mailbox;
820
976
  return {
821
977
  name: recipeName,
822
978
  ...(typeof substituted.description === "string" &&
823
979
  substituted.description.trim()
824
980
  ? { description: substituted.description.trim() }
825
- : {}),
981
+ : typeof delegated?.description === "string"
982
+ ? { description: delegated.description }
983
+ : {}),
826
984
  ...(typeof substituted.disabled === "boolean"
827
985
  ? { disabled: substituted.disabled }
828
- : {}),
986
+ : typeof delegated?.disabled === "boolean"
987
+ ? { disabled: delegated.disabled }
988
+ : {}),
829
989
  ...(substituted.async === true
830
990
  ? { async: true }
831
991
  : substituted.async === false
832
992
  ? { async: false }
833
- : {}),
993
+ : delegated?.async === true
994
+ ? { async: true }
995
+ : delegated?.async === false
996
+ ? { async: false }
997
+ : {}),
834
998
  ...(typeof substituted.state_dir === "string"
835
999
  ? { state_dir: substituted.state_dir }
836
- : {}),
1000
+ : typeof delegated?.state_dir === "string"
1001
+ ? { state_dir: delegated.state_dir }
1002
+ : {}),
837
1003
  ...(Object.keys(imports).length > 0
838
1004
  ? { imports: getRecipeImports(raw) }
839
1005
  : {}),
840
1006
  template: templateWithContext,
841
1007
  ...(Array.isArray(substituted.args)
842
1008
  ? { args: substituted.args as string[] }
843
- : {}),
844
- ...(isRecord(substituted.defaults)
845
- ? { defaults: substituted.defaults }
846
- : {}),
1009
+ : Array.isArray(delegated?.args)
1010
+ ? { args: delegated.args }
1011
+ : {}),
1012
+ ...(mergedDefaults ? { defaults: mergedDefaults } : {}),
847
1013
  ...(typeof substituted.parallel === "boolean"
848
1014
  ? { parallel: substituted.parallel }
849
1015
  : {}),
@@ -865,31 +1031,31 @@ export function readResolvedRecipeConfig(
865
1031
  ...(typeof substituted.output === "string"
866
1032
  ? { output: substituted.output }
867
1033
  : {}),
868
- ...(isRecord(substituted.artifacts)
1034
+ ...(isRecord(artifactSource)
869
1035
  ? {
870
1036
  artifacts: Object.fromEntries(
871
- Object.entries(substituted.artifacts).filter(
1037
+ Object.entries(artifactSource).filter(
872
1038
  (entry): entry is [string, string] =>
873
1039
  typeof entry[1] === "string",
874
1040
  ),
875
1041
  ),
876
1042
  }
877
1043
  : {}),
878
- ...(isRecord(substituted.mailbox)
1044
+ ...(isRecord(mailboxSource)
879
1045
  ? {
880
1046
  mailbox: {
881
- ...(Array.isArray(substituted.mailbox.accepts)
1047
+ ...(Array.isArray(mailboxSource.accepts)
882
1048
  ? {
883
- accepts: substituted.mailbox.accepts.filter(
1049
+ accepts: mailboxSource.accepts.filter(
884
1050
  (value): value is TemplateRecipeMailboxEntry =>
885
1051
  typeof value === "string" ||
886
1052
  (isRecord(value) && typeof value.type === "string"),
887
1053
  ),
888
1054
  }
889
1055
  : {}),
890
- ...(Array.isArray(substituted.mailbox.emits)
1056
+ ...(Array.isArray(mailboxSource.emits)
891
1057
  ? {
892
- emits: substituted.mailbox.emits.filter(
1058
+ emits: mailboxSource.emits.filter(
893
1059
  (value): value is TemplateRecipeMailboxEntry =>
894
1060
  typeof value === "string" ||
895
1061
  (isRecord(value) && typeof value.type === "string"),
@@ -899,7 +1065,8 @@ export function readResolvedRecipeConfig(
899
1065
  },
900
1066
  }
901
1067
  : {}),
902
- ...(substituted.retire_when === "children_terminal"
1068
+ ...(substituted.retire_when === "children_terminal" ||
1069
+ delegated?.retire_when === "children_terminal"
903
1070
  ? { retire_when: "children_terminal" as const }
904
1071
  : {}),
905
1072
  ...(typeof substituted.retry === "number" ||
package/lib/registry.ts CHANGED
@@ -50,7 +50,7 @@ export interface RegisterToolResult {
50
50
  export interface RegisterToolRuntimeDeps<TContext> {
51
51
  configPath: string;
52
52
  recipeRoot?: string;
53
- getExternalToolConflict: (name: string) => string | undefined;
53
+ getToolNameBlocker: (name: string) => string | undefined;
54
54
  getTools: () => Map<string, Config.RegisteredTool>;
55
55
  getActiveTools: () => string[];
56
56
  notify: (
@@ -132,8 +132,8 @@ function promoteDraftRecipe<TContext>(
132
132
  const targetPath = getToolRecipePath(deps, name);
133
133
  const tools = deps.getTools();
134
134
  const existing = tools.get(name);
135
- const conflict = deps.getExternalToolConflict(name);
136
- if (conflict) throw new Error(ExecutionOutput.formatToolText(conflict));
135
+ const blocker = deps.getToolNameBlocker(name);
136
+ if (blocker) throw new Error(ExecutionOutput.formatToolText(blocker));
137
137
  if ((existing || existsSync(targetPath)) && !input.update) {
138
138
  throw new Error(
139
139
  ExecutionOutput.formatToolText(
@@ -383,8 +383,8 @@ export async function executeRegisterTool<TContext>(
383
383
  return deleteTool(name, ctx, deps);
384
384
  const tools = deps.getTools();
385
385
  const existing = tools.get(name);
386
- const conflict = deps.getExternalToolConflict(name);
387
- if (conflict) throw new Error(ExecutionOutput.formatToolText(conflict));
386
+ const blocker = deps.getToolNameBlocker(name);
387
+ if (blocker) throw new Error(ExecutionOutput.formatToolText(blocker));
388
388
  if (existing && !input.update) {
389
389
  throw new Error(
390
390
  ExecutionOutput.formatToolText(
@@ -147,6 +147,7 @@ export function createFileRuntimeNotifier(
147
147
  mkdirSync(dirname(file), { recursive: true });
148
148
  let position =
149
149
  options.replay || !existsSync(file) ? 0 : statSync(file).size;
150
+ let pending = "";
150
151
  let closed = false;
151
152
  const reconcile = (reason: RuntimeReconcileReason): void => {
152
153
  if (closed) return;
@@ -160,10 +161,15 @@ export function createFileRuntimeNotifier(
160
161
  const drain = (): void => {
161
162
  if (closed || !existsSync(file)) return;
162
163
  const buffer = readFileSync(file);
163
- if (position > buffer.length) position = 0;
164
- const chunk = buffer.subarray(position).toString("utf8");
164
+ if (position > buffer.length) {
165
+ position = 0;
166
+ pending = "";
167
+ }
168
+ const chunk = pending + buffer.subarray(position).toString("utf8");
165
169
  position = buffer.length;
166
- for (const line of chunk.split("\n")) {
170
+ const lines = chunk.split("\n");
171
+ pending = chunk.endsWith("\n") ? "" : (lines.pop() ?? "");
172
+ for (const line of lines) {
167
173
  if (!line.trim()) continue;
168
174
  const event = parseRuntimeWakeEventLine(line);
169
175
  if (event && event.actor === actor) {
package/lib/runtime.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tool registry runtime coordinator
3
3
  * Zones: runtime coordination, registry loading, pi tools
4
- * Owns persisted tool loading, conflict detection, runtime registration, and warning notification
4
+ * Owns persisted tool loading, reserved-name guards, runtime registration, and warning notification
5
5
  */
6
6
 
7
7
  import { existsSync, watch, type FSWatcher } from "node:fs";
@@ -19,17 +19,12 @@ export interface RuntimeContext {
19
19
  };
20
20
  }
21
21
 
22
- export interface ToolInfoLike {
23
- name: string;
24
- }
25
-
26
22
  export interface ToolRegistryRuntimeDeps {
27
23
  configPath: string;
28
24
  exec: RegisteredToolExec;
29
25
  packagedRecipeRoot?: string;
30
26
  recipeRoot?: string;
31
27
  getActiveTools?: () => string[];
32
- getAllTools: () => ToolInfoLike[];
33
28
  registerTool: (
34
29
  definition: ReturnType<typeof ToolsLocal.createRuntimeToolDefinition>,
35
30
  ) => void;
@@ -38,7 +33,7 @@ export interface ToolRegistryRuntimeDeps {
38
33
  }
39
34
 
40
35
  export interface ToolRegistryRuntime {
41
- getExternalToolConflict(name: string): string | undefined;
36
+ getToolNameBlocker(name: string): string | undefined;
42
37
  getTools(): Map<string, Config.RegisteredTool>;
43
38
  loadTools(ctx: RuntimeContext): void;
44
39
  notify(
@@ -67,11 +62,9 @@ export function createAutoToolsRuntime(
67
62
  ) {
68
63
  if (ctx.hasUI) ctx.ui.notify(message, type);
69
64
  }
70
- function getExternalToolConflict(name: string): string | undefined {
71
- if (runtimeTools.has(name)) return undefined;
72
- const existing = deps.getAllTools().find((tool) => tool.name === name);
73
- return existing
74
- ? `Tool "${name}" is already registered outside pi-actors.`
65
+ function getToolNameBlocker(name: string): string | undefined {
66
+ return deps.reservedToolNames.has(name)
67
+ ? `Reserved tool name: ${name}`
75
68
  : undefined;
76
69
  }
77
70
  function getToolFingerprint(cfg: Config.RegisteredTool): string {
@@ -105,6 +98,7 @@ export function createAutoToolsRuntime(
105
98
  runtimeToolFingerprints.set(cfg.name, fingerprint);
106
99
  }
107
100
  function isStartupActionableRegistryWarning(warning: string): boolean {
101
+ if (warning.includes(" shadows ")) return false;
108
102
  if (
109
103
  warning.includes("invokes bash;") &&
110
104
  warning.includes("trusted executable content")
@@ -166,9 +160,9 @@ export function createAutoToolsRuntime(
166
160
  }
167
161
  deactivateMissingRuntimeTools(new Set(tools.keys()));
168
162
  for (const cfg of tools.values()) {
169
- const conflict = getExternalToolConflict(cfg.name);
170
- if (conflict) {
171
- warnings.push(conflict);
163
+ const blocker = getToolNameBlocker(cfg.name);
164
+ if (blocker) {
165
+ warnings.push(blocker);
172
166
  continue;
173
167
  }
174
168
  registerRuntimeTool(cfg);
@@ -179,7 +173,7 @@ export function createAutoToolsRuntime(
179
173
  }
180
174
  }
181
175
  return {
182
- getExternalToolConflict,
176
+ getToolNameBlocker,
183
177
  getTools: () => tools,
184
178
  loadTools,
185
179
  notify,