@llblab/pi-actors 0.34.0 → 0.35.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
@@ -53,55 +53,6 @@ No open hotfix items.
53
53
 
54
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.
55
55
 
56
- ### M-15 Worker Stale-Claim Dogfood
57
-
58
- - Priority: Medium.
59
- - Status: Planned.
60
- - Goal: Validate and harden actor-worker v2 stale-claim visibility under intentionally stale claimed branch messages.
61
- - Why now: M-09 exposed `stale_claims`; real dogfood should verify the operator can diagnose stuck claimed work before adding recovery policy.
62
- - Direction:
63
- - Create deterministic stale claimed branch inbox fixtures or smoke tests.
64
- - Verify `worker-status.json`, room events, and inspect surfaces make stale claims visible.
65
- - Defer auto-recovery unless workflow evidence proves it is safe.
66
- - Acceptance:
67
- - Stale claims are reproducible and visible in worker status.
68
- - Tests cover stale-claim counting without adding scheduler/broker policy.
69
-
70
- ### M-17 Message Delivery Outcome Contract
71
-
72
- - Priority: High.
73
- - Status: Planned.
74
- - Goal: Normalize `message` results so operators can distinguish delivered, queued, persisted, forwarded, unsupported, and ownership-denied outcomes.
75
- - Why now: Branch message UX already treats durable branch mailbox persistence as a successful queued outcome when a parent endpoint is unavailable; that local fix should become a consistent message-result membrane.
76
- - Direction:
77
- - Define compact delivery fields: `queued`, `delivered`, `persisted`, `forwarded`, `consumer`, `reason`, and `hint`.
78
- - Apply the shape to `run:<id>`, `branch:<run>/<branch>`, `room:<run>`, `coordinator`, `session:`, and `tool:<name>` where meaningful.
79
- - Reuse M-14 session mismatch shape for ownership-denied outcomes.
80
- - Do not claim guaranteed live consumption unless a known consumer exists.
81
- - Do not add a broker, distributed delivery semantics, or a new public noun.
82
- - Acceptance:
83
- - Branch messages clearly report queued/persisted state and known worker-consumer state where available.
84
- - Room messages distinguish timeline append success from forwarded branch-targeted copies.
85
- - Tests cover at least run, branch, room, coordinator, and ownership-denied outcomes.
86
-
87
- ### M-18 Draft Recipe Promotion UX
88
-
89
- - Priority: High.
90
- - Status: Planned.
91
- - Goal: Make successful ad hoc actor patterns easy to promote manually from draft memory into active user recipe memory.
92
- - Why now: Draft recipes under `~/.pi/agent/recipes/drafts` are replayable but intentionally not active tools, and the two-stage memory model needs an explicit operator-gated promotion path.
93
- - Direction:
94
- - List draft recipes with source run, timestamp, fingerprint, description/template preview, and validation status.
95
- - Promote a selected draft to `~/.pi/agent/recipes/<name>.json` only through an explicit action or explicit tool argument.
96
- - Run recipe validation/doctor before writing and expose collision/shadowing diagnostics.
97
- - Preserve draft files unless deletion is explicitly requested.
98
- - Prefer extending existing registry/tool surfaces over adding a new public noun.
99
- - Acceptance:
100
- - Draft recipes remain non-tools until promotion.
101
- - Promotion writes atomically and never auto-promotes.
102
- - Tests cover valid promotion, invalid draft, name collision, and packaged-recipe shadowing.
103
- - Docs explain draft memory vs active tool memory in one compact section.
104
-
105
56
  ### M-19 Recipe Doctor Risk Labels v2
106
57
 
107
58
  - Priority: Medium.
@@ -185,7 +136,7 @@ These are valid ideas but not current focus. Reintroduce only with concrete evid
185
136
  ## Suggested Milestone Order
186
137
 
187
138
  ```text
188
- Next milestone: M-15 Worker Stale-Claim Dogfood.
189
- Then: M-17 Message Delivery Outcome Contract → M-18 Draft Recipe Promotion UX.
139
+ Next milestone: M-19 Recipe Doctor Risk Labels v2.
140
+ Then: M-20 Runtime Recipe Triage.
190
141
  Small cleanup lane: continue opportunistic domain polish only when a real ownership boundary appears.
191
142
  ```
package/CHANGELOG.md CHANGED
@@ -1,27 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.35.0: Draft Recipe Promotion UX
6
+
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.
8
+
9
+ ## 0.34.1: Message Delivery Outcome Hotfix
10
+
11
+ - `[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.
14
+
3
15
  ## 0.34.0: Actor Kernel Domain Compression
4
16
 
5
- - `[Context]` Added a critical tools-domain split track and durable naming guidance: split broad buckets proactively, use `tool-<verb>.ts` for independent public agent-tool domains when deleting `tools.ts`, reserve `tools-<part>.ts` only for a retained `tools.ts` aggregate-root architecture, keep non-tool domains unprefixed, and avoid new `utils` or compatibility barrels when direct owned-domain imports work.
6
- - `[Domains]` Started the `tools.ts` split by moving JSON schema builders into `schema.ts` and compact response helpers into the tool-family response path, reducing the remaining tool bucket to public tool composition rather than generic schema ownership.
7
- - `[Domains]` Moved the public `message` actor tool behavior into `tools-message.ts`, including run controls, branch/room delivery, tool actor invocation, and compact delivery next actions.
8
- - `[Domains]` Moved shared session ownership guards and normalized mismatch diagnostics into `tools-access.ts` so public tools can share one access contract without copying ownership checks.
9
- - `[Domains]` Moved mailbox contract normalization and accepted/emitted type extraction into `tools-mailbox.ts`, removing duplicated mailbox metadata handling from message and inspect tool paths.
10
- - `[Domains]` Added `tools-response.ts` for compact public tool responses and next-action rendering shared by spawn, inspect, and registered tool paths; moved recipe registry compact summaries and doctor/import next-action rendering into it.
11
- - `[Domains]` Moved the public `inspect` actor tool behavior into `tools-inspect.ts`, including recipe registry inspection, room/session/tool/run views, and inspect-specific compact observation formatting.
12
- - `[Domains]` Moved the public `spawn` actor tool behavior into `tools-spawn.ts`, including actor launch, draft recipe capture, shadowed recipe launch diagnostics, and spawn next-action feedback.
13
- - `[Domains]` Removed the over-thin `tool-context.ts` extraction and kept tool execution context types local to the domains that use them.
14
- - `[Domains]` Moved saved local capability execution into `tools-local.ts`, including generated schemas, argument usage hints, runtime value normalization, and async recipe launch behavior.
15
- - `[Domains]` Moved public `register_tool` behavior into `tools-register.ts`, including the register schema and registry mutation bridge for persisted local capabilities.
16
- - `[Domains]` Kept `tools.ts` as the public tool-family composition owner, moved decomposed behavior into `tools-*` subdomains, removed the redundant `actor-tools.ts` prefix because this package is already actor-scoped, and documented that any `tools-*` helper reused by non-tools domains must drop the prefix in the same slice.
17
- - `[Domains]` Removed redundant internal `actor-` prefixes from core library domains: `inspector.ts`, `messages.ts`, `recipes-context.ts`, and `rooms.ts`; public recipe/script/docs names that intentionally expose actor terminology stay unchanged.
18
- - `[Scripts]` Collapsed the one-off `run-executor.ts`, `worker.ts`, `validate-recipe.ts`, `recipe-utils.ts`, `locker.ts`, and `coordinator.ts` domains into their owning scripts; reusable lifecycle, room, mailbox, and recipe-reference primitives stay in `lib/`, while public scripts own their executable control loops directly.
19
- - `[Domains]` Started decomposing the oversized `async-runs.ts` lifecycle aggregate into `runs-*` subdomains, moving artifact manifest handling to `runs-artifacts.ts`, durable run inbox locking/claim handling to `runs-mailbox.ts`, process-control signal helpers to `runs-control.ts`, outbox event parsing/payload formatting to `runs-outbox.ts`, run-state index discovery/rebuild logic to `runs-index.ts`, id normalization to `runs-identity.ts`, archive/prune retention behavior to `runs-retention.ts`, process identity/liveness checks to `runs-process.ts`, run message delivery to `runs-messages.ts`, status/log derivation to `runs-status.ts`, and start lock/reuse guards to `runs-start.ts` while preserving the `async-runs.ts` facade.
20
- - `[Context]` Reversed the thin-script default: `scripts/*.mjs` should own script-only executable behavior, and behavior should move into `lib/` only for real non-script reuse or existing reusable domain ownership; packaging/tests/shim neatness alone no longer justify a lib domain.
21
- - `[Context]` Polished domain ownership headers after the file moves so command templates, actor messages, rooms, recipe context, inspector previews, and mailbox loops describe their actual reasons to change without generic helper wording.
22
- - `[Domains]` Renamed generic `output.ts` to `execution-output.ts` so registered-tool stdout/stderr truncation and temp artifact formatting are tied to the execution domain instead of a broad output bucket.
23
- - `[Domains]` Renamed the recipe domain family from singular `recipe-*` to plural `recipes-*` (`recipes-references.ts`, `recipes-discovery.ts`, `recipes-usage.ts`, `recipes-context.ts`) to match the `tools-*` and `runs-*` family convention.
24
- - `[Tests]` Extended installed-package contract coverage to assert stale renamed lib domains are absent from `dist/lib`, keeping packaged JS output aligned with the current domain names.
17
+ - `[Domains]` Compressed the actor kernel into explicit domain families: `tools.ts` remains the public tool-family owner while `tools-*` owns message, inspect, spawn, register, local execution, response, access, and mailbox-contract behavior; `async-runs.ts` remains the lifecycle facade while `runs-*` owns artifacts, mailbox, process control, delivery, outbox, index, retention, status, start guards, and identity internals.
18
+ - `[Domains]` Removed redundant internal `actor-` prefixes and renamed the recipe family to plural `recipes-*`, leaving public actor-named recipe/script/docs surfaces intact while making core library ownership match the `tools-*` and `runs-*` convention.
19
+ - `[Scripts]` Collapsed script-only runner, worker, validator, recipe-utils, locker, and coordinator library shims back into their owning `scripts/*.mjs` entrypoints; reusable lifecycle, room, mailbox-loop, command-template, and recipe-reference primitives remain in `lib/`.
20
+ - `[Context]` Reversed the thin-script default, polished ownership headers, and kept completed script-autonomy/domain-compression work in the changelog instead of the backlog.
21
+ - `[Tests]` Mirrored renamed domains in test filenames and extended installed-package contract coverage to ensure stale renamed lib domains are absent from `dist/lib` while packaged JS-only script execution still works.
25
22
 
26
23
  ## 0.33.0: Signal-First Compatibility Pruning
27
24
 
package/README.md CHANGED
@@ -207,7 +207,8 @@ Rules:
207
207
  - User recipes override same-name lower-priority recipes;
208
208
  - Same-id JSON recipes shadow Markdown recipes in the same priority layer;
209
209
  - Packaged recipes are standard-library components, not automatically installed operator policy;
210
- - `register_tool` creates, updates, lists, or deletes user recipe files through the normal agent interface.
210
+ - Draft recipes in `~/.pi/agent/recipes/drafts/` are replayable memory, not active tools;
211
+ - `register_tool` creates, updates, lists, deletes, or explicitly promotes draft recipe files through the normal agent interface.
211
212
 
212
213
  Example foreground tool:
213
214
 
@@ -226,6 +227,14 @@ register_tool name=docs_review \
226
227
  args="scope:path,model:string"
227
228
  ```
228
229
 
230
+ Promote a successful captured draft only after an explicit operator decision:
231
+
232
+ ```text
233
+ register_tool name=docs_review draft=~/.pi/agent/recipes/drafts/spawned-run.json
234
+ ```
235
+
236
+ Promotion validates the draft, writes `~/.pi/agent/recipes/<name>.json`, preserves the draft, and rejects collisions unless `update=true` is supplied.
237
+
229
238
  Inspect the discovered registry:
230
239
 
231
240
  ```text
@@ -10,6 +10,7 @@ export declare const ONBOARDING_SYSTEM_PROMPT = "pi-actors quick model:\n- Local
10
10
  export declare const REGISTER_TOOL_PARAM_DESCRIPTIONS: {
11
11
  readonly name: "Tool name in snake_case (e.g., 'transcribe')";
12
12
  readonly description: "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.";
13
+ readonly draft: "Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.";
13
14
  readonly async: "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.";
14
15
  readonly state_dir: "Optional async run state directory for a co-located template recipe.";
15
16
  readonly template: "Command template with {arg} or {arg=default} placeholders, or a template recipe JSON path/name. With async, this is the co-located recipe body. Bare recipe names resolve under ~/.pi/agent/recipes. Omitted updates keep the old template. Empty string deletes the tool.";
@@ -30,6 +30,7 @@ export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
30
30
  export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
31
31
  name: "Tool name in snake_case (e.g., 'transcribe')",
32
32
  description: "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.",
33
+ draft: "Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.",
33
34
  async: "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
34
35
  state_dir: "Optional async run state directory for a co-located template recipe.",
35
36
  template: "Command template with {arg} or {arg=default} placeholders, or a template recipe JSON path/name. With async, this is the co-located recipe body. Bare recipe names resolve under ~/.pi/agent/recipes. Omitted updates keep the old template. Empty string deletes the tool.",
@@ -425,14 +425,39 @@ export function getShadowedLaunchDiagnostic(result, id) {
425
425
  reason: active.invalid ? "shadowed_invalid" : "shadowed_disabled",
426
426
  };
427
427
  }
428
+ function templatePreview(value) {
429
+ const rendered = typeof value === "string"
430
+ ? value
431
+ : value === undefined
432
+ ? undefined
433
+ : JSON.stringify(value);
434
+ return rendered && rendered.length > 120
435
+ ? `${rendered.slice(0, 117)}...`
436
+ : rendered;
437
+ }
428
438
  export function listDraftRecipes(root) {
429
439
  return listRecipeFiles(root).map((path) => {
430
440
  const id = RecipesReferences.getRecipeIdFromPath(path);
441
+ const bytes = readFileSync(path);
442
+ const stat = statSync(path);
431
443
  const config = RecipesReferences.readRawRecipeConfig(path);
444
+ const resolved = RecipesReferences.readResolvedRecipeConfig(path);
445
+ const diagnostics = getRecipeConfigDiagnostics(path, resolved);
446
+ const sourceRun = String(config?.description ?? "").match(/spawn run ([^\s]+)/)?.[1];
447
+ const preview = templatePreview(config?.template);
432
448
  return {
433
449
  id,
434
450
  path,
451
+ sha256: createHash("sha256").update(bytes).digest("hex"),
452
+ size: bytes.byteLength,
453
+ created_at: stat.birthtime.toISOString(),
454
+ modified_at: stat.mtime.toISOString(),
455
+ valid: Boolean(resolved),
456
+ diagnostics,
435
457
  ...(config?.description ? { description: config.description } : {}),
458
+ ...(sourceRun ? { source_run: sourceRun } : {}),
459
+ ...(config?.async !== undefined ? { async: config.async } : {}),
460
+ ...(preview ? { template_preview: preview } : {}),
436
461
  };
437
462
  });
438
463
  }
@@ -11,6 +11,7 @@ export interface RegisterToolInput {
11
11
  async?: boolean;
12
12
  state_dir?: string;
13
13
  template?: CommandTemplates.CommandTemplateValue | null;
14
+ draft?: string;
14
15
  args?: string;
15
16
  update?: boolean;
16
17
  values?: Record<string, unknown>;
@@ -20,6 +21,8 @@ export interface RegisterToolResultDetails {
20
21
  async?: boolean;
21
22
  config?: string;
22
23
  defaults?: Record<string, string>;
24
+ draft?: string;
25
+ promoted?: boolean;
23
26
  recipeName?: string;
24
27
  state_dir?: string;
25
28
  template?: CommandTemplates.CommandTemplateValue;
@@ -4,7 +4,7 @@
4
4
  * Owns register/update/delete validation, persistence, runtime side effects, and result payloads
5
5
  */
6
6
  import { existsSync, mkdirSync, unlinkSync } from "node:fs";
7
- import { dirname, join } from "node:path";
7
+ import { dirname, join, relative, resolve } from "node:path";
8
8
  import * as CommandTemplates from "./command-templates.js";
9
9
  import * as ExecutionOutput from "./execution-output.js";
10
10
  import { writeJsonAtomic } from "./file-state.js";
@@ -32,6 +32,62 @@ function getRecipeRoot(deps) {
32
32
  function getToolRecipePath(deps, name) {
33
33
  return join(getRecipeRoot(deps), `${name}.json`);
34
34
  }
35
+ function assertDraftPath(deps, draft) {
36
+ const draftRoot = resolve(getRecipeRoot(deps), "drafts");
37
+ const path = resolve(draft);
38
+ const relation = relative(draftRoot, path);
39
+ if (relation === "" ||
40
+ relation.startsWith("..") ||
41
+ resolve(relation) === relation) {
42
+ throw new Error(ExecutionOutput.formatToolText(`Draft must be under ${draftRoot}. Use inspect target=recipes view=summary to list drafts.`));
43
+ }
44
+ if (!existsSync(path)) {
45
+ throw new Error(ExecutionOutput.formatToolText(`Draft not found: ${path}`));
46
+ }
47
+ return path;
48
+ }
49
+ function promoteDraftRecipe(name, input, ctx, deps) {
50
+ const draftPath = assertDraftPath(deps, String(input.draft ?? ""));
51
+ const targetPath = getToolRecipePath(deps, name);
52
+ const tools = deps.getTools();
53
+ const existing = tools.get(name);
54
+ const conflict = deps.getExternalToolConflict(name);
55
+ if (conflict)
56
+ throw new Error(ExecutionOutput.formatToolText(conflict));
57
+ if ((existing || existsSync(targetPath)) && !input.update) {
58
+ throw new Error(ExecutionOutput.formatToolText(`Tool "${name}" already registered. Use update=true to overwrite.`));
59
+ }
60
+ const config = RecipesReferences.readResolvedRecipeConfig(draftPath);
61
+ if (!config) {
62
+ const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(draftPath);
63
+ throw new Error(ExecutionOutput.formatToolText(`Draft recipe is invalid${reason ? `: ${reason}` : "."}`));
64
+ }
65
+ const promoted = buildConfig(name, {
66
+ ...input,
67
+ description: input.description ?? config.description,
68
+ template: draftPath,
69
+ }, existing);
70
+ const raw = RecipesReferences.readRawRecipeConfig(draftPath);
71
+ mkdirSync(dirname(targetPath), { recursive: true });
72
+ writeJsonAtomic(targetPath, raw);
73
+ promoted.template = targetPath;
74
+ promoted.sourcePath = targetPath;
75
+ tools.set(name, promoted);
76
+ deps.registerRuntimeTool(promoted);
77
+ deps.notify(ctx, `Promoted draft recipe: ${name}`, "info");
78
+ return {
79
+ content: [
80
+ textContent(ExecutionOutput.formatToolText(`${existing ? "Updated" : "Registered"} tool "${name}" from draft recipe.`)),
81
+ ],
82
+ details: {
83
+ args: promoted.args,
84
+ config: targetPath,
85
+ draft: draftPath,
86
+ promoted: true,
87
+ tool: name,
88
+ },
89
+ };
90
+ }
35
91
  function persistToolRecipe(deps, cfg) {
36
92
  const path = getToolRecipePath(deps, cfg.name);
37
93
  mkdirSync(dirname(path), { recursive: true });
@@ -173,6 +229,9 @@ export async function executeRegisterTool(params, ctx, deps) {
173
229
  if (deps.reservedToolNames.has(name)) {
174
230
  throw new Error(ExecutionOutput.formatToolText(`Reserved tool name: ${name}`));
175
231
  }
232
+ if (typeof input.draft === "string" && input.draft.trim()) {
233
+ return promoteDraftRecipe(name, input, ctx, deps);
234
+ }
176
235
  const templateProvided = Object.hasOwn(input, "template");
177
236
  const template = getInputTemplate(input.template);
178
237
  if (templateProvided && (template === null || template === ""))
@@ -101,6 +101,73 @@ function getRoomMulticastRecipients(message, run) {
101
101
  return Messages.formatActorAddress(parsed);
102
102
  });
103
103
  }
104
+ function normalizeDeliveryOutcome(address, result) {
105
+ if (result.reason)
106
+ return result;
107
+ if (address.kind === "run") {
108
+ if (result.stopped === true) {
109
+ return {
110
+ ...result,
111
+ consumer: "run-control",
112
+ delivered: true,
113
+ persisted: true,
114
+ reason: "control_applied",
115
+ };
116
+ }
117
+ return {
118
+ ...result,
119
+ consumer: result.control_type ?? result.control ?? "run-control",
120
+ delivered: result.sent === true && result.queued !== true,
121
+ persisted: true,
122
+ reason: result.delivery_error
123
+ ? "delivery_failed_persisted"
124
+ : result.queued === true
125
+ ? "queued_mailbox"
126
+ : "delivered",
127
+ };
128
+ }
129
+ if (address.kind === "branch") {
130
+ return {
131
+ ...result,
132
+ consumer: "branch-mailbox",
133
+ delivered: result.sent === true,
134
+ persisted: true,
135
+ reason: result.delivery_error
136
+ ? "branch_persisted_parent_unavailable"
137
+ : "branch_persisted_forwarded",
138
+ };
139
+ }
140
+ if (address.kind === "room") {
141
+ return {
142
+ ...result,
143
+ consumer: "room-timeline",
144
+ delivered: true,
145
+ forwarded: Number(result.multicast_count ?? 0) > 0,
146
+ persisted: true,
147
+ reason: "room_persisted",
148
+ };
149
+ }
150
+ if (address.kind === "tool") {
151
+ return {
152
+ ...result,
153
+ consumer: "tool",
154
+ delivered: true,
155
+ persisted: false,
156
+ reason: "tool_invoked",
157
+ };
158
+ }
159
+ if (address.kind === "coordinator" || address.kind === "session") {
160
+ return {
161
+ ...result,
162
+ consumer: "run-outbox",
163
+ delivered: false,
164
+ persisted: true,
165
+ queued: true,
166
+ reason: `${address.kind}_outbox_persisted`,
167
+ };
168
+ }
169
+ return result;
170
+ }
104
171
  function actorMessageNextActions(message, result) {
105
172
  const actions = [];
106
173
  const address = Messages.parseActorAddress(message.to);
@@ -132,8 +199,18 @@ function compactActorMessageResult(message, result) {
132
199
  ];
133
200
  if (result.bytes !== undefined)
134
201
  tokens.push(`bytes=${String(result.bytes)}`);
202
+ if (result.delivered !== undefined)
203
+ tokens.push(`delivered=${String(result.delivered)}`);
135
204
  if (result.queued === true)
136
205
  tokens.push("queued=true");
206
+ if (result.persisted !== undefined)
207
+ tokens.push(`persisted=${String(result.persisted)}`);
208
+ if (result.forwarded !== undefined)
209
+ tokens.push(`forwarded=${String(result.forwarded)}`);
210
+ if (result.consumer)
211
+ tokens.push(`consumer=${String(result.consumer)}`);
212
+ if (result.reason)
213
+ tokens.push(`reason=${String(result.reason)}`);
137
214
  if (result.control)
138
215
  tokens.push(`control=${String(result.control)}`);
139
216
  if (result.outbox)
@@ -252,13 +329,17 @@ export function createActorMessageToolDefinition(deps = {}) {
252
329
  throw new Error(`${message.to} has no run state directory.`);
253
330
  const recipients = getRoomMulticastRecipients(message, runId);
254
331
  const roomResult = Rooms.appendRoomMessage(stateDir, address.room, message);
255
- await Promise.all(recipients.map((recipient) => routeBranchEnvelope(stateDir, runId, recipient, message, {
332
+ const multicastResults = await Promise.all(recipients.map(async (recipient) => normalizeDeliveryOutcome({ kind: "branch", branch: recipient.split("/").at(-1), value: runId }, await routeBranchEnvelope(stateDir, runId, recipient, message, {
256
333
  source: "room-multicast",
257
- })));
334
+ }))));
258
335
  result = {
259
336
  ...roomResult,
260
337
  ...(recipients.length > 0
261
- ? { multicast: recipients, multicast_count: recipients.length }
338
+ ? {
339
+ multicast: recipients,
340
+ multicast_count: recipients.length,
341
+ multicast_results: multicastResults,
342
+ }
262
343
  : {}),
263
344
  };
264
345
  }
@@ -328,6 +409,7 @@ export function createActorMessageToolDefinition(deps = {}) {
328
409
  else {
329
410
  throw new Error(`message currently supports run:<id>, branch:<run>/<branch>, room:<run>, tool:<name>, coordinator, and session:<id> destinations; unsupported destination: ${message.to}`);
330
411
  }
412
+ result = normalizeDeliveryOutcome(address, result);
331
413
  const nextActions = actorMessageNextActions(message, result);
332
414
  const resultWithNext = nextActions.length
333
415
  ? { ...result, next_actions: nextActions }
@@ -24,6 +24,7 @@ export function createRegisterToolDefinition(deps) {
24
24
  args: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.args),
25
25
  async: booleanSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.async),
26
26
  description: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.description),
27
+ draft: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.draft),
27
28
  name: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.name),
28
29
  state_dir: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.state_dir),
29
30
  template: unionSchema([
@@ -137,7 +137,11 @@ export async function runActorWorker(argv = process.argv.slice(2)) {
137
137
  { display: branch, role: "worker", status: "present" },
138
138
  `${branch} joined as mailbox worker`,
139
139
  );
140
- room("awaiting_assignment", `${branch} awaiting assignment`, { branch });
140
+ room("awaiting_assignment", `${branch} awaiting assignment`, {
141
+ branch,
142
+ stale_claim_ms: staleClaimMs,
143
+ stale_claims: staleClaims(),
144
+ });
141
145
  journal("worker.started", {
142
146
  artifact_dir: artifactDir,
143
147
  branch,
@@ -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.34.0
5
+ version: 0.35.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.34.0
5
+ version: 0.35.0
6
6
  ---
7
7
 
8
8
  # Swarm
@@ -10,7 +10,7 @@ The registry source is location-discovered recipes, not a live tool-only JSON fi
10
10
 
11
11
  - `~/.pi/agent/recipes/*.json` and `*.md` are the highest-priority user recipe root and the operator-managed tool set.
12
12
  - Recipes in that root are tools by location.
13
- - `~/.pi/agent/recipes/drafts/*.json` stores captured inline-spawn draft recipes, not registered tools. Promote one by moving or copying it up one level into `~/.pi/agent/recipes`. `inspect target=recipes view=summary` reports their count, and verbose output lists their paths/descriptions for explicit replay by file path.
13
+ - `~/.pi/agent/recipes/drafts/*.json` stores captured inline-spawn draft recipes, not registered tools. Promote one with `register_tool name=<tool_name> draft=<draft_path>` or by manually moving/copying it up one level into `~/.pi/agent/recipes`. `inspect target=recipes view=summary` reports their count, and verbose output lists their paths, timestamps, fingerprints, validation state, source run when known, descriptions, and template previews for explicit replay or promotion.
14
14
  - Packaged pi-actors recipes are the lower-priority standard library of declarative actor config components, not automatically registered tools.
15
15
  - Ad hoc recipe files outside the user recipe root are components unless explicitly registered/copied into `~/.pi/agent/recipes`.
16
16
  - Recipe identity is the filename basename; `~/.pi/agent/recipes/docs_review.json` and `docs_review.md` both have id/tool name `docs_review`.
@@ -51,7 +51,7 @@ register_tool name=call_subagent \
51
51
  template="pi -p --model {model} --no-tools {prompt}" args="prompt:string,model:string"
52
52
  ```
53
53
 
54
- Use `update=true` to overwrite an existing tool. Omit `template` and co-located recipe fields during update to keep the previous execution binding.
54
+ Use `update=true` to overwrite an existing tool. Omit `template` and co-located recipe fields during update to keep the previous execution binding. To promote a captured draft, pass `name` plus `draft` with a path under `~/.pi/agent/recipes/drafts`; promotion validates the draft before writing `~/.pi/agent/recipes/<name>.json`, preserves the draft file, rejects name collisions unless `update=true`, and leaves shadowing evidence visible through `inspect target=recipes view=summary` or `view=doctor`.
55
55
 
56
56
  `template` may also be a standard command-template sequence for multi-step tools. Timeout is disabled by default; add explicit positive `timeout` values when individual steps should fail closed:
57
57
 
package/lib/prompts.ts CHANGED
@@ -38,6 +38,8 @@ export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
38
38
  name: "Tool name in snake_case (e.g., 'transcribe')",
39
39
  description:
40
40
  "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.",
41
+ draft:
42
+ "Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.",
41
43
  async:
42
44
  "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
43
45
  state_dir:
@@ -574,14 +574,43 @@ export function getShadowedLaunchDiagnostic(
574
574
  };
575
575
  }
576
576
 
577
+ function templatePreview(value: unknown): string | undefined {
578
+ const rendered =
579
+ typeof value === "string"
580
+ ? value
581
+ : value === undefined
582
+ ? undefined
583
+ : JSON.stringify(value);
584
+ return rendered && rendered.length > 120
585
+ ? `${rendered.slice(0, 117)}...`
586
+ : rendered;
587
+ }
588
+
577
589
  export function listDraftRecipes(root: string): Array<Record<string, unknown>> {
578
590
  return listRecipeFiles(root).map((path) => {
579
591
  const id = RecipesReferences.getRecipeIdFromPath(path);
592
+ const bytes = readFileSync(path);
593
+ const stat = statSync(path);
580
594
  const config = RecipesReferences.readRawRecipeConfig(path);
595
+ const resolved = RecipesReferences.readResolvedRecipeConfig(path);
596
+ const diagnostics = getRecipeConfigDiagnostics(path, resolved);
597
+ const sourceRun = String(config?.description ?? "").match(
598
+ /spawn run ([^\s]+)/,
599
+ )?.[1];
600
+ const preview = templatePreview(config?.template);
581
601
  return {
582
602
  id,
583
603
  path,
604
+ sha256: createHash("sha256").update(bytes).digest("hex"),
605
+ size: bytes.byteLength,
606
+ created_at: stat.birthtime.toISOString(),
607
+ modified_at: stat.mtime.toISOString(),
608
+ valid: Boolean(resolved),
609
+ diagnostics,
584
610
  ...(config?.description ? { description: config.description } : {}),
611
+ ...(sourceRun ? { source_run: sourceRun } : {}),
612
+ ...(config?.async !== undefined ? { async: config.async } : {}),
613
+ ...(preview ? { template_preview: preview } : {}),
585
614
  };
586
615
  });
587
616
  }
package/lib/registry.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync, mkdirSync, unlinkSync } from "node:fs";
8
- import { dirname, join } from "node:path";
8
+ import { dirname, join, relative, resolve } from "node:path";
9
9
 
10
10
  import * as CommandTemplates from "./command-templates.ts";
11
11
  import * as Config from "./config.ts";
@@ -22,6 +22,7 @@ export interface RegisterToolInput {
22
22
  async?: boolean;
23
23
  state_dir?: string;
24
24
  template?: CommandTemplates.CommandTemplateValue | null;
25
+ draft?: string;
25
26
  args?: string;
26
27
  update?: boolean;
27
28
  values?: Record<string, unknown>;
@@ -32,6 +33,8 @@ export interface RegisterToolResultDetails {
32
33
  async?: boolean;
33
34
  config?: string;
34
35
  defaults?: Record<string, string>;
36
+ draft?: string;
37
+ promoted?: boolean;
35
38
  recipeName?: string;
36
39
  state_dir?: string;
37
40
  template?: CommandTemplates.CommandTemplateValue;
@@ -95,6 +98,93 @@ function getToolRecipePath<TContext>(
95
98
  return join(getRecipeRoot(deps), `${name}.json`);
96
99
  }
97
100
 
101
+ function assertDraftPath<TContext>(
102
+ deps: RegisterToolRuntimeDeps<TContext>,
103
+ draft: string,
104
+ ): string {
105
+ const draftRoot = resolve(getRecipeRoot(deps), "drafts");
106
+ const path = resolve(draft);
107
+ const relation = relative(draftRoot, path);
108
+ if (
109
+ relation === "" ||
110
+ relation.startsWith("..") ||
111
+ resolve(relation) === relation
112
+ ) {
113
+ throw new Error(
114
+ ExecutionOutput.formatToolText(
115
+ `Draft must be under ${draftRoot}. Use inspect target=recipes view=summary to list drafts.`,
116
+ ),
117
+ );
118
+ }
119
+ if (!existsSync(path)) {
120
+ throw new Error(ExecutionOutput.formatToolText(`Draft not found: ${path}`));
121
+ }
122
+ return path;
123
+ }
124
+
125
+ function promoteDraftRecipe<TContext>(
126
+ name: string,
127
+ input: RegisterToolInput,
128
+ ctx: TContext,
129
+ deps: RegisterToolRuntimeDeps<TContext>,
130
+ ): RegisterToolResult {
131
+ const draftPath = assertDraftPath(deps, String(input.draft ?? ""));
132
+ const targetPath = getToolRecipePath(deps, name);
133
+ const tools = deps.getTools();
134
+ const existing = tools.get(name);
135
+ const conflict = deps.getExternalToolConflict(name);
136
+ if (conflict) throw new Error(ExecutionOutput.formatToolText(conflict));
137
+ if ((existing || existsSync(targetPath)) && !input.update) {
138
+ throw new Error(
139
+ ExecutionOutput.formatToolText(
140
+ `Tool "${name}" already registered. Use update=true to overwrite.`,
141
+ ),
142
+ );
143
+ }
144
+ const config = RecipesReferences.readResolvedRecipeConfig(draftPath);
145
+ if (!config) {
146
+ const reason = RecipesReferences.diagnoseRawRecipeConfigFailure(draftPath);
147
+ throw new Error(
148
+ ExecutionOutput.formatToolText(
149
+ `Draft recipe is invalid${reason ? `: ${reason}` : "."}`,
150
+ ),
151
+ );
152
+ }
153
+ const promoted = buildConfig(
154
+ name,
155
+ {
156
+ ...input,
157
+ description: input.description ?? config.description,
158
+ template: draftPath,
159
+ },
160
+ existing,
161
+ );
162
+ const raw = RecipesReferences.readRawRecipeConfig(draftPath)!;
163
+ mkdirSync(dirname(targetPath), { recursive: true });
164
+ writeJsonAtomic(targetPath, raw);
165
+ promoted.template = targetPath;
166
+ promoted.sourcePath = targetPath;
167
+ tools.set(name, promoted);
168
+ deps.registerRuntimeTool(promoted);
169
+ deps.notify(ctx, `Promoted draft recipe: ${name}`, "info");
170
+ return {
171
+ content: [
172
+ textContent(
173
+ ExecutionOutput.formatToolText(
174
+ `${existing ? "Updated" : "Registered"} tool "${name}" from draft recipe.`,
175
+ ),
176
+ ),
177
+ ],
178
+ details: {
179
+ args: promoted.args,
180
+ config: targetPath,
181
+ draft: draftPath,
182
+ promoted: true,
183
+ tool: name,
184
+ } as RegisterToolResultDetails & Record<string, unknown>,
185
+ };
186
+ }
187
+
98
188
  function persistToolRecipe<TContext>(
99
189
  deps: RegisterToolRuntimeDeps<TContext>,
100
190
  cfg: Config.RegisteredTool,
@@ -284,6 +374,9 @@ export async function executeRegisterTool<TContext>(
284
374
  ExecutionOutput.formatToolText(`Reserved tool name: ${name}`),
285
375
  );
286
376
  }
377
+ if (typeof input.draft === "string" && input.draft.trim()) {
378
+ return promoteDraftRecipe(name, input, ctx, deps);
379
+ }
287
380
  const templateProvided = Object.hasOwn(input, "template");
288
381
  const template = getInputTemplate(input.template);
289
382
  if (templateProvided && (template === null || template === ""))
@@ -144,6 +144,76 @@ function getRoomMulticastRecipients(
144
144
  });
145
145
  }
146
146
 
147
+ function normalizeDeliveryOutcome(
148
+ address: Messages.ActorAddress,
149
+ result: Record<string, unknown>,
150
+ ): Record<string, unknown> {
151
+ if (result.reason) return result;
152
+ if (address.kind === "run") {
153
+ if (result.stopped === true) {
154
+ return {
155
+ ...result,
156
+ consumer: "run-control",
157
+ delivered: true,
158
+ persisted: true,
159
+ reason: "control_applied",
160
+ };
161
+ }
162
+ return {
163
+ ...result,
164
+ consumer: result.control_type ?? result.control ?? "run-control",
165
+ delivered: result.sent === true && result.queued !== true,
166
+ persisted: true,
167
+ reason: result.delivery_error
168
+ ? "delivery_failed_persisted"
169
+ : result.queued === true
170
+ ? "queued_mailbox"
171
+ : "delivered",
172
+ };
173
+ }
174
+ if (address.kind === "branch") {
175
+ return {
176
+ ...result,
177
+ consumer: "branch-mailbox",
178
+ delivered: result.sent === true,
179
+ persisted: true,
180
+ reason: result.delivery_error
181
+ ? "branch_persisted_parent_unavailable"
182
+ : "branch_persisted_forwarded",
183
+ };
184
+ }
185
+ if (address.kind === "room") {
186
+ return {
187
+ ...result,
188
+ consumer: "room-timeline",
189
+ delivered: true,
190
+ forwarded: Number(result.multicast_count ?? 0) > 0,
191
+ persisted: true,
192
+ reason: "room_persisted",
193
+ };
194
+ }
195
+ if (address.kind === "tool") {
196
+ return {
197
+ ...result,
198
+ consumer: "tool",
199
+ delivered: true,
200
+ persisted: false,
201
+ reason: "tool_invoked",
202
+ };
203
+ }
204
+ if (address.kind === "coordinator" || address.kind === "session") {
205
+ return {
206
+ ...result,
207
+ consumer: "run-outbox",
208
+ delivered: false,
209
+ persisted: true,
210
+ queued: true,
211
+ reason: `${address.kind}_outbox_persisted`,
212
+ };
213
+ }
214
+ return result;
215
+ }
216
+
147
217
  function actorMessageNextActions(
148
218
  message: Messages.ActorMessage,
149
219
  result: Record<string, unknown>,
@@ -183,7 +253,15 @@ function compactActorMessageResult(
183
253
  `message=${result.sent === true || result.stopped === true ? "sent" : "not_sent"}`,
184
254
  ];
185
255
  if (result.bytes !== undefined) tokens.push(`bytes=${String(result.bytes)}`);
256
+ if (result.delivered !== undefined)
257
+ tokens.push(`delivered=${String(result.delivered)}`);
186
258
  if (result.queued === true) tokens.push("queued=true");
259
+ if (result.persisted !== undefined)
260
+ tokens.push(`persisted=${String(result.persisted)}`);
261
+ if (result.forwarded !== undefined)
262
+ tokens.push(`forwarded=${String(result.forwarded)}`);
263
+ if (result.consumer) tokens.push(`consumer=${String(result.consumer)}`);
264
+ if (result.reason) tokens.push(`reason=${String(result.reason)}`);
187
265
  if (result.control) tokens.push(`control=${String(result.control)}`);
188
266
  if (result.outbox) tokens.push(`messages=${String(result.outbox)}`);
189
267
  if (result.message_count !== undefined)
@@ -364,17 +442,24 @@ export function createActorMessageToolDefinition<TContext = unknown>(
364
442
  address.room,
365
443
  message,
366
444
  );
367
- await Promise.all(
368
- recipients.map((recipient) =>
369
- routeBranchEnvelope(stateDir, runId, recipient, message, {
370
- source: "room-multicast",
371
- }),
445
+ const multicastResults = await Promise.all(
446
+ recipients.map(async (recipient) =>
447
+ normalizeDeliveryOutcome(
448
+ { kind: "branch", branch: recipient.split("/").at(-1), value: runId },
449
+ await routeBranchEnvelope(stateDir, runId, recipient, message, {
450
+ source: "room-multicast",
451
+ }),
452
+ ),
372
453
  ),
373
454
  );
374
455
  result = {
375
456
  ...roomResult,
376
457
  ...(recipients.length > 0
377
- ? { multicast: recipients, multicast_count: recipients.length }
458
+ ? {
459
+ multicast: recipients,
460
+ multicast_count: recipients.length,
461
+ multicast_results: multicastResults,
462
+ }
378
463
  : {}),
379
464
  };
380
465
  } else if (address.kind === "tool" && address.value) {
@@ -461,6 +546,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
461
546
  `message currently supports run:<id>, branch:<run>/<branch>, room:<run>, tool:<name>, coordinator, and session:<id> destinations; unsupported destination: ${message.to}`,
462
547
  );
463
548
  }
549
+ result = normalizeDeliveryOutcome(address, result);
464
550
  const nextActions = actorMessageNextActions(message, result);
465
551
  const resultWithNext = nextActions.length
466
552
  ? { ...result, next_actions: nextActions }
@@ -36,6 +36,7 @@ export function createRegisterToolDefinition<TContext>(
36
36
  description: stringSchema(
37
37
  Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.description,
38
38
  ),
39
+ draft: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.draft),
39
40
  name: stringSchema(Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.name),
40
41
  state_dir: stringSchema(
41
42
  Prompts.REGISTER_TOOL_PARAM_DESCRIPTIONS.state_dir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -137,7 +137,11 @@ export async function runActorWorker(argv = process.argv.slice(2)) {
137
137
  { display: branch, role: "worker", status: "present" },
138
138
  `${branch} joined as mailbox worker`,
139
139
  );
140
- room("awaiting_assignment", `${branch} awaiting assignment`, { branch });
140
+ room("awaiting_assignment", `${branch} awaiting assignment`, {
141
+ branch,
142
+ stale_claim_ms: staleClaimMs,
143
+ stale_claims: staleClaims(),
144
+ });
141
145
  journal("worker.started", {
142
146
  artifact_dir: artifactDir,
143
147
  branch,
@@ -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.34.0
5
+ version: 0.35.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.34.0
5
+ version: 0.35.0
6
6
  ---
7
7
 
8
8
  # Swarm