@llblab/pi-actors 0.20.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { existsSync, readFileSync, statSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
- import { basename, dirname, resolve } from "node:path";
8
+ import { basename, dirname, extname, resolve } from "node:path";
9
9
  import * as CommandTemplates from "./command-templates.js";
10
10
  import * as Paths from "./paths.js";
11
11
  const MAX_RECIPE_FILE_BYTES = 1024 * 1024;
@@ -23,26 +23,31 @@ export function resolveRecipePath(value, recipeRoot = Paths.getRecipeRoot()) {
23
23
  return resolve(homedir(), expanded.slice(2));
24
24
  if (expanded.includes("/"))
25
25
  return resolve(expanded);
26
- return resolve(recipeRoot, expanded.endsWith(".json") ? expanded : `${expanded}.json`);
26
+ return resolve(recipeRoot, expanded.endsWith(".json") || expanded.endsWith(".md")
27
+ ? expanded
28
+ : `${expanded}.json`);
27
29
  }
28
30
  function isBareRecipeName(value) {
29
31
  const trimmed = value.trim();
30
32
  return Boolean(trimmed) && !trimmed.includes("/") && !trimmed.startsWith("~") && !trimmed.includes("{");
31
33
  }
32
- function recipeNameFile(value) {
34
+ function recipeNameFiles(value) {
33
35
  const trimmed = value.trim();
34
- return trimmed.endsWith(".json") ? trimmed : `${trimmed}.json`;
36
+ if (trimmed.endsWith(".json") || trimmed.endsWith(".md"))
37
+ return [trimmed];
38
+ return [`${trimmed}.json`, `${trimmed}.md`];
35
39
  }
36
40
  function resolveRecipeImportPath(value, currentRecipeRoot) {
37
41
  if (!isBareRecipeName(value))
38
42
  return resolveRecipePath(value, currentRecipeRoot);
39
- const file = recipeNameFile(value);
40
43
  const roots = [
41
44
  Paths.getRecipeRoot(),
42
45
  currentRecipeRoot,
43
46
  Paths.getPackagedRecipeRoot(),
44
47
  ];
45
- const candidates = [...new Set(roots.map((root) => resolve(root, file)))];
48
+ const candidates = [
49
+ ...new Set(roots.flatMap((root) => recipeNameFiles(value).map((file) => resolve(root, file)))),
50
+ ];
46
51
  return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
47
52
  }
48
53
  export function getRecipePath(value, recipeRoot = Paths.getRecipeRoot()) {
@@ -51,13 +56,15 @@ export function getRecipePath(value, recipeRoot = Paths.getRecipeRoot()) {
51
56
  const trimmed = value.trim();
52
57
  if (!trimmed || hasWhitespace(trimmed))
53
58
  return undefined;
54
- if (trimmed.endsWith(".json"))
59
+ if (trimmed.endsWith(".json") || trimmed.endsWith(".md"))
55
60
  return resolveRecipePath(trimmed, recipeRoot);
56
- const path = resolveRecipePath(trimmed, recipeRoot);
61
+ const jsonPath = resolveRecipePath(trimmed, recipeRoot);
62
+ const mdPath = resolveRecipePath(`${trimmed}.md`, recipeRoot);
63
+ const path = existsSync(jsonPath) ? jsonPath : mdPath;
57
64
  if (!existsSync(path))
58
65
  return undefined;
59
66
  try {
60
- const raw = JSON.parse(readFileSync(path, "utf8"));
67
+ const raw = readRawRecipeConfig(path);
61
68
  return raw && typeof raw === "object" && Object.hasOwn(raw, "template")
62
69
  ? path
63
70
  : undefined;
@@ -137,6 +144,122 @@ function getRecipeCommandTemplate(raw) {
137
144
  }
138
145
  return normalizeRecipeTemplate({ ...envelope, template });
139
146
  }
147
+ function parseMarkdownScalar(value) {
148
+ const trimmed = value.trim();
149
+ if (!trimmed)
150
+ return "";
151
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
152
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
153
+ try {
154
+ return JSON.parse(trimmed);
155
+ }
156
+ catch {
157
+ return trimmed;
158
+ }
159
+ }
160
+ const quoted = trimmed.match(/^(?:"([^"]*)"|'([^']*)')$/);
161
+ if (quoted)
162
+ return quoted[1] ?? quoted[2] ?? "";
163
+ if (trimmed === "true")
164
+ return true;
165
+ if (trimmed === "false")
166
+ return false;
167
+ if (trimmed === "null")
168
+ return null;
169
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed))
170
+ return Number(trimmed);
171
+ return trimmed;
172
+ }
173
+ function parseMarkdownFrontmatterObject(lines) {
174
+ if (lines.every((line) => /^\s*-\s+/.test(line))) {
175
+ return lines.map((line) => parseMarkdownScalar(line.replace(/^\s*-\s+/, "")));
176
+ }
177
+ const result = {};
178
+ for (let index = 0; index < lines.length; index += 1) {
179
+ const match = lines[index].match(/^\s{2}([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
180
+ if (!match)
181
+ continue;
182
+ if (match[2]) {
183
+ result[match[1]] = parseMarkdownScalar(match[2]);
184
+ continue;
185
+ }
186
+ const nested = [];
187
+ while (index + 1 < lines.length && /^\s{4}/.test(lines[index + 1])) {
188
+ index += 1;
189
+ nested.push(lines[index].slice(2));
190
+ }
191
+ result[match[1]] = parseMarkdownFrontmatterObject(nested);
192
+ }
193
+ return result;
194
+ }
195
+ function parseMarkdownFrontmatter(value) {
196
+ const result = {};
197
+ const lines = value.split(/\r?\n/);
198
+ for (let index = 0; index < lines.length; index += 1) {
199
+ const line = lines[index];
200
+ if (!line.trim() || line.trim().startsWith("#"))
201
+ continue;
202
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
203
+ if (!match)
204
+ continue;
205
+ if (match[2]) {
206
+ result[match[1]] = parseMarkdownScalar(match[2]);
207
+ continue;
208
+ }
209
+ const nested = [];
210
+ while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
211
+ index += 1;
212
+ nested.push(lines[index]);
213
+ }
214
+ result[match[1]] = parseMarkdownFrontmatterObject(nested);
215
+ }
216
+ return result;
217
+ }
218
+ function findMarkdownRecipeFence(body) {
219
+ const pattern = /```([^\n`]*)\n([\s\S]*?)```/g;
220
+ for (const match of body.matchAll(pattern)) {
221
+ const info = match[1].trim().toLowerCase();
222
+ if (info.includes("recipe") ||
223
+ info.includes("template") ||
224
+ info.includes("command") ||
225
+ info.includes("json")) {
226
+ return { info, body: match[2].trim() };
227
+ }
228
+ }
229
+ return undefined;
230
+ }
231
+ function parseMarkdownRecipeConfig(content) {
232
+ const lines = content.split(/\r?\n/);
233
+ if (lines[0]?.trim() !== "---")
234
+ return undefined;
235
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
236
+ if (end === -1)
237
+ return undefined;
238
+ const frontmatter = parseMarkdownFrontmatter(lines.slice(1, end).join("\n"));
239
+ const fence = findMarkdownRecipeFence(lines.slice(end + 1).join("\n"));
240
+ if (!fence)
241
+ return Object.hasOwn(frontmatter, "template") ? frontmatter : undefined;
242
+ const text = fence.body.trim();
243
+ if (!text)
244
+ return undefined;
245
+ if (fence.info.includes("json") ||
246
+ fence.info.includes("recipe") ||
247
+ text.startsWith("{") ||
248
+ text.startsWith("[") ||
249
+ text.startsWith('"')) {
250
+ try {
251
+ const parsed = JSON.parse(text);
252
+ if (isRecord(parsed) && Object.hasOwn(parsed, "template"))
253
+ return { ...frontmatter, ...parsed };
254
+ return { ...frontmatter, template: parsed };
255
+ }
256
+ catch {
257
+ if (fence.info.includes("json") || fence.info.includes("recipe"))
258
+ return undefined;
259
+ }
260
+ }
261
+ return { ...frontmatter, template: text };
262
+ }
140
263
  export function readRawRecipeConfig(path) {
141
264
  if (!existsSync(path))
142
265
  return undefined;
@@ -145,7 +268,10 @@ export function readRawRecipeConfig(path) {
145
268
  throw new Error(`Recipe file exceeds size limit ${MAX_RECIPE_FILE_BYTES} bytes: ${path}`);
146
269
  }
147
270
  try {
148
- const raw = JSON.parse(readFileSync(path, "utf8"));
271
+ const content = readFileSync(path, "utf8");
272
+ if (path.endsWith(".md"))
273
+ return parseMarkdownRecipeConfig(content);
274
+ const raw = JSON.parse(content);
149
275
  return raw && typeof raw === "object" ? raw : undefined;
150
276
  }
151
277
  catch {
@@ -153,7 +279,7 @@ export function readRawRecipeConfig(path) {
153
279
  }
154
280
  }
155
281
  export function getRecipeIdFromPath(file) {
156
- return basename(file, ".json");
282
+ return basename(file, extname(file));
157
283
  }
158
284
  function readRecipeConfig(value) {
159
285
  const path = getRecipePath(value);
package/dist/lib/tools.js CHANGED
@@ -790,7 +790,7 @@ export function createActorMessageToolDefinition(deps = {}) {
790
790
  result = AsyncRuns.killRun(address.value);
791
791
  }
792
792
  else {
793
- result = AsyncRuns.sendRunMessage(address.value, messageBodyToRunLine(message));
793
+ result = await AsyncRuns.sendRunMessage(address.value, messageBodyToRunLine(message));
794
794
  }
795
795
  }
796
796
  else if (address.kind === "branch" && address.value) {
@@ -820,7 +820,7 @@ export function createActorMessageToolDefinition(deps = {}) {
820
820
  ActorRooms.writeCommunicationSnapshot(stateDir, runId);
821
821
  ActorRooms.appendBranchInboxMessage(stateDir, runId, message.to, message);
822
822
  }
823
- result = AsyncRuns.sendRunMessage(address.value, JSON.stringify(message));
823
+ result = await AsyncRuns.sendRunMessage(address.value, JSON.stringify(message));
824
824
  }
825
825
  else if (address.kind === "room" && address.value && address.room) {
826
826
  const runId = address.value;
@@ -831,11 +831,11 @@ export function createActorMessageToolDefinition(deps = {}) {
831
831
  throw new Error(`${message.to} has no run state directory.`);
832
832
  const recipients = getRoomMulticastRecipients(message, runId);
833
833
  const roomResult = ActorRooms.appendRoomMessage(stateDir, address.room, message);
834
- const multicast = recipients.map((recipient) => AsyncRuns.sendRunMessage(runId, JSON.stringify({ ...message, to: recipient })));
834
+ await Promise.all(recipients.map((recipient) => AsyncRuns.sendRunMessage(runId, JSON.stringify({ ...message, to: recipient }))));
835
835
  result = {
836
836
  ...roomResult,
837
- ...(multicast.length > 0
838
- ? { multicast: recipients, multicast_count: multicast.length }
837
+ ...(recipients.length > 0
838
+ ? { multicast: recipients, multicast_count: recipients.length }
839
839
  : {}),
840
840
  };
841
841
  }
package/docs/README.md CHANGED
@@ -5,7 +5,7 @@ Living index of all documentation in the `/docs` directory.
5
5
  ## Documents
6
6
 
7
7
  - [command-templates.md](./command-templates.md) — Portable synchronous command execution standard
8
- - [template-recipes.md](./template-recipes.md) — Saved JSON recipe standard, imports, and reusable command-template graph composition
8
+ - [template-recipes.md](./template-recipes.md) — Saved JSON/Markdown recipe standard, imports, and reusable command-template graph composition
9
9
  - [async-runs.md](./async-runs.md) — Detached run lifecycle, state files, actor messages, cancellation, and ambient indicators
10
10
  - [actor-messages.md](./actor-messages.md) — Actor/message protocol for symmetric communication primitives
11
11
  - [tool-registry.md](./tool-registry.md) — Local `pi-actors` registry storage and `register_tool` adaptation
@@ -44,6 +44,8 @@ An alternate implementation shape is a dedicated non-LLM communication actor: a
44
44
 
45
45
  That actor-backed shape can also reduce direct file storage. Instead of every protocol feature owning JSON files as primary state, a helper actor can keep live room/roster structures in memory or another local structure and write files only as snapshots, audit logs, artifacts, or recovery checkpoints. The decision boundary is practical: keep files when durability and inspectability are the main value; prefer actor-owned structures when live coordination, subscriptions, fanout, unread state, or mutation consistency becomes the main value.
46
46
 
47
+ Current backend decision: keep the file-backed adapter for now. The covered workload is append-heavy room coordination plus direct branch inbox queueing/claiming, where durable local files are still the useful source of truth for recovery and `inspect`. A communication helper should be introduced only when a real workflow needs long-lived subscriptions, live fanout policy, or shared mutable room state beyond the current lock/debounce/compaction safeguards.
48
+
47
49
  Package-specific endpoints may still exist, but the envelope stays the same.
48
50
 
49
51
  ## Message Envelope
@@ -94,7 +96,7 @@ Transports differ, but the public contract does not:
94
96
 
95
97
  - `to: run:<id>` routes through the run-local control channel selected by that recipe or runtime adapter.
96
98
  - `to: coordinator` routes to the runtime attention path when `from` names a run actor. `to: session:<id>` uses the same actor-message path only when the sender run is owned by that session, making explicit session-directed checkpoints possible without exposing runtime delivery knobs. Generic async-runner `command.done` messages and explicit coordinator/session-bound messages include the actor envelope fields alongside runtime metadata.
97
- - `to: branch:<run>/<branch>` currently routes through the parent run mailbox with the full envelope preserved so the run or recipe-specific worker protocol can dispatch branch-local control. It also persists a queued branch-local copy under `branches/<branch>/inbox.jsonl`, inspectable with `inspect branch:<run>/<branch> view=mailbox`; compact inspection includes the inbox message `id`, status, route, type, and timestamps so worker protocols can correlate claims/retries. Branch-local inbox append and status rewrites are guarded by a small lock so direct delivery and coordinator claims do not overwrite each other during bursts. Coordinator claim handling also assigns an ID to older/manual queued records that do not have one so they can still transition to `handled` or `failed` instead of repeating forever. It is not a broadcast room and it does not make an arbitrary prompt process consume the message automatically. Target direction: direct branch messages should become initiating inbox work for long-lived branch runners, delivered into the recipient's next prompt/context as soon as the runner can accept work.
99
+ - `to: branch:<run>/<branch>` currently routes through the parent run mailbox with the full envelope preserved so the run or recipe-specific worker protocol can dispatch branch-local control. It also persists a queued branch-local copy under `branches/<branch>/inbox.jsonl`, inspectable with `inspect branch:<run>/<branch> view=mailbox`; compact inspection includes the inbox message `id`, status, route, type, and timestamps so worker protocols can correlate claims/retries. Branch-local inbox append and status rewrites are guarded by a small lock so direct delivery and coordinator claims do not overwrite each other during bursts. Status transitions preserve active queued/claimed records and compact older handled/failed terminal records with bounded retention, so persistent runners do not accumulate unbounded completed inbox history. Coordinator claim handling also assigns an ID to older/manual queued records that do not have one so they can still transition to `handled` or `failed` instead of repeating forever. It is not a broadcast room and it does not make an arbitrary prompt process consume the message automatically. Target direction: direct branch messages should become initiating inbox work for long-lived branch runners, delivered into the recipient's next prompt/context as soon as the runner can accept work.
98
100
  - `to: room:<run>` appends the full envelope to the room timeline, updates room state for room-control types such as `actor.join` and `actor.leave`, and can route selected-recipient multicast when `metadata.recipients` contains same-run `branch:<run>/<branch>` addresses.
99
101
  - `to: tool:<name>` invokes an executable pi tool by name. Object bodies become tool parameters; primitive bodies are passed as `{ "input": body }`.
100
102
 
@@ -205,6 +207,7 @@ Runtime operations use the actor/message vocabulary:
205
207
  create detached work -> spawn
206
208
  run-local control -> message to run:<id>
207
209
  run stop/kill -> message type control.stop/control.kill
210
+ platform control -> internal adapter selected from run state
208
211
  coordinator signal -> message to coordinator/session
209
212
  tool execution -> message to tool:<name>
210
213
  intentional observe -> inspect
@@ -158,7 +158,7 @@ The actor-level surface is:
158
158
  - `message`: send one typed envelope to `run:<id>`, `branch:<run>/<branch>`, `room:<run>`, `tool:<name>`, `coordinator`, or `session:<id>`.
159
159
  - `inspect`: intentionally read owned `run:<id>` status, tail, messages, artifacts, files, mailbox metadata, or communication snapshot; read `room:<run>` status, messages, previews, roster, or contacts; read current `coordinator` run inventory only when a coordinator session is known; read `session:<id>` or `session:all` run inventory with optional status filtering when the session is explicit; read `tool:<name>` status or schema for registered tool actors.
160
160
 
161
- Opt-in supervisor retirement uses `retire_when: "children_terminal"` as lifecycle metadata. Candidate detection is conservative: a supervisor is not retirement-ready while command-template progress or descendant `pi -p` worker processes are still active; future retirement execution must also verify child async-run state and flushed outputs before stopping the supervisor.
161
+ Opt-in supervisor retirement uses `retire_when: "children_terminal"` as lifecycle metadata. Run summaries discover nested child run state dirs under the visible state root so bounded supervisor trees are observable. Candidate detection is conservative: a supervisor is not retirement-ready while command-template progress, descendant `pi -p` worker processes, or nested child async runs under the supervisor state dir are still active. Candidate metadata includes observed child-run counts. When the session watcher observes a ready candidate, it sends a graceful `stop` control message once; if the run has no ready control endpoint, it falls back to owned-run cancellation and records the terminal action through normal run events. Persistent or non-opt-in runs are not retirement candidates.
162
162
 
163
163
  Low-level async actions map into the actor surface instead of forming a second public model:
164
164
 
@@ -185,7 +185,7 @@ Some recipes expose a run-local control channel. When present, a caller can send
185
185
 
186
186
  For `run:<id>`, `message` adapts the body to the recipe's run-local control channel. For `branch:<run>/<branch>`, it sends the full envelope through the parent run mailbox and records a queued branch-local inbox entry at `branches/<branch>/inbox.jsonl` so the run can dispatch branch-local control. Current consumers are recipe-specific worker protocols that read the parent run mailbox or branch inbox; independent one-shot prompt processes do not automatically consume branch inbox entries. For `tool:<name>`, object bodies become the target tool parameters and primitive bodies are passed as `{ "input": body }`. The generic runtime records control messages but does not interpret arbitrary run mailbox content. For example, a music player may accept `play`, `pause`, `next`, and `stop`, while a collaborative agent recipe may accept `continue`, `revise:<note>`, `approve`, or `abort`. Recipes may treat terminal control messages such as `stop` as synchronously handled so the later process exit does not generate a duplicate async follow-up.
187
187
 
188
- The standard run-local transport is Unix-oriented. Use WSL/Linux/macOS for packaged message-controlled recipes, or let a Windows-specific recipe expose its own transport such as a Windows named pipe or localhost socket.
188
+ Run-local control uses a platform adapter under the same `message` API. Unix recipes may keep the existing FIFO endpoint, and native Windows recipes can expose a named-pipe endpoint in run state. Recipe authors should document message vocabulary through `mailbox.accepts`, not through transport arguments. Packaged scripts that still create Unix-only endpoints remain WSL/Linux/macOS-only until migrated.
189
189
 
190
190
  ## Coordinator Notifications
191
191
 
@@ -219,7 +219,7 @@ Use coordinator/session-bound messages for completion and decision points, not f
219
219
 
220
220
  An async run belongs to the current user, cwd, and launching agent session at start time. Send, cancellation, and force-kill target only the recorded runner pid when command line and cwd still match the recorded owner data. Stale pid reuse must fail closed.
221
221
 
222
- On Unix-like systems, cancel and kill signal the runner process group when available, then fall back to the runner pid. The runner starts command-template children in that process group, so long-running descendants such as audio players stop with the run instead of becoming orphaned background processes. After the process exits, status reflects the operator action as `cancelled` or `killed` instead of a generic `exited`.
222
+ On Unix-like systems, cancel and kill signal the runner process group when available, then fall back to the runner pid. On native Windows, cancel and kill use Windows process-tree termination through the platform adapter. The runner starts command-template children in the owned process tree, so long-running descendants such as audio players should stop with the run instead of becoming orphaned background processes. After the process exits, status reflects the operator action as `cancelled` or `killed` instead of a generic `exited`.
223
223
 
224
224
  State is append-only where practical. Final result writes should be atomic. Recipe-local control endpoints and actor-message logs may live in the state dir. pi-actors core owns the generic run-local message adapter and runtime attention policy; command and message vocabularies belong to the recipe/script.
225
225
 
@@ -70,7 +70,7 @@ inspect target=run:docs_review view=tail
70
70
 
71
71
  Pipeline recipes demonstrate second-order composition:
72
72
 
73
- - `recipes/coordinator-locker.json`: Long-lived coordinator cell with queue, acquire/renew/release lease locks, journal, and actor messages for worker coordination.
73
+ - `recipes/coordinator-locker.json`: Long-lived coordinator cell with queue, acquire/renew/release lease locks, journal, actor messages for worker coordination, and platform-adapted control metadata.
74
74
  - `recipes/subagent-review-coordinator.json`: Lens reviewers → verifier → merger → judge → normalizer.
75
75
  - `recipes/pipeline-release-readiness.json`: Task-first release cell: changelog section → package summary → packaged skill summary → validation → release review → artifact report.
76
76
  - `recipes/pipeline-release-summary.json`: Evidence-only release summary cell: changelog section → package summary → packaged skill summary → validation → release summary / risks / PR body draft artifact. It does not commit, open a PR, merge, tag, publish, or perform external release side effects.
@@ -84,7 +84,7 @@ Pipeline recipes demonstrate second-order composition:
84
84
  - `recipes/pipeline-development-tasking.json`: Plan → task card → critique → integrator handoff.
85
85
  - `recipes/pipeline-docs-maintenance.json`: Docs index → documentation review → maintenance plan → artifact report.
86
86
  - `recipes/pipeline-media-library.json`: Playlist build → media-library artifact report.
87
- - `recipes/pipeline-room-swarm.json`: Room participants join `room:<run>`, coordinate over repeated room-visible rounds, leave cleanly, and synthesize the room transcript into a caller-provided artifact path. Keep model/thinking/mission policy caller-owned. Custom roles can be supplied with `roles_path` as a JSON array of `{ "name", "persona" }` objects; `name` stays ASCII-safe for `branch:<run>/<name>` addresses and debugger output remains plain and name-driven. The packaged swarm uses contacts for peer awareness but does not rely on direct branch delivery unless a caller-specific worker protocol consumes branch envelopes. Set `locker=true` to compose a local `coordinator-locker` cell under `{state_dir}/locker` for artifact ownership, resource lease locks, and a decision journal without merging locker policy into the room-participant script.
87
+ - `recipes/pipeline-room-swarm.json`: Room participants join `room:<run>`, coordinate over repeated room-visible rounds, leave cleanly, and synthesize the room transcript into a caller-provided artifact path. Supported coordinator modes are `consensus`, `pipeline`, `fanout`, and `pool`; unknown modes fail closed instead of silently running consensus. Keep model/thinking/mission policy caller-owned. Custom roles can be supplied with `roles_path` as a JSON array of `{ "name", "persona" }` objects; `name` stays ASCII-safe for `branch:<run>/<name>` addresses and debugger output remains plain and name-driven. The packaged swarm uses contacts for peer awareness but does not rely on direct branch delivery unless a caller-specific worker protocol consumes branch envelopes. Set `locker=true` to compose a local `coordinator-locker` cell under `{state_dir}/locker` for artifact ownership, resource lease locks, and a decision journal without merging locker policy into the room-participant script.
88
88
  - `recipes/pipeline-artifact-report.json`: Normalize → artifact-shaped output → actor-message-shaped record. This pipeline prepares a candidate artifact and emits `artifact.prepared`/`artifact.blocked`; the `artifact_path` is a target path, not a guarantee that the file was written.
89
89
  - `recipes/pipeline-artifact-write.json`: Normalize → artifact-shaped output → deterministic artifact write → actor-message-shaped record. Use only when the caller explicitly wants filesystem writes; `write_mode` is `create`, `overwrite`, or `append`.
90
90
  - `recipes/pipeline-artifact-bundle.json`: Optional validation → deterministic artifact write → machine-readable manifest generation → deterministic manifest write → actor-message-shaped record. Use when the caller explicitly wants a filesystem handoff bundle with both artifact and manifest paths.
@@ -97,7 +97,7 @@ Utility recipes cover local operator workflows that do not need subagents:
97
97
 
98
98
  - `recipes/utility-markdown-index.json`: List Markdown files in a directory as input for README/docs index maintenance.
99
99
  - `recipes/utility-jsonl-tail.json`: Tail a JSONL message/log file with a configurable line count.
100
- - `recipes/utility-validation-wrapper.json`: Run a caller-supplied validation command in a scoped directory with a bounded timeout.
100
+ - `recipes/utility-validation-wrapper.json`: Run a caller-supplied validation command in a scoped directory with a bounded timeout. This intentionally crosses a trusted shell boundary; discovery surfaces it as a diagnostic, and callers should pass explicit validation commands only.
101
101
  - `recipes/utility-git-status.json`: Read concise branch/worktree state for a repo.
102
102
  - `recipes/utility-git-log.json`: Read recent decorated commit history for a repo.
103
103
  - `recipes/utility-run-state-files.json`: List run-state files such as `run.json` under an async run state root.
@@ -115,7 +115,17 @@ 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
- These recipes are intentionally small. Register them only for trusted local commands and prefer narrow scopes. The helper-backed utilities share `scripts/recipe-utils.mjs` so repeated parsing/listing logic stays out of recipe strings.
118
+ 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
+
120
+ ## Actor OS Smoke Matrix
121
+
122
+ The repeatable smoke surface is the normal validation suite:
123
+
124
+ ```text
125
+ npm test
126
+ ```
127
+
128
+ The scenario coverage is intentionally local-first and bounded: shared room coordination and roster snapshots (`actor-rooms` / `tools` tests), direct branch delivery and claim/handle transitions (`tools` and coordinator tests), inspector navigation (`actor-inspector-tui` tests), recipe context injection (`actor-recipe-context` / async-run tests), recipe persistence suggestions (`observability` tests), and opt-in retirement candidate/execution smoke (`observability` / async-run tests). These scenarios exercise public `spawn` / `message` / `inspect` behavior or the packaged script surfaces rather than relying on manual swarm demos.
119
129
 
120
130
  ## Music Player
121
131
 
@@ -1,10 +1,10 @@
1
1
  # Template Recipe Standard
2
2
 
3
- Template recipes are saved JSON definitions around the synchronous [Command Template Standard](./command-templates.md).
3
+ Template recipes are saved definitions around the synchronous [Command Template Standard](./command-templates.md). JSON remains the canonical precise format; Markdown is a literate authoring format that compiles into the same recipe model.
4
4
 
5
5
  **Meta-contract:** a recipe stores a command-template graph plus defaults and run mode. It does not create a second execution language.
6
6
 
7
- **Scope:** reusable JSON shape, recipe naming, file-backed recipes, co-located recipes, recipe-layer imports/references, call-time values, foreground execution, and the `async: true` handoff to the [Async Run Standard](./async-runs.md).
7
+ **Scope:** reusable JSON/Markdown shape, recipe naming, file-backed recipes, co-located recipes, recipe-layer imports/references, call-time values, foreground execution, and the `async: true` handoff to the [Async Run Standard](./async-runs.md).
8
8
 
9
9
  ---
10
10
 
@@ -27,7 +27,7 @@ Packaged recipes are the pi-actors recipe standard library: declarative actor co
27
27
 
28
28
  Template-recipe standard owns:
29
29
 
30
- - Saved JSON definitions around one command-template graph.
30
+ - Saved JSON definitions around one command-template graph, plus Markdown-authored recipes that compile to that shape.
31
31
  - File-backed and co-located recipe shapes.
32
32
  - Recipe identity through file-backed filename or co-located tool id.
33
33
  - Recipe defaults, values, imports, import references, and import-node expansion.
@@ -67,6 +67,33 @@ Async recipe:
67
67
 
68
68
  A file-backed recipe's id comes from its filename, not a JSON `name` field. Legacy files may still contain `name`, but loaders ignore it for identity. `template` is the command-template tree. `async: true` selects detached run mode when the recipe is invoked through a registered tool.
69
69
 
70
+ ## Markdown Authoring
71
+
72
+ Markdown recipes use `.md` files with YAML-like frontmatter for recipe metadata and one fenced executable block for the recipe/template body. Runtime behavior comes only from frontmatter plus the fenced block; surrounding prose is advisory for humans and future recipe-context use.
73
+
74
+ ````markdown
75
+ ---
76
+ description: Literate docs check
77
+ args:
78
+ - scope:path
79
+ defaults:
80
+ scope: docs
81
+ mailbox:
82
+ accepts:
83
+ - control.stop
84
+ ---
85
+
86
+ Human notes can explain intent, examples, or review guidance.
87
+
88
+ ```template
89
+ npm run check -- {scope}
90
+ ```
91
+ ````
92
+
93
+ Fenced blocks marked `template`, `command-template`, `json`, or `recipe` are executable. A `template` fence stores its text as the command-template string. A JSON fence can contain either a full recipe object with `template` or a raw command-template value. Frontmatter supports the recipe metadata used by JSON recipes, including `args`, `defaults`, `imports`, `mailbox`, `artifacts`, `async`, and command-template flags.
94
+
95
+ JSON remains the source-of-truth format for precise machine editing. If `<id>.json` and `<id>.md` exist in the same discovery priority layer, `<id>.json` wins and the Markdown recipe is reported as shadowed.
96
+
70
97
  ## Discovery Priority
71
98
 
72
99
  Recipe priority only matters when two discovered recipes have the same filename id. The conceptual ladder from lowest to highest priority is:
@@ -74,11 +101,11 @@ Recipe priority only matters when two discovered recipes have the same filename
74
101
  1. No recipe for that id.
75
102
  2. Packaged pi-actors recipe components, acting as the standard library.
76
103
  3. Explicitly referenced ad hoc user recipe files located outside `~/.pi/agent/recipes`.
77
- 4. User recipe files under `~/.pi/agent/recipes/*.json`.
104
+ 4. User recipe files under `~/.pi/agent/recipes/*.json` or `*.md`.
78
105
 
79
106
  The high-priority user recipe directory is also the default tool set: recipes placed there are agent tools by location. This preserves the old advantage of a tool-only registry because listing `~/.pi/agent/recipes` shows the operator-managed tool surface. Packaged and ad hoc recipes are recipe components by default; they become tools only when copied or registered into the agent recipe root.
80
107
 
81
- Higher-priority files shadow lower-priority files with the same basename. A highest-priority invalid recipe is still visible and blocks fallback so operators do not accidentally run packaged behavior when a user override is broken. A highest-priority recipe with `disabled: true` also blocks fallback and intentionally disables that id.
108
+ Higher-priority files shadow lower-priority files with the same basename. Within one priority layer, same-id JSON shadows Markdown because JSON is the canonical precise format. A highest-priority invalid recipe is still visible and blocks fallback so operators do not accidentally run packaged behavior when a user override is broken. A highest-priority recipe with `disabled: true` also blocks fallback and intentionally disables that id.
82
109
 
83
110
  ## Usage Metadata
84
111
 
@@ -203,19 +230,22 @@ Reusable local recipes live in:
203
230
 
204
231
  ```text
205
232
  ~/.pi/agent/recipes/*.json
233
+ ~/.pi/agent/recipes/*.md
206
234
  ```
207
235
 
208
236
  Bare recipe names resolve under that directory, so `file: "review-docs"` loads:
209
237
 
210
238
  ```text
211
239
  ~/.pi/agent/recipes/review-docs.json
240
+ # or, when no same-id JSON file exists:
241
+ ~/.pi/agent/recipes/review-docs.md
212
242
  ```
213
243
 
214
244
  Call-time params override file params. `values` are merged with file values; call-time values win. If a run id is omitted for an explicit async start, the file basename becomes the default run id.
215
245
 
216
246
  ## Registered Recipe Tools
217
247
 
218
- A registered tool is a recipe file exposed as an agent tool. User recipes under `~/.pi/agent/recipes/*.json` are tools by location; packaged/ad hoc recipes are components unless copied or registered into that user recipe root:
248
+ A registered tool is a recipe file exposed as an agent tool. User recipes under `~/.pi/agent/recipes/*.json` or `*.md` are tools by location; packaged/ad hoc recipes are components unless copied or registered into that user recipe root:
219
249
 
220
250
  ```json
221
251
  {
@@ -312,6 +342,6 @@ Nested object keys are dot-separated. Import references are resolved before norm
312
342
 
313
343
  ## Recipe Shape
314
344
 
315
- Use the filename for file-backed recipe ids, and use `async: true` for detached runs. Use `parallel: true` for fanout, `when` for node guards, and semantic public args such as `tools`, `all`, or `timeout_ms` instead of leaking CLI fragments or reusing node-control names. Local files belong under `~/.pi/agent/recipes/*.json` before relying on recipe launchers.
345
+ Use the filename for file-backed recipe ids, and use `async: true` for detached runs. Use `parallel: true` for fanout, `when` for node guards, and semantic public args such as `tools`, `all`, or `timeout_ms` instead of leaking CLI fragments or reusing node-control names. Local files belong under `~/.pi/agent/recipes/*.json` or `*.md` before relying on recipe launchers.
316
346
 
317
347
  If a proposed recipe needs a scheduler, queue daemon, `goto`, or custom workflow syntax, stop. Keep the recipe as saved command-template JSON and put policy in the registered tool, script, or caller.
@@ -1,6 +1,6 @@
1
1
  # Tool Registry
2
2
 
3
- `pi-actors` stores persistent agent tools as recipe files under `~/.pi/agent/recipes/*.json` and registers the active tool set automatically on session start.
3
+ `pi-actors` stores persistent agent tools as recipe files under `~/.pi/agent/recipes/*.json` or `*.md` and registers the active tool set automatically on session start.
4
4
 
5
5
  This document is the local adaptation of the portable [Command Template Standard](./command-templates.md) and the recipe-file runtime described in [Template Recipe Standard](./template-recipes.md).
6
6
 
@@ -8,11 +8,12 @@ This document is the local adaptation of the portable [Command Template Standard
8
8
 
9
9
  The registry source is location-discovered recipes, not a live tool-only JSON file and not a recipe-owned boolean:
10
10
 
11
- - `~/.pi/agent/recipes/*.json` is the highest-priority user recipe root and the operator-managed tool set.
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
13
  - Packaged pi-actors recipes are the lower-priority standard library of declarative actor config components, not automatically registered tools.
14
14
  - Ad hoc recipe files outside the user recipe root are components unless explicitly registered/copied into `~/.pi/agent/recipes`.
15
- - Recipe identity is the filename basename; `~/.pi/agent/recipes/docs_review.json` has id/tool name `docs_review`.
15
+ - Recipe identity is the filename basename; `~/.pi/agent/recipes/docs_review.json` and `docs_review.md` both have id/tool name `docs_review`.
16
+ - Same-id JSON shadows Markdown in the same priority layer.
16
17
 
17
18
 
18
19
  Because the user recipe directory is sticky agent muscle memory, runtime launches update `usage.calls`, `usage.last_called`, and a content `usage.fingerprint` on user-owned recipe files. If authored recipe content changes, the next launch resets `usage.calls` and records `usage.reset_at` before counting the launch, so usage evidence follows the current recipe meaning rather than an older file history. `inspect target=recipes view=summary verbose=true` includes usage metadata and operator-gated cleanup recommendations for invalid, shadowed, disabled, component-only, unused, or overriding recipes. Recommended actions stay explicit: keep as a tool/component, enable, merge, fix, delete, or archive. The extension does not maintain a failure counter and agents should not silently clean tools during unrelated work.
package/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Runtime extension entrypoint wrapper.
3
+ *
4
+ * Installed npm packages load compiled JS from dist so Node does not try to strip
5
+ * TypeScript under node_modules. Source checkouts fall back to index.ts for local
6
+ * development before dist has been built.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { dirname, resolve } from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const compiledEntry = resolve(here, "dist", "index.js");
15
+ const sourceEntry = resolve(here, "index.ts");
16
+ const entry = existsSync(compiledEntry) ? compiledEntry : sourceEntry;
17
+ const entryModule = await import(pathToFileURL(entry).href);
18
+
19
+ export default entryModule.default;
package/index.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  } from "@earendil-works/pi-coding-agent";
14
14
 
15
15
  import * as ActorInspectorTui from "./lib/actor-inspector-tui.ts";
16
+ import * as AsyncRuns from "./lib/async-runs.ts";
16
17
  import * as CommandTemplates from "./lib/command-templates.ts";
17
18
  import * as Observability from "./lib/observability.ts";
18
19
  import * as Paths from "./lib/paths.ts";
@@ -47,6 +48,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
47
48
  const runDirWatchers = new Map<string, FSWatcher>();
48
49
  const observedRuns = new Map<string, Observability.RunObservedStatus>();
49
50
  const observedRunEventLines = new Map<string, number>();
51
+ const retirementAttempts = new Set<string>();
50
52
  let runStatusFrame = 0;
51
53
  let communicationWidgetVisible = false;
52
54
  let actorInspectorRows = 12;
@@ -61,6 +63,17 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
61
63
  let recipeWatcherFailureNotified = false;
62
64
  const getRunOwnerId = (ctx: ExtensionContext): string =>
63
65
  ctx.sessionManager.getSessionId();
66
+ const retireCandidateRuns = (
67
+ ctx: ExtensionContext,
68
+ summary: Observability.RunSummary,
69
+ ): void => {
70
+ void Observability.executeRunRetirements(summary, {
71
+ attempted: retirementAttempts,
72
+ cancelRun: (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
73
+ notify: (message, level) => ctx.ui.notify(message, level),
74
+ sendStop: (candidate) => AsyncRuns.sendRunMessage(candidate.stateDir, "stop"),
75
+ });
76
+ };
64
77
  const updateRunUi = (ctx: ExtensionContext, notify = false): void => {
65
78
  const ownerId = getRunOwnerId(ctx);
66
79
  const summary = Observability.summarizeRuns(undefined, ownerId);
@@ -139,6 +152,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
139
152
  summary,
140
153
  );
141
154
  if (!notify) return;
155
+ retireCandidateRuns(ctx, summary);
142
156
  for (const transition of transitions) {
143
157
  if (!Observability.shouldNotifyRunTransition(transition)) continue;
144
158
  const text = Observability.formatRunTransitionMessage(transition);
@@ -13,6 +13,7 @@ import type { ActorMessage } from "./actor-messages.ts";
13
13
  const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
14
14
  const STATE_LOCK_TIMEOUT_MS = 5000;
15
15
  const DEFAULT_ROOM_MAX_MESSAGES = 10000;
16
+ const DEFAULT_BRANCH_INBOX_TERMINAL_RETAINED = 2000;
16
17
  const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = 250;
17
18
 
18
19
  export interface RoomMember {
@@ -241,6 +242,15 @@ function readJsonlLineCount(file: string): number {
241
242
  }
242
243
  }
243
244
 
245
+ function readRoomMessageCount(stateDir: string, room: string): number {
246
+ try {
247
+ return readJsonlLineCount(messagesFile(stateDir, room));
248
+ } catch (error) {
249
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0;
250
+ throw error;
251
+ }
252
+ }
253
+
244
254
  function readJsonlTailLines(file: string, limit: number): string[] {
245
255
  const lineLimit = Math.max(1, limit);
246
256
  const stat = fs.statSync(file);
@@ -377,6 +387,26 @@ export function readBranchInboxMessages(
377
387
  }
378
388
  }
379
389
 
390
+ export function getBranchInboxTerminalRetainLimit(): number {
391
+ const value = Number(process.env.PI_ACTORS_BRANCH_INBOX_TERMINAL_RETAINED ?? "");
392
+ return Number.isInteger(value) && value >= 0
393
+ ? value
394
+ : DEFAULT_BRANCH_INBOX_TERMINAL_RETAINED;
395
+ }
396
+
397
+ function compactBranchInboxMessages<T extends { status?: string }>(
398
+ messages: T[],
399
+ ): T[] {
400
+ const retainTerminal = getBranchInboxTerminalRetainLimit();
401
+ const active = messages.filter(
402
+ (message) => message.status !== "handled" && message.status !== "failed",
403
+ );
404
+ const terminal = messages.filter(
405
+ (message) => message.status === "handled" || message.status === "failed",
406
+ );
407
+ return [...terminal.slice(-retainTerminal), ...active];
408
+ }
409
+
380
410
  export function appendBranchInboxMessage(
381
411
  stateDir: string,
382
412
  run: string,
@@ -419,7 +449,8 @@ export function updateBranchInboxMessageStatus(
419
449
  return { ...message, ...metadata, [timestampKey]: new Date().toISOString(), status };
420
450
  });
421
451
  if (!changed) return false;
422
- fs.writeFileSync(file, `${updated.map((message) => JSON.stringify(message)).join("\n")}\n`);
452
+ const compacted = compactBranchInboxMessages(updated);
453
+ fs.writeFileSync(file, `${compacted.map((message) => JSON.stringify(message)).join("\n")}\n`);
423
454
  return true;
424
455
  } finally {
425
456
  releaseLock();
@@ -446,7 +477,7 @@ export function appendRoomMessage(
446
477
  }
447
478
  }
448
479
  return {
449
- message_count: readRoomMessages(stateDir, room).length,
480
+ message_count: readRoomMessageCount(stateDir, room),
450
481
  room,
451
482
  roster_count: Object.keys(roster).length,
452
483
  sent: true,
@@ -496,12 +527,7 @@ export function readRoomMessagePreviews(
496
527
  }
497
528
 
498
529
  export function getRoomStatus(stateDir: string, room: string): RoomStatus {
499
- let messageCount = 0;
500
- try {
501
- messageCount = readJsonlLineCount(messagesFile(stateDir, room));
502
- } catch (error) {
503
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
504
- }
530
+ const messageCount = readRoomMessageCount(stateDir, room);
505
531
  const [last] = readRoomMessages(stateDir, room, 1);
506
532
  return {
507
533
  ...(last
@@ -529,7 +555,7 @@ export function ensureRoomMember(
529
555
  const roster = readRoomRoster(stateDir, room);
530
556
  if (roster[address]) {
531
557
  return {
532
- message_count: readRoomMessages(stateDir, room).length,
558
+ message_count: readRoomMessageCount(stateDir, room),
533
559
  room,
534
560
  roster_count: Object.keys(roster).length,
535
561
  sent: true,