@llblab/pi-actors 0.46.0 → 0.46.1

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 (50) hide show
  1. package/BACKLOG.md +582 -0
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +1 -1
  4. package/dist/lib/async-runs.d.ts +1 -0
  5. package/dist/lib/async-runs.js +4 -1
  6. package/dist/lib/execution.d.ts +1 -0
  7. package/dist/lib/execution.js +1 -0
  8. package/dist/lib/extension-runtime.js +25 -9
  9. package/dist/lib/inspector.js +1 -0
  10. package/dist/lib/prompts.d.ts +1 -1
  11. package/dist/lib/prompts.js +2 -2
  12. package/dist/lib/recipes-context.d.ts +12 -3
  13. package/dist/lib/recipes-context.js +28 -4
  14. package/dist/lib/recipes-discovery.d.ts +18 -0
  15. package/dist/lib/recipes-discovery.js +95 -22
  16. package/dist/lib/recipes-references.d.ts +18 -0
  17. package/dist/lib/recipes-references.js +120 -33
  18. package/dist/lib/registry.d.ts +14 -1
  19. package/dist/lib/registry.js +110 -87
  20. package/dist/lib/runtime.d.ts +30 -3
  21. package/dist/lib/runtime.js +73 -9
  22. package/dist/lib/tools-inspect.d.ts +3 -0
  23. package/dist/lib/tools-inspect.js +40 -20
  24. package/dist/lib/tools-local.d.ts +2 -2
  25. package/dist/lib/tools-local.js +5 -2
  26. package/dist/lib/tools-response.js +2 -0
  27. package/dist/lib/tools-spawn.d.ts +2 -2
  28. package/dist/lib/tools-spawn.js +1 -1
  29. package/dist/lib/tools.d.ts +4 -1
  30. package/dist/lib/tools.js +2 -0
  31. package/dist/skills/actors/SKILL.md +3 -3
  32. package/docs/template-recipes.md +3 -1
  33. package/docs/tool-registry.md +9 -3
  34. package/lib/async-runs.ts +5 -1
  35. package/lib/execution.ts +2 -0
  36. package/lib/extension-runtime.ts +33 -14
  37. package/lib/inspector.ts +1 -0
  38. package/lib/prompts.ts +2 -2
  39. package/lib/recipes-context.ts +49 -4
  40. package/lib/recipes-discovery.ts +164 -24
  41. package/lib/recipes-references.ts +163 -39
  42. package/lib/registry.ts +185 -94
  43. package/lib/runtime.ts +108 -12
  44. package/lib/tools-inspect.ts +57 -23
  45. package/lib/tools-local.ts +9 -3
  46. package/lib/tools-response.ts +2 -0
  47. package/lib/tools-spawn.ts +3 -2
  48. package/lib/tools.ts +4 -1
  49. package/package.json +1 -1
  50. package/skills/actors/SKILL.md +3 -3
@@ -11,7 +11,24 @@ import * as RecipeControl from "./recipe-control.js";
11
11
  import * as Schema from "./schema.js";
12
12
  const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
13
13
  const MAX_RECIPE_IMPORT_DEPTH = 32;
14
- const RUNTIME_RECIPE_VALUE_NAMES = new Set(["recipe_dir", "skill_dir"]);
14
+ const RESERVED_AUTHORED_RECIPE_VALUE_NAMES = new Set([
15
+ "recipe_dir",
16
+ "skill_dir",
17
+ ]);
18
+ export const RUNTIME_OWNED_RECIPE_INPUT_NAMES = Object.freeze([
19
+ "owner_id",
20
+ "recipe_dir",
21
+ "run_instance_id",
22
+ "runtime_state_root",
23
+ "session_id",
24
+ "skill_dir",
25
+ "state_dir",
26
+ "trace_file",
27
+ ]);
28
+ const RUNTIME_OWNED_RECIPE_INPUT_SET = new Set(RUNTIME_OWNED_RECIPE_INPUT_NAMES);
29
+ export function isRuntimeOwnedRecipeInput(name) {
30
+ return RUNTIME_OWNED_RECIPE_INPUT_SET.has(name);
31
+ }
15
32
  export function createActiveSkillRecipeContext(skills) {
16
33
  const rootsByName = new Map();
17
34
  for (const skill of skills) {
@@ -49,19 +66,31 @@ function nestedRecipeFiles(root) {
49
66
  return entry.isFile() && /\.(?:json|md)$/u.test(entry.name) ? [path] : [];
50
67
  });
51
68
  }
52
- export function listActiveSkillRecipeComponents(context) {
69
+ export function inventoryActiveSkillRecipeComponents(context) {
53
70
  const components = [];
71
+ const rejected = [];
54
72
  for (const [skill, roots] of Object.entries(context.namespaces)) {
55
- if (roots.length > 1)
56
- throw new Error(`Duplicate active Skill identity ${skill}: ${roots.join(", ")}`);
73
+ if (roots.length > 1) {
74
+ rejected.push({
75
+ reason: `Duplicate active Skill identity ${skill}: ${roots.join(", ")}`,
76
+ skill,
77
+ });
78
+ continue;
79
+ }
57
80
  const root = roots[0];
58
81
  if (!root || !existsSync(root))
59
82
  continue;
60
83
  const direct = readdirSync(root, { withFileTypes: true });
61
84
  for (const entry of direct.filter((candidate) => candidate.isDirectory())) {
62
85
  const nested = nestedRecipeFiles(join(root, entry.name));
63
- if (nested.length > 0)
64
- throw new Error(`Nested Skill Recipe file is not addressable: ${nested[0]}`);
86
+ for (const file of nested) {
87
+ rejected.push({
88
+ file,
89
+ reason: `Nested Skill Recipe file is not addressable: ${file}`,
90
+ skill,
91
+ stem: getRecipeIdFromPath(file),
92
+ });
93
+ }
65
94
  }
66
95
  const files = direct
67
96
  .filter((entry) => entry.isFile() && /\.(?:json|md)$/u.test(entry.name))
@@ -74,28 +103,58 @@ export function listActiveSkillRecipeComponents(context) {
74
103
  byStem.set(stem, matches);
75
104
  }
76
105
  for (const [stem, matches] of [...byStem].sort(([a], [b]) => a.localeCompare(b))) {
77
- if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(stem))
78
- throw new Error(`Invalid Skill Recipe filename stem: ${stem}`);
79
- if (matches.length > 1)
80
- throw new Error(`Skill Recipe stem collision: ${skill}/${stem} has both .json and .md files`);
106
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(stem)) {
107
+ rejected.push({
108
+ file: matches[0],
109
+ reason: `Invalid Skill Recipe filename stem: ${stem}`,
110
+ skill,
111
+ stem,
112
+ });
113
+ continue;
114
+ }
115
+ if (matches.length > 1) {
116
+ rejected.push({
117
+ file: matches[0],
118
+ reason: `Skill Recipe stem collision: ${skill}/${stem} has both .json and .md files`,
119
+ skill,
120
+ stem,
121
+ });
122
+ continue;
123
+ }
81
124
  const file = matches[0];
82
- const raw = readRawRecipeConfig(file);
83
- if (!raw || !Object.hasOwn(raw, "template"))
84
- throw new Error(`Invalid Skill Recipe component: ${skill}/${stem}`);
85
- assertRecipeHasNoDeclaredName(raw);
86
- components.push({
87
- file,
88
- identity: `${skill}/${stem}`,
89
- imports: Object.fromEntries(Object.entries(getRecipeImports(raw)).map(([alias, value]) => [
90
- alias,
91
- getImportFrom(value),
92
- ])),
93
- skill,
94
- stem,
95
- });
125
+ try {
126
+ const raw = readRawRecipeConfig(file);
127
+ if (!raw || !Object.hasOwn(raw, "template"))
128
+ throw new Error(`Invalid Skill Recipe component: ${skill}/${stem}`);
129
+ assertRecipeHasNoDeclaredName(raw);
130
+ components.push({
131
+ file,
132
+ identity: `${skill}/${stem}`,
133
+ imports: Object.fromEntries(Object.entries(getRecipeImports(raw)).map(([alias, value]) => [
134
+ alias,
135
+ getImportFrom(value),
136
+ ])),
137
+ skill,
138
+ stem,
139
+ });
140
+ }
141
+ catch (error) {
142
+ rejected.push({
143
+ file,
144
+ reason: error instanceof Error ? error.message : String(error),
145
+ skill,
146
+ stem,
147
+ });
148
+ }
96
149
  }
97
150
  }
98
- return components;
151
+ return { components, partial: rejected.length > 0, rejected };
152
+ }
153
+ export function listActiveSkillRecipeComponents(context) {
154
+ const inventory = inventoryActiveSkillRecipeComponents(context);
155
+ if (inventory.rejected.length > 0)
156
+ throw new Error(inventory.rejected[0].reason);
157
+ return inventory.components;
99
158
  }
100
159
  const SKILL_RECIPE_REFERENCE = /^([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\/([a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/;
101
160
  function qualifiedRecipeNameForFile(file, context) {
@@ -170,7 +229,7 @@ function assertRuntimeRecipeValuesNotDeclared(raw) {
170
229
  const args = Array.isArray(raw.args)
171
230
  ? Schema.getExplicitToolArgNames(raw.args.map(String))
172
231
  : [];
173
- for (const name of RUNTIME_RECIPE_VALUE_NAMES) {
232
+ for (const name of RESERVED_AUTHORED_RECIPE_VALUE_NAMES) {
174
233
  if (args.includes(name) ||
175
234
  (isRecord(raw.defaults) && Object.hasOwn(raw.defaults, name)) ||
176
235
  (isRecord(raw.values) && Object.hasOwn(raw.values, name))) {
@@ -731,6 +790,28 @@ function getDirectDelegatedRecipe(value, currentRecipeFile, stack, options = {})
731
790
  }
732
791
  return undefined;
733
792
  }
793
+ function resolveDirectDelegationArgs(delegated, wrapperArgs, wrapperDefaults) {
794
+ if (!Array.isArray(delegated.args))
795
+ return Array.isArray(wrapperArgs) ? wrapperArgs.map(String) : undefined;
796
+ const delegatedSpec = Schema.parseToolArgDeclarationList(delegated.args);
797
+ if (delegatedSpec.error)
798
+ throw new Error(delegatedSpec.error);
799
+ const wrapperDeclarations = Array.isArray(wrapperArgs)
800
+ ? wrapperArgs.map(String)
801
+ : [];
802
+ const wrapperSpec = Schema.parseToolArgDeclarationList(wrapperDeclarations);
803
+ if (wrapperSpec.error)
804
+ throw new Error(wrapperSpec.error);
805
+ const declared = new Set(delegatedSpec.args);
806
+ for (const arg of wrapperSpec.args) {
807
+ if (!declared.has(arg))
808
+ throw new Error(`Unknown delegated Recipe argument: ${arg}`);
809
+ }
810
+ Schema.assertCompatibleToolArgTypes(delegatedSpec.argTypes, wrapperSpec.argTypes);
811
+ assertRecipeDefaultsDeclared(delegated.args, wrapperDefaults, "delegated Recipe default");
812
+ Schema.normalizeRuntimeValues(Object.fromEntries(Object.entries(wrapperDefaults ?? {}).filter(([, value]) => typeof value !== "string" || !/\{[^{}]+\}/.test(value))), delegatedSpec.argTypes);
813
+ return delegated.args;
814
+ }
734
815
  function expandImportNodes(value, imports, options = {}) {
735
816
  if (typeof value === "string")
736
817
  return value;
@@ -783,7 +864,9 @@ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
783
864
  if (stack.length >= MAX_RECIPE_IMPORT_DEPTH) {
784
865
  throw new Error(`Recipe import depth exceeds limit ${MAX_RECIPE_IMPORT_DEPTH}: ${[...stack, path].join(" -> ")}`);
785
866
  }
786
- const raw = readRawRecipeConfig(path);
867
+ const raw = options.authoredEntry?.path === path
868
+ ? options.authoredEntry.recipe
869
+ : readRawRecipeConfig(path);
787
870
  if (!raw || !Object.hasOwn(raw, "template"))
788
871
  return undefined;
789
872
  assertRecipeHasNoDeclaredName(raw);
@@ -844,7 +927,15 @@ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
844
927
  role: stack.length > 0 ? "import" : "entry",
845
928
  })
846
929
  : expandedTemplate;
847
- const mergedDefaults = mergeDefaults(delegated?.defaults, isRecord(substituted.defaults) ? substituted.defaults : undefined);
930
+ const wrapperDefaults = isRecord(substituted.defaults)
931
+ ? substituted.defaults
932
+ : undefined;
933
+ const mergedDefaults = mergeDefaults(delegated?.defaults, wrapperDefaults);
934
+ const effectiveArgs = delegated
935
+ ? resolveDirectDelegationArgs(delegated, substituted.args, wrapperDefaults)
936
+ : Array.isArray(substituted.args)
937
+ ? substituted.args
938
+ : undefined;
848
939
  const artifactSource = isRecord(substituted.artifacts)
849
940
  ? substituted.artifacts
850
941
  : delegated?.artifacts;
@@ -879,11 +970,7 @@ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
879
970
  ? { imports: getRecipeImports(raw) }
880
971
  : {}),
881
972
  template: templateWithContext,
882
- ...(Array.isArray(substituted.args)
883
- ? { args: substituted.args }
884
- : Array.isArray(delegated?.args)
885
- ? { args: delegated.args }
886
- : {}),
973
+ ...(effectiveArgs ? { args: effectiveArgs } : {}),
887
974
  ...(mergedDefaults ? { defaults: mergedDefaults } : {}),
888
975
  ...(typeof substituted.parallel === "boolean"
889
976
  ? { parallel: substituted.parallel }
@@ -16,16 +16,24 @@ export interface RegisterToolInput {
16
16
  values?: Record<string, unknown>;
17
17
  }
18
18
  export interface RegisterToolResultDetails {
19
+ active_tool?: boolean;
20
+ activation?: "current_session" | "unverified";
19
21
  args?: string[];
20
22
  async?: boolean;
23
+ callable_now?: boolean;
21
24
  config?: string;
22
25
  defaults?: Record<string, string>;
23
26
  draft?: string;
27
+ host_registered?: boolean;
28
+ persisted?: boolean;
24
29
  promoted?: boolean;
30
+ registry_active?: boolean;
31
+ resolved?: boolean;
25
32
  recipeName?: string;
26
33
  template?: CommandTemplates.CommandTemplateValue;
27
34
  templateWarnings?: string[];
28
35
  tool: string;
36
+ validated?: boolean;
29
37
  }
30
38
  export interface RegisterToolResult {
31
39
  content: Array<{
@@ -41,7 +49,12 @@ export interface RegisterToolRuntimeDeps<TContext> {
41
49
  getTools: () => Map<string, Config.RegisteredTool>;
42
50
  getActiveTools: () => string[];
43
51
  notify: (ctx: TContext, message: string, type: "info" | "warning" | "error") => void;
44
- registerRuntimeTool: (cfg: Config.RegisteredTool) => void;
52
+ registerRuntimeTool: (cfg: Config.RegisteredTool) => {
53
+ active_tool: boolean;
54
+ activation: "current_session" | "unverified";
55
+ callable_now: boolean;
56
+ host_registered: boolean;
57
+ } | void;
45
58
  reservedToolNames: Set<string>;
46
59
  setActiveTools: (toolNames: string[]) => void;
47
60
  }
@@ -8,9 +8,11 @@ import { dirname, join, relative, resolve } from "node:path";
8
8
  import * as CommandTemplates from "./command-templates.js";
9
9
  import * as Config from "./config.js";
10
10
  import * as ExecutionOutput from "./execution-output.js";
11
- import { withFileMutationLock, writeJsonAtomic } from "./file-state.js";
11
+ import { withFileMutationLock, writeJsonAtomic, writeTextAtomic, } from "./file-state.js";
12
12
  import * as Identity from "./identity.js";
13
13
  import * as Paths from "./paths.js";
14
+ import * as RecipesContext from "./recipes-context.js";
15
+ import * as RecipesDiscovery from "./recipes-discovery.js";
14
16
  import * as RecipesReferences from "./recipes-references.js";
15
17
  import * as Schema from "./schema.js";
16
18
  function textContent(text) {
@@ -58,21 +60,24 @@ function promoteDraftRecipe(name, input, ctx, deps) {
58
60
  if ((existing || existsSync(targetPath)) && !input.update) {
59
61
  throw new Error(ExecutionOutput.formatToolText(`Tool "${name}" already registered. Use update=true to overwrite.`));
60
62
  }
61
- const config = RecipesReferences.readResolvedRecipeConfig(draftPath);
62
- if (!config) {
63
+ const raw = RecipesReferences.readRawRecipeConfig(draftPath);
64
+ if (!raw) {
63
65
  const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(draftPath);
64
66
  throw new Error(ExecutionOutput.formatToolText(`Draft recipe is invalid${reason ? `: ${reason}` : "."}`));
65
67
  }
66
- const promoted = buildConfig(name, {
68
+ const authoredRecipe = buildAuthoredRecipe({
67
69
  ...input,
68
- description: input.description ?? config.description,
69
- template: draftPath,
70
- }, existing);
71
- const raw = RecipesReferences.readRawRecipeConfig(draftPath);
70
+ description: input.description ??
71
+ (typeof raw.description === "string" ? raw.description : undefined),
72
+ }, existing, raw);
73
+ const resolutionContext = getRegistrationResolutionContext(ctx, deps);
74
+ const candidate = RecipesDiscovery.admitUserRecipe(targetPath, resolutionContext, authoredRecipe);
75
+ if (!candidate.validated || !candidate.tool) {
76
+ throw new Error(ExecutionOutput.formatToolText(`Draft recipe is invalid: ${candidate.diagnostics.join("; ")}`));
77
+ }
72
78
  mkdirSync(dirname(targetPath), { recursive: true });
73
- writeJsonAtomic(targetPath, raw);
74
- promoted.template = targetPath;
75
- promoted.sourcePath = targetPath;
79
+ writeJsonAtomic(targetPath, authoredRecipe);
80
+ const promoted = RecipesDiscovery.admitUserRecipe(targetPath, resolutionContext).tool;
76
81
  tools.set(name, promoted);
77
82
  deps.registerRuntimeTool(promoted);
78
83
  deps.notify(ctx, `Promoted draft recipe: ${name}`, "info");
@@ -89,17 +94,10 @@ function promoteDraftRecipe(name, input, ctx, deps) {
89
94
  },
90
95
  };
91
96
  }
92
- function persistToolRecipe(deps, cfg) {
93
- const path = getToolRecipePath(deps, cfg.name);
97
+ function persistToolRecipe(deps, name, recipe) {
98
+ const path = getToolRecipePath(deps, name);
94
99
  mkdirSync(dirname(path), { recursive: true });
95
- writeJsonAtomic(path, {
96
- description: cfg.description,
97
- ...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
98
- ...(cfg.storedArgs ? { args: cfg.storedArgs } : {}),
99
- ...(cfg.storedDefaults ? { defaults: cfg.storedDefaults } : {}),
100
- ...(cfg.recipe?.values ? { values: cfg.recipe.values } : {}),
101
- template: cfg.template,
102
- });
100
+ writeJsonAtomic(path, recipe);
103
101
  return path;
104
102
  }
105
103
  function deleteTool(name, ctx, deps) {
@@ -130,11 +128,15 @@ function readAuthoritativeStoredTool(deps, name) {
130
128
  if (!existsSync(path))
131
129
  return { exists: false };
132
130
  try {
133
- const normalized = Config.normalizeStoredTool(name, JSON.parse(readFileSync(path, "utf8")), deps.reservedToolNames);
131
+ const bytes = readFileSync(path, "utf8");
132
+ const recipe = JSON.parse(bytes);
133
+ const normalized = Config.normalizeStoredTool(name, recipe, deps.reservedToolNames);
134
134
  if (!normalized.cfg)
135
- return { exists: true };
135
+ return { bytes, exists: true, recipe };
136
136
  return {
137
+ bytes,
137
138
  exists: true,
139
+ recipe,
138
140
  tool: { ...normalized.cfg, sourcePath: path },
139
141
  };
140
142
  }
@@ -161,7 +163,17 @@ function getInputTemplate(value) {
161
163
  }
162
164
  throw new Error(ExecutionOutput.formatToolText("Tool template must be a string, object, or sequence."));
163
165
  }
164
- function buildConfig(name, input, existing) {
166
+ function getRegistrationResolutionContext(ctx, deps) {
167
+ if (ctx &&
168
+ typeof ctx === "object" &&
169
+ "recipeResolutionContext" in ctx) {
170
+ const resolutionContext = ctx.recipeResolutionContext;
171
+ if (resolutionContext)
172
+ return resolutionContext;
173
+ }
174
+ return RecipesContext.createEmptyRecipeResolutionContext("offline-register-tool", getRecipeRoot(deps));
175
+ }
176
+ function buildAuthoredRecipe(input, existing, existingRecipe) {
165
177
  const explicitArgs = input.args === undefined
166
178
  ? undefined
167
179
  : Schema.parseToolArgDeclarations(input.args);
@@ -175,63 +187,29 @@ function buildConfig(name, input, existing) {
175
187
  if (template === null) {
176
188
  throw new Error(ExecutionOutput.formatToolText("Tool template cannot be null here."));
177
189
  }
178
- const finalTemplate = template === undefined || template === "" ? existing?.template : template;
190
+ const finalTemplate = template === undefined || template === ""
191
+ ? existingRecipe?.template ?? existing?.template
192
+ : template;
179
193
  if (!finalTemplate) {
180
194
  throw new Error(ExecutionOutput.formatToolText("Tool template is required."));
181
195
  }
182
- const inputRecipe = typeof input.async === "boolean" ? name : undefined;
183
- const recipe = inputRecipe
184
- ? {
185
- name: inputRecipe,
186
- ...(typeof input.async === "boolean" ? { async: input.async } : {}),
187
- template: finalTemplate,
188
- ...(input.values && typeof input.values === "object"
189
- ? { values: input.values }
190
- : {}),
191
- }
192
- : template === undefined
193
- ? existing?.recipe
194
- : undefined;
195
- const defaults = explicitArgs?.defaults ?? existing?.storedDefaults ?? {};
196
- const storedArgs = explicitArgs
197
- ? explicitArgs.declarations
198
- : existing?.storedArgs;
199
- const storedDefaults = Object.keys(defaults).length > 0 ? defaults : undefined;
200
- const recipeTemplate = RecipesReferences.getRecipeTemplate(finalTemplate);
201
- const argTemplate = recipeTemplate ?? finalTemplate;
202
- const argTemplateConfig = typeof argTemplate === "object" && !Array.isArray(argTemplate)
203
- ? {
204
- ...argTemplate,
205
- ...(storedArgs !== undefined ? { args: storedArgs } : {}),
206
- defaults: { ...(argTemplate.defaults ?? {}), ...defaults },
207
- }
208
- : {
209
- args: storedArgs,
210
- defaults,
211
- template: argTemplate,
212
- };
213
- const inferredArgTypes = Schema.getTemplateArgTypes(argTemplateConfig);
214
- const argTypes = {
215
- ...inferredArgTypes,
216
- ...(existing?.argTypes ?? {}),
217
- ...(explicitArgs?.argTypes ?? {}),
218
- };
219
- return {
220
- name,
196
+ const authored = {
197
+ ...(existingRecipe ?? {}),
221
198
  description,
222
199
  template: finalTemplate,
223
- ...(recipe ? { recipe } : {}),
224
- args: RecipesReferences.isRecipeTool(finalTemplate, recipe) &&
225
- storedArgs !== undefined
226
- ? Schema.getExplicitToolArgNames(storedArgs)
227
- : RecipesReferences.isRecipeReference(finalTemplate) && !recipeTemplate
228
- ? Schema.getExplicitToolArgNames(storedArgs)
229
- : Schema.getToolArgNames(argTemplateConfig),
230
- defaults,
231
- ...(Object.keys(argTypes).length > 0 ? { argTypes } : {}),
232
- ...(storedArgs !== undefined ? { storedArgs } : {}),
233
- ...(storedDefaults !== undefined ? { storedDefaults } : {}),
234
200
  };
201
+ if (typeof input.async === "boolean")
202
+ authored.async = input.async;
203
+ if (explicitArgs) {
204
+ authored.args = explicitArgs.declarations;
205
+ if (Object.keys(explicitArgs.defaults).length > 0)
206
+ authored.defaults = explicitArgs.defaults;
207
+ else
208
+ delete authored.defaults;
209
+ }
210
+ if (input.values && typeof input.values === "object")
211
+ authored.values = input.values;
212
+ return authored;
235
213
  }
236
214
  function executeRegisterToolUnlocked(params, ctx, deps) {
237
215
  const input = params;
@@ -262,37 +240,82 @@ function executeRegisterToolUnlocked(params, ctx, deps) {
262
240
  if (template === undefined && !existing) {
263
241
  throw new Error(ExecutionOutput.formatToolText("Tool template is required for new registrations."));
264
242
  }
265
- const cfg = buildConfig(name, input, existing);
266
- let recipePath;
243
+ const recipePath = getToolRecipePath(deps, name);
244
+ const authoredRecipe = buildAuthoredRecipe(input, existing, authoritative.recipe);
245
+ const resolutionContext = getRegistrationResolutionContext(ctx, deps);
246
+ const candidate = RecipesDiscovery.admitUserRecipe(recipePath, resolutionContext, authoredRecipe);
247
+ if (!candidate.validated || !candidate.tool) {
248
+ throw new Error(ExecutionOutput.formatToolText(`Tool recipe validation failed: ${candidate.diagnostics.join("; ")}`));
249
+ }
250
+ const activeBefore = deps.getActiveTools();
251
+ let persisted = false;
252
+ let activation;
253
+ let cfg;
267
254
  try {
268
- recipePath = persistToolRecipe(deps, cfg);
255
+ persistToolRecipe(deps, name, authoredRecipe);
256
+ persisted = true;
257
+ const admitted = RecipesDiscovery.admitUserRecipe(recipePath, resolutionContext);
258
+ if (!admitted.validated || !admitted.tool) {
259
+ throw new Error(`Persisted tool recipe admission failed: ${admitted.diagnostics.join("; ")}`);
260
+ }
261
+ cfg = admitted.tool;
262
+ tools.set(name, cfg);
263
+ activation = deps.registerRuntimeTool(cfg) ?? undefined;
264
+ if (activation &&
265
+ (!activation.host_registered ||
266
+ !activation.active_tool ||
267
+ !activation.callable_now)) {
268
+ throw new Error(`Host activation verification failed: ${JSON.stringify(activation)}`);
269
+ }
269
270
  }
270
271
  catch (error) {
271
- throw new Error(ExecutionOutput.formatToolText(`Failed to persist tool recipe: ${error instanceof Error ? error.message : String(error)}`));
272
+ if (persisted) {
273
+ if (authoritative.bytes !== undefined)
274
+ writeTextAtomic(recipePath, authoritative.bytes);
275
+ else if (existsSync(recipePath))
276
+ unlinkSync(recipePath);
277
+ }
278
+ if (existing) {
279
+ tools.set(name, existing);
280
+ deps.registerRuntimeTool(existing);
281
+ }
282
+ else {
283
+ tools.delete(name);
284
+ }
285
+ deps.setActiveTools(activeBefore);
286
+ throw new Error(ExecutionOutput.formatToolText(`Tool registration transaction failed: ${error instanceof Error ? error.message : String(error)}`));
272
287
  }
273
- cfg.sourcePath = recipePath;
274
- tools.set(name, cfg);
275
- deps.registerRuntimeTool(cfg);
276
- deps.notify(ctx, `Tool persisted: ${name}`, "info");
277
- const templateWarnings = CommandTemplates.getCommandTemplateWarnings(typeof cfg.template === "object" && !Array.isArray(cfg.template)
278
- ? cfg.template
279
- : { template: cfg.template });
288
+ deps.notify(ctx, `Tool activated: ${name}`, "info");
289
+ const templateWarnings = CommandTemplates.getCommandTemplateWarnings(typeof cfg.recipe?.template === "object" && !Array.isArray(cfg.recipe.template)
290
+ ? cfg.recipe.template
291
+ : { template: cfg.recipe?.template ?? cfg.template });
280
292
  const warningText = templateWarnings.length > 0
281
293
  ? `\nWarnings:\n${templateWarnings.map((warning) => `- ${warning}`).join("\n")}`
282
294
  : "";
295
+ const outcomeText = activation?.callable_now
296
+ ? `${existing ? "Updated" : "Registered"} and activated tool "${name}"`
297
+ : `Persisted tool "${name}"; current-session activation is unverified`;
283
298
  return {
284
299
  content: [
285
- textContent(ExecutionOutput.formatToolText(`${existing ? "Updated" : "Registered"} tool "${name}" (args: ${Schema.formatToolArgs(cfg.args)}).${warningText}`)),
300
+ textContent(ExecutionOutput.formatToolText(`${outcomeText} (args: ${Schema.formatToolArgs(cfg.args)}).${warningText}`)),
286
301
  ],
287
302
  details: {
303
+ active_tool: activation?.active_tool ?? false,
304
+ activation: activation?.activation ?? "unverified",
288
305
  args: cfg.args,
306
+ callable_now: activation?.callable_now ?? false,
289
307
  config: recipePath,
290
308
  defaults: cfg.defaults,
309
+ host_registered: activation?.host_registered ?? false,
310
+ persisted: true,
311
+ registry_active: tools.get(name) === cfg,
312
+ resolved: true,
291
313
  ...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
292
314
  ...(cfg.recipe?.name ? { recipeName: cfg.recipe.name } : {}),
293
315
  ...(cfg.template ? { template: cfg.template } : {}),
294
316
  ...(templateWarnings.length > 0 ? { templateWarnings } : {}),
295
317
  tool: name,
318
+ validated: true,
296
319
  },
297
320
  };
298
321
  }
@@ -6,6 +6,7 @@
6
6
  import { watch } from "node:fs";
7
7
  import * as Config from "./config.ts";
8
8
  import type { RegisteredToolExec } from "./execution.ts";
9
+ import type { RecipeResolutionContext } from "./recipes-context.ts";
9
10
  import * as ToolsLocal from "./tools-local.ts";
10
11
  export interface RuntimeContext {
11
12
  hasUI: boolean;
@@ -13,21 +14,46 @@ export interface RuntimeContext {
13
14
  notify(message: string, type?: "info" | "warning" | "error"): void;
14
15
  };
15
16
  }
17
+ export interface RuntimeToolActivation {
18
+ active_tool: boolean;
19
+ activation: "current_session" | "unverified";
20
+ callable_now: boolean;
21
+ host_registered: boolean;
22
+ }
16
23
  export interface ToolRegistryRuntimeDeps {
17
24
  configPath: string;
18
25
  exec: RegisteredToolExec;
19
26
  recipeRoot?: string;
20
27
  getActiveTools?: () => string[];
28
+ getAllTools?: () => Array<{
29
+ name: string;
30
+ }>;
21
31
  registerTool: (definition: ReturnType<typeof ToolsLocal.createRuntimeToolDefinition>) => void;
22
32
  reservedToolNames: Set<string>;
23
33
  setActiveTools?: (toolNames: string[]) => void;
24
34
  }
35
+ export interface RecipeRegistryStatus {
36
+ active_tool_count: number;
37
+ last_scan_counts: {
38
+ active: number;
39
+ rejected: number;
40
+ scanned: number;
41
+ };
42
+ last_scan_at?: string;
43
+ registry_generation: number;
44
+ resolution_generation?: string;
45
+ watch_status: "closed" | "failed" | "watching_parent" | "watching_root";
46
+ watched_root: string;
47
+ }
25
48
  export interface ToolRegistryRuntime {
49
+ getStatus(): RecipeRegistryStatus;
50
+ getToolStatus(name: string): Record<string, unknown> | undefined;
26
51
  getToolNameBlocker(name: string): string | undefined;
27
52
  getTools(): Map<string, Config.RegisteredTool>;
28
- loadTools(ctx: RuntimeContext): void;
53
+ loadTools(ctx: RuntimeContext, resolutionContext?: RecipeResolutionContext): void;
29
54
  notify(ctx: RuntimeContext, message: string, type: "info" | "warning" | "error"): void;
30
- registerRuntimeTool(cfg: Config.RegisteredTool): void;
55
+ registerRuntimeTool(cfg: Config.RegisteredTool): RuntimeToolActivation;
56
+ setWatchStatus(status: RecipeRegistryStatus["watch_status"]): void;
31
57
  }
32
58
  export interface RecipeToolReloadWatcher {
33
59
  close(): void;
@@ -36,7 +62,8 @@ export interface RecipeToolReloadWatcher {
36
62
  export declare function createAutoToolsRuntime(deps: ToolRegistryRuntimeDeps): ToolRegistryRuntime;
37
63
  export interface RecipeToolReloadWatcherDeps {
38
64
  exists?: (path: string) => boolean;
65
+ getResolutionContext?: () => RecipeResolutionContext | undefined;
39
66
  recipeRoot?: string;
40
67
  watchPath?: typeof watch;
41
68
  }
42
- export declare function createRecipeToolReloadWatcher(runtime: Pick<ToolRegistryRuntime, "loadTools">, deps?: RecipeToolReloadWatcherDeps): RecipeToolReloadWatcher;
69
+ export declare function createRecipeToolReloadWatcher(runtime: Pick<ToolRegistryRuntime, "loadTools"> & Partial<Pick<ToolRegistryRuntime, "setWatchStatus">>, deps?: RecipeToolReloadWatcherDeps): RecipeToolReloadWatcher;