@llblab/pi-actors 0.20.1 → 0.21.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.
package/lib/async-runs.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Owns detached run state, observation, log tailing, listing, and cancellation safety
5
5
  */
6
6
 
7
- import { spawn } from "node:child_process";
7
+ import { spawn, spawnSync } from "node:child_process";
8
8
  import {
9
9
  closeSync,
10
10
  constants,
@@ -19,6 +19,7 @@ import {
19
19
  writeFileSync,
20
20
  writeSync,
21
21
  } from "node:fs";
22
+ import { createConnection } from "node:net";
22
23
  import { platform } from "node:os";
23
24
  import { basename, dirname, extname, join, resolve } from "node:path";
24
25
  import { fileURLToPath } from "node:url";
@@ -37,8 +38,14 @@ const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
37
38
 
38
39
  export type AsyncRunLaunchSource = "spawn" | "tool";
39
40
 
41
+ export interface AsyncRunControlEndpoint {
42
+ path: string;
43
+ type: "fifo" | "named-pipe";
44
+ }
45
+
40
46
  export interface AsyncRunStartParams {
41
47
  async?: boolean;
48
+ control?: AsyncRunControlEndpoint;
42
49
  file?: string;
43
50
  launch_source?: AsyncRunLaunchSource;
44
51
  name?: string;
@@ -112,6 +119,7 @@ export interface AsyncRunMeta {
112
119
  template: CommandTemplateValue;
113
120
  values: Record<string, unknown>;
114
121
  artifacts?: Record<string, string>;
122
+ control?: AsyncRunControlEndpoint;
115
123
  mailbox?: RecipeReferences.TemplateRecipeMailbox;
116
124
  recipe_context_records?: RecipeReferences.TemplateRecipeContextRecord[];
117
125
  retire_when?: "children_terminal";
@@ -207,7 +215,10 @@ function assertNoActiveRunState(stateDir: string): void {
207
215
  }
208
216
 
209
217
  function resolveRecipeFile(file: string): string {
210
- return RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT);
218
+ return (
219
+ RecipeReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
220
+ RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT)
221
+ );
211
222
  }
212
223
 
213
224
  function isMutableUsageRecipeFile(file: string): boolean {
@@ -446,6 +457,7 @@ export function startRun(
446
457
  template: resolved.template,
447
458
  values,
448
459
  ...(artifacts ? { artifacts } : {}),
460
+ ...(startParams.control ? { control: startParams.control } : {}),
449
461
  ...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
450
462
  ...(recipeContextRecords && recipeContextRecords.length > 0
451
463
  ? { recipe_context_records: recipeContextRecords }
@@ -700,15 +712,115 @@ export function appendRunOutboxEvent(
700
712
  };
701
713
  }
702
714
 
703
- export function sendRunMessage(
704
- runOrDir: string,
715
+ export interface SendRunMessageOptions {
716
+ namedPipeSend?: (path: string, payload: string) => Promise<number>;
717
+ platform?: NodeJS.Platform;
718
+ }
719
+
720
+ function getRunControlEndpoint(
721
+ status: Record<string, unknown>,
722
+ stateDir: string,
723
+ ): AsyncRunControlEndpoint {
724
+ const control = status.control;
725
+ if (control && typeof control === "object" && !Array.isArray(control)) {
726
+ const record = control as Record<string, unknown>;
727
+ if (
728
+ (record.type === "fifo" || record.type === "named-pipe") &&
729
+ typeof record.path === "string" &&
730
+ record.path.trim()
731
+ ) {
732
+ return { path: record.path, type: record.type };
733
+ }
734
+ }
735
+ return { path: join(stateDir, "control.fifo"), type: "fifo" };
736
+ }
737
+
738
+ function writeRunMessageReceipt(
739
+ stateDir: string,
705
740
  message: string,
706
- ): Record<string, unknown> {
707
- if (process.platform === "win32") {
708
- throw new Error(
709
- "run actor messages require Unix FIFO support; use WSL/Linux/macOS or a recipe-specific Windows transport.",
741
+ bytes: number,
742
+ ): void {
743
+ const trimmedMessage = message.trim().toLowerCase();
744
+ const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
745
+ trimmedMessage,
746
+ );
747
+ const ts = new Date().toISOString();
748
+ writeFileSync(
749
+ join(stateDir, "events.jsonl"),
750
+ `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`,
751
+ { flag: "a" },
752
+ );
753
+ try {
754
+ const envelope = JSON.parse(message) as Record<string, unknown>;
755
+ writeFileSync(
756
+ join(stateDir, "inbox.jsonl"),
757
+ `${JSON.stringify({ ...envelope, received_at: ts })}\n`,
758
+ { flag: "a" },
710
759
  );
760
+ } catch {
761
+ // Plain control lines are already represented in events.jsonl.
762
+ }
763
+ if (terminalMessage) {
764
+ markTerminalHandled(stateDir, {
765
+ event: "run.message",
766
+ message: trimmedMessage,
767
+ });
768
+ }
769
+ }
770
+
771
+ function sendRunMessageToFifo(
772
+ endpoint: AsyncRunControlEndpoint,
773
+ payload: string,
774
+ ): number {
775
+ if (!existsSync(endpoint.path))
776
+ throw new Error(`Run control FIFO not found: ${endpoint.path}`);
777
+ const stat = statSync(endpoint.path);
778
+ if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
779
+ throw new Error(`Run control endpoint is not a FIFO: ${endpoint.path}`);
711
780
  }
781
+ let fd: number | undefined;
782
+ try {
783
+ fd = openSync(endpoint.path, constants.O_WRONLY | constants.O_NONBLOCK);
784
+ return writeSync(fd, payload);
785
+ } finally {
786
+ if (fd !== undefined) closeSync(fd);
787
+ }
788
+ }
789
+
790
+ function sendRunMessageToNamedPipe(
791
+ endpoint: AsyncRunControlEndpoint,
792
+ payload: string,
793
+ send?: (path: string, payload: string) => Promise<number>,
794
+ ): Promise<number> {
795
+ if (send) return send(endpoint.path, payload);
796
+ return new Promise((resolve, reject) => {
797
+ const socket = createConnection(endpoint.path);
798
+ let settled = false;
799
+ const timeout = setTimeout(() => {
800
+ if (settled) return;
801
+ settled = true;
802
+ socket.destroy();
803
+ reject(new Error("named pipe connection timed out"));
804
+ }, 5000);
805
+ const finish = (error?: Error): void => {
806
+ if (settled) return;
807
+ settled = true;
808
+ clearTimeout(timeout);
809
+ if (error) reject(error);
810
+ else resolve(Buffer.byteLength(payload));
811
+ };
812
+ socket.on("error", finish);
813
+ socket.on("connect", () => {
814
+ socket.end(payload, () => finish());
815
+ });
816
+ });
817
+ }
818
+
819
+ export async function sendRunMessage(
820
+ runOrDir: string,
821
+ message: string,
822
+ options: SendRunMessageOptions = {},
823
+ ): Promise<Record<string, unknown>> {
712
824
  const status = getRunStatus(runOrDir);
713
825
  const stateDir = String(status.state_dir);
714
826
  const run = String(status.run ?? runOrDir);
@@ -718,64 +830,90 @@ export function sendRunMessage(
718
830
  if (!pid || !isAlive(pid)) throw new Error(`Run pid is not alive: ${run}`);
719
831
  if (!pidMatchesRun(pid, String(status.cwd), stateDir))
720
832
  throw new Error(`Run pid owner mismatch: ${run}`);
721
- const controlPath = join(stateDir, "control.fifo");
722
- if (!existsSync(controlPath))
723
- throw new Error(`Run control FIFO not found: ${controlPath}`);
724
- const stat = statSync(controlPath);
725
- if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
726
- throw new Error(`Run control endpoint is not a FIFO: ${controlPath}`);
727
- }
833
+ const endpoint = getRunControlEndpoint(status, stateDir);
728
834
  const payload = message.endsWith("\n") ? message : `${message}\n`;
729
- let fd: number | undefined;
835
+ const runtimePlatform = options.platform ?? process.platform;
730
836
  try {
731
- fd = openSync(controlPath, constants.O_WRONLY | constants.O_NONBLOCK);
732
- const bytes = writeSync(fd, payload);
733
- const trimmedMessage = message.trim().toLowerCase();
734
- const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
735
- trimmedMessage,
736
- );
737
- const ts = new Date().toISOString();
738
- writeFileSync(
739
- join(stateDir, "events.jsonl"),
740
- `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`,
741
- { flag: "a" },
742
- );
743
- try {
744
- const envelope = JSON.parse(message) as Record<string, unknown>;
745
- writeFileSync(
746
- join(stateDir, "inbox.jsonl"),
747
- `${JSON.stringify({ ...envelope, received_at: ts })}\n`,
748
- { flag: "a" },
749
- );
750
- } catch {
751
- // Plain control lines are already represented in events.jsonl.
752
- }
753
- if (terminalMessage) {
754
- markTerminalHandled(stateDir, {
755
- event: "run.message",
756
- message: trimmedMessage,
757
- });
837
+ if (endpoint.type === "fifo") {
838
+ if (runtimePlatform === "win32") {
839
+ throw new Error(
840
+ "run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.",
841
+ );
842
+ }
843
+ const bytes = sendRunMessageToFifo(endpoint, payload);
844
+ writeRunMessageReceipt(stateDir, message, bytes);
845
+ return {
846
+ bytes,
847
+ control: "control.fifo",
848
+ control_path: endpoint.path,
849
+ control_type: endpoint.type,
850
+ run,
851
+ sent: true,
852
+ state_dir: stateDir,
853
+ };
758
854
  }
855
+ const bytes = await sendRunMessageToNamedPipe(
856
+ endpoint,
857
+ payload,
858
+ options.namedPipeSend,
859
+ );
860
+ writeRunMessageReceipt(stateDir, message, bytes);
759
861
  return {
760
862
  bytes,
761
- control: "control.fifo",
863
+ control: endpoint.path,
864
+ control_path: endpoint.path,
865
+ control_type: endpoint.type,
762
866
  run,
763
867
  sent: true,
764
868
  state_dir: stateDir,
765
869
  };
766
870
  } catch (error) {
767
871
  throw new Error(
768
- `Run control FIFO is not ready: ${controlPath}: ${error instanceof Error ? error.message : String(error)}`,
872
+ `Run control endpoint is not ready: ${endpoint.path}: ${error instanceof Error ? error.message : String(error)}`,
769
873
  );
770
- } finally {
771
- if (fd !== undefined) closeSync(fd);
772
874
  }
773
875
  }
774
876
 
877
+ export interface RunProcessSignalPlan {
878
+ args?: string[];
879
+ command?: string;
880
+ signalTarget: "processGroup" | "process" | "processTree";
881
+ }
882
+
883
+ export function getRunProcessSignalPlan(
884
+ pid: number,
885
+ signal: NodeJS.Signals,
886
+ runtimePlatform: NodeJS.Platform = process.platform,
887
+ ): RunProcessSignalPlan {
888
+ if (runtimePlatform === "win32") {
889
+ return {
890
+ args: [
891
+ "/PID",
892
+ String(pid),
893
+ "/T",
894
+ ...(signal === "SIGKILL" ? ["/F"] : []),
895
+ ],
896
+ command: "taskkill",
897
+ signalTarget: "processTree",
898
+ };
899
+ }
900
+ return { signalTarget: "processGroup" };
901
+ }
902
+
775
903
  function signalOwnedRunProcess(
776
904
  pid: number,
777
905
  signal: NodeJS.Signals,
778
- ): { signalTarget: "processGroup" | "process" } {
906
+ ): RunProcessSignalPlan {
907
+ const plan = getRunProcessSignalPlan(pid, signal);
908
+ if (plan.command && plan.args) {
909
+ const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
910
+ if (result.status !== 0) {
911
+ throw new Error(
912
+ result.stderr?.trim() || result.stdout?.trim() || `${plan.command} failed`,
913
+ );
914
+ }
915
+ return plan;
916
+ }
779
917
  try {
780
918
  process.kill(-pid, signal);
781
919
  return { signalTarget: "processGroup" };
@@ -157,8 +157,15 @@ function getExecutableName(command: string | undefined): string {
157
157
  return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
158
158
  }
159
159
 
160
+ function matchesFlag(arg: string, flag: string): boolean {
161
+ if (arg === flag) return true;
162
+ if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
163
+ return arg.slice(1).includes(flag.slice(1));
164
+ return false;
165
+ }
166
+
160
167
  function hasAnyFlag(args: string[], flags: string[]): boolean {
161
- return args.some((arg) => flags.includes(arg));
168
+ return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
162
169
  }
163
170
 
164
171
  function hasRiskyPathArg(args: string[]): boolean {
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync, readdirSync, readFileSync } from "node:fs";
8
- import { basename, dirname, join, relative, resolve } from "node:path";
8
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
9
9
 
10
10
  import * as AsyncRuns from "./async-runs.ts";
11
11
  import * as Paths from "./paths.ts";
@@ -52,9 +52,25 @@ export interface RunSummary {
52
52
 
53
53
  export interface RunRetirementCandidate {
54
54
  activeSubagents: number;
55
+ childRuns: number;
55
56
  descendantSubagents: number;
56
57
  run: string;
57
58
  stateDir: string;
59
+ terminalChildRuns: number;
60
+ }
61
+
62
+ export interface RunRetirementExecution {
63
+ action: "stop" | "cancel" | "skip" | "failed";
64
+ error?: string;
65
+ run: string;
66
+ stateDir: string;
67
+ }
68
+
69
+ export interface RunRetirementExecutorOptions {
70
+ attempted?: Set<string>;
71
+ cancelRun: (candidate: RunRetirementCandidate) => Record<string, unknown>;
72
+ notify?: (message: string, level: "info" | "warning" | "error") => void;
73
+ sendStop: (candidate: RunRetirementCandidate) => Promise<unknown>;
58
74
  }
59
75
 
60
76
  export interface RunTransition {
@@ -93,6 +109,7 @@ const TERMINAL = new Set<RunObservedStatus>([
93
109
  "killed",
94
110
  ]);
95
111
  const PROC_DESCENDANT_SCAN_TTL_MS = 1000;
112
+ const RUN_STATE_DISCOVERY_MAX_DEPTH = 8;
96
113
 
97
114
  const procDescendantScanCache = new Map<
98
115
  string,
@@ -120,6 +137,30 @@ function getUpdatedAt(status: Record<string, unknown>): string | undefined {
120
137
  : undefined;
121
138
  }
122
139
 
140
+ function listRunStateDirs(
141
+ stateRoot: string,
142
+ depth = 0,
143
+ seen = new Set<string>(),
144
+ ): string[] {
145
+ if (!existsSync(stateRoot) || seen.has(stateRoot)) return [];
146
+ seen.add(stateRoot);
147
+ let entries: import("node:fs").Dirent[];
148
+ try {
149
+ entries = readdirSync(stateRoot, { withFileTypes: true });
150
+ } catch {
151
+ return [];
152
+ }
153
+ const result: string[] = [];
154
+ for (const entry of entries) {
155
+ if (!entry.isDirectory()) continue;
156
+ const child = join(stateRoot, entry.name);
157
+ if (existsSync(join(child, "run.json"))) result.push(child);
158
+ if (depth + 1 < RUN_STATE_DISCOVERY_MAX_DEPTH)
159
+ result.push(...listRunStateDirs(child, depth + 1, seen));
160
+ }
161
+ return result;
162
+ }
163
+
123
164
  function observeRun(stateDir: string): RunObservation | undefined {
124
165
  try {
125
166
  const status = AsyncRuns.getRunStatus(stateDir);
@@ -178,9 +219,8 @@ export function summarizeRuns(
178
219
  total: 0,
179
220
  };
180
221
  }
181
- const runs = readdirSync(stateRoot, { withFileTypes: true })
182
- .filter((entry) => entry.isDirectory())
183
- .map((entry) => observeRun(join(stateRoot, entry.name)))
222
+ const runs = listRunStateDirs(stateRoot)
223
+ .map((stateDir) => observeRun(stateDir))
184
224
  .filter((run): run is RunObservation => Boolean(run))
185
225
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
186
226
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
@@ -242,7 +282,7 @@ function getRunningRunPidMap(stateRoot: string, ownerId?: string): Map<string, s
242
282
  const pids = new Map<string, string>();
243
283
  for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
244
284
  if (run.status !== "running") continue;
245
- const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
285
+ const status = AsyncRuns.getRunStatus(run.stateDir ?? join(stateRoot, run.run));
246
286
  const pid = Number(status.pid || 0);
247
287
  if (pid > 0) pids.set(String(pid), run.run);
248
288
  }
@@ -264,9 +304,8 @@ function summarizeRunsWithoutSubagents(
264
304
  runs: [],
265
305
  total: 0,
266
306
  };
267
- const runs = readdirSync(stateRoot, { withFileTypes: true })
268
- .filter((entry) => entry.isDirectory())
269
- .map((entry) => observeRun(join(stateRoot, entry.name)))
307
+ const runs = listRunStateDirs(stateRoot)
308
+ .map((stateDir) => observeRun(stateDir))
270
309
  .filter((run): run is RunObservation => Boolean(run))
271
310
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
272
311
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
@@ -370,28 +409,102 @@ export function renderRunStatus(
370
409
  return renderSubagentStatus(summary.runningSubagents, frame);
371
410
  }
372
411
 
412
+ function isNestedStateDir(parent: string, child: string): boolean {
413
+ const path = relative(parent, child);
414
+ return Boolean(path) && !path.startsWith("..") && !isAbsolute(path);
415
+ }
416
+
373
417
  export function findRunRetirementCandidates(
374
418
  summary: RunSummary,
375
419
  ): RunRetirementCandidate[] {
376
420
  return summary.runs
377
- .filter((run) => {
421
+ .map((run) => {
378
422
  const activeSubagents = Math.max(0, Math.floor(run.activeSubagents ?? 0));
379
423
  const descendantSubagents = Math.max(0, Math.floor(run.descendantSubagents ?? 0));
380
- return (
381
- run.status === "running" &&
382
- run.retireWhen === "children_terminal" &&
383
- run.stateDir &&
384
- activeSubagents + descendantSubagents <= 0
385
- );
424
+ const childRuns = run.stateDir
425
+ ? summary.runs.filter(
426
+ (child) =>
427
+ child.stateDir !== undefined &&
428
+ child.stateDir !== run.stateDir &&
429
+ isNestedStateDir(run.stateDir!, child.stateDir),
430
+ )
431
+ : [];
432
+ const runningChildRuns = childRuns.filter((child) => child.status === "running").length;
433
+ return {
434
+ activeSubagents,
435
+ childRuns: childRuns.length,
436
+ descendantSubagents,
437
+ ready:
438
+ run.status === "running" &&
439
+ run.retireWhen === "children_terminal" &&
440
+ run.stateDir !== undefined &&
441
+ !run.terminalHandled &&
442
+ activeSubagents + descendantSubagents + runningChildRuns <= 0,
443
+ run,
444
+ terminalChildRuns: childRuns.filter((child) => TERMINAL.has(child.status)).length,
445
+ };
386
446
  })
387
- .map((run) => ({
388
- activeSubagents: Math.max(0, Math.floor(run.activeSubagents ?? 0)),
389
- descendantSubagents: Math.max(0, Math.floor(run.descendantSubagents ?? 0)),
390
- run: run.run,
391
- stateDir: run.stateDir!,
447
+ .filter((item) => item.ready)
448
+ .map((item) => ({
449
+ activeSubagents: item.activeSubagents,
450
+ childRuns: item.childRuns,
451
+ descendantSubagents: item.descendantSubagents,
452
+ run: item.run.run,
453
+ stateDir: item.run.stateDir!,
454
+ terminalChildRuns: item.terminalChildRuns,
392
455
  }));
393
456
  }
394
457
 
458
+ export async function executeRunRetirements(
459
+ summary: RunSummary,
460
+ options: RunRetirementExecutorOptions,
461
+ ): Promise<RunRetirementExecution[]> {
462
+ const results: RunRetirementExecution[] = [];
463
+ for (const candidate of findRunRetirementCandidates(summary)) {
464
+ if (options.attempted?.has(candidate.stateDir)) {
465
+ results.push({ action: "skip", run: candidate.run, stateDir: candidate.stateDir });
466
+ continue;
467
+ }
468
+ options.attempted?.add(candidate.stateDir);
469
+ try {
470
+ await options.sendStop(candidate);
471
+ options.notify?.(
472
+ `Retiring actor ${candidate.run} after child runs reached terminal state`,
473
+ "info",
474
+ );
475
+ results.push({ action: "stop", run: candidate.run, stateDir: candidate.stateDir });
476
+ continue;
477
+ } catch (error) {
478
+ try {
479
+ const cancelResult = options.cancelRun(candidate);
480
+ const cancelled = Boolean((cancelResult as { cancelled?: unknown }).cancelled);
481
+ options.notify?.(
482
+ cancelled
483
+ ? `Retiring actor ${candidate.run} by cancellation after graceful stop failed`
484
+ : `Actor retirement skipped for ${candidate.run}: ${error instanceof Error ? error.message : String(error)}`,
485
+ cancelled ? "warning" : "error",
486
+ );
487
+ results.push({
488
+ action: cancelled ? "cancel" : "skip",
489
+ ...(cancelled ? {} : { error: error instanceof Error ? error.message : String(error) }),
490
+ run: candidate.run,
491
+ stateDir: candidate.stateDir,
492
+ });
493
+ } catch (cancelError) {
494
+ const message = cancelError instanceof Error ? cancelError.message : String(cancelError);
495
+ options.notify?.(`Actor retirement failed for ${candidate.run}: ${message}`, "error");
496
+ results.push({
497
+ action: "failed",
498
+ error: message,
499
+ run: candidate.run,
500
+ stateDir: candidate.stateDir,
501
+ });
502
+ }
503
+ }
504
+ }
505
+ return results;
506
+ }
507
+
395
508
  export function detectRunTransitions(
396
509
  previous: Map<string, RunObservedStatus>,
397
510
  summary: RunSummary,
@@ -88,11 +88,11 @@ function listRecipeFiles(root: string): string[] {
88
88
  .filter(
89
89
  (entry) =>
90
90
  entry.isFile() &&
91
- entry.name.endsWith(".json") &&
91
+ (entry.name.endsWith(".json") || entry.name.endsWith(".md")) &&
92
92
  entry.name !== "legacy-tool-registry-migration-report.json",
93
93
  )
94
94
  .map((entry) => join(root, entry.name))
95
- .sort();
95
+ .sort((a, b) => a.replace(/\.md$/, ".json").localeCompare(b.replace(/\.md$/, ".json")) || (a.endsWith(".json") ? -1 : 1));
96
96
  }
97
97
 
98
98
  function getRecipeConfigDiagnostics(
@@ -100,9 +100,15 @@ function getRecipeConfigDiagnostics(
100
100
  config: TemplateRecipeConfig | undefined,
101
101
  ): string[] {
102
102
  if (!config) return [`Invalid recipe: ${file}`];
103
+ const commandTemplateConfig =
104
+ typeof config.template === "object" && config.template !== null
105
+ ? config.template
106
+ : config;
103
107
  return CommandTemplates.getCommandTemplateWarnings(
104
- typeof config.template === "string" ? config.template : { template: config.template },
105
- ).map((warning) => `Recipe ${file}: ${warning}`);
108
+ commandTemplateConfig as CommandTemplates.CommandTemplateConfig,
109
+ ).map(
110
+ (warning) => `Recipe ${file}: ${warning}`,
111
+ );
106
112
  }
107
113
 
108
114
  function readDiscoveredRecipe(
@@ -218,13 +224,22 @@ export function discoverRecipeSources(
218
224
  const diagnostics: string[] = getRecipeRootDiagnostics(sources);
219
225
  for (const [id, bucket] of byId) {
220
226
  bucket.sort(
221
- (a, b) => a.priority - b.priority || a.path.localeCompare(b.path),
227
+ (a, b) =>
228
+ a.priority - b.priority ||
229
+ a.path.replace(/\.md$/, ".json").localeCompare(b.path.replace(/\.md$/, ".json")) ||
230
+ (a.path.endsWith(".json") ? -1 : 1),
222
231
  );
223
232
  const winner = bucket[0];
224
233
  winner.active = true;
225
234
  winner.shadows = bucket.slice(1).map((entry) => entry.path);
226
235
  active.set(id, winner);
227
- for (const shadow of bucket.slice(1)) shadow.shadowed = true;
236
+ for (const shadow of bucket.slice(1)) {
237
+ shadow.shadowed = true;
238
+ if (winner.path.endsWith(".json") && shadow.path.endsWith(".md"))
239
+ shadow.diagnostics.push(
240
+ `Markdown recipe ${shadow.path} is shadowed by JSON recipe ${winner.path}`,
241
+ );
242
+ }
228
243
  if (winner.invalid)
229
244
  diagnostics.push(
230
245
  `Recipe ${id} is invalid and blocks lower-priority recipes`,