@llblab/pi-actors 0.22.3 → 0.22.4

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.
@@ -272,12 +272,47 @@ function updateRosterForMessage(stateDir, room, message, receivedAt) {
272
272
  }
273
273
  return roster;
274
274
  }
275
- export function readBranchInboxMessages(stateDir, run, address, limit = 40) {
275
+ function readBranchInboxFile(stateDir, run, address, limit = 40) {
276
+ const branch = branchIdFromAddress(address, run);
277
+ if (!branch)
278
+ throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
279
+ try {
280
+ const lines = readJsonlTailLines(branchInboxFile(stateDir, branch), limit);
281
+ const messages = [];
282
+ let corrupted = 0;
283
+ for (const line of lines) {
284
+ try {
285
+ messages.push(JSON.parse(line));
286
+ }
287
+ catch {
288
+ corrupted += 1;
289
+ }
290
+ }
291
+ return { corrupted, messages };
292
+ }
293
+ catch (error) {
294
+ if (error.code === "ENOENT")
295
+ return { corrupted: 0, messages: [] };
296
+ throw error;
297
+ }
298
+ }
299
+ function readAllBranchInboxLines(stateDir, run, address) {
276
300
  const branch = branchIdFromAddress(address, run);
277
301
  if (!branch)
278
302
  throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
279
303
  try {
280
- return readJsonlTailLines(branchInboxFile(stateDir, branch), limit).map((line) => JSON.parse(line));
304
+ const content = fs.readFileSync(branchInboxFile(stateDir, branch), "utf8");
305
+ return content
306
+ .split("\n")
307
+ .filter((line) => line.length > 0)
308
+ .map((line) => {
309
+ try {
310
+ return { message: JSON.parse(line) };
311
+ }
312
+ catch {
313
+ return { raw: line };
314
+ }
315
+ });
281
316
  }
282
317
  catch (error) {
283
318
  if (error.code === "ENOENT")
@@ -285,6 +320,12 @@ export function readBranchInboxMessages(stateDir, run, address, limit = 40) {
285
320
  throw error;
286
321
  }
287
322
  }
323
+ export function readBranchInboxMessages(stateDir, run, address, limit = 40) {
324
+ return readBranchInboxFile(stateDir, run, address, limit).messages;
325
+ }
326
+ export function readBranchInboxDiagnostics(stateDir, run, address, limit = 40) {
327
+ return readBranchInboxFile(stateDir, run, address, limit);
328
+ }
288
329
  export function getBranchInboxTerminalRetainLimit() {
289
330
  const value = Number(process.env.PI_ACTORS_BRANCH_INBOX_TERMINAL_RETAINED ?? "");
290
331
  return Number.isInteger(value) && value >= 0
@@ -320,19 +361,27 @@ export function updateBranchInboxMessageStatus(stateDir, run, address, id, statu
320
361
  const releaseLock = acquireBranchInboxLock(stateDir, branch);
321
362
  try {
322
363
  const file = branchInboxFile(stateDir, branch);
323
- const messages = readBranchInboxMessages(stateDir, run, address, Number.MAX_SAFE_INTEGER);
364
+ const records = readAllBranchInboxLines(stateDir, run, address);
324
365
  let changed = false;
325
366
  const timestampKey = `${status}_at`;
326
- const updated = messages.map((message) => {
327
- if (message.id !== id)
328
- return message;
367
+ const updated = records.map((record) => {
368
+ if (!("message" in record) || record.message.id !== id)
369
+ return record;
329
370
  changed = true;
330
- return { ...message, ...metadata, [timestampKey]: new Date().toISOString(), status };
371
+ return { message: { ...record.message, ...metadata, [timestampKey]: new Date().toISOString(), status } };
331
372
  });
332
373
  if (!changed)
333
374
  return false;
334
- const compacted = compactBranchInboxMessages(updated);
335
- fs.writeFileSync(file, `${compacted.map((message) => JSON.stringify(message)).join("\n")}\n`);
375
+ const validMessages = updated.flatMap((record) => "message" in record ? [record.message] : []);
376
+ const compactedIds = new Set(compactBranchInboxMessages(validMessages).map((message) => message.id));
377
+ const lines = updated.flatMap((record) => {
378
+ if ("raw" in record)
379
+ return [record.raw];
380
+ if (record.message.id && !compactedIds.has(record.message.id))
381
+ return [];
382
+ return [JSON.stringify(record.message)];
383
+ });
384
+ fs.writeFileSync(file, lines.length ? `${lines.join("\n")}\n` : "");
336
385
  notifyActorWake(stateDir, address, "branch.inbox.status", { id, status });
337
386
  return true;
338
387
  }
@@ -3,8 +3,8 @@
3
3
  * Zones: async runtime, lifecycle, state files
4
4
  * Owns detached run state, observation, log tailing, listing, and cancellation safety
5
5
  */
6
- import { randomUUID } from "node:crypto";
7
6
  import { spawn, spawnSync } from "node:child_process";
7
+ import { randomUUID } from "node:crypto";
8
8
  import { closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
9
9
  import { createConnection } from "node:net";
10
10
  import { platform } from "node:os";
@@ -22,7 +22,8 @@ const DEFAULT_STATE_ROOT = Paths.getRunStateRoot();
22
22
  const DEFAULT_RECIPE_ROOT = Paths.getRecipeRoot();
23
23
  function packageRoot() {
24
24
  const moduleDir = dirname(fileURLToPath(import.meta.url));
25
- if (basename(moduleDir) === "lib" && basename(dirname(moduleDir)) === "dist") {
25
+ if (basename(moduleDir) === "lib" &&
26
+ basename(dirname(moduleDir)) === "dist") {
26
27
  return dirname(dirname(moduleDir));
27
28
  }
28
29
  return dirname(moduleDir);
@@ -263,7 +264,8 @@ export function startRun(params, cwd) {
263
264
  ? resolveRecipeFile(startParams.file)
264
265
  : undefined;
265
266
  const recipe = startParams.name || getRunIdFromFile(recipeFile);
266
- const includeActorRecipeContext = startParams.actor_context !== false && startParams.actor_context !== "off";
267
+ const includeActorRecipeContext = startParams.actor_context !== false &&
268
+ startParams.actor_context !== "off";
267
269
  const recipeContextRecords = recipeFile && includeActorRecipeContext
268
270
  ? RecipeReferences.buildRecipeContextRecords(recipeFile)
269
271
  : undefined;
@@ -290,7 +292,9 @@ export function startRun(params, cwd) {
290
292
  argv: [process.execPath, ...argv],
291
293
  createdAt: new Date().toISOString(),
292
294
  cwd,
293
- ...(startParams.launch_source ? { launch_source: startParams.launch_source } : {}),
295
+ ...(startParams.launch_source
296
+ ? { launch_source: startParams.launch_source }
297
+ : {}),
294
298
  ...(startParams.ownerId ? { ownerId: startParams.ownerId } : {}),
295
299
  pid: 0,
296
300
  ...(recipe ? { recipe } : {}),
@@ -582,7 +586,9 @@ export function claimRunInboxMessage(runOrDir, owner = "runtime", statuses = ["q
582
586
  ...messages[index],
583
587
  claimed_at: new Date().toISOString(),
584
588
  claimed_by: owner,
585
- id: typeof messages[index].id === "string" ? messages[index].id : randomUUID(),
589
+ id: typeof messages[index].id === "string"
590
+ ? messages[index].id
591
+ : randomUUID(),
586
592
  status: "claimed",
587
593
  };
588
594
  messages[index] = claimed;
@@ -669,9 +675,10 @@ function appendRunInboxMessage(stateDir, message) {
669
675
  let record;
670
676
  try {
671
677
  const parsed = JSON.parse(message);
672
- record = parsed && typeof parsed === "object" && !Array.isArray(parsed)
673
- ? parsed
674
- : { body: parsed, type: "run.message" };
678
+ record =
679
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
680
+ ? parsed
681
+ : { body: parsed, type: "run.message" };
675
682
  }
676
683
  catch {
677
684
  record = { body: message, type: "run.message" };
@@ -781,6 +788,7 @@ export async function sendRunMessage(runOrDir, message, options = {}) {
781
788
  control: "inbox.jsonl",
782
789
  control_path: endpoint.path,
783
790
  control_type: endpoint.type,
791
+ inbox_id: inboxId,
784
792
  queued: true,
785
793
  run,
786
794
  sent: true,
@@ -800,6 +808,7 @@ export async function sendRunMessage(runOrDir, message, options = {}) {
800
808
  control: "control.fifo",
801
809
  control_path: endpoint.path,
802
810
  control_type: endpoint.type,
811
+ inbox_id: inboxId,
803
812
  run,
804
813
  sent: true,
805
814
  state_dir: stateDir,
@@ -813,13 +822,24 @@ export async function sendRunMessage(runOrDir, message, options = {}) {
813
822
  control: endpoint.path,
814
823
  control_path: endpoint.path,
815
824
  control_type: endpoint.type,
825
+ inbox_id: inboxId,
816
826
  run,
817
827
  sent: true,
818
828
  state_dir: stateDir,
819
829
  };
820
830
  }
821
831
  catch (error) {
822
- throw new Error(`Run control endpoint is not ready: ${endpoint.path}: ${error instanceof Error ? error.message : String(error)}`);
832
+ const deliveryError = error instanceof Error ? error.message : String(error);
833
+ throw Object.assign(new Error(`Run control endpoint is not ready: ${endpoint.path}: ${deliveryError}`), {
834
+ control_path: endpoint.path,
835
+ control_type: endpoint.type,
836
+ delivery_error: deliveryError,
837
+ inbox_id: inboxId,
838
+ queued: true,
839
+ run,
840
+ sent: false,
841
+ state_dir: stateDir,
842
+ });
823
843
  }
824
844
  }
825
845
  export function getRunProcessSignalPlan(pid, signal, runtimePlatform = process.platform) {
@@ -842,7 +862,9 @@ function signalOwnedRunProcess(pid, signal) {
842
862
  if (plan.command && plan.args) {
843
863
  const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
844
864
  if (result.status !== 0) {
845
- throw new Error(result.stderr?.trim() || result.stdout?.trim() || `${plan.command} failed`);
865
+ throw new Error(result.stderr?.trim() ||
866
+ result.stdout?.trim() ||
867
+ `${plan.command} failed`);
846
868
  }
847
869
  return plan;
848
870
  }
@@ -4,7 +4,7 @@
4
4
  * Owns ambient summaries, terminal events, and run outbox delivery for detached command-template runs
5
5
  */
6
6
  import { existsSync, readdirSync, readFileSync } from "node:fs";
7
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
8
8
  import * as AsyncRuns from "./async-runs.js";
9
9
  import * as Paths from "./paths.js";
10
10
  const TERMINAL = new Set([
@@ -324,14 +324,22 @@ export async function executeRunRetirements(summary, options) {
324
324
  const results = [];
325
325
  for (const candidate of findRunRetirementCandidates(summary)) {
326
326
  if (options.attempted?.has(candidate.stateDir)) {
327
- results.push({ action: "skip", run: candidate.run, stateDir: candidate.stateDir });
327
+ results.push({
328
+ action: "skip",
329
+ run: candidate.run,
330
+ stateDir: candidate.stateDir,
331
+ });
328
332
  continue;
329
333
  }
330
334
  options.attempted?.add(candidate.stateDir);
331
335
  try {
332
336
  await options.sendStop(candidate);
333
337
  options.notify?.(`Retiring actor ${candidate.run} after child runs reached terminal state`, "info");
334
- results.push({ action: "stop", run: candidate.run, stateDir: candidate.stateDir });
338
+ results.push({
339
+ action: "stop",
340
+ run: candidate.run,
341
+ stateDir: candidate.stateDir,
342
+ });
335
343
  continue;
336
344
  }
337
345
  catch (error) {
@@ -343,13 +351,19 @@ export async function executeRunRetirements(summary, options) {
343
351
  : `Actor retirement skipped for ${candidate.run}: ${error instanceof Error ? error.message : String(error)}`, cancelled ? "warning" : "error");
344
352
  results.push({
345
353
  action: cancelled ? "cancel" : "skip",
346
- ...(cancelled ? {} : { error: error instanceof Error ? error.message : String(error) }),
354
+ ...(cancelled
355
+ ? {}
356
+ : {
357
+ error: error instanceof Error ? error.message : String(error),
358
+ }),
347
359
  run: candidate.run,
348
360
  stateDir: candidate.stateDir,
349
361
  });
350
362
  }
351
363
  catch (cancelError) {
352
- const message = cancelError instanceof Error ? cancelError.message : String(cancelError);
364
+ const message = cancelError instanceof Error
365
+ ? cancelError.message
366
+ : String(cancelError);
353
367
  options.notify?.(`Actor retirement failed for ${candidate.run}: ${message}`, "error");
354
368
  results.push({
355
369
  action: "failed",
@@ -362,10 +376,14 @@ export async function executeRunRetirements(summary, options) {
362
376
  }
363
377
  return results;
364
378
  }
379
+ function runObservationKey(run) {
380
+ return run.stateDir ?? run.run;
381
+ }
365
382
  export function detectRunTransitions(previous, summary) {
366
383
  const transitions = [];
367
384
  for (const run of summary.runs) {
368
- const old = previous.get(run.run);
385
+ const key = runObservationKey(run);
386
+ const old = previous.get(key);
369
387
  if (old && old !== run.status && TERMINAL.has(run.status)) {
370
388
  transitions.push({
371
389
  from: old,
@@ -379,7 +397,7 @@ export function detectRunTransitions(previous, summary) {
379
397
  ...(run.tool ? { tool: run.tool } : {}),
380
398
  });
381
399
  }
382
- previous.set(run.run, run.status);
400
+ previous.set(key, run.status);
383
401
  }
384
402
  return transitions;
385
403
  }
@@ -440,11 +458,11 @@ function readOutboxLines(run) {
440
458
  return content ? content.split("\n") : [];
441
459
  }
442
460
  export function pruneRunObservationState(previousStatuses, previousLineCounts, summary, terminalRuns = []) {
443
- const activeRuns = new Set(summary.runs.map((run) => run.run));
461
+ const activeRuns = new Set(summary.runs.map((run) => runObservationKey(run)));
444
462
  const terminalRunSet = new Set(terminalRuns);
445
463
  const terminalLineKeys = new Set(summary.runs
446
- .filter((run) => terminalRunSet.has(run.run))
447
- .map((run) => run.stateDir ?? run.run));
464
+ .filter((run) => terminalRunSet.has(runObservationKey(run)))
465
+ .map((run) => runObservationKey(run)));
448
466
  const activeLineKeys = new Set(summary.runs.map((run) => run.stateDir ?? run.run));
449
467
  for (const run of terminalRunSet)
450
468
  previousStatuses.delete(run);
@@ -24,7 +24,9 @@ function assertToolSafeRepeatConfig(config, argTypes, defaults) {
24
24
  const trimmed = node.repeat.trim();
25
25
  if (!/^\d+$/.test(trimmed)) {
26
26
  const match = trimmed.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\.length\}?$/);
27
- if (!match || (argTypes[match[1]]?.kind !== "array" && !Array.isArray(defaults[match[1]])))
27
+ if (!match ||
28
+ (argTypes[match[1]]?.kind !== "array" &&
29
+ !Array.isArray(defaults[match[1]])))
28
30
  throw new Error("Command template repeat must be a positive integer or {array.length} with an array argument/default.");
29
31
  }
30
32
  }
@@ -39,11 +41,16 @@ function listRecipeFiles(root) {
39
41
  (entry.name.endsWith(".json") || entry.name.endsWith(".md")) &&
40
42
  entry.name !== "legacy-tool-registry-migration-report.json")
41
43
  .map((entry) => join(root, entry.name))
42
- .sort((a, b) => a.replace(/\.md$/, ".json").localeCompare(b.replace(/\.md$/, ".json")) || (a.endsWith(".json") ? -1 : 1));
44
+ .sort((a, b) => a
45
+ .replace(/\.md$/, ".json")
46
+ .localeCompare(b.replace(/\.md$/, ".json")) ||
47
+ (a.endsWith(".json") ? -1 : 1));
43
48
  }
44
49
  function getRecipeConfigDiagnostics(file, config) {
45
- if (!config)
46
- return [`Invalid recipe: ${file}`];
50
+ if (!config) {
51
+ const reason = RecipeReferences.diagnoseRawRecipeConfigFailure(file);
52
+ return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
53
+ }
47
54
  const commandTemplateConfig = typeof config.template === "object" && config.template !== null
48
55
  ? config.template
49
56
  : config;
@@ -94,7 +101,14 @@ function filesForSource(source) {
94
101
  const defaultTool = source.defaultTool === true;
95
102
  const mutableUsage = source.mutableUsage === true;
96
103
  if (source.file)
97
- return [{ root: source.root ?? source.file, file: source.file, defaultTool, mutableUsage }];
104
+ return [
105
+ {
106
+ root: source.root ?? source.file,
107
+ file: source.file,
108
+ defaultTool,
109
+ mutableUsage,
110
+ },
111
+ ];
98
112
  return source.root
99
113
  ? listRecipeFiles(source.root).map((file) => ({
100
114
  root: source.root,
@@ -139,7 +153,9 @@ export function discoverRecipeSources(sources) {
139
153
  const diagnostics = getRecipeRootDiagnostics(sources);
140
154
  for (const [id, bucket] of byId) {
141
155
  bucket.sort((a, b) => a.priority - b.priority ||
142
- a.path.replace(/\.md$/, ".json").localeCompare(b.path.replace(/\.md$/, ".json")) ||
156
+ a.path
157
+ .replace(/\.md$/, ".json")
158
+ .localeCompare(b.path.replace(/\.md$/, ".json")) ||
143
159
  (a.path.endsWith(".json") ? -1 : 1));
144
160
  const winner = bucket[0];
145
161
  winner.active = true;
@@ -209,7 +225,13 @@ function cleanupRecommendation(entry) {
209
225
  id: entry.id,
210
226
  path: entry.path,
211
227
  reason: "user recipe is a component, not an active tool",
212
- actions: ["keep component", "move into tool root", "merge", "delete", "archive"],
228
+ actions: [
229
+ "keep component",
230
+ "move into tool root",
231
+ "merge",
232
+ "delete",
233
+ "archive",
234
+ ],
213
235
  };
214
236
  }
215
237
  if (entry.shadows.length > 0) {
@@ -241,6 +263,66 @@ export function createRecipeIntegrityManifest(result) {
241
263
  })
242
264
  .sort((a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path));
243
265
  }
266
+ function diagnosticSeverity(message) {
267
+ if (/invalid|failed to load|not found|cyclic|exceeds|must define|repeat must/i.test(message)) {
268
+ return "error";
269
+ }
270
+ if (/world-writable|group-writable|invokes bash|eval|destructive|unsafe/i.test(message)) {
271
+ return "warning";
272
+ }
273
+ return "info";
274
+ }
275
+ function diagnosticSuggestedAction(message) {
276
+ if (/must define template/i.test(message))
277
+ return "add a template field or remove the recipe";
278
+ if (/JSON|Expected|parse/i.test(message))
279
+ return "fix recipe syntax or archive the file";
280
+ if (/Markdown recipe/i.test(message))
281
+ return "fix frontmatter and add a fenced template or recipe block";
282
+ if (/cyclic/i.test(message))
283
+ return "break the import cycle";
284
+ if (/exceeds.*size/i.test(message))
285
+ return "split large prompt or data into separate files";
286
+ if (/repeat must/i.test(message))
287
+ return "use a positive repeat count or an array-typed repeat source";
288
+ if (/world-writable|group-writable/i.test(message))
289
+ return "tighten recipe root permissions";
290
+ if (/invokes bash/i.test(message))
291
+ return "audit the trusted shell boundary or move details to recipe doctor";
292
+ if (/shadows/i.test(message))
293
+ return "confirm the active override or rename one recipe";
294
+ if (/disabled/i.test(message))
295
+ return "keep disabled intentionally or delete/archive the file";
296
+ return "inspect the recipe and fix or archive it if unexpected";
297
+ }
298
+ function diagnosticDetails(result) {
299
+ const details = [];
300
+ const seen = new Set();
301
+ const push = (message, entry) => {
302
+ const key = `${entry?.path ?? "root"}\n${message}`;
303
+ if (seen.has(key))
304
+ return;
305
+ seen.add(key);
306
+ details.push({
307
+ ...(entry ? { id: entry.id, path: entry.path } : {}),
308
+ action: diagnosticSuggestedAction(message),
309
+ message,
310
+ severity: diagnosticSeverity(message),
311
+ });
312
+ };
313
+ for (const message of result.diagnostics)
314
+ push(message);
315
+ for (const entry of result.entries) {
316
+ for (const message of entry.diagnostics)
317
+ push(message, entry);
318
+ }
319
+ return details.sort((a, b) => {
320
+ const rank = { error: 0, warning: 1, info: 2 };
321
+ return (rank[a.severity] -
322
+ rank[b.severity] ||
323
+ String(a.message).localeCompare(String(b.message)));
324
+ });
325
+ }
244
326
  function recommendationForEntry(entry, activePath) {
245
327
  const recommendation = cleanupRecommendation(entry);
246
328
  if (!recommendation)
@@ -257,9 +339,11 @@ export function summarizeDiscovery(result) {
257
339
  const recommendations = result.entries
258
340
  .map((entry) => recommendationForEntry(entry, result.active.get(entry.id)?.path))
259
341
  .filter((entry) => Boolean(entry))
260
- .sort((a, b) => String(a.id).localeCompare(String(b.id)) || String(a.path).localeCompare(String(b.path)));
342
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)) ||
343
+ String(a.path).localeCompare(String(b.path)));
261
344
  return {
262
- active: [...result.active.values()].map((entry) => ({
345
+ active: [...result.active.values()]
346
+ .map((entry) => ({
263
347
  id: entry.id,
264
348
  path: entry.path,
265
349
  description: entry.config?.description,
@@ -267,15 +351,26 @@ export function summarizeDiscovery(result) {
267
351
  disabled: entry.disabled,
268
352
  invalid: entry.invalid,
269
353
  shadows: entry.shadows,
270
- ...(recipeUsage(entry.config) ? { usage: recipeUsage(entry.config) } : {}),
271
- })).sort((a, b) => a.id.localeCompare(b.id)),
354
+ ...(recipeUsage(entry.config)
355
+ ? { usage: recipeUsage(entry.config) }
356
+ : {}),
357
+ }))
358
+ .sort((a, b) => a.id.localeCompare(b.id)),
272
359
  shadowed: result.entries
273
360
  .filter((entry) => entry.shadowed)
274
- .map((entry) => ({ id: entry.id, path: entry.path, shadowedBy: result.active.get(entry.id)?.path }))
361
+ .map((entry) => ({
362
+ id: entry.id,
363
+ path: entry.path,
364
+ shadowedBy: result.active.get(entry.id)?.path,
365
+ }))
275
366
  .sort((a, b) => a.id.localeCompare(b.id) || a.path.localeCompare(b.path)),
276
367
  invalid: result.entries
277
368
  .filter((entry) => entry.invalid)
278
- .map((entry) => ({ id: entry.id, path: entry.path, diagnostics: entry.diagnostics }))
369
+ .map((entry) => ({
370
+ id: entry.id,
371
+ path: entry.path,
372
+ diagnostics: entry.diagnostics,
373
+ }))
279
374
  .sort((a, b) => a.id.localeCompare(b.id)),
280
375
  disabled: result.entries
281
376
  .filter((entry) => entry.disabled)
@@ -283,6 +378,7 @@ export function summarizeDiscovery(result) {
283
378
  .sort((a, b) => a.id.localeCompare(b.id)),
284
379
  recommendations,
285
380
  diagnostics: result.diagnostics,
381
+ diagnostic_details: diagnosticDetails(result),
286
382
  integrity_manifest: createRecipeIntegrityManifest(result),
287
383
  };
288
384
  }
@@ -297,7 +393,10 @@ export function toRegisteredTool(entry) {
297
393
  ? {
298
394
  ...argTemplate,
299
395
  ...(cfg.args !== undefined ? { args: cfg.args } : {}),
300
- defaults: { ...(argTemplate.defaults ?? {}), ...(cfg.defaults ?? {}) },
396
+ defaults: {
397
+ ...(argTemplate.defaults ?? {}),
398
+ ...(cfg.defaults ?? {}),
399
+ },
301
400
  }
302
401
  : { args: cfg.args, defaults: cfg.defaults ?? {}, template: argTemplate };
303
402
  const explicitArgTypes = Object.fromEntries((cfg.args ?? []).map((arg) => {
@@ -312,13 +411,19 @@ export function toRegisteredTool(entry) {
312
411
  template,
313
412
  recipe: cfg,
314
413
  args: Schema.getToolArgNames(argTemplateConfig),
315
- defaults: Object.fromEntries(Object.entries(cfg.defaults ?? {}).map(([key, value]) => [key, String(value)])),
414
+ defaults: Object.fromEntries(Object.entries(cfg.defaults ?? {}).map(([key, value]) => [
415
+ key,
416
+ String(value),
417
+ ])),
316
418
  ...(Object.keys(argTypes).length > 0 ? { argTypes } : {}),
317
419
  ...(entry.mutableUsage ? { sourcePath: entry.path } : {}),
318
420
  ...(cfg.args ? { storedArgs: cfg.args } : {}),
319
421
  ...(cfg.defaults
320
422
  ? {
321
- storedDefaults: Object.fromEntries(Object.entries(cfg.defaults).map(([key, value]) => [key, String(value)])),
423
+ storedDefaults: Object.fromEntries(Object.entries(cfg.defaults).map(([key, value]) => [
424
+ key,
425
+ String(value),
426
+ ])),
322
427
  }
323
428
  : {}),
324
429
  };
@@ -57,6 +57,7 @@ export interface ReadResolvedRecipeConfigOptions {
57
57
  }
58
58
  export declare function resolveRecipePath(value: string, recipeRoot?: string): string;
59
59
  export declare function getRecipePath(value: unknown, recipeRoot?: string): string | undefined;
60
+ export declare function diagnoseRawRecipeConfigFailure(path: string): string | undefined;
60
61
  export declare function readRawRecipeConfig(path: string): Record<string, unknown> | undefined;
61
62
  export declare function getRecipeIdFromPath(file: string): string;
62
63
  export declare function readResolvedRecipeConfig(file: string, stack?: string[], options?: ReadResolvedRecipeConfigOptions): TemplateRecipeConfig | undefined;
@@ -29,7 +29,10 @@ export function resolveRecipePath(value, recipeRoot = Paths.getRecipeRoot()) {
29
29
  }
30
30
  function isBareRecipeName(value) {
31
31
  const trimmed = value.trim();
32
- return Boolean(trimmed) && !trimmed.includes("/") && !trimmed.startsWith("~") && !trimmed.includes("{");
32
+ return (Boolean(trimmed) &&
33
+ !trimmed.includes("/") &&
34
+ !trimmed.startsWith("~") &&
35
+ !trimmed.includes("{"));
33
36
  }
34
37
  function recipeNameFiles(value) {
35
38
  const trimmed = value.trim();
@@ -260,6 +263,42 @@ function parseMarkdownRecipeConfig(content) {
260
263
  }
261
264
  return { ...frontmatter, template: text };
262
265
  }
266
+ export function diagnoseRawRecipeConfigFailure(path) {
267
+ if (!existsSync(path))
268
+ return "file does not exist";
269
+ const size = statSync(path).size;
270
+ if (size > MAX_RECIPE_FILE_BYTES) {
271
+ return `file exceeds size limit ${MAX_RECIPE_FILE_BYTES} bytes`;
272
+ }
273
+ try {
274
+ const content = readFileSync(path, "utf8");
275
+ if (path.endsWith(".md")) {
276
+ const lines = content.split(/\r?\n/);
277
+ if (lines[0]?.trim() !== "---") {
278
+ return "Markdown recipe must start with frontmatter";
279
+ }
280
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
281
+ if (end === -1)
282
+ return "Markdown recipe frontmatter is not closed";
283
+ const parsed = parseMarkdownRecipeConfig(content);
284
+ if (!parsed)
285
+ return "Markdown recipe has no executable recipe/template fence";
286
+ if (!Object.hasOwn(parsed, "template"))
287
+ return "recipe must define template";
288
+ return undefined;
289
+ }
290
+ const raw = JSON.parse(content);
291
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
292
+ return "JSON recipe must be an object";
293
+ }
294
+ if (!Object.hasOwn(raw, "template"))
295
+ return "recipe must define template";
296
+ return undefined;
297
+ }
298
+ catch (error) {
299
+ return error instanceof Error ? error.message : String(error);
300
+ }
301
+ }
263
302
  export function readRawRecipeConfig(path) {
264
303
  if (!existsSync(path))
265
304
  return undefined;
@@ -82,7 +82,11 @@ function getInputTemplate(value) {
82
82
  throw new Error(Output.formatToolText("Tool template sequence is empty."));
83
83
  return value;
84
84
  }
85
- throw new Error(Output.formatToolText("Tool template must be a string or sequence."));
85
+ if (value && typeof value === "object") {
86
+ CommandTemplates.expandCommandTemplateConfigs(value);
87
+ return value;
88
+ }
89
+ throw new Error(Output.formatToolText("Tool template must be a string, object, or sequence."));
86
90
  }
87
91
  function buildConfig(name, input, existing) {
88
92
  const explicitArgs = input.args === undefined