@llblab/pi-actors 0.35.0 → 0.36.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/AGENTS.md CHANGED
@@ -113,9 +113,11 @@ Pi host
113
113
  - `~/.pi/agent/recipes/*.json` is executable muscle memory: recipes there become persistent tools by location.
114
114
  - Preserve filename identity, atomic writes, explicit operator-gated changes, and local transportability.
115
115
  - Packaged/ad hoc recipes outside the agent root are components, not user tools.
116
+ - Register existing recipes by importing them from the user-root wrapper and using a `{ "name": "alias" }` template node; do not duplicate a ready recipe's script command, defaults, mailbox, or artifact contract in the wrapper.
117
+ - Skill-owned scripts must be exposed through skill-owned recipes first. If a local tool needs that capability, import the skill recipe via `{agent}/skills/<skill>/recipes/<recipe>.json` instead of calling `{agent}/skills/<skill>/scripts/*` directly.
116
118
  - Tool definitions use `template`, not `script`, and built-in/core tool names must not be shadowed.
117
119
  - Packaged recipe growth is demand-driven: prefer reusable components over speculative scenario catalogs.
118
- - Recipe templates may point directly at executable helper scripts; keep script executable bits and avoid unnecessary `node` prefixes.
120
+ - Recipe templates may point directly at executable helper scripts when the recipe owns that script boundary; keep script executable bits and avoid unnecessary `node` prefixes.
119
121
 
120
122
  ## Command And Recipe Layers
121
123
 
package/BACKLOG.md CHANGED
@@ -51,73 +51,9 @@ No open hotfix items.
51
51
 
52
52
  ## Minor Backlog
53
53
 
54
- The backlog is intentionally pruned to the 20% of work most likely to deliver 80% of value for `pi-actors` as a local actor kernel. Bias toward consolidation, smaller public surface area, and reliability over new feature breadth.
54
+ No open minor items.
55
55
 
56
- ### M-19 Recipe Doctor Risk Labels v2
57
-
58
- - Priority: Medium.
59
- - Status: Planned.
60
- - Goal: Evolve recipe doctor into a compact capability-risk membrane without pretending to sandbox trusted local execution.
61
- - Why now: Recipe doctor already has remediation UX; the next useful slice is deterministic advisory risk classification for local capabilities.
62
- - Direction:
63
- - Add advisory labels such as `risk.shell`, `risk.eval`, `risk.broad_fs_write`, `risk.destructive_fs`, `risk.network`, `risk.external_side_effect`, `risk.long_running`, `risk.platform_specific`, and `risk.secret_touching`.
64
- - Keep labels advisory and deterministic; do not block execution unless existing validation already blocks it.
65
- - Expose compact risk summaries in `inspect target=recipes view=doctor` and verbose per-recipe labels.
66
- - Keep launch-time warnings quiet except for already-failing or clearly dangerous cases.
67
- - Preserve honest wording: trusted local execution, not isolation.
68
- - Acceptance:
69
- - Risk labels are deterministic and tested.
70
- - Existing risky shell-boundary diagnostics remain intact.
71
- - Doctor output stays compact by default.
72
- - README/docs do not introduce sandbox or security-boundary claims.
73
-
74
- ### M-20 Runtime Triage Surface
75
-
76
- - Priority: Medium.
77
- - Status: Planned.
78
- - Goal: Add one compact operator triage view that answers what needs attention right now without performing repairs.
79
- - Why now: Runtime status, recipe doctor, drafts, stale claims, session mismatches, failed runs, and other-session counts are currently separate bounded surfaces.
80
- - Direction:
81
- - Add `inspect target=tool:pi-actors view=triage` or an equivalent existing inspect surface.
82
- - Summarize runtime version/mode, active runs, other-session runs, invalid or blocking recipes, high-risk recipes, draft recipes, stale worker claims, recent failed runs, attention messages, and suggested next inspect actions.
83
- - Keep every warning tied to a next inspect/action hint.
84
- - Do not auto-repair, auto-prune, relax ownership, or hide detailed source-of-truth views.
85
- - Acceptance:
86
- - Triage output is compact enough for agent context.
87
- - Healthy and degraded states are covered by tests.
88
- - Detailed inspect/doctor/status views remain source of truth.
89
-
90
- ### M-21 Packaged Recipe QA Matrix
91
-
92
- - Priority: Medium.
93
- - Status: Planned.
94
- - Goal: Prevent packaged recipes from drifting into inconsistent mailbox, artifact, platform, or package-root behavior.
95
- - Why now: Packaged recipes are standard-library components; they should be boringly consistent before operators copy or register them as durable local capabilities.
96
- - Direction:
97
- - Add an internal QA check over `recipes/*.json` for descriptions, async mailbox contracts, termination vocabulary, artifact declarations, platform notes, installed-package-safe helper paths, and compiled shim coverage.
98
- - Keep `control.kill` as generic runtime termination and allow `control.stop` / `control.cancel` only as actor-domain vocabulary.
99
- - Fail with exact recipe/path/key diagnostics.
100
- - Avoid a broad recipe-library rewrite beyond violations discovered by the check.
101
- - Acceptance:
102
- - QA runs under an existing validation command or a clearly named subcheck used by `npm run validate`.
103
- - Tests/fixtures cover at least one positive and one negative case.
104
- - Packaged recipes remain optional components, not policy workflows.
105
-
106
- ### M-22 Wake and Watcher Chaos Fixtures
107
-
108
- - Priority: Medium.
109
- - Status: Planned.
110
- - Goal: Harden the invariant that durable files are canonical and wake notifications are advisory acceleration.
111
- - Why now: Wake, watcher, line-counter, and JSONL resilience are central to operator trust as actor counts grow.
112
- - Direction:
113
- - Add deterministic fixtures for watcher restart, line-counter reset, duplicate terminal events, missing wake with present inbox record, wake before file catch-up, corrupt JSONL with later valid records, and killed run with stale progress phase.
114
- - Preserve event-driven observability without reintroducing polling-first coordination examples.
115
- - Keep tests fast and local.
116
- - Acceptance:
117
- - Duplicate follow-ups do not reappear.
118
- - Missing wake does not lose durable messages.
119
- - Corrupt records degrade inspect but do not kill it.
120
- - Killed/stale progress states remain diagnosable.
56
+ The backlog is intentionally pruned to the 20% of work most likely to deliver 80% of value for `pi-actors` as a local actor kernel. Bias toward consolidation, smaller public surface area, and reliability over new feature breadth.
121
57
 
122
58
  ## Explicitly Deferred
123
59
 
@@ -127,7 +63,7 @@ These are valid ideas but not current focus. Reintroduce only with concrete evid
127
63
  - Run restart/reattach policy: risky for isolation; defer until corruption recovery and protocol fixtures are stronger.
128
64
  - Cross-session force kill or attach/adopt/reparent: useful later, but ownership policy should not change until observability makes current boundaries clear.
129
65
  - Actor address helper CLI: keep diagnostics improving opportunistically inside existing parser/tests.
130
- - Golden flow docs and flow conformance runner: useful after M-14, M-15, M-17, and M-18 make the diagnostic and promotion surfaces stable.
66
+ - Golden flow docs and flow conformance runner: useful after the diagnostic and promotion surfaces are stable.
131
67
  - Documentation refactor: defer until the canonical mailbox loop and worker recipe exist; avoid rewriting docs twice.
132
68
  - Host-level tool unregistration: blocked on host API support.
133
69
  - Branch-local checkpoint semantics: wait for real collaborative branch-runner experiments.
@@ -135,8 +71,4 @@ These are valid ideas but not current focus. Reintroduce only with concrete evid
135
71
 
136
72
  ## Suggested Milestone Order
137
73
 
138
- ```text
139
- Next milestone: M-19 Recipe Doctor Risk Labels v2.
140
- Then: M-20 Runtime Recipe Triage.
141
- Small cleanup lane: continue opportunistic domain polish only when a real ownership boundary appears.
142
- ```
74
+ No current milestone. Reassess runtime evidence before adding the next reliability slice.
package/CHANGELOG.md CHANGED
@@ -2,15 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.36.0: Recipe Diagnostics And Runtime Triage
6
+
7
+ - `[Recipe Doctor]` Added deterministic advisory risk labels for discovered recipes, including shell, eval, filesystem mutation, network, external side effect, long-running, platform-specific, and secret-touching signals in verbose recipe inspection plus compact doctor risk counts.
8
+ - `[Runtime]` Added `inspect target=tool:pi-actors view=triage` as a compact read-only operator attention surface covering runtime mode, active and 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.
9
+ - `[Recipes]` Added packaged recipe QA through `validate-recipe.mjs --qa` and `npm run recipes:qa`, with exact diagnostics for async mailbox contracts, termination vocabulary, artifact paths, platform scope, installed-package-safe helper paths, and missing helper scripts.
10
+ - `[Runtime]` Hardened wake/watch chaos behavior with deterministic coverage for watcher restarts, partial wake records before file catch-up, corrupt wake JSONL with later valid records, missing wake records with durable inbox work, and killed runs with stale progress state.
11
+ - `[Docs]` Documented recipe-doctor risk labels, runtime triage, packaged recipe QA, and import-first ready-recipe registration as operator review aids and source-of-truth preservation, not sandbox, repair, or security-boundary claims.
12
+
5
13
  ## 0.35.0: Draft Recipe Promotion UX
6
14
 
7
- - `[Recipes]` Completed M-18 Draft Recipe Promotion UX: `inspect target=recipes view=summary verbose=true` now exposes draft timestamps, fingerprints, validation state, source run when known, and template previews, while `register_tool name=<tool> draft=<path>` promotes a validated draft into active recipe memory without deleting the draft and rejects collisions unless `update=true` is explicit.
15
+ - `[Recipes]` Added draft recipe promotion UX: `inspect target=recipes view=summary verbose=true` now exposes draft timestamps, fingerprints, validation state, source run when known, and template previews, while `register_tool name=<tool> draft=<path>` promotes a validated draft into active recipe memory without deleting the draft and rejects collisions unless `update=true` is explicit.
8
16
 
9
17
  ## 0.34.1: Message Delivery Outcome Hotfix
10
18
 
11
19
  - `[Dogfood]` Added a deterministic actor-worker stale-claim smoke covering an intentionally claimed branch inbox record; the worker now reports stale claim counts in both `worker-status.json` and its awaiting-assignment room event without adding auto-recovery or scheduler policy.
12
- - `[Messages]` Completed the M-17 message delivery outcome contract by normalizing public message results with `delivered`, `persisted`, `queued`, `forwarded`, `consumer`, and `reason` fields across run, branch, room, coordinator, session, and tool destinations; room multicast now also exposes per-recipient branch delivery outcomes.
13
- - `[Backlog]` Closed M-15 Worker Stale-Claim Dogfood and M-17 Message Delivery Outcome Contract after adding delivery-result coverage for run, branch, room, coordinator, session, tool, and ownership-denied paths.
20
+ - `[Messages]` Normalized public message results with `delivered`, `persisted`, `queued`, `forwarded`, `consumer`, and `reason` fields across run, branch, room, coordinator, session, and tool destinations; room multicast now also exposes per-recipient branch delivery outcomes.
21
+ - `[Backlog]` Closed the worker stale-claim dogfood and message delivery outcome work after adding delivery-result coverage for run, branch, room, coordinator, session, tool, and ownership-denied paths.
14
22
 
15
23
  ## 0.34.0: Actor Kernel Domain Compression
16
24
 
package/README.md CHANGED
@@ -132,6 +132,7 @@ docs_review scope="README.md" model="current-review-model" run_id=docs_review
132
132
  Inspect only when there is a reason:
133
133
 
134
134
  ```text
135
+ inspect target=tool:pi-actors view=triage
135
136
  inspect target=run:docs_review view=status
136
137
  inspect target=run:docs_review view=tail lines=80
137
138
  inspect target=run:docs_review view=messages
@@ -307,7 +308,7 @@ Packaged recipes should prefer mailbox/wake behavior for portable control. Recip
307
308
 
308
309
  Commands execute directly without shell evaluation where possible, but trusted executables still run with the same system permissions as Pi. Only register commands, scripts, recipes, and paths you trust.
309
310
 
310
- High-risk templates such as shells, interpreter eval modes, and broad filesystem mutation may surface warnings, but the runtime is not a security boundary.
311
+ High-risk templates such as shells, interpreter eval modes, and broad filesystem mutation may surface warnings, but the runtime is not a security boundary. Recipe doctor also exposes advisory labels like `risk.shell`, `risk.eval`, `risk.destructive_fs`, `risk.network`, and `risk.external_side_effect` for operator review.
311
312
 
312
313
  Prefer:
313
314
 
@@ -50,6 +50,7 @@ export interface CommandTemplateExecResult {
50
50
  code: number;
51
51
  killed: boolean;
52
52
  }
53
+ export type CommandTemplateRiskLabel = "risk.shell" | "risk.eval" | "risk.broad_fs_write" | "risk.destructive_fs" | "risk.network" | "risk.external_side_effect" | "risk.long_running" | "risk.platform_specific" | "risk.secret_touching";
53
54
  export type CommandTemplateExecCommand = (command: string, args: string[], options?: CommandTemplateExecOptions) => Promise<CommandTemplateExecResult>;
54
55
  export declare function normalizeCommandTemplateConfig(config: CommandTemplateConfig): CommandTemplateObjectConfig;
55
56
  export declare function resolveInheritedDefaultReferences(ownDefaults: Record<string, unknown> | undefined, inheritedDefaults: Record<string, unknown> | undefined, runtimeValues?: Record<string, unknown>): Record<string, unknown> | undefined;
@@ -58,6 +59,7 @@ export declare function isCommandTemplateRepeatPlaceholder(name: string): boolea
58
59
  export declare function getCommandTemplateRepeatDefaults(index: number, repeat: number): Record<string, string>;
59
60
  export declare function expandCommandTemplateConfigs(config: CommandTemplateConfig, inherited?: Pick<CommandTemplateObjectConfig, "args" | "defaults">): CommandTemplateLeafConfig[];
60
61
  export declare function getCommandTemplateWarnings(config: CommandTemplateConfig): string[];
62
+ export declare function getCommandTemplateRiskLabels(config: CommandTemplateConfig): CommandTemplateRiskLabel[];
61
63
  export declare function getCommandTemplateDefaults(config: CommandTemplateConfig | undefined): Record<string, string>;
62
64
  export declare function splitCommandTemplate(input: string): string[];
63
65
  export declare function expandCommandTemplateExecutable(command: string, cwd: string): string;
@@ -6,6 +6,17 @@
6
6
  import { spawn } from "node:child_process";
7
7
  import { homedir } from "node:os";
8
8
  import { isAbsolute, resolve } from "node:path";
9
+ const COMMAND_TEMPLATE_RISK_LABEL_ORDER = [
10
+ "risk.shell",
11
+ "risk.eval",
12
+ "risk.destructive_fs",
13
+ "risk.broad_fs_write",
14
+ "risk.external_side_effect",
15
+ "risk.secret_touching",
16
+ "risk.network",
17
+ "risk.long_running",
18
+ "risk.platform_specific",
19
+ ];
9
20
  function normalizeCommandTemplateArgs(value) {
10
21
  if (!Array.isArray(value))
11
22
  return [];
@@ -93,6 +104,68 @@ function hasRiskyPathArg(args) {
93
104
  arg.startsWith("~/") ||
94
105
  arg.startsWith("/"));
95
106
  }
107
+ function sortRiskLabels(labels) {
108
+ const unique = new Set(labels);
109
+ return COMMAND_TEMPLATE_RISK_LABEL_ORDER.filter((label) => unique.has(label));
110
+ }
111
+ function hasAnyArg(args, values) {
112
+ return args.some((arg) => values.includes(arg.toLowerCase()));
113
+ }
114
+ function hasSecretTouchingText(parts) {
115
+ return parts.some((part) => /(^|[{}._\-\s/])(?:secret|token|password|passwd|credential|api[_-]?key|private[_-]?key|\.env|ssh[_-]?key)(?:[{}._\-\s/]|$)/i.test(part));
116
+ }
117
+ function getLeafCommandTemplateRiskLabels(config) {
118
+ const parts = splitCommandTemplate(config.template);
119
+ const command = getExecutableName(parts[0]);
120
+ const args = parts.slice(1);
121
+ const labels = new Set();
122
+ if (["bash", "sh", "zsh", "fish"].includes(command)) {
123
+ labels.add("risk.shell");
124
+ if (hasAnyFlag(args, ["-c"]))
125
+ labels.add("risk.eval");
126
+ }
127
+ if (["node", "deno", "bun"].includes(command) &&
128
+ hasAnyFlag(args, ["-e", "--eval"])) {
129
+ labels.add("risk.eval");
130
+ }
131
+ if (["python", "python3", "perl", "ruby"].includes(command) &&
132
+ hasAnyFlag(args, ["-c", "-e"])) {
133
+ labels.add("risk.eval");
134
+ }
135
+ if (command === "rm" &&
136
+ (args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
137
+ hasRiskyPathArg(args))) {
138
+ labels.add("risk.destructive_fs");
139
+ }
140
+ if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
141
+ labels.add("risk.broad_fs_write");
142
+ }
143
+ if (["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(command) ||
144
+ (command === "git" &&
145
+ hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
146
+ ["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)) {
147
+ labels.add("risk.network");
148
+ }
149
+ if (["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
150
+ (command === "git" && hasAnyArg(args, ["push"])) ||
151
+ (["npm", "pnpm", "yarn"].includes(command) &&
152
+ hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))) {
153
+ labels.add("risk.external_side_effect");
154
+ }
155
+ if (command === "sleep" ||
156
+ command === "watch" ||
157
+ (command === "tail" && hasAnyFlag(args, ["-f"])) ||
158
+ hasAnyArg(args, ["--watch", "--serve", "serve"])) {
159
+ labels.add("risk.long_running");
160
+ }
161
+ if (["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(command)) {
162
+ labels.add("risk.platform_specific");
163
+ }
164
+ if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
165
+ labels.add("risk.secret_touching");
166
+ }
167
+ return sortRiskLabels(labels);
168
+ }
96
169
  function getLeafCommandTemplateWarnings(config) {
97
170
  const parts = splitCommandTemplate(config.template);
98
171
  const command = getExecutableName(parts[0]);
@@ -206,6 +279,9 @@ export function getCommandTemplateWarnings(config) {
206
279
  ...new Set(expandCommandTemplateConfigs(config).flatMap((leaf) => getLeafCommandTemplateWarnings(leaf))),
207
280
  ];
208
281
  }
282
+ export function getCommandTemplateRiskLabels(config) {
283
+ return sortRiskLabels(expandCommandTemplateConfigs(config).flatMap((leaf) => getLeafCommandTemplateRiskLabels(leaf)));
284
+ }
209
285
  function parseCommandTemplateArgToken(value) {
210
286
  const separatorIndex = value.indexOf("=");
211
287
  const rawName = separatorIndex === -1 ? value : value.slice(0, separatorIndex);
@@ -3,6 +3,7 @@
3
3
  * Zones: recipe discovery, tool exposure, registry diagnostics
4
4
  * Owns filename identity discovery across prioritized recipe roots
5
5
  */
6
+ import * as CommandTemplates from "./command-templates.ts";
6
7
  import type { RegisteredTool } from "./config.ts";
7
8
  import type { TemplateRecipeConfig } from "./recipes-references.ts";
8
9
  export interface DiscoveredRecipe {
@@ -18,6 +19,7 @@ export interface DiscoveredRecipe {
18
19
  tool: boolean;
19
20
  mutableUsage: boolean;
20
21
  diagnostics: string[];
22
+ riskLabels: CommandTemplates.CommandTemplateRiskLabel[];
21
23
  shadows: string[];
22
24
  }
23
25
  export interface RecipeIntegrityManifestEntry {
@@ -45,15 +45,35 @@ function listRecipeFiles(root) {
45
45
  .localeCompare(b.replace(/\.md$/, ".json")) ||
46
46
  (a.endsWith(".json") ? -1 : 1));
47
47
  }
48
+ function getRecipeCommandTemplateConfig(config) {
49
+ return (typeof config.template === "object" && config.template !== null
50
+ ? config.template
51
+ : config);
52
+ }
48
53
  function getRecipeConfigDiagnostics(file, config) {
49
54
  if (!config) {
50
55
  const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(file);
51
56
  return [`Invalid recipe: ${file}${reason ? `: ${reason}` : ""}`];
52
57
  }
53
- const commandTemplateConfig = typeof config.template === "object" && config.template !== null
54
- ? config.template
55
- : config;
56
- return CommandTemplates.getCommandTemplateWarnings(commandTemplateConfig).map((warning) => `Recipe ${file}: ${warning}`);
58
+ return CommandTemplates.getCommandTemplateWarnings(getRecipeCommandTemplateConfig(config)).map((warning) => `Recipe ${file}: ${warning}`);
59
+ }
60
+ function getRecipeRiskLabels(config) {
61
+ if (!config)
62
+ return [];
63
+ const labels = new Set(CommandTemplates.getCommandTemplateRiskLabels(getRecipeCommandTemplateConfig(config)));
64
+ if (config.async === true)
65
+ labels.add("risk.long_running");
66
+ return [
67
+ "risk.shell",
68
+ "risk.eval",
69
+ "risk.destructive_fs",
70
+ "risk.broad_fs_write",
71
+ "risk.external_side_effect",
72
+ "risk.secret_touching",
73
+ "risk.network",
74
+ "risk.long_running",
75
+ "risk.platform_specific",
76
+ ].filter((label) => labels.has(label));
57
77
  }
58
78
  function readDiscoveredRecipe(root, file, priority, defaultTool = false, mutableUsage = false) {
59
79
  const id = RecipesReferences.getRecipeIdFromPath(file);
@@ -74,6 +94,7 @@ function readDiscoveredRecipe(root, file, priority, defaultTool = false, mutable
74
94
  tool: defaultTool && !disabled && !invalid,
75
95
  mutableUsage,
76
96
  diagnostics: getRecipeConfigDiagnostics(file, config),
97
+ riskLabels: getRecipeRiskLabels(config),
77
98
  shadows: [],
78
99
  };
79
100
  }
@@ -92,6 +113,7 @@ function readDiscoveredRecipe(root, file, priority, defaultTool = false, mutable
92
113
  diagnostics: [
93
114
  `Failed to load recipe ${file}: ${error instanceof Error ? error.message : String(error)}`,
94
115
  ],
116
+ riskLabels: [],
95
117
  shadows: [],
96
118
  };
97
119
  }
@@ -266,7 +288,7 @@ function diagnosticSeverity(message) {
266
288
  if (/invalid|failed to load|not found|cyclic|exceeds|must define|repeat must/i.test(message)) {
267
289
  return "error";
268
290
  }
269
- if (/world-writable|group-writable|invokes bash|eval|destructive|unsafe/i.test(message)) {
291
+ if (/world-writable|group-writable|invokes bash|eval|destructive|broad filesystem|unsafe/i.test(message)) {
270
292
  return "warning";
271
293
  }
272
294
  return "info";
@@ -304,6 +326,7 @@ function diagnosticDetails(result) {
304
326
  seen.add(key);
305
327
  details.push({
306
328
  ...(entry ? { id: entry.id, path: entry.path } : {}),
329
+ ...(entry?.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
307
330
  action: diagnosticSuggestedAction(message),
308
331
  message,
309
332
  severity: diagnosticSeverity(message),
@@ -357,6 +380,7 @@ function remediationForEntry(entry, activePath) {
357
380
  kind: "risky_shell_boundary",
358
381
  severity: "warning",
359
382
  path: entry.path,
383
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
360
384
  reason: riskyDiagnostics[0],
361
385
  action: "audit trusted command boundary; keep only if the recipe is local and intentional",
362
386
  };
@@ -400,6 +424,25 @@ function discoveryRemediations(result) {
400
424
  String(a.id).localeCompare(String(b.id)) ||
401
425
  String(a.path).localeCompare(String(b.path)));
402
426
  }
427
+ function summarizeRiskLabels(entries) {
428
+ const counts = new Map();
429
+ for (const entry of entries) {
430
+ for (const label of entry.riskLabels) {
431
+ const current = counts.get(label) ?? { count: 0, ids: new Set() };
432
+ current.count += 1;
433
+ current.ids.add(entry.id);
434
+ counts.set(label, current);
435
+ }
436
+ }
437
+ return [...counts.entries()]
438
+ .map(([label, value]) => ({
439
+ label,
440
+ count: value.count,
441
+ recipes: [...value.ids].sort(),
442
+ }))
443
+ .sort((a, b) => Number(b.count) - Number(a.count) ||
444
+ String(a.label).localeCompare(String(b.label)));
445
+ }
403
446
  function recommendationForEntry(entry, activePath) {
404
447
  const recommendation = cleanupRecommendation(entry);
405
448
  if (!recommendation)
@@ -445,6 +488,7 @@ export function listDraftRecipes(root) {
445
488
  const diagnostics = getRecipeConfigDiagnostics(path, resolved);
446
489
  const sourceRun = String(config?.description ?? "").match(/spawn run ([^\s]+)/)?.[1];
447
490
  const preview = templatePreview(config?.template);
491
+ const riskLabels = getRecipeRiskLabels(resolved);
448
492
  return {
449
493
  id,
450
494
  path,
@@ -454,6 +498,7 @@ export function listDraftRecipes(root) {
454
498
  modified_at: stat.mtime.toISOString(),
455
499
  valid: Boolean(resolved),
456
500
  diagnostics,
501
+ ...(riskLabels.length ? { risk_labels: riskLabels } : {}),
457
502
  ...(config?.description ? { description: config.description } : {}),
458
503
  ...(sourceRun ? { source_run: sourceRun } : {}),
459
504
  ...(config?.async !== undefined ? { async: config.async } : {}),
@@ -478,6 +523,7 @@ export function summarizeDiscovery(result) {
478
523
  disabled: entry.disabled,
479
524
  invalid: entry.invalid,
480
525
  shadows: entry.shadows,
526
+ ...(entry.riskLabels.length ? { risk_labels: entry.riskLabels } : {}),
481
527
  ...(entry.config?.imports ? { imports: entry.config.imports } : {}),
482
528
  ...(recipeUsage(entry.config)
483
529
  ? { usage: recipeUsage(entry.config) }
@@ -504,6 +550,7 @@ export function summarizeDiscovery(result) {
504
550
  .filter((entry) => entry.disabled)
505
551
  .map((entry) => ({ id: entry.id, path: entry.path }))
506
552
  .sort((a, b) => a.id.localeCompare(b.id)),
553
+ risk_summary: summarizeRiskLabels(result.entries),
507
554
  recommendations,
508
555
  remediations,
509
556
  top_action: remediations[0],
@@ -74,6 +74,7 @@ export function createFileRuntimeNotifier(stateDir, options = {}) {
74
74
  subscribe: (actor, onWake, subscribeOptions = {}) => {
75
75
  mkdirSync(dirname(file), { recursive: true });
76
76
  let position = options.replay || !existsSync(file) ? 0 : statSync(file).size;
77
+ let pending = "";
77
78
  let closed = false;
78
79
  const reconcile = (reason) => {
79
80
  if (closed)
@@ -89,11 +90,15 @@ export function createFileRuntimeNotifier(stateDir, options = {}) {
89
90
  if (closed || !existsSync(file))
90
91
  return;
91
92
  const buffer = readFileSync(file);
92
- if (position > buffer.length)
93
+ if (position > buffer.length) {
93
94
  position = 0;
94
- const chunk = buffer.subarray(position).toString("utf8");
95
+ pending = "";
96
+ }
97
+ const chunk = pending + buffer.subarray(position).toString("utf8");
95
98
  position = buffer.length;
96
- for (const line of chunk.split("\n")) {
99
+ const lines = chunk.split("\n");
100
+ pending = chunk.endsWith("\n") ? "" : (lines.pop() ?? "");
101
+ for (const line of lines) {
97
102
  if (!line.trim())
98
103
  continue;
99
104
  const event = parseRuntimeWakeEventLine(line);
@@ -212,6 +212,170 @@ function getPiActorsRuntimeStatus() {
212
212
  function compactPiActorsRuntimeStatus(status) {
213
213
  return `\npi-actors version=${String(status.version)} mode=${String(status.mode)} path=${String(status.package_root)} entrypoint=${String(status.entrypoint)}${status.git_commit ? ` git=${String(status.git_commit)}` : ""}`;
214
214
  }
215
+ function isStaleClaim(message, now) {
216
+ if (message.status !== "claimed")
217
+ return false;
218
+ const claimedAt = Date.parse(String(message.claimed_at ?? ""));
219
+ return Number.isFinite(claimedAt) && now - claimedAt > 5 * 60 * 1000;
220
+ }
221
+ function getRunTriageSignals(runs) {
222
+ const now = Date.now();
223
+ const staleClaims = [];
224
+ const attentionMessages = [];
225
+ for (const run of runs) {
226
+ const stateDir = String(run.state_dir ?? "");
227
+ const runId = String(run.run ?? "");
228
+ if (!stateDir)
229
+ continue;
230
+ try {
231
+ for (const message of AsyncRuns.readRunInboxMessages(stateDir, 200)) {
232
+ if (!isStaleClaim(message, now))
233
+ continue;
234
+ staleClaims.push({
235
+ run: runId,
236
+ id: message.id,
237
+ claimed_at: message.claimed_at,
238
+ claimed_by: message.claimed_by,
239
+ type: message.type,
240
+ });
241
+ }
242
+ }
243
+ catch { }
244
+ try {
245
+ for (const event of AsyncRuns.readRunEvents(stateDir, 80)) {
246
+ if (event.metadata?.requires_response !== true)
247
+ continue;
248
+ attentionMessages.push({
249
+ run: runId,
250
+ id: event.id,
251
+ summary: event.summary,
252
+ type: event.type ?? event.event,
253
+ });
254
+ }
255
+ }
256
+ catch { }
257
+ }
258
+ return { attention_messages: attentionMessages, stale_claims: staleClaims };
259
+ }
260
+ function isTriageHighRiskRecipe(recipe) {
261
+ if (recipe.tool !== true)
262
+ return false;
263
+ const labels = Array.isArray(recipe.risk_labels)
264
+ ? recipe.risk_labels.map((label) => String(label))
265
+ : [];
266
+ return labels.some((label) => label !== "risk.long_running");
267
+ }
268
+ function getPiActorsTriage(ctx, deps) {
269
+ const runtime = getPiActorsRuntimeStatus();
270
+ const currentSession = ToolsAccess.getContextSessionId(ctx);
271
+ const allRuns = AsyncRuns.listRuns().map((run) => AsyncRuns.getRunStatus(String(run.state_dir)));
272
+ const visibleRuns = currentSession
273
+ ? allRuns.filter((run) => !run.ownerId || run.ownerId === currentSession)
274
+ : allRuns;
275
+ const activeRuns = visibleRuns.filter((run) => run.status === "running");
276
+ const failedRuns = visibleRuns.filter((run) => run.status === "failed");
277
+ const otherRuns = currentSession
278
+ ? allRuns.filter((run) => run.ownerId && run.ownerId !== currentSession)
279
+ : [];
280
+ const recipeRoot = deps.recipeRoot ?? Paths.getRecipeRoot();
281
+ const discovered = RecipesDiscovery.discoverRecipeSources([
282
+ { root: recipeRoot, defaultTool: true, mutableUsage: true },
283
+ { root: deps.packagedRecipeRoot ?? Paths.getPackagedRecipeRoot() },
284
+ ]);
285
+ const recipeSummary = {
286
+ ...RecipesDiscovery.summarizeDiscovery(discovered),
287
+ drafts: RecipesDiscovery.listDraftRecipes(join(recipeRoot, "drafts")),
288
+ };
289
+ const activeRecipes = Array.isArray(recipeSummary.active)
290
+ ? recipeSummary.active
291
+ : [];
292
+ const highRiskRecipes = activeRecipes.filter(isTriageHighRiskRecipe);
293
+ const signals = getRunTriageSignals(visibleRuns);
294
+ const attentionMessages = signals.attention_messages;
295
+ const staleClaims = signals.stale_claims;
296
+ const invalidRecipes = Array.isArray(recipeSummary.invalid)
297
+ ? recipeSummary.invalid
298
+ : [];
299
+ const remediations = Array.isArray(recipeSummary.remediations)
300
+ ? recipeSummary.remediations
301
+ : [];
302
+ const drafts = Array.isArray(recipeSummary.drafts)
303
+ ? recipeSummary.drafts
304
+ : [];
305
+ const nextActions = [
306
+ invalidRecipes.length || remediations.length
307
+ ? "inspect target=recipes view=doctor"
308
+ : "",
309
+ drafts.length ? "inspect target=recipes view=summary verbose=true" : "",
310
+ failedRuns[0]?.run
311
+ ? `inspect target=run:${String(failedRuns[0].run)} view=tail lines=80`
312
+ : "",
313
+ attentionMessages[0]?.run
314
+ ? `inspect target=run:${String(attentionMessages[0].run)} view=messages`
315
+ : "",
316
+ activeRuns.length
317
+ ? "inspect target=session:all view=runs status=active"
318
+ : "",
319
+ ].filter(Boolean);
320
+ return {
321
+ runtime,
322
+ current_session: currentSession ?? null,
323
+ active_runs: activeRuns.map((run) => ({
324
+ run: run.run,
325
+ ownerId: run.ownerId,
326
+ recipe: run.recipe,
327
+ status: run.status,
328
+ })),
329
+ other_session_runs: otherRuns.length,
330
+ invalid_recipes: invalidRecipes,
331
+ blocking_recipes: remediations.filter((item) => String(item.kind ?? "").startsWith("blocking_")),
332
+ high_risk_recipes: highRiskRecipes.map((recipe) => ({
333
+ id: recipe.id,
334
+ path: recipe.path,
335
+ risk_labels: recipe.risk_labels,
336
+ })),
337
+ draft_recipes: drafts,
338
+ stale_claims: staleClaims,
339
+ recent_failed_runs: failedRuns.slice(0, 5).map((run) => ({
340
+ run: run.run,
341
+ recipe: run.recipe,
342
+ status: run.status,
343
+ })),
344
+ attention_messages: attentionMessages.slice(-10),
345
+ next_actions: [...new Set(nextActions)].slice(0, 5),
346
+ };
347
+ }
348
+ function compactPiActorsTriage(summary) {
349
+ const runtime = asRecord(summary.runtime);
350
+ const activeRuns = Array.isArray(summary.active_runs)
351
+ ? summary.active_runs.length
352
+ : 0;
353
+ const invalidRecipes = Array.isArray(summary.invalid_recipes)
354
+ ? summary.invalid_recipes.length
355
+ : 0;
356
+ const blockingRecipes = Array.isArray(summary.blocking_recipes)
357
+ ? summary.blocking_recipes.length
358
+ : 0;
359
+ const highRiskRecipes = Array.isArray(summary.high_risk_recipes)
360
+ ? summary.high_risk_recipes.length
361
+ : 0;
362
+ const drafts = Array.isArray(summary.draft_recipes)
363
+ ? summary.draft_recipes.length
364
+ : 0;
365
+ const staleClaims = Array.isArray(summary.stale_claims)
366
+ ? summary.stale_claims.length
367
+ : 0;
368
+ const failedRuns = Array.isArray(summary.recent_failed_runs)
369
+ ? summary.recent_failed_runs.length
370
+ : 0;
371
+ const attention = Array.isArray(summary.attention_messages)
372
+ ? summary.attention_messages.length
373
+ : 0;
374
+ const nextActions = Array.isArray(summary.next_actions)
375
+ ? summary.next_actions
376
+ : [];
377
+ return `\ntriage version=${String(runtime.version ?? "unknown")} mode=${String(runtime.mode ?? "unknown")} active_runs=${activeRuns} other_runs=${String(summary.other_session_runs ?? 0)} invalid_recipes=${invalidRecipes} blocking_recipes=${blockingRecipes} high_risk_recipes=${highRiskRecipes} drafts=${drafts} stale_claims=${staleClaims} failed_runs=${failedRuns} attention=${attention}${ToolsResponse.compactNextActions(nextActions)}`;
378
+ }
215
379
  function compactToolActor(name, tool) {
216
380
  const parameters = asRecord(tool.parameters);
217
381
  const required = Array.isArray(parameters.required)
@@ -314,15 +478,19 @@ export function createInspectToolDefinition(deps = {}) {
314
478
  }
315
479
  if (address.kind === "tool" && address.value) {
316
480
  if (address.value === "pi-actors") {
317
- if (view !== "status") {
318
- throw new Error("inspect tool:pi-actors supports view=status.");
481
+ if (view !== "status" && view !== "triage") {
482
+ throw new Error("inspect tool:pi-actors supports view=status or view=triage.");
319
483
  }
320
- const details = getPiActorsRuntimeStatus();
484
+ const details = view === "triage"
485
+ ? getPiActorsTriage(ctx, deps)
486
+ : getPiActorsRuntimeStatus();
321
487
  return {
322
488
  content: [
323
489
  {
324
490
  type: "text",
325
- text: maybeJsonText(details, input.verbose === true, compactPiActorsRuntimeStatus(details)),
491
+ text: maybeJsonText(details, input.verbose === true, view === "triage"
492
+ ? compactPiActorsTriage(details)
493
+ : compactPiActorsRuntimeStatus(details)),
326
494
  },
327
495
  ],
328
496
  details,