@llblab/pi-actors 0.35.0 → 0.37.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.
Files changed (42) hide show
  1. package/AGENTS.md +3 -1
  2. package/BACKLOG.md +4 -72
  3. package/CHANGELOG.md +17 -3
  4. package/README.md +2 -1
  5. package/dist/index.js +0 -1
  6. package/dist/lib/async-runs.js +4 -3
  7. package/dist/lib/command-templates.d.ts +2 -0
  8. package/dist/lib/command-templates.js +95 -5
  9. package/dist/lib/recipes-discovery.d.ts +2 -0
  10. package/dist/lib/recipes-discovery.js +52 -5
  11. package/dist/lib/recipes-references.d.ts +1 -0
  12. package/dist/lib/recipes-references.js +122 -35
  13. package/dist/lib/registry.d.ts +1 -1
  14. package/dist/lib/registry.js +6 -6
  15. package/dist/lib/runtime-notifier.js +8 -3
  16. package/dist/lib/runtime.d.ts +2 -6
  17. package/dist/lib/runtime.js +10 -11
  18. package/dist/lib/tools-inspect.js +172 -4
  19. package/dist/lib/tools-response.js +13 -2
  20. package/dist/lib/tools.d.ts +1 -1
  21. package/dist/lib/tools.js +1 -1
  22. package/dist/scripts/validate-recipe.mjs +164 -7
  23. package/dist/skills/actors/SKILL.md +45 -13
  24. package/dist/skills/swarm/SKILL.md +1 -1
  25. package/docs/recipe-library.md +2 -0
  26. package/docs/template-recipes.md +20 -1
  27. package/docs/tool-registry.md +15 -10
  28. package/index.ts +0 -1
  29. package/lib/async-runs.ts +4 -7
  30. package/lib/command-templates.ts +146 -7
  31. package/lib/recipes-discovery.ts +69 -6
  32. package/lib/recipes-references.ts +201 -34
  33. package/lib/registry.ts +5 -5
  34. package/lib/runtime-notifier.ts +9 -3
  35. package/lib/runtime.ts +10 -16
  36. package/lib/tools-inspect.ts +186 -4
  37. package/lib/tools-response.ts +16 -2
  38. package/lib/tools.ts +2 -2
  39. package/package.json +3 -2
  40. package/scripts/validate-recipe.mjs +164 -7
  41. package/skills/actors/SKILL.md +45 -13
  42. package/skills/swarm/SKILL.md +1 -1
@@ -27,9 +27,9 @@ const { readResolvedRecipeConfig } = await importRuntimeModule("recipes-referenc
27
27
 
28
28
  export function validateRecipeUsage() {
29
29
  return `Usage:
30
- validate-recipe.mjs <recipe-file-or-dir> [--all]
30
+ validate-recipe.mjs <recipe-file-or-dir> [--all] [--qa] [--summary]
31
31
 
32
- Validates one template recipe file, or all *.json/*.md files in a directory when --all is set.`;
32
+ Validates one template recipe file, or all *.json/*.md files in a directory when --all is set. Add --qa for packaged-recipe quality checks. Add --summary for compact CLI output.`;
33
33
  }
34
34
 
35
35
  function expandPath(value) {
@@ -63,14 +63,133 @@ function recipeFiles(target, all) {
63
63
  .map((file) => resolve(target, file));
64
64
  }
65
65
 
66
- function validateFile(file) {
66
+ function mailboxType(value) {
67
+ if (typeof value === "string") return value;
68
+ if (value && typeof value === "object" && typeof value.type === "string")
69
+ return value.type;
70
+ return undefined;
71
+ }
72
+
73
+ function collectTemplateStrings(value, out = []) {
74
+ if (typeof value === "string") out.push(value);
75
+ else if (Array.isArray(value)) value.forEach((item) => collectTemplateStrings(item, out));
76
+ else if (value && typeof value === "object") {
77
+ collectTemplateStrings(value.template, out);
78
+ collectTemplateStrings(value.recover, out);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function hasPlatformNote(config) {
84
+ const text = [
85
+ config.description,
86
+ config.platforms,
87
+ config.platform_notes,
88
+ config.requirements,
89
+ ]
90
+ .filter(Boolean)
91
+ .join(" ");
92
+ return /linux|macos|darwin|windows|win32|unix|wsl|cross-platform|portable/i.test(text);
93
+ }
94
+
95
+ function validateArtifactDeclarations(config) {
96
+ const diagnostics = [];
97
+ const artifacts = config.artifacts;
98
+ if (artifacts === undefined) return diagnostics;
99
+ if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts)) {
100
+ diagnostics.push("artifacts: must be an object of named artifact paths");
101
+ return diagnostics;
102
+ }
103
+ for (const [name, value] of Object.entries(artifacts)) {
104
+ const path = typeof value === "string" ? value : value?.path;
105
+ if (typeof path !== "string" || !path.trim())
106
+ diagnostics.push(`artifacts.${name}: must declare a non-empty path`);
107
+ if (typeof path === "string" && /^\/home\//.test(path))
108
+ diagnostics.push(`artifacts.${name}: must not use a machine-local absolute path`);
109
+ }
110
+ return diagnostics;
111
+ }
112
+
113
+ function validateHelperPaths(file, config) {
114
+ const diagnostics = [];
115
+ for (const template of collectTemplateStrings(config.template)) {
116
+ if (/(^|\s)(?:node\s+)?scripts\/[\w.-]+\.mjs/.test(template))
117
+ diagnostics.push("template: helper scripts must be referenced through {repo}/scripts for installed packages");
118
+ for (const match of template.matchAll(/\{repo\}\/scripts\/([^\s"']+\.mjs)/g)) {
119
+ const scriptPath = join(packageRoot(), "scripts", match[1]);
120
+ if (!existsSync(scriptPath))
121
+ diagnostics.push(`template: referenced helper script not found: scripts/${match[1]}`);
122
+ }
123
+ }
124
+ return diagnostics;
125
+ }
126
+
127
+ function validateMailboxContract(file, config) {
128
+ const diagnostics = [];
129
+ const mailbox = config.mailbox;
130
+ const accepts = Array.isArray(mailbox?.accepts) ? mailbox.accepts : [];
131
+ const emits = Array.isArray(mailbox?.emits) ? mailbox.emits : [];
132
+ if (config.async === true && !mailbox)
133
+ diagnostics.push("mailbox: async recipes must declare mailbox metadata");
134
+ if (config.async === true && !accepts.map(mailboxType).includes("control.kill"))
135
+ diagnostics.push("mailbox.accepts: async recipes must include control.kill");
136
+ const allowedLegacyTypes = new Set(["awaiting_assignment"]);
137
+ for (const [key, entries] of Object.entries({ accepts, emits })) {
138
+ entries.forEach((entry, index) => {
139
+ const type = mailboxType(entry);
140
+ if (!type) diagnostics.push(`mailbox.${key}[${index}]: must be a string or object with type`);
141
+ else if (
142
+ !allowedLegacyTypes.has(type) &&
143
+ !/^[a-z][a-z0-9_-]*\.(?:[a-z][a-z0-9_-]*|\*)$/.test(type)
144
+ )
145
+ diagnostics.push(`mailbox.${key}[${index}]: message type must use channel.action form`);
146
+ });
147
+ }
148
+ const allowedDomainTermination = new Set([
149
+ "coordinator-locker.json",
150
+ "locker.json",
151
+ "music-player.json",
152
+ ]);
153
+ const hasDomainTermination = accepts
154
+ .map(mailboxType)
155
+ .some((type) => type === "control.stop" || type === "control.cancel");
156
+ if (hasDomainTermination && !allowedDomainTermination.has(file.split(/[\\/]/).pop()))
157
+ diagnostics.push("mailbox.accepts: control.stop/control.cancel are reserved for actor-domain handlers");
158
+ return diagnostics;
159
+ }
160
+
161
+ function qaDiagnostics(file, config) {
162
+ const diagnostics = [];
163
+ const warnings = [];
164
+ if (typeof config.description !== "string" || !config.description.trim())
165
+ warnings.push("description: missing or empty");
166
+ diagnostics.push(...validateMailboxContract(file, config));
167
+ diagnostics.push(...validateArtifactDeclarations(config));
168
+ diagnostics.push(...validateHelperPaths(file, config));
169
+ const platformSpecificTemplate = collectTemplateStrings(config.template).some(
170
+ (template) =>
171
+ /(^|\s)(?:systemctl|launchctl|osascript|powershell|pwsh|cmd\.exe|apt|apt-get|dnf|yum|brew|pacman|apk)(\s|$)/i.test(
172
+ template,
173
+ ),
174
+ );
175
+ if (platformSpecificTemplate && !hasPlatformNote(config))
176
+ diagnostics.push("platform: platform-specific templates must document platform scope");
177
+ return { diagnostics, warnings };
178
+ }
179
+
180
+ function qaOk(qaReport) {
181
+ return qaReport.diagnostics.length === 0;
182
+ }
183
+
184
+ function validateFile(file, qa = false) {
67
185
  try {
68
186
  const config = readResolvedRecipeConfig(file);
69
187
  if (!config?.template)
70
188
  throw new Error("Recipe must define a non-empty template.");
189
+ const qaReport = qa ? qaDiagnostics(file, config) : { diagnostics: [], warnings: [] };
71
190
  return {
72
191
  file,
73
- ok: true,
192
+ ok: qaOk(qaReport),
74
193
  name: config.name ?? "",
75
194
  async: Boolean(config.async),
76
195
  args: Array.isArray(config.args) ? config.args : [],
@@ -93,6 +212,15 @@ function validateFile(file) {
93
212
  : [],
94
213
  }
95
214
  : undefined,
215
+ ...(qa
216
+ ? {
217
+ qa: {
218
+ ok: qaOk(qaReport),
219
+ diagnostics: qaReport.diagnostics,
220
+ warnings: qaReport.warnings,
221
+ },
222
+ }
223
+ : {}),
96
224
  template: templateKind(config.template),
97
225
  };
98
226
  } catch (error) {
@@ -107,12 +235,13 @@ function validateFile(file) {
107
235
  export function validateRecipes(argv) {
108
236
  const targetArg = argv.find((arg) => !arg.startsWith("-"));
109
237
  const all = argv.includes("--all");
238
+ const qa = argv.includes("--qa");
110
239
  if (!targetArg || argv.includes("--help") || argv.includes("-h")) {
111
240
  return { help: true, ok: Boolean(targetArg), usage: validateRecipeUsage() };
112
241
  }
113
242
 
114
243
  const files = recipeFiles(expandPath(targetArg), all);
115
- const results = files.map(validateFile);
244
+ const results = files.map((file) => validateFile(file, qa));
116
245
  const failed = results.filter((result) => !result.ok).length;
117
246
  return {
118
247
  ok: failed === 0,
@@ -123,10 +252,38 @@ export function validateRecipes(argv) {
123
252
  };
124
253
  }
125
254
 
255
+ function summarizeReport(report) {
256
+ const results = Array.isArray(report.results) ? report.results : [];
257
+ return {
258
+ ok: report.ok,
259
+ total: report.total,
260
+ passed: report.passed,
261
+ failed: report.failed,
262
+ diagnostics: results.reduce(
263
+ (total, result) => total + (result.qa?.diagnostics?.length ?? 0),
264
+ 0,
265
+ ),
266
+ warnings: results.reduce(
267
+ (total, result) => total + (result.qa?.warnings?.length ?? 0),
268
+ 0,
269
+ ),
270
+ failed_files: results
271
+ .filter((result) => !result.ok)
272
+ .map((result) => ({
273
+ file: result.file,
274
+ ...(result.error ? { error: result.error } : {}),
275
+ ...(result.qa?.diagnostics?.length
276
+ ? { diagnostics: result.qa.diagnostics }
277
+ : {}),
278
+ })),
279
+ };
280
+ }
281
+
126
282
  try {
127
- const report = validateRecipes(process.argv.slice(2));
283
+ const argv = process.argv.slice(2);
284
+ const report = validateRecipes(argv);
128
285
  if (report.help) console.error(report.usage);
129
- else console.log(JSON.stringify(report, null, 2));
286
+ else console.log(JSON.stringify(argv.includes("--summary") ? summarizeReport(report) : report, null, 2));
130
287
  process.exit(report.ok ? 0 : 1);
131
288
  } catch (error) {
132
289
  console.error(error instanceof Error ? error.message : String(error));
@@ -2,7 +2,7 @@
2
2
  name: actors
3
3
  description: Required practical guide for non-trivial pi-actors use. Read before using or changing spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.35.0
5
+ version: 0.37.0
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)
@@ -194,12 +194,14 @@ Rules:
194
194
  2. `async: true` makes spawned work a detached actor run.
195
195
  3. Public knobs belong in `args`/`defaults`; hidden launch mechanics stay inside `template`.
196
196
  4. Use `imports` to compose recipes; imported recipes are definitions, not nested async runs.
197
- 5. Declare `mailbox` for actors that accept or emit meaningful messages.
198
- 6. Declare `artifacts` for durable outputs the coordinator should inspect.
199
- 7. File-backed recipe identity comes from the filename basename; legacy top-level `name` fields are ignored by loaders.
200
- 8. File-backed async recipes pass child `pi -p` actors a bounded JSONL recipe context bundle by default: raw entry/import recipe records, derived `name`, import path/alias, and `"you_are_here": true` on the launching recipe node. Set `"actor_context": false` or `"off"` to suppress it for minimal prompts.
201
- 9. Keep packaged recipes generic: no machine-local paths, no private companion identities, no project-specific defaults unless the recipe is explicitly project-specific.
202
- 10. Do not ship concrete model-version defaults in packaged recipes; expose `model`, `models`, and stage-specific model args so the caller must choose current policy at launch.
197
+ 5. Direct recipe delegation is the thin-wrapper case: when a `template` value is just a ready recipe name/path, the intended behavior is to delegate to that recipe rather than execute the recipe file as a program. Use this for simple handoffs and wrapper tools; use `imports` + `{ "name": "alias" }` when you need rich composition, multiple nodes, or import-specific values/defaults.
198
+ 6. When exposing an already-authored recipe as a user tool before direct delegation is available or when composition is needed, make a small wrapper recipe in `~/.pi/agent/recipes` that imports the source recipe and uses a `{ "name": "alias" }` node. Do not copy the ready recipe's script command, defaults, mailbox, or artifacts into a second template.
199
+ 7. Declare `mailbox` for actors that accept or emit meaningful messages.
200
+ 8. Declare `artifacts` for durable outputs the coordinator should inspect.
201
+ 9. File-backed recipe identity comes from the filename basename; legacy top-level `name` fields are ignored by loaders.
202
+ 10. File-backed async recipes pass child `pi -p` actors a bounded JSONL recipe context bundle by default: raw entry/import recipe records, derived `name`, import path/alias, and `"you_are_here": true` on the launching recipe node. Set `"actor_context": false` or `"off"` to suppress it for minimal prompts.
203
+ 11. Keep packaged recipes generic: no machine-local paths, no private companion identities, no project-specific defaults unless the recipe is explicitly project-specific.
204
+ 12. Do not ship concrete model-version defaults in packaged recipes; expose `model`, `models`, and stage-specific model args so the caller must choose current policy at launch.
203
205
 
204
206
  Priority for same-id recipes:
205
207
 
@@ -208,7 +210,7 @@ Priority for same-id recipes:
208
210
  3. Explicit ad hoc user recipe file outside `~/.pi/agent/recipes`.
209
211
  4. User recipe in `~/.pi/agent/recipes/*.json` or `*.md`: highest-priority operator tool surface.
210
212
 
211
- Only matching filename ids compete. Higher priority shadows lower priority; within one priority layer, same-id JSON shadows Markdown. An invalid or `disabled: true` higher-priority recipe blocks fallback so the agent does not silently run standard-library behavior when a user override is broken or intentionally disabled.
213
+ Only matching filename ids compete. Higher priority shadows lower priority; within one priority layer, same-id JSON shadows Markdown. Same-id overrides are normal composition/delegation behavior, not startup-warning material. An invalid or `disabled: true` higher-priority recipe blocks fallback so the agent does not silently run standard-library behavior when a user override is broken or intentionally disabled.
212
214
 
213
215
  Muscle-memory lens: pi-actors has two durable executable-memory layers.
214
216
 
@@ -227,23 +229,53 @@ Cleanup rule: periodically inspect `~/.pi/agent/recipes` as the live muscle-memo
227
229
 
228
230
  `register_tool` persists trusted local capabilities as recipe files in `~/.pi/agent/recipes/*.json`; hand-authored Markdown recipes in the same directory are also discovered as tools.
229
231
 
230
- Use it when a command/template/recipe should become durable agent muscle memory. Prefer typed args or placeholder-derived args; use `update=true` for replacement and `template=null` or `template=""` for deletion. `register_tool` should create/update/delete recipe files in the user recipe root; direct file editing is allowed but is the lower-level path.
232
+ Use it when a command/template/recipe should become durable agent muscle memory. Prefer typed args or placeholder-derived args; use `update=true` for replacement and `template=null` or `template=""` for deletion. `register_tool` should create/update/delete simple recipe files in the user recipe root; direct recipe-file editing is the right path when the wrapper needs `imports` or other top-level recipe metadata not exposed by the interactive mutation API.
233
+
234
+ Ready-recipe registration patterns:
235
+
236
+ Thin delegation target shape:
237
+
238
+ ```json
239
+ {
240
+ "description": "Run a ready recipe through a local tool name.",
241
+ "args": ["source:path", "volume:int=70"],
242
+ "template": "/path/to/ready-recipe.json"
243
+ }
244
+ ```
245
+
246
+ Delegation is for one-to-one handoff: expose or call a maintained recipe directly, preserving that recipe as the source of truth. If the runtime does not yet support direct recipe references in `template`, or if you need composition, use the import-node wrapper below.
247
+
248
+ Composition/import wrapper:
249
+
250
+ ```json
251
+ {
252
+ "description": "Run the ABCd context validator through its skill recipe.",
253
+ "imports": {
254
+ "validate_context": "{agent}/skills/abcd-context/recipes/validate-context.json"
255
+ },
256
+ "args": ["path:path=."],
257
+ "template": { "name": "validate_context" }
258
+ }
259
+ ```
260
+
261
+ Use delegation or this import pattern whenever a reusable recipe already exists: packaged pi-actors components, project-local recipes, ad hoc reviewed recipe files, and especially skill-owned recipes that wrap skill scripts. The wrapper owns only the public tool name, description, optional narrowed args/defaults, and local usage metadata. The delegated/imported recipe remains the source of truth for the script path, default values, mailbox contract, artifacts, and future fixes.
231
262
 
232
263
  Tool-registration lenses are open-ended prompts for deciding what deserves durable tool status:
233
264
 
234
265
  1. **Reliability lens**: register wrappers for operations where agents commonly omit checks, run steps out of order, pass ambiguous inputs, or recover poorly from partial failure.
235
266
  2. **Safety lens**: prefer read-only diagnostics, dry-runs, preflights, confirmations, or bounded adapters around high-impact operations before registering direct action tools.
236
267
  3. **Context-affordance lens**: register tools whose mere presence in the injected capability list should steer agents toward the right operational habit.
237
- 4. **Existing-recipe lens**: scan already-authored recipes before inventing a new tool. Packaged recipes, ad hoc project recipes, and recipes co-located under skill directories are often the first candidates to copy/register into the user recipe root when they match a recurring local workflow.
238
- 5. **Composition lens**: register small semantic entrypoints over reusable recipe components instead of baking one large scenario-specific shell command into a tool.
239
- 6. **Portability lens**: keep recipe files transportable; make tool exposure a consequence of placement in `~/.pi/agent/recipes`, not recipe-owned markers or machine-local assumptions.
268
+ 4. **Existing-recipe lens**: scan already-authored recipes before inventing a new tool. Packaged recipes, ad hoc project recipes, and recipes co-located under skill directories are the first candidates to delegate to or import from a user-root wrapper when they match a recurring local workflow.
269
+ 5. **Skill-recipe lens**: when a skill ships a recipe for its script, local tools must delegate to or import that recipe instead of calling the skill script directly. This preserves the skill's maintained interface and keeps future script/default changes centralized.
270
+ 6. **Composition lens**: register small semantic entrypoints over reusable recipe components instead of baking one large scenario-specific shell command into a tool; prefer direct delegation for one recipe, imports for composed graphs.
271
+ 7. **Portability lens**: keep recipe files transportable; make tool exposure a consequence of placement in `~/.pi/agent/recipes`, not recipe-owned markers or machine-local assumptions.
240
272
 
241
273
  Default bias: register diagnostic/preflight tools before action tools, and promote existing recipes before writing new orchestration. A good persistent tool shrinks the chance of a subtle operational mistake, not just the number of keystrokes.
242
274
 
243
275
  Tool templates may be:
244
276
 
245
277
  - A foreground command template.
246
- - A file-backed recipe name/path.
278
+ - A file-backed recipe name/path for thin delegation.
247
279
  - A complete recipe body, optionally `async: true`.
248
280
 
249
281
  The user recipe root is the default tool set by location. It accepts canonical JSON recipes and literate Markdown recipes with frontmatter plus fenced `template`/`json recipe` blocks; same-id JSON shadows Markdown in the same priority layer. Packaged recipes are lower-priority standard-library components and are not tools unless copied or registered into the agent recipe root. Ideal runtime behavior is reactive: create/edit/delete recipe files, validate them, then connect valid tools or surface diagnostics without requiring agents to hand-maintain a separate registry.
@@ -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.35.0
5
+ version: 0.37.0
6
6
  ---
7
7
 
8
8
  # Swarm
@@ -115,6 +115,8 @@ Utility recipes cover local operator workflows that do not need subagents:
115
115
  - `recipes/utility-skill-summary.json`: Use `scripts/recipe-utils.mjs` to summarize packaged skill frontmatter, body shape, formatter-safe scalar lines, and package-version alignment.
116
116
  - `recipes/utility-validate-recipe.json`: Use `scripts/validate-recipe.mjs` to validate one template recipe file, or all packaged recipes in a directory with `all: true`.
117
117
 
118
+ Packaged QA is available through the `recipes:qa` npm script. It reports description warnings and fails exact diagnostics for async mailbox contracts, termination vocabulary, artifact paths, platform scope, helper script paths, and missing helper scripts.
119
+
118
120
  These recipes are intentionally small. Register them only for trusted local commands and prefer narrow scopes. Discovery diagnostics flag obvious trust-boundary shapes such as shell/eval/destructive commands; those warnings are operator review aids, not a sandbox. The helper-backed utilities share `scripts/recipe-utils.mjs` so repeated parsing/listing logic stays out of recipe strings.
119
121
 
120
122
  ## Actor OS Smoke Matrix
@@ -292,7 +292,26 @@ An import binding may be either a string recipe path/name or an object with:
292
292
 
293
293
  A template node of `{ "name": "alias" }` is replaced with the imported recipe's command-template graph. Imported recipe defaults are merged with import `defaults`, import `values`, node `defaults`, and node `values`; later layers win. This lets a parent recipe embed a reusable recipe in a sequence or `parallel: true` branch without inventing a workflow language.
294
294
 
295
- Async composition stays explicit: importing a recipe reuses its command-template-shaped definition. It does not start a nested async run. Put `async: true` on the parent recipe when the combined imported graph should run detached as one run with one state dir. Ephemeral coordinator recipes may declare `retire_when: "children_terminal"` as an opt-in lifecycle hint for future graceful retirement handling; persistent services and implementer loops should omit it. For agent-callable fanout, prefer public inputs such as `prompts:array` plus `repeat: "{prompts.length}"`, then select each branch value with `{prompts[index]}` instead of baking concrete prompts or file names into the reusable recipe.
295
+ Use imports as the default adapter for composed ready recipes. A user-root recipe such as `~/.pi/agent/recipes/repo_context_check.json` can import the maintained source recipe and call `{ "name": "alias" }` instead of duplicating the source recipe's script command. Skill scripts are the strongest version of this rule: when a skill provides `recipes/<name>.json` for its `scripts/*` entrypoint, local tools should delegate to or import the skill recipe instead of invoking the script path directly.
296
+
297
+ ## Direct Recipe Delegation
298
+
299
+ A `template` string that resolves to a recipe name or recipe file path delegates to that recipe instead of executing the recipe file as a binary:
300
+
301
+ ```json
302
+ {
303
+ "description": "Play music through the packaged player recipe.",
304
+ "async": true,
305
+ "defaults": { "source": "~/Music", "volume": "70" },
306
+ "template": "music-player"
307
+ }
308
+ ```
309
+
310
+ Delegation is a one-to-one handoff for thin wrappers and simple reuse. It uses the same bare-name priority as imports: user-root recipes under `~/.pi/agent/recipes`, then the importing recipe's directory, then packaged standard-library recipes. A higher-priority invalid or disabled recipe still blocks lower-priority fallback so operator overrides fail closed.
311
+
312
+ Delegated recipes remain the source of truth for their command template, async setting, args/defaults, mailbox, artifacts, and future fixes. Wrapper recipe fields may narrow args/defaults or override lifecycle metadata. Use imports plus `{ "name": "alias" }` when you need rich composition, multiple recipe nodes, import-specific values/defaults, or a pipeline where the reusable recipe is only one step.
313
+
314
+ Async composition stays explicit: importing or delegating to a recipe reuses its command-template-shaped definition. It does not start a nested async run. Put `async: true` on the parent recipe when the combined imported graph should run detached as one run with one state dir; thin delegation may inherit `async: true` from the delegated recipe. Ephemeral coordinator recipes may declare `retire_when: "children_terminal"` as an opt-in lifecycle hint for future graceful retirement handling; persistent services and implementer loops should omit it. For agent-callable fanout, prefer public inputs such as `prompts:array` plus `repeat: "{prompts.length}"`, then select each branch value with `{prompts[index]}` instead of baking concrete prompts or file names into the reusable recipe.
296
315
 
297
316
  ```json
298
317
  {
@@ -24,14 +24,15 @@ Inspect the loaded pi-actors runtime and discovered registry with:
24
24
 
25
25
  ```text
26
26
  inspect target=tool:pi-actors view=status
27
+ inspect target=tool:pi-actors view=triage
27
28
  inspect target=recipes view=status
28
29
  inspect target=recipes view=doctor
29
30
  inspect target=recipes view=summary verbose=true
30
31
  ```
31
32
 
32
- `tool:pi-actors` is a reserved runtime-status actor: it reports the loaded package version, package root, source/dist mode, entrypoint path, recipe roots, and git commit when available. Use it after reloads to confirm which extension code is actually live.
33
+ `tool:pi-actors` is a reserved runtime-status actor. `view=status` reports the loaded package version, package root, source/dist mode, entrypoint path, recipe roots, and git commit when available. Use it after reloads to confirm which extension code is actually live. `view=triage` adds a compact attention surface for active runs, other-session runs, invalid or blocking recipes, exposed tool recipes with non-lifecycle risk labels, drafts, stale claims, failed runs, attention messages, and next inspect actions without repairing anything. Packaged components and recipes whose only label is `risk.long_running` stay in recipe doctor/summary evidence rather than triage attention.
33
34
 
34
- 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 fallback paths when a broken or disabled higher-priority recipe masks a fallback.
35
+ 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, risk-label counts, and ordered actions for invalid/blocking, disabled, risky shell-boundary, and shadowed recipes. Verbose inspection keeps per-recipe `risk_labels`, the structured `risk_summary`, `remediations`, `top_action`, diagnostic details, and blocked lower-priority fallback paths when a broken or disabled higher-priority recipe masks a fallback. Risk labels are deterministic review aids, not execution blockers or sandbox claims.
35
36
 
36
37
  Routine shadowing is quiet. If a bare `spawn` recipe launch already fails because an invalid or `disabled: true` user recipe blocks a lower-priority fallback, the launch error adds compact tokens such as `reason=shadowed_invalid` or `reason=shadowed_disabled`, `active_path`, `blocked_fallback`, and `hint=inspect_recipes_doctor`.
37
38
 
@@ -62,18 +63,22 @@ Use `update=true` to overwrite an existing tool. Omit `template` and co-located
62
63
  ]
63
64
  ```
64
65
 
65
- For reusable actor workflows, register a small tool whose `template` points to an existing actor recipe instead of embedding the launch graph in the tool itself:
66
+ For reusable actor workflows, expose an existing recipe by writing a small wrapper recipe in `~/.pi/agent/recipes` that imports the ready recipe and calls it by alias. This keeps the imported recipe as the source of truth for its script path, defaults, mailbox, artifacts, and future fixes:
66
67
 
67
- ```text
68
- register_tool name=docs_review \
69
- description="Start an async docs review actor" \
70
- template="docs_review" \
71
- args="scope:path,model:string"
68
+ ```json
69
+ {
70
+ "description": "Run the ABCd context validator through its skill recipe.",
71
+ "imports": {
72
+ "validate_context": "{agent}/skills/abcd-context/recipes/validate-context.json"
73
+ },
74
+ "args": ["path:path=."],
75
+ "template": { "name": "validate_context" }
76
+ }
72
77
  ```
73
78
 
74
- This writes or updates `~/.pi/agent/recipes/docs_review.json` with a recipe-reference template. Its location in the user recipe root makes it a tool. If the referenced recipe contains `async: true`, calling the tool starts a detached actor run and returns metadata immediately. If `async` is omitted or false, the same recipe runs foreground and returns normal tool output.
79
+ Use the same pattern for packaged pi-actors components, reviewed ad hoc recipes, project-local recipe files, and especially skill recipes that wrap skill scripts. Do not register a local tool that calls `~/.pi/agent/skills/<skill>/scripts/*` directly when the skill already ships a recipe. The wrapper's location in the user recipe root makes it a tool; the import preserves the ready recipe's maintained interface.
75
80
 
76
- When co-location is clearer than a separate file, `register_tool` writes the recipe fields directly into the user recipe file:
81
+ When no ready recipe exists and co-location is clearer than a separate file, `register_tool` writes the recipe fields directly into the user recipe file:
77
82
 
78
83
  ```json
79
84
  {
package/index.ts CHANGED
@@ -106,7 +106,6 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
106
106
  configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
107
107
  exec: CommandTemplates.execCommandTemplate,
108
108
  getActiveTools: () => pi.getActiveTools(),
109
- getAllTools: () => pi.getAllTools(),
110
109
  registerTool: (definition) => {
111
110
  actorToolDefinitions.set(definition.name, definition);
112
111
  pi.registerTool(definition);
package/lib/async-runs.ts CHANGED
@@ -67,11 +67,7 @@ import {
67
67
  deliverRunMessage,
68
68
  type SendRunMessageOptions,
69
69
  } from "./runs-messages.ts";
70
- import {
71
- buildRunStatus,
72
- tailFile,
73
- tailLines,
74
- } from "./runs-status.ts";
70
+ import { buildRunStatus, tailFile, tailLines } from "./runs-status.ts";
75
71
  import { readJsonFileResilient } from "./state-readers.ts";
76
72
 
77
73
  const RUNNER_IDENTITY_GRACE_MS = 5000;
@@ -213,8 +209,9 @@ function assertNoActiveRunState(stateDir: string): void {
213
209
 
214
210
  function resolveRecipeFile(file: string): string {
215
211
  return (
216
- RecipesReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
217
- RecipesReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT)
212
+ RecipesReferences.resolveRecipeReferencePath(file, Paths.getRecipeRoot()) ??
213
+ RecipesReferences.getRecipePath(file, Paths.getRecipeRoot()) ??
214
+ RecipesReferences.resolveRecipePath(file, Paths.getRecipeRoot())
218
215
  );
219
216
  }
220
217
 
@@ -67,6 +67,29 @@ export interface CommandTemplateExecResult {
67
67
  killed: boolean;
68
68
  }
69
69
 
70
+ export type CommandTemplateRiskLabel =
71
+ | "risk.shell"
72
+ | "risk.eval"
73
+ | "risk.broad_fs_write"
74
+ | "risk.destructive_fs"
75
+ | "risk.network"
76
+ | "risk.external_side_effect"
77
+ | "risk.long_running"
78
+ | "risk.platform_specific"
79
+ | "risk.secret_touching";
80
+
81
+ const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
82
+ "risk.shell",
83
+ "risk.eval",
84
+ "risk.destructive_fs",
85
+ "risk.broad_fs_write",
86
+ "risk.external_side_effect",
87
+ "risk.secret_touching",
88
+ "risk.network",
89
+ "risk.long_running",
90
+ "risk.platform_specific",
91
+ ];
92
+
70
93
  export type CommandTemplateExecCommand = (
71
94
  command: string,
72
95
  args: string[],
@@ -111,18 +134,33 @@ export function resolveInheritedDefaultReferences(
111
134
  inheritedDefaults: Record<string, unknown> | undefined,
112
135
  runtimeValues: Record<string, unknown> = {},
113
136
  ): Record<string, unknown> | undefined {
114
- if (!ownDefaults || !inheritedDefaults) return ownDefaults;
137
+ if (!ownDefaults) return ownDefaults;
115
138
  const resolved = { ...ownDefaults };
139
+ const values = { ...(inheritedDefaults ?? {}), ...runtimeValues };
116
140
  for (const [key, value] of Object.entries(ownDefaults)) {
117
141
  if (typeof value !== "string") continue;
118
142
  const exact = /^\{([A-Za-z_][A-Za-z0-9_-]*)\}$/.exec(value);
119
- if (
120
- !exact ||
121
- Object.hasOwn(runtimeValues, exact[1]) ||
122
- !Object.hasOwn(inheritedDefaults, exact[1])
123
- )
143
+ if (exact && Object.hasOwn(values, exact[1])) {
144
+ resolved[key] = values[exact[1]];
124
145
  continue;
125
- resolved[key] = inheritedDefaults[exact[1]];
146
+ }
147
+ const indexed = value.match(
148
+ /^\{([A-Za-z_][A-Za-z0-9_-]*)\[([A-Za-z_][A-Za-z0-9_-]*|\d+)\]\}$/,
149
+ );
150
+ if (!indexed) continue;
151
+ const source = values[indexed[1]];
152
+ const indexValue = /^\d+$/.test(indexed[2])
153
+ ? indexed[2]
154
+ : values[indexed[2]];
155
+ const index = Number(indexValue);
156
+ if (
157
+ Array.isArray(source) &&
158
+ Number.isInteger(index) &&
159
+ index >= 0 &&
160
+ index < source.length
161
+ ) {
162
+ resolved[key] = source[index] ?? "";
163
+ }
126
164
  }
127
165
  return resolved;
128
166
  }
@@ -181,6 +219,97 @@ function hasRiskyPathArg(args: string[]): boolean {
181
219
  );
182
220
  }
183
221
 
222
+ function sortRiskLabels(
223
+ labels: Iterable<CommandTemplateRiskLabel>,
224
+ ): CommandTemplateRiskLabel[] {
225
+ const unique = new Set(labels);
226
+ return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
227
+ }
228
+
229
+ function hasAnyArg(args: string[], values: string[]): boolean {
230
+ return args.some((arg) => values.includes(arg.toLowerCase()));
231
+ }
232
+
233
+ function hasSecretTouchingText(parts: string[]): boolean {
234
+ return parts.some((part) =>
235
+ /(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(
236
+ part,
237
+ ),
238
+ );
239
+ }
240
+
241
+ function getLeafCommandTemplateRiskLabels(
242
+ config: CommandTemplateLeafConfig,
243
+ ): CommandTemplateRiskLabel[] {
244
+ const parts = splitCommandTemplate(config.template);
245
+ const command = getExecutableName(parts[0]);
246
+ const args = parts.slice(1);
247
+ const labels = new Set<CommandTemplateRiskLabel>();
248
+ if (["bash", "sh", "zsh", "fish"].includes(command)) {
249
+ labels.add("risk.shell");
250
+ if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
251
+ }
252
+ if (
253
+ ["node", "deno", "bun"].includes(command) &&
254
+ hasAnyFlag(args, ["-e", "--eval"])
255
+ ) {
256
+ labels.add("risk.eval");
257
+ }
258
+ if (
259
+ ["python", "python3", "perl", "ruby"].includes(command) &&
260
+ hasAnyFlag(args, ["-c", "-e"])
261
+ ) {
262
+ labels.add("risk.eval");
263
+ }
264
+ if (
265
+ command === "rm" &&
266
+ (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
267
+ hasRiskyPathArg(args))
268
+ ) {
269
+ labels.add("risk.destructive_fs");
270
+ }
271
+ if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
272
+ labels.add("risk.broad_fs_write");
273
+ }
274
+ if (
275
+ ["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
276
+ command,
277
+ ) ||
278
+ (command === "git" &&
279
+ hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
280
+ ["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
281
+ ) {
282
+ labels.add("risk.network");
283
+ }
284
+ if (
285
+ ["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
286
+ (command === "git" && hasAnyArg(args, ["push"])) ||
287
+ (["npm", "pnpm", "yarn"].includes(command) &&
288
+ hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
289
+ ) {
290
+ labels.add("risk.external_side_effect");
291
+ }
292
+ if (
293
+ command === "sleep" ||
294
+ command === "watch" ||
295
+ (command === "tail" && hasAnyFlag(args, ["-f"])) ||
296
+ hasAnyArg(args, ["--watch", "--serve", "serve"])
297
+ ) {
298
+ labels.add("risk.long_running");
299
+ }
300
+ if (
301
+ ["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(
302
+ command,
303
+ )
304
+ ) {
305
+ labels.add("risk.platform_specific");
306
+ }
307
+ if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
308
+ labels.add("risk.secret_touching");
309
+ }
310
+ return sortRiskLabels(labels);
311
+ }
312
+
184
313
  function getLeafCommandTemplateWarnings(
185
314
  config: CommandTemplateLeafConfig,
186
315
  ): string[] {
@@ -347,6 +476,16 @@ export function getCommandTemplateWarnings(
347
476
  ];
348
477
  }
349
478
 
479
+ export function getCommandTemplateRiskLabels(
480
+ config: CommandTemplateConfig,
481
+ ): CommandTemplateRiskLabel[] {
482
+ return sortRiskLabels(
483
+ expandCommandTemplateConfigs(config).flatMap((leaf) =>
484
+ getLeafCommandTemplateRiskLabels(leaf),
485
+ ),
486
+ );
487
+ }
488
+
350
489
  function parseCommandTemplateArgToken(value: string): {
351
490
  name: string;
352
491
  defaultValue?: string;