@llblab/pi-actors 0.15.0 → 0.16.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.
package/lib/prompts.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  export const REGISTER_TOOL_DESCRIPTION =
8
8
  "Register a persistent custom tool from a command template, template recipe path, or co-located template recipe. " +
9
- "Definitions are stored in actors-tools.json across reloads. " +
9
+ "Definitions are stored as recipe files under ~/.pi/agent/recipes across reloads. " +
10
10
  "Use update=true to overwrite an existing tool, template=null/empty to delete.";
11
11
 
12
12
  export const REGISTER_TOOL_PROMPT_SNIPPET =
@@ -28,7 +28,7 @@ export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
28
28
  - Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.
29
29
  - Use spawn/message/inspect for actor-level start/send/observe; avoid runtime/FIFO/outbox vocabulary in public guidance.
30
30
  - Run state lives under ~/.pi/agent/tmp/pi-actors/runs; inspect status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.
31
- - Register_tool makes persistent tools from command templates, recipe names/paths, or co-located recipes; args may be typed or placeholder-derived.
31
+ - Register_tool writes user recipe files in ~/.pi/agent/recipes; that root is the default tool set, while packaged recipes are the lower-priority standard library.
32
32
  - Foreground tools/templates fit short work; async recipes/runs fit subagents, services, fanout, media, and long pipelines.
33
33
  - Long fanout = parent async recipe wrapping template(parallel:true) and imports; packaged fanout recipes bubble branch completion messages.
34
34
  - For deeper pi-actors guidance, inspect installed extension sources/docs/recipes; README and docs are not automatically in context.`;
@@ -0,0 +1,231 @@
1
+ /**
2
+ * File-discovered recipe registry helpers
3
+ * Zones: recipe discovery, tool exposure, migration diagnostics
4
+ * Owns filename identity discovery across prioritized recipe roots
5
+ */
6
+
7
+ import { existsSync, readdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ import type { RegisteredTool } from "./config.ts";
11
+ import type { TemplateRecipeConfig } from "./recipe-references.ts";
12
+ import * as RecipeReferences from "./recipe-references.ts";
13
+ import * as Schema from "./schema.ts";
14
+
15
+ export interface DiscoveredRecipe {
16
+ id: string;
17
+ path: string;
18
+ root: string;
19
+ priority: number;
20
+ config?: TemplateRecipeConfig;
21
+ active: boolean;
22
+ shadowed: boolean;
23
+ invalid: boolean;
24
+ disabled: boolean;
25
+ tool: boolean;
26
+ mutableUsage: boolean;
27
+ diagnostics: string[];
28
+ shadows: string[];
29
+ }
30
+
31
+ export interface RecipeDiscoveryResult {
32
+ active: Map<string, DiscoveredRecipe>;
33
+ entries: DiscoveredRecipe[];
34
+ diagnostics: string[];
35
+ }
36
+
37
+ export interface RecipeDiscoverySource {
38
+ root?: string;
39
+ file?: string;
40
+ defaultTool?: boolean;
41
+ mutableUsage?: boolean;
42
+ }
43
+
44
+ function listRecipeFiles(root: string): string[] {
45
+ if (!existsSync(root)) return [];
46
+ return readdirSync(root, { withFileTypes: true })
47
+ .filter(
48
+ (entry) =>
49
+ entry.isFile() &&
50
+ entry.name.endsWith(".json") &&
51
+ entry.name !== "actors-tools-migration-report.json",
52
+ )
53
+ .map((entry) => join(root, entry.name))
54
+ .sort();
55
+ }
56
+
57
+ function readDiscoveredRecipe(
58
+ root: string,
59
+ file: string,
60
+ priority: number,
61
+ defaultTool = false,
62
+ mutableUsage = false,
63
+ ): DiscoveredRecipe {
64
+ const id = RecipeReferences.getRecipeIdFromPath(file);
65
+ try {
66
+ const config = RecipeReferences.readResolvedRecipeConfig(file);
67
+ const invalid = !config;
68
+ const disabled = config?.disabled === true;
69
+ return {
70
+ id,
71
+ path: file,
72
+ root,
73
+ priority,
74
+ config,
75
+ active: false,
76
+ shadowed: false,
77
+ invalid,
78
+ disabled,
79
+ tool: (config?.tool ?? defaultTool) === true && !disabled && !invalid,
80
+ mutableUsage,
81
+ diagnostics: invalid ? [`Invalid recipe: ${file}`] : [],
82
+ shadows: [],
83
+ };
84
+ } catch (error) {
85
+ return {
86
+ id,
87
+ path: file,
88
+ root,
89
+ priority,
90
+ active: false,
91
+ shadowed: false,
92
+ invalid: true,
93
+ disabled: false,
94
+ tool: false,
95
+ mutableUsage,
96
+ diagnostics: [
97
+ `Failed to load recipe ${file}: ${error instanceof Error ? error.message : String(error)}`,
98
+ ],
99
+ shadows: [],
100
+ };
101
+ }
102
+ }
103
+
104
+ function filesForSource(
105
+ source: RecipeDiscoverySource,
106
+ ): Array<{ root: string; file: string; defaultTool: boolean; mutableUsage: boolean }> {
107
+ const defaultTool = source.defaultTool === true;
108
+ const mutableUsage = source.mutableUsage === true;
109
+ if (source.file)
110
+ return [{ root: source.root ?? source.file, file: source.file, defaultTool, mutableUsage }];
111
+ return source.root
112
+ ? listRecipeFiles(source.root).map((file) => ({
113
+ root: source.root!,
114
+ file,
115
+ defaultTool,
116
+ mutableUsage,
117
+ }))
118
+ : [];
119
+ }
120
+
121
+ export function discoverRecipeSources(
122
+ sources: RecipeDiscoverySource[],
123
+ ): RecipeDiscoveryResult {
124
+ const entries = sources.flatMap((source, priority) =>
125
+ filesForSource(source).map(({ root, file, defaultTool, mutableUsage }) =>
126
+ readDiscoveredRecipe(root, file, priority, defaultTool, mutableUsage),
127
+ ),
128
+ );
129
+ const byId = new Map<string, DiscoveredRecipe[]>();
130
+ for (const entry of entries) {
131
+ const bucket = byId.get(entry.id) ?? [];
132
+ bucket.push(entry);
133
+ byId.set(entry.id, bucket);
134
+ }
135
+
136
+ const active = new Map<string, DiscoveredRecipe>();
137
+ const diagnostics: string[] = [];
138
+ for (const [id, bucket] of byId) {
139
+ bucket.sort(
140
+ (a, b) => a.priority - b.priority || a.path.localeCompare(b.path),
141
+ );
142
+ const winner = bucket[0];
143
+ winner.active = true;
144
+ winner.shadows = bucket.slice(1).map((entry) => entry.path);
145
+ active.set(id, winner);
146
+ for (const shadow of bucket.slice(1)) shadow.shadowed = true;
147
+ if (winner.invalid)
148
+ diagnostics.push(
149
+ `Recipe ${id} is invalid and blocks lower-priority recipes`,
150
+ );
151
+ if (winner.disabled)
152
+ diagnostics.push(
153
+ `Recipe ${id} is disabled and blocks lower-priority recipes`,
154
+ );
155
+ if (winner.shadows.length > 0)
156
+ diagnostics.push(
157
+ `Recipe ${id} shadows ${winner.shadows.length} lower-priority recipe(s)`,
158
+ );
159
+ diagnostics.push(...winner.diagnostics);
160
+ }
161
+
162
+ return { active, entries, diagnostics };
163
+ }
164
+
165
+ export function discoverRecipes(roots: string[]): RecipeDiscoveryResult {
166
+ return discoverRecipeSources(roots.map((root) => ({ root })));
167
+ }
168
+
169
+ export function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string, unknown> {
170
+ return {
171
+ active: [...result.active.values()].map((entry) => ({
172
+ id: entry.id,
173
+ path: entry.path,
174
+ description: entry.config?.description,
175
+ tool: entry.tool,
176
+ disabled: entry.disabled,
177
+ invalid: entry.invalid,
178
+ shadows: entry.shadows,
179
+ })).sort((a, b) => a.id.localeCompare(b.id)),
180
+ shadowed: result.entries
181
+ .filter((entry) => entry.shadowed)
182
+ .map((entry) => ({ id: entry.id, path: entry.path, shadowedBy: result.active.get(entry.id)?.path }))
183
+ .sort((a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path)),
184
+ invalid: result.entries
185
+ .filter((entry) => entry.invalid)
186
+ .map((entry) => ({ id: entry.id, path: entry.path, diagnostics: entry.diagnostics }))
187
+ .sort((a, b) => a.id.localeCompare(b.id)),
188
+ disabled: result.entries
189
+ .filter((entry) => entry.disabled)
190
+ .map((entry) => ({ id: entry.id, path: entry.path }))
191
+ .sort((a, b) => a.id.localeCompare(b.id)),
192
+ diagnostics: result.diagnostics,
193
+ };
194
+ }
195
+
196
+ export function toRegisteredTool(entry: DiscoveredRecipe): RegisteredTool | undefined {
197
+ if (!entry.tool || entry.invalid || entry.disabled || !entry.config) return undefined;
198
+ const cfg = entry.config;
199
+ const template = entry.path;
200
+ const description = cfg.description ?? `Execute template recipe: ${entry.id}`;
201
+ const argTemplate = cfg.template;
202
+ const argTemplateConfig =
203
+ typeof argTemplate === "object" && !Array.isArray(argTemplate)
204
+ ? {
205
+ ...argTemplate,
206
+ ...(cfg.args !== undefined ? { args: cfg.args } : {}),
207
+ defaults: { ...(argTemplate.defaults ?? {}), ...(cfg.defaults ?? {}) },
208
+ }
209
+ : { args: cfg.args, defaults: cfg.defaults ?? {}, template: argTemplate };
210
+ const argTypes = Schema.getTemplateArgTypes(argTemplateConfig);
211
+ return {
212
+ name: entry.id,
213
+ description,
214
+ template,
215
+ recipe: cfg,
216
+ args: Schema.getToolArgNames(argTemplateConfig),
217
+ defaults: Object.fromEntries(
218
+ Object.entries(cfg.defaults ?? {}).map(([key, value]) => [key, String(value)]),
219
+ ),
220
+ ...(Object.keys(argTypes).length > 0 ? { argTypes } : {}),
221
+ ...(entry.mutableUsage ? { sourcePath: entry.path } : {}),
222
+ ...(cfg.args ? { storedArgs: cfg.args } : {}),
223
+ ...(cfg.defaults
224
+ ? {
225
+ storedDefaults: Object.fromEntries(
226
+ Object.entries(cfg.defaults).map(([key, value]) => [key, String(value)]),
227
+ ),
228
+ }
229
+ : {}),
230
+ };
231
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Legacy actors-tools.json to recipe-file migration helpers
3
+ * Zones: registry migration, recipe persistence, compatibility diagnostics
4
+ * Owns one-way migration from legacy tool registry entries into user recipe files
5
+ */
6
+
7
+ import { existsSync, mkdirSync, renameSync } from "node:fs";
8
+ import { basename, join } from "node:path";
9
+
10
+ import * as Config from "./config.ts";
11
+ import { writeJsonAtomic } from "./file-state.ts";
12
+ import type { TemplateRecipeConfig } from "./recipe-references.ts";
13
+ import * as RecipeReferences from "./recipe-references.ts";
14
+
15
+ export interface LegacyRegistryMigrationResult {
16
+ migrated: string[];
17
+ skipped: string[];
18
+ conflicts: string[];
19
+ invalid: string[];
20
+ archive?: string;
21
+ report?: string;
22
+ warnings: string[];
23
+ }
24
+
25
+ export interface LegacyRegistryMigrationOptions {
26
+ configPath: string;
27
+ recipeRoot: string;
28
+ reservedToolNames: Set<string>;
29
+ archive?: boolean;
30
+ }
31
+
32
+ function recipePathForTool(recipeRoot: string, name: string): string {
33
+ return join(recipeRoot, `${name}.json`);
34
+ }
35
+
36
+ function toRecipeConfig(tool: Config.RegisteredTool): TemplateRecipeConfig {
37
+ return {
38
+ name: tool.name,
39
+ description: tool.description,
40
+ tool: true,
41
+ ...(tool.recipe?.async !== undefined ? { async: tool.recipe.async } : {}),
42
+ ...(tool.recipe?.state_dir ? { state_dir: tool.recipe.state_dir } : {}),
43
+ ...(tool.storedArgs ? { args: tool.storedArgs } : {}),
44
+ ...(tool.storedDefaults ? { defaults: tool.storedDefaults } : {}),
45
+ template: tool.template!,
46
+ ...(tool.recipe?.values ? { values: tool.recipe.values } : {}),
47
+ };
48
+ }
49
+
50
+ function nextArchivePath(configPath: string): string {
51
+ const base = `${configPath}.migrated`;
52
+ if (!existsSync(base)) return base;
53
+ for (let index = 1; index < 1000; index += 1) {
54
+ const path = `${base}.${index}`;
55
+ if (!existsSync(path)) return path;
56
+ }
57
+ return `${base}.${Date.now()}`;
58
+ }
59
+
60
+ export function migrateLegacyToolRegistry(
61
+ options: LegacyRegistryMigrationOptions,
62
+ ): LegacyRegistryMigrationResult {
63
+ const { configPath, recipeRoot, reservedToolNames } = options;
64
+ const result: LegacyRegistryMigrationResult = {
65
+ migrated: [],
66
+ skipped: [],
67
+ conflicts: [],
68
+ invalid: [],
69
+ warnings: [],
70
+ };
71
+ if (!existsSync(configPath)) return result;
72
+
73
+ const loaded = Config.loadToolConfig(configPath, reservedToolNames);
74
+ result.warnings.push(...loaded.warnings);
75
+ mkdirSync(recipeRoot, { recursive: true });
76
+
77
+ for (const [name, tool] of loaded.tools) {
78
+ const path = recipePathForTool(recipeRoot, name);
79
+ if (existsSync(path)) {
80
+ result.conflicts.push(name);
81
+ continue;
82
+ }
83
+ const recipe = toRecipeConfig(tool);
84
+ try {
85
+ writeJsonAtomic(path, recipe);
86
+ const parsed = RecipeReferences.readResolvedRecipeConfig(path);
87
+ if (!parsed) {
88
+ result.invalid.push(name);
89
+ continue;
90
+ }
91
+ result.migrated.push(name);
92
+ } catch (error) {
93
+ result.invalid.push(
94
+ `${name}: ${error instanceof Error ? error.message : String(error)}`,
95
+ );
96
+ }
97
+ }
98
+
99
+ if (loaded.tools.size === 0) result.skipped.push(basename(configPath));
100
+
101
+ const reportPath = join(recipeRoot, "actors-tools-migration-report.json");
102
+ writeJsonAtomic(reportPath, {
103
+ source: configPath,
104
+ migrated: result.migrated,
105
+ skipped: result.skipped,
106
+ conflicts: result.conflicts,
107
+ invalid: result.invalid,
108
+ warnings: result.warnings,
109
+ });
110
+ result.report = reportPath;
111
+
112
+ if (
113
+ options.archive !== false &&
114
+ result.conflicts.length === 0 &&
115
+ result.invalid.length === 0
116
+ ) {
117
+ const archive = nextArchivePath(configPath);
118
+ renameSync(configPath, archive);
119
+ result.archive = archive;
120
+ }
121
+
122
+ return result;
123
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { dirname, resolve } from "node:path";
9
+ import { basename, dirname, resolve } from "node:path";
10
10
 
11
11
  import type {
12
12
  CommandTemplateConfig,
@@ -30,6 +30,9 @@ export interface TemplateRecipeMailbox {
30
30
 
31
31
  export interface TemplateRecipeDefinition {
32
32
  name?: string;
33
+ description?: string;
34
+ tool?: boolean;
35
+ disabled?: boolean;
33
36
  imports?: Record<string, TemplateRecipeImport>;
34
37
  template: CommandTemplateValue;
35
38
  args?: string[];
@@ -194,14 +197,16 @@ function readRawRecipeConfig(
194
197
  string,
195
198
  unknown
196
199
  >;
197
- return raw && typeof raw === "object" && !Object.hasOwn(raw, "tool")
198
- ? raw
199
- : undefined;
200
+ return raw && typeof raw === "object" ? raw : undefined;
200
201
  } catch {
201
202
  return undefined;
202
203
  }
203
204
  }
204
205
 
206
+ export function getRecipeIdFromPath(file: string): string {
207
+ return basename(file, ".json");
208
+ }
209
+
205
210
  function readRecipeConfig(value: unknown): TemplateRecipeConfig | undefined {
206
211
  const path = getRecipePath(value);
207
212
  return path ? readResolvedRecipeConfig(path) : undefined;
@@ -489,7 +494,20 @@ export function readResolvedRecipeConfig(
489
494
  if (!template) return undefined;
490
495
  const expandedTemplate = expandImportNodes(template, imports);
491
496
  return {
492
- ...(typeof substituted.name === "string" ? { name: substituted.name } : {}),
497
+ name:
498
+ typeof substituted.name === "string"
499
+ ? substituted.name
500
+ : getRecipeIdFromPath(path),
501
+ ...(typeof substituted.description === "string" &&
502
+ substituted.description.trim()
503
+ ? { description: substituted.description.trim() }
504
+ : {}),
505
+ ...(typeof substituted.tool === "boolean"
506
+ ? { tool: substituted.tool }
507
+ : {}),
508
+ ...(typeof substituted.disabled === "boolean"
509
+ ? { disabled: substituted.disabled }
510
+ : {}),
493
511
  ...(substituted.async === true
494
512
  ? { async: true }
495
513
  : substituted.async === false
@@ -514,13 +532,16 @@ export function readResolvedRecipeConfig(
514
532
  ...(typeof substituted.label === "string"
515
533
  ? { label: substituted.label }
516
534
  : {}),
517
- ...(typeof substituted.when === "string" || typeof substituted.when === "boolean"
535
+ ...(typeof substituted.when === "string" ||
536
+ typeof substituted.when === "boolean"
518
537
  ? { when: substituted.when }
519
538
  : {}),
520
- ...(typeof substituted.timeout === "number" || typeof substituted.timeout === "string"
539
+ ...(typeof substituted.timeout === "number" ||
540
+ typeof substituted.timeout === "string"
521
541
  ? { timeout: substituted.timeout }
522
542
  : {}),
523
- ...(typeof substituted.delay === "number" || typeof substituted.delay === "string"
543
+ ...(typeof substituted.delay === "number" ||
544
+ typeof substituted.delay === "string"
524
545
  ? { delay: substituted.delay }
525
546
  : {}),
526
547
  ...(typeof substituted.output === "string"
@@ -529,8 +550,10 @@ export function readResolvedRecipeConfig(
529
550
  ...(isRecord(substituted.artifacts)
530
551
  ? {
531
552
  artifacts: Object.fromEntries(
532
- Object.entries(substituted.artifacts)
533
- .filter((entry): entry is [string, string] => typeof entry[1] === "string"),
553
+ Object.entries(substituted.artifacts).filter(
554
+ (entry): entry is [string, string] =>
555
+ typeof entry[1] === "string",
556
+ ),
534
557
  ),
535
558
  }
536
559
  : {}),
@@ -538,15 +561,24 @@ export function readResolvedRecipeConfig(
538
561
  ? {
539
562
  mailbox: {
540
563
  ...(Array.isArray(substituted.mailbox.accepts)
541
- ? { accepts: substituted.mailbox.accepts.filter((value): value is string => typeof value === "string") }
564
+ ? {
565
+ accepts: substituted.mailbox.accepts.filter(
566
+ (value): value is string => typeof value === "string",
567
+ ),
568
+ }
542
569
  : {}),
543
570
  ...(Array.isArray(substituted.mailbox.emits)
544
- ? { emits: substituted.mailbox.emits.filter((value): value is string => typeof value === "string") }
571
+ ? {
572
+ emits: substituted.mailbox.emits.filter(
573
+ (value): value is string => typeof value === "string",
574
+ ),
575
+ }
545
576
  : {}),
546
577
  },
547
578
  }
548
579
  : {}),
549
- ...(typeof substituted.retry === "number" || typeof substituted.retry === "string"
580
+ ...(typeof substituted.retry === "number" ||
581
+ typeof substituted.retry === "string"
550
582
  ? { retry: substituted.retry }
551
583
  : {}),
552
584
  ...(substituted.failure === "continue" ||
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Recipe usage metadata helpers
3
+ * Zones: recipe telemetry, muscle-memory cleanup evidence
4
+ * Owns lightweight launch counters for user-owned recipe files
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+
9
+ import { writeJsonAtomic } from "./file-state.ts";
10
+
11
+ interface RecipeUsageRecord {
12
+ calls?: number;
13
+ last_called?: string;
14
+ }
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
18
+ }
19
+
20
+ function normalizeCalls(value: unknown): number {
21
+ return typeof value === "number" && Number.isFinite(value) && value > 0
22
+ ? Math.floor(value)
23
+ : 0;
24
+ }
25
+
26
+ export function recordRecipeLaunch(path: string, now = new Date()): boolean {
27
+ if (!existsSync(path)) return false;
28
+ try {
29
+ const raw = JSON.parse(readFileSync(path, "utf8"));
30
+ if (!isRecord(raw)) return false;
31
+ const usage: RecipeUsageRecord = isRecord(raw.usage) ? raw.usage : {};
32
+ writeJsonAtomic(path, {
33
+ ...raw,
34
+ usage: {
35
+ ...usage,
36
+ calls: normalizeCalls(usage.calls) + 1,
37
+ last_called: now.toISOString(),
38
+ },
39
+ });
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
package/lib/registry.ts CHANGED
@@ -4,10 +4,15 @@
4
4
  * Owns register/update/delete validation, persistence, runtime side effects, and result payloads
5
5
  */
6
6
 
7
+ import { existsSync, mkdirSync, unlinkSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+
7
10
  import * as Config from "./config.ts";
8
11
  import * as Identity from "./identity.ts";
9
12
  import * as Output from "./output.ts";
10
13
  import * as CommandTemplates from "./command-templates.ts";
14
+ import { writeJsonAtomic } from "./file-state.ts";
15
+ import * as Paths from "./paths.ts";
11
16
  import * as RecipeReferences from "./recipe-references.ts";
12
17
  import * as Schema from "./schema.ts";
13
18
 
@@ -41,6 +46,7 @@ export interface RegisterToolResult {
41
46
 
42
47
  export interface RegisterToolRuntimeDeps<TContext> {
43
48
  configPath: string;
49
+ recipeRoot?: string;
44
50
  getExternalToolConflict: (name: string) => string | undefined;
45
51
  getTools: () => Map<string, Config.RegisteredTool>;
46
52
  getActiveTools: () => string[];
@@ -76,6 +82,38 @@ function listTools<TContext>(
76
82
  };
77
83
  }
78
84
 
85
+ function getRecipeRoot<TContext>(
86
+ deps: RegisterToolRuntimeDeps<TContext>,
87
+ ): string {
88
+ return deps.recipeRoot ?? Paths.getRecipeRoot(dirname(deps.configPath));
89
+ }
90
+
91
+ function getToolRecipePath<TContext>(
92
+ deps: RegisterToolRuntimeDeps<TContext>,
93
+ name: string,
94
+ ): string {
95
+ return join(getRecipeRoot(deps), `${name}.json`);
96
+ }
97
+
98
+ function persistToolRecipe<TContext>(
99
+ deps: RegisterToolRuntimeDeps<TContext>,
100
+ cfg: Config.RegisteredTool,
101
+ ): string {
102
+ const path = getToolRecipePath(deps, cfg.name);
103
+ mkdirSync(dirname(path), { recursive: true });
104
+ writeJsonAtomic(path, {
105
+ description: cfg.description,
106
+ tool: true,
107
+ ...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
108
+ ...(cfg.recipe?.state_dir ? { state_dir: cfg.recipe.state_dir } : {}),
109
+ ...(cfg.storedArgs ? { args: cfg.storedArgs } : {}),
110
+ ...(cfg.storedDefaults ? { defaults: cfg.storedDefaults } : {}),
111
+ ...(cfg.recipe?.values ? { values: cfg.recipe.values } : {}),
112
+ template: cfg.template,
113
+ });
114
+ return path;
115
+ }
116
+
79
117
  function deleteTool<TContext>(
80
118
  name: string,
81
119
  ctx: TContext,
@@ -90,14 +128,8 @@ function deleteTool<TContext>(
90
128
  details: { tool: name },
91
129
  };
92
130
  }
93
- const nextTools = new Map(tools);
94
- nextTools.delete(name);
95
- const saveError = Config.saveTools(deps.configPath, nextTools);
96
- if (saveError) {
97
- throw new Error(
98
- Output.formatToolText(`Failed to persist tool deletion: ${saveError}`),
99
- );
100
- }
131
+ const recipePath = getToolRecipePath(deps, name);
132
+ if (existsSync(recipePath)) unlinkSync(recipePath);
101
133
  tools.delete(name);
102
134
  deps.setActiveTools(
103
135
  deps.getActiveTools().filter((toolName) => toolName !== name),
@@ -111,7 +143,7 @@ function deleteTool<TContext>(
111
143
  ),
112
144
  ),
113
145
  ],
114
- details: { config: deps.configPath, tool: name },
146
+ details: { config: recipePath, tool: name },
115
147
  };
116
148
  }
117
149
 
@@ -257,16 +289,17 @@ export async function executeRegisterTool<TContext>(
257
289
  );
258
290
  }
259
291
  const cfg = buildConfig(name, input, existing);
260
- const nextTools = new Map(tools);
261
- nextTools.set(name, cfg);
262
- const saveError = Config.saveTools(deps.configPath, nextTools);
263
- if (saveError) {
292
+ let recipePath: string;
293
+ try {
294
+ recipePath = persistToolRecipe(deps, cfg);
295
+ } catch (error) {
264
296
  throw new Error(
265
297
  Output.formatToolText(
266
- `Failed to persist tool registration: ${saveError}`,
298
+ `Failed to persist tool recipe: ${error instanceof Error ? error.message : String(error)}`,
267
299
  ),
268
300
  );
269
301
  }
302
+ cfg.sourcePath = recipePath;
270
303
  tools.set(name, cfg);
271
304
  deps.registerRuntimeTool(cfg);
272
305
  deps.notify(ctx, `Tool persisted: ${name}`, "info");
@@ -289,7 +322,7 @@ export async function executeRegisterTool<TContext>(
289
322
  ],
290
323
  details: {
291
324
  args: cfg.args,
292
- config: deps.configPath,
325
+ config: recipePath,
293
326
  defaults: cfg.defaults,
294
327
  ...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
295
328
  ...(cfg.recipe?.name ? { recipeName: cfg.recipe.name } : {}),