@llblab/pi-actors 0.27.1 → 0.28.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/BACKLOG.md CHANGED
@@ -247,18 +247,21 @@ The backlog is intentionally pruned to the 20% of work most likely to deliver 80
247
247
  - Coordinator/session status exposes other-session counts without leaking unrelated run details by default.
248
248
  - Tests cover version inspection, session mismatch shape, and other-session count summaries.
249
249
 
250
- ### M-13 Shadowed Recipe Launch UX
250
+ ### M-13 Shadowed Recipe Launch Diagnostics
251
251
 
252
252
  - Priority: High.
253
- - Status: Planned.
254
- - Goal: Make broken user recipes that shadow packaged/ad hoc candidates obvious at launch time.
253
+ - Status: Done.
254
+ - Goal: Make broken user recipes that shadow packaged/ad hoc candidates obvious only at the moment a launch already fails.
255
255
  - Why now: 0.26 dogfood found a broken `~/.pi/agent/recipes/actor-worker.json` shadowing the packaged `actor-worker`; Recipe Doctor exposed the evidence, but the launch path did not provide a direct hint.
256
256
  - Direction:
257
+ - Treat shadowing as a normal, intentional override mechanism; do not warn on healthy shadowing.
257
258
  - When recipe resolution or launch fails because the active user recipe is invalid/disabled and a lower-priority candidate exists, surface `reason=shadowed_invalid` or `reason=shadowed_disabled` where practical.
258
- - Include active path, blocked candidate path, and compact hint: `inspect target=recipes view=doctor`.
259
- - Keep remediation advisory only; do not auto-disable, delete, or rewrite user recipes.
259
+ - Treat disabled template recipes as non-launchable so disabled shadowing fails consistently instead of silently executing.
260
+ - Include minimal compact tokens: active path, blocked candidate path, and `hint=inspect_recipes_doctor`.
261
+ - Keep remediation advisory only; do not auto-disable, delete, rewrite, or nag about user recipes.
260
262
  - Acceptance:
261
- - Launch failures caused by shadowing include a compact actionable hint.
263
+ - Healthy user overrides remain silent.
264
+ - Launch failures caused by invalid/disabled shadowing include a compact actionable hint.
262
265
  - Verbose details expose the active broken recipe and blocked fallback candidate.
263
266
  - Tests cover invalid and disabled user recipes shadowing a packaged candidate.
264
267
 
@@ -306,5 +309,5 @@ These are valid ideas but not current focus. Reintroduce only with concrete evid
306
309
  ## Suggested Milestone Order
307
310
 
308
311
  ```text
309
- Next milestone: M-13 Shadowed Recipe Launch UX.
312
+ Next milestone: M-14 Session Mismatch Follow-through.
310
313
  ```
package/CHANGELOG.md CHANGED
@@ -2,10 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.28.0: Shadowed Recipe Launch Diagnostics
6
+
7
+ - `[Spawn]` Added minimal shadowed-recipe diagnostics when a bare recipe launch already fails because an invalid or disabled higher-priority recipe blocks a lower-priority fallback; healthy recipe overrides remain silent.
8
+ - `[Async Runs]` Treat disabled template recipes as non-launchable so disabled shadowing fails consistently instead of silently executing.
9
+ - `[Docs]` Clarified the quiet shadowing contract, disabled recipe launch behavior, and recipe-doctor hint path.
10
+ - `[Backlog]` Reframed shadowed recipe launch diagnostics as diagnostic-on-failure only, preserving shadowing as an intentional user override mechanism without startup warnings or automatic remediation.
11
+
5
12
  ## 0.27.1: Changelog and Backlog Hotfix
6
13
 
7
14
  - `[Changelog]` Moved the 0.27.0 runtime/session observability notes out of `Unreleased` into a proper release section so published package history matches the npm/tag release.
8
- - `[Backlog]` Marked M-12 complete and added the next evidence-backed candidates for shadowed recipe launch UX, session mismatch follow-through, and worker stale-claim dogfood.
15
+ - `[Backlog]` Marked runtime/session observability complete and added the next evidence-backed candidates for shadowed recipe launch UX, session mismatch follow-through, and worker stale-claim dogfood.
9
16
 
10
17
  ## 0.27.0: Runtime and Session Observability UX
11
18
 
@@ -164,6 +164,9 @@ function readRecipeFile(file) {
164
164
  if (!config) {
165
165
  throw new Error(`Template recipe must define template: ${path}`);
166
166
  }
167
+ if (config.disabled === true) {
168
+ throw new Error(`Template recipe is disabled: ${path}`);
169
+ }
167
170
  return {
168
171
  ...config,
169
172
  file: path,
@@ -46,5 +46,6 @@ export interface RecipeDiscoverySource {
46
46
  export declare function discoverRecipeSources(sources: RecipeDiscoverySource[]): RecipeDiscoveryResult;
47
47
  export declare function discoverRecipes(roots: string[]): RecipeDiscoveryResult;
48
48
  export declare function createRecipeIntegrityManifest(result: RecipeDiscoveryResult): RecipeIntegrityManifestEntry[];
49
+ export declare function getShadowedLaunchDiagnostic(result: RecipeDiscoveryResult, id: string): Record<string, unknown> | undefined;
49
50
  export declare function summarizeDiscovery(result: RecipeDiscoveryResult): Record<string, unknown>;
50
51
  export declare function toRegisteredTool(entry: DiscoveredRecipe): RegisteredTool | undefined;
@@ -413,6 +413,19 @@ function recommendationForEntry(entry, activePath) {
413
413
  }
414
414
  return recommendation;
415
415
  }
416
+ export function getShadowedLaunchDiagnostic(result, id) {
417
+ const active = result.active.get(id.trim());
418
+ if (!active || active.shadows.length === 0)
419
+ return undefined;
420
+ if (!active.invalid && !active.disabled)
421
+ return undefined;
422
+ return {
423
+ active_path: active.path,
424
+ blocked_candidate: active.shadows[0],
425
+ hint: "inspect_recipes_doctor",
426
+ reason: active.invalid ? "shadowed_invalid" : "shadowed_disabled",
427
+ };
428
+ }
416
429
  export function summarizeDiscovery(result) {
417
430
  const recommendations = result.entries
418
431
  .map((entry) => recommendationForEntry(entry, result.active.get(entry.id)?.path))
package/dist/lib/tools.js CHANGED
@@ -572,6 +572,25 @@ function formatToolActorFailure(tool, message, params, error) {
572
572
  tool,
573
573
  });
574
574
  }
575
+ function shadowedRecipeLaunchDiagnostic(recipe) {
576
+ if (typeof recipe !== "string" || recipe.includes("/") || recipe.includes("~"))
577
+ return undefined;
578
+ const discovery = RecipeDiscovery.discoverRecipeSources([
579
+ { root: Paths.getRecipeRoot(), defaultTool: true, mutableUsage: true },
580
+ { root: Paths.getPackagedRecipeRoot(), defaultTool: false },
581
+ ]);
582
+ return RecipeDiscovery.getShadowedLaunchDiagnostic(discovery, recipe);
583
+ }
584
+ function enhanceSpawnRecipeError(error, recipe) {
585
+ const diagnostic = shadowedRecipeLaunchDiagnostic(recipe);
586
+ if (!diagnostic)
587
+ return error instanceof Error ? error : new Error(String(error));
588
+ const original = error instanceof Error ? error.message : String(error);
589
+ return Object.assign(new Error(`${original} reason=${diagnostic.reason} active_path=${diagnostic.active_path} blocked_candidate=${diagnostic.blocked_candidate} hint=${diagnostic.hint}`), {
590
+ ...diagnostic,
591
+ original_error: original,
592
+ });
593
+ }
575
594
  function runIdFromActorAddress(address) {
576
595
  if (!address)
577
596
  return undefined;
@@ -654,30 +673,37 @@ export function createSpawnToolDefinition() {
654
673
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
655
674
  const input = asRecord(params);
656
675
  const runId = runIdFromActorAddress(typeof input.as === "string" ? input.as : undefined);
657
- const meta = AsyncRuns.startRun({
658
- file: typeof input.file === "string"
659
- ? input.file
660
- : typeof input.recipe === "string"
661
- ? input.recipe
662
- : undefined,
663
- launch_source: "spawn",
664
- ownerId: getRunOwnerId(ctx),
665
- run_id: runId,
666
- state_dir: typeof input.state_dir === "string" ? input.state_dir : undefined,
667
- ...(input.template !== undefined
668
- ? {
669
- template: input.template,
670
- }
671
- : {}),
672
- values: asRecord(input.values),
673
- ...(input.artifacts &&
674
- typeof input.artifacts === "object" &&
675
- !Array.isArray(input.artifacts)
676
- ? {
677
- artifacts: input.artifacts,
678
- }
679
- : {}),
680
- }, ctx.cwd);
676
+ const recipe = typeof input.file === "string"
677
+ ? input.file
678
+ : typeof input.recipe === "string"
679
+ ? input.recipe
680
+ : undefined;
681
+ let meta;
682
+ try {
683
+ meta = AsyncRuns.startRun({
684
+ file: recipe,
685
+ launch_source: "spawn",
686
+ ownerId: getRunOwnerId(ctx),
687
+ run_id: runId,
688
+ state_dir: typeof input.state_dir === "string" ? input.state_dir : undefined,
689
+ ...(input.template !== undefined
690
+ ? {
691
+ template: input.template,
692
+ }
693
+ : {}),
694
+ values: asRecord(input.values),
695
+ ...(input.artifacts &&
696
+ typeof input.artifacts === "object" &&
697
+ !Array.isArray(input.artifacts)
698
+ ? {
699
+ artifacts: input.artifacts,
700
+ }
701
+ : {}),
702
+ }, ctx.cwd);
703
+ }
704
+ catch (error) {
705
+ throw enhanceSpawnRecipeError(error, recipe);
706
+ }
681
707
  ActorRooms.ensureDefaultRoom(meta.state_dir, String(meta.run));
682
708
  ActorRooms.writeCommunicationSnapshot(meta.state_dir, String(meta.run));
683
709
  return {
@@ -2,7 +2,7 @@
2
2
  name: actors
3
3
  description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.27.1
5
+ version: 0.28.0
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)
@@ -2,7 +2,7 @@
2
2
  name: swarm
3
3
  description: Subagent orchestration with scoped locks and quorum consensus. Use for multi-model review, parallel scoped work, delegated audit, and coordinated subagent execution.
4
4
  metadata:
5
- version: 0.27.1
5
+ version: 0.28.0
6
6
  ---
7
7
 
8
8
  # Swarm
@@ -105,7 +105,7 @@ Recipe priority only matters when two discovered recipes have the same filename
105
105
 
106
106
  The high-priority user recipe directory is also the default tool set: recipes placed there are agent tools by location. This preserves the old advantage of a tool-only registry because listing `~/.pi/agent/recipes` shows the operator-managed tool surface. Packaged and ad hoc recipes are recipe components by default; they become tools only when copied or registered into the agent recipe root.
107
107
 
108
- Higher-priority files shadow lower-priority files with the same basename. Within one priority layer, same-id JSON shadows Markdown because JSON is the canonical precise format. A highest-priority invalid recipe is still visible and blocks fallback so operators do not accidentally run packaged behavior when a user override is broken. A highest-priority recipe with `disabled: true` also blocks fallback and intentionally disables that id.
108
+ Higher-priority files shadow lower-priority files with the same basename. Within one priority layer, same-id JSON shadows Markdown because JSON is the canonical precise format. A highest-priority invalid recipe is still visible and blocks fallback so operators do not accidentally run packaged behavior when a user override is broken. A highest-priority recipe with `disabled: true` also blocks fallback, is not launchable, and intentionally disables that id. Healthy overrides are silent; failed bare-name launches caused by invalid or disabled shadowing include compact `reason=shadowed_invalid` or `reason=shadowed_disabled` diagnostics with the active path, blocked candidate, and recipe-doctor hint.
109
109
 
110
110
  ## Usage Metadata
111
111
 
@@ -32,6 +32,8 @@ inspect target=recipes view=summary verbose=true
32
32
 
33
33
  The recipe summary reports active, shadowed, invalid, disabled, and diagnostic entries so operators can answer why a tool is present, hidden, broken, or disabled. The doctor view keeps the same registry evidence but promotes an advisory action surface: compact output includes the highest-priority `top` remediation plus ordered actions for invalid/blocking, disabled, risky shell-boundary, and shadowed recipes. Verbose inspection keeps the structured `remediations`, `top_action`, diagnostic details, and blocked lower-priority candidate paths when a broken or disabled higher-priority recipe masks a fallback.
34
34
 
35
+ Routine shadowing is quiet. If a bare `spawn` recipe launch already fails because an invalid or `disabled: true` user recipe blocks a lower-priority candidate, the launch error adds compact tokens such as `reason=shadowed_invalid` or `reason=shadowed_disabled`, `active_path`, `blocked_candidate`, and `hint=inspect_recipes_doctor`.
36
+
35
37
  ## Registering Tools
36
38
 
37
39
  `register_tool` is the interactive API for listing, creating, updating, or deleting persistent tools. Call it without arguments to list registered tools.
package/lib/async-runs.ts CHANGED
@@ -318,6 +318,9 @@ function readRecipeFile(file: string): AsyncRunStartParams {
318
318
  if (!config) {
319
319
  throw new Error(`Template recipe must define template: ${path}`);
320
320
  }
321
+ if (config.disabled === true) {
322
+ throw new Error(`Template recipe is disabled: ${path}`);
323
+ }
321
324
  return {
322
325
  ...(config as AsyncRunStartParams),
323
326
  file: path,
@@ -473,7 +473,8 @@ function remediationForEntry(
473
473
  reason: blockedCandidate
474
474
  ? "invalid higher-priority recipe blocks a lower-priority candidate"
475
475
  : "recipe is invalid and cannot be exposed as a tool",
476
- action: "fix recipe syntax/config, or disable/delete/archive it to restore fallback",
476
+ action:
477
+ "fix recipe syntax/config, or disable/delete/archive it to restore fallback",
477
478
  };
478
479
  }
479
480
  if (entry.disabled && entry.active) {
@@ -486,7 +487,8 @@ function remediationForEntry(
486
487
  reason: blockedCandidate
487
488
  ? "disabled higher-priority recipe intentionally blocks a lower-priority candidate"
488
489
  : "recipe is disabled and not exposed as a tool",
489
- action: "keep disabled intentionally, re-enable, or delete/archive the file",
490
+ action:
491
+ "keep disabled intentionally, re-enable, or delete/archive the file",
490
492
  };
491
493
  }
492
494
  if (riskyDiagnostics.length > 0) {
@@ -496,7 +498,8 @@ function remediationForEntry(
496
498
  severity: "warning",
497
499
  path: entry.path,
498
500
  reason: riskyDiagnostics[0],
499
- action: "audit trusted command boundary; keep only if the recipe is local and intentional",
501
+ action:
502
+ "audit trusted command boundary; keep only if the recipe is local and intentional",
500
503
  };
501
504
  }
502
505
  if (entry.shadowed) {
@@ -557,6 +560,21 @@ function recommendationForEntry(
557
560
  return recommendation;
558
561
  }
559
562
 
563
+ export function getShadowedLaunchDiagnostic(
564
+ result: RecipeDiscoveryResult,
565
+ id: string,
566
+ ): Record<string, unknown> | undefined {
567
+ const active = result.active.get(id.trim());
568
+ if (!active || active.shadows.length === 0) return undefined;
569
+ if (!active.invalid && !active.disabled) return undefined;
570
+ return {
571
+ active_path: active.path,
572
+ blocked_candidate: active.shadows[0],
573
+ hint: "inspect_recipes_doctor",
574
+ reason: active.invalid ? "shadowed_invalid" : "shadowed_disabled",
575
+ };
576
+ }
577
+
560
578
  export function summarizeDiscovery(
561
579
  result: RecipeDiscoveryResult,
562
580
  ): Record<string, unknown> {
package/lib/tools.ts CHANGED
@@ -710,6 +710,33 @@ function formatToolActorFailure(
710
710
  );
711
711
  }
712
712
 
713
+ function shadowedRecipeLaunchDiagnostic(
714
+ recipe: unknown,
715
+ ): Record<string, unknown> | undefined {
716
+ if (typeof recipe !== "string" || recipe.includes("/") || recipe.includes("~"))
717
+ return undefined;
718
+ const discovery = RecipeDiscovery.discoverRecipeSources([
719
+ { root: Paths.getRecipeRoot(), defaultTool: true, mutableUsage: true },
720
+ { root: Paths.getPackagedRecipeRoot(), defaultTool: false },
721
+ ]);
722
+ return RecipeDiscovery.getShadowedLaunchDiagnostic(discovery, recipe);
723
+ }
724
+
725
+ function enhanceSpawnRecipeError(error: unknown, recipe: unknown): Error {
726
+ const diagnostic = shadowedRecipeLaunchDiagnostic(recipe);
727
+ if (!diagnostic) return error instanceof Error ? error : new Error(String(error));
728
+ const original = error instanceof Error ? error.message : String(error);
729
+ return Object.assign(
730
+ new Error(
731
+ `${original} reason=${diagnostic.reason} active_path=${diagnostic.active_path} blocked_candidate=${diagnostic.blocked_candidate} hint=${diagnostic.hint}`,
732
+ ),
733
+ {
734
+ ...diagnostic,
735
+ original_error: original,
736
+ },
737
+ );
738
+ }
739
+
713
740
  function runIdFromActorAddress(
714
741
  address: string | undefined,
715
742
  ): string | undefined {
@@ -845,39 +872,45 @@ export function createSpawnToolDefinition<
845
872
  const runId = runIdFromActorAddress(
846
873
  typeof input.as === "string" ? input.as : undefined,
847
874
  );
848
- const meta = AsyncRuns.startRun(
849
- {
850
- file:
851
- typeof input.file === "string"
852
- ? input.file
853
- : typeof input.recipe === "string"
854
- ? input.recipe
855
- : undefined,
856
- launch_source: "spawn",
857
- ownerId: getRunOwnerId(ctx),
858
- run_id: runId,
859
- state_dir:
860
- typeof input.state_dir === "string" ? input.state_dir : undefined,
861
- ...(input.template !== undefined
862
- ? {
863
- template:
864
- input.template as AsyncRuns.AsyncRunStartParams["template"],
865
- }
866
- : {}),
867
- values: asRecord(input.values),
868
- ...(input.artifacts &&
869
- typeof input.artifacts === "object" &&
870
- !Array.isArray(input.artifacts)
871
- ? {
872
- artifacts: input.artifacts as Record<
873
- string,
874
- AsyncRuns.RunArtifactDeclaration
875
- >,
876
- }
877
- : {}),
878
- },
879
- ctx.cwd,
880
- );
875
+ const recipe =
876
+ typeof input.file === "string"
877
+ ? input.file
878
+ : typeof input.recipe === "string"
879
+ ? input.recipe
880
+ : undefined;
881
+ let meta: AsyncRuns.AsyncRunMeta;
882
+ try {
883
+ meta = AsyncRuns.startRun(
884
+ {
885
+ file: recipe,
886
+ launch_source: "spawn",
887
+ ownerId: getRunOwnerId(ctx),
888
+ run_id: runId,
889
+ state_dir:
890
+ typeof input.state_dir === "string" ? input.state_dir : undefined,
891
+ ...(input.template !== undefined
892
+ ? {
893
+ template:
894
+ input.template as AsyncRuns.AsyncRunStartParams["template"],
895
+ }
896
+ : {}),
897
+ values: asRecord(input.values),
898
+ ...(input.artifacts &&
899
+ typeof input.artifacts === "object" &&
900
+ !Array.isArray(input.artifacts)
901
+ ? {
902
+ artifacts: input.artifacts as Record<
903
+ string,
904
+ AsyncRuns.RunArtifactDeclaration
905
+ >,
906
+ }
907
+ : {}),
908
+ },
909
+ ctx.cwd,
910
+ );
911
+ } catch (error) {
912
+ throw enhanceSpawnRecipeError(error, recipe);
913
+ }
881
914
  ActorRooms.ensureDefaultRoom(meta.state_dir, String(meta.run));
882
915
  ActorRooms.writeCommunicationSnapshot(meta.state_dir, String(meta.run));
883
916
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -2,7 +2,7 @@
2
2
  name: actors
3
3
  description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.27.1
5
+ version: 0.28.0
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)
@@ -2,7 +2,7 @@
2
2
  name: swarm
3
3
  description: Subagent orchestration with scoped locks and quorum consensus. Use for multi-model review, parallel scoped work, delegated audit, and coordinated subagent execution.
4
4
  metadata:
5
- version: 0.27.1
5
+ version: 0.28.0
6
6
  ---
7
7
 
8
8
  # Swarm