@llblab/pi-actors 0.19.3 → 0.19.10

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
@@ -14,7 +14,7 @@
14
14
  ## Topology
15
15
 
16
16
  - `/index.ts`: Minimal extension coordinator/composition root; it wires live pi ports and should avoid owning domain behavior
17
- - `/lib/*.ts`: Flat Domain DAG modules for cohesive reusable behavior; `command-templates.ts` mirrors the shared portable command-template standard, `schema.ts` owns tool arg declarations and placeholder-derived tool schemas, `identity.ts` owns names, `config.ts` owns config persistence, `registry.ts` owns registry register/update/delete use-cases, `output.ts` owns result formatting/truncation, `execution.ts` owns registered-tool execution, `recipe-references.ts` owns template recipe reference detection and path resolution, `async-runs.ts` owns async run state, `actor-rooms.ts` owns room timelines/rosters/communication snapshots, `actor-inspector-tui.ts` owns compact terminal previews and selected-message inspection for actor communications, `observability.ts` owns ambient run summaries, `temp.ts` owns pi-agent temp cleanup, `prompts.ts` owns LLM-facing copy, `tools.ts` owns pi-facing tool definitions for both `register_tool`, async run primitives, and generated runtime tools, `runtime.ts` owns load/conflict/registration coordination, and `paths.ts` owns config/tmp path resolution
17
+ - `/lib/*.ts`: Flat Domain DAG modules for cohesive reusable behavior; `command-templates.ts` mirrors the shared portable command-template standard, `schema.ts` owns tool arg declarations and placeholder-derived tool schemas, `identity.ts` owns names, `config.ts` owns config persistence, `registry.ts` owns registry register/update/delete use-cases, `output.ts` owns result formatting/truncation, `execution.ts` owns registered-tool execution, `recipe-references.ts` owns template recipe reference detection and path resolution, `async-runs.ts` owns async run state, `actor-rooms.ts` owns room timelines/rosters/communication snapshots with burst-safe roster and branch snapshot writes, locked branch-local inbox mutations, plus status reads that avoid parsing full timelines, `actor-inspector-tui.ts` owns compact terminal previews, branch-inbox unread/current-branch filtering, and selected-message inspection for actor communications, `observability.ts` owns ambient run summaries, `temp.ts` owns pi-agent temp cleanup, `prompts.ts` owns LLM-facing copy, `tools.ts` owns pi-facing tool definitions for both `register_tool`, async run primitives, and generated runtime tools, `runtime.ts` owns load/conflict/registration coordination, and `paths.ts` owns config/tmp path resolution
18
18
  - `index.ts` should import local domains as namespaces (`import * as CommandTemplates from "./lib/command-templates.ts"`) so orchestration reads through domain names instead of flat helper imports
19
19
  - `/scripts/*.mjs`: Thin helper processes for detached async run execution; keep policy in registered tool config and reusable logic in `/lib`
20
20
  - `/recipes/*.json`: Packaged standard recipe library; keep recipes optional, composable, and policy-light; prefer public args for operator/agent decisions instead of baking project-specific prompts, file names, or concrete model-version defaults into reusable recipes
@@ -39,7 +39,7 @@
39
39
 
40
40
  - `Knowledge surface separation`: pi-actors has distinct knowledge surfaces with different context-entry behavior: injected prompt is always present and should stay a tiny bootstrap/reminder; packaged `actors` skill is auto-matched by name/description and should signal that its body is the highest-density practical guide for operating the extension plus the shortest navigator to bundled recipes; packaged `swarm` skill is auto-matched for multi-agent methodology, strategies, standards, and portable examples; README is the human entrypoint explaining concept, rhythm, benefits, and scenarios but is not automatically in context; `/docs` are detailed transportable standards read on demand; `AGENTS.md` is project context and architectural constraints for agents changing the repo | Trigger: Editing prompt copy, README, docs, skills, or project context | Action: Keep each surface on its own wave, avoid duplicating prompt and skill headers, keep the actors skill recipe navigator compact and concrete, avoid duplicating scenario catalogs or changelog narratives in the actors skill, avoid turning the prompt into docs, keep multi-agent methodology in swarm-oriented guidance rather than the actors skill, keep packaged extension skill metadata versions synchronized with `package.json` version, and avoid extra colons in skill frontmatter scalar lines because skill formatters treat them poorly
41
41
  - `Tool registry is executable muscle memory`: `~/.pi/agent/recipes/*.json` is the persistent user tool surface by location: every recipe in that agent root is automatically registered as an agent tool across sessions, and `register_tool` creates/updates/deletes recipe files there under the hood | Trigger: Any runtime registration, recipe discovery, migration, docs, skill, prompt, or recipe authoring work | Action: Treat the directory like `MEMORY.md` for executable habits; preserve filename identity, atomic writes, explicit operator-gated migration paths, and never author a recipe-owned `tool` property in repository recipes, docs, or fixtures; packaged/ad hoc recipes outside the agent root are components, not tools
42
- - `Current runtime contract`: Register trusted command templates with tool names from registry keys, placeholder-derived args, progressive typed arg declarations, inline/default/`??`/ternary config fallback, placeholder-derived numeric node controls, split-first command-arg construction, sequential or `parallel: true` composition, direct no-shell execution, optional per-node `when`, optional per-node positive `timeout` disabled by default, lightweight warnings for obvious trusted-executable risk shapes, per-node `delay`, bounded leaf/node `retry`, `failure: "continue|branch|root"` propagation, `recover` cleanup between retry attempts, template recipes with explicit `async: true` detached mode, actor-oriented `spawn`/`message`/`inspect` tools with run-local JSONL outbox messages, Unix FIFO send, graceful cancel, and force kill, generic detached run primitives with process-group cancellation, injected async `{run_id}` and `{state_dir}` values, coordinator-scoped event-driven observability with at least one triangle per active async run and extra triangles for active parallel branches, runtime-inferred `command.done` bubbling for packaged multi-agent fanout, terminal follow-ups for `done`/`failed`/unhandled `killed`/`exited` states, recipe-persistence suggestions for successful direct inline/ad hoc `spawn` runs that are not already durable user recipes, named recipe `artifacts`, recipe `mailbox` metadata, `template` recipe references, recipe-layer `imports`, file-backed async recipe JSONL context bundles for child `pi -p` actors with raw entry/import recipes and `"you_are_here": true`, co-located recipe entries, `~/.pi/agent/recipes/*.json` template recipe files, run state under `~/.pi/agent/tmp/pi-actors/runs`, and `{file}` as the canonical local file path arg | Trigger: Changing registration or invocation behavior | Action: Keep README, command-template docs, template-recipe docs, async-run docs, actor-message docs, implementation, and migration notes aligned
42
+ - `Current runtime contract`: Register trusted command templates with tool names from registry keys, placeholder-derived args, progressive typed arg declarations, inline/default/`??`/ternary config fallback, placeholder-derived numeric node controls, split-first command-arg construction, sequential or `parallel: true` composition, direct no-shell execution, optional per-node `when`, optional per-node positive `timeout` disabled by default, lightweight warnings for obvious trusted-executable risk shapes, per-node `delay`, bounded leaf/node `retry`, `failure: "continue|branch|root"` propagation, `recover` cleanup between retry attempts, template recipes with explicit `async: true` detached mode, actor-oriented `spawn`/`message`/`inspect` tools with run-local JSONL outbox messages, Unix FIFO send, graceful cancel, and force kill, generic detached run primitives with process-group cancellation, injected async `{run_id}` and `{state_dir}` values, coordinator-scoped event-driven observability with at least one triangle per active async run and extra triangles for active parallel branches, runtime-inferred `command.done` bubbling for packaged multi-agent fanout, terminal follow-ups for `done`/`failed`/unhandled `killed`/`exited` states, recipe-persistence suggestions for successful direct inline/ad hoc `spawn` runs and successful recipes outside the durable user recipe root, named recipe `artifacts`, recipe `mailbox` metadata, `template` recipe references, recipe-layer `imports`, file-backed async recipe JSONL context bundles for child `pi -p` actors with raw entry/import recipes and `"you_are_here": true`, co-located recipe entries, `~/.pi/agent/recipes/*.json` template recipe files, run state under `~/.pi/agent/tmp/pi-actors/runs`, and `{file}` as the canonical local file path arg | Trigger: Changing registration or invocation behavior | Action: Keep README, command-template docs, template-recipe docs, async-run docs, actor-message docs, implementation, and migration notes aligned
43
43
  - `Typed arg authoring`: Typed args support `string`, `path`, `int`, `number`, `bool`, and `enum(...)` plus two equivalent readability styles: metadata-first (`args` + `defaults` + simple `{name}` placeholders) for long command lines, and inline-first (`{name:type=default}` placeholders) for compact one-property templates | Trigger: Changing arg parsing, docs, schema generation, or registry serialization | Action: Preserve both styles, keep explicit `args` type declarations higher priority than inline placeholder types, and make breaking cleanup explicit when removing old arg shapes
44
44
  - `Template recipe graph`: The valid execution chain is `tool → template → recipe → run → template`; file-backed and co-located recipes are storage variants of that chain | Trigger: Adding registry bindings, recipes, docs, or runtime shortcuts | Action: Keep command templates synchronous and portable, use `async: true` as the detached run switch, require every recipe to own `template` directly, and reject cyclic shortcuts such as recipe-owned `tool`
45
45
  - `Layer boundary discipline`: Command-template evolution must be separated from template-recipe configuration and async-run lifecycle configuration | Trigger: Adding syntax, placeholders, imports, async controls, or docs | Action: Put portable execution graph semantics in `docs/command-templates.md`, recipe storage/import/default/reference behavior in `docs/template-recipes.md`, and detached lifecycle/state/IPC behavior in `docs/async-runs.md`; type imported recipes as command-template-shaped recipe definitions, not async-run instances
@@ -50,10 +50,10 @@
50
50
  - `Runtime IO discipline`: Tool stdout and temp state must stay bounded and local | Trigger: Changing execution, formatting, temp files, run state, logs, or artifacts | Action: Keep tail truncation/full-output temp files/failure formatting intact; keep extension-owned temp state under `~/.pi/agent/tmp/pi-actors` unless explicitly overridden
51
51
  - `Backlog is planning, not history`: `BACKLOG.md` should contain only completable future work with current task/scope/exit criteria; completed delivery history belongs in `CHANGELOG.md`, and durable or evergreen behavior belongs in `AGENTS.md`, README, docs, or skills | Trigger: Editing backlog or reconciling completed slices | Action: Remove historical progress narratives, version-scoped headings, watch-mode/monitoring principles, open-ended “continue evolving” items, and conditional “if usage proves” notes unless they are framed as a concrete gated task; keep priority order and prefer an 80/20 focus list when many remaining tasks compete for attention
52
52
  - `Release artifact hygiene`: PR/release summaries become stale during active branch work and do not belong in the repository documentation tree | Trigger: Preparing release notes or PR bodies | Action: Create temporary/operator-facing artifacts outside the repo only during explicit release finalization; keep durable release evidence in `CHANGELOG.md` and open gates in `BACKLOG.md`
53
- - `Graceful actor retirement`: Coordinator/helper actors that exist only to supervise a bounded worker tree should have explicit retirement semantics instead of relying on the operator or LLM to remember cleanup | Trigger: Designing coordinator recipes, helper actors, worker fanout, locker-backed swarms, or auto-stop behavior | Action: Make retirement opt-in through recipe/run metadata, retire only after observed child actors or descendant workers are terminal and outputs are flushed, prefer graceful control messages before process termination, record retirement events, and never infer retirement for persistent services or backlog implementers
53
+ - `Graceful actor retirement`: Coordinator/helper actors that exist only to supervise a bounded worker tree should have explicit retirement semantics instead of relying on the operator or LLM to remember cleanup | Trigger: Designing coordinator recipes, helper actors, worker fanout, locker-backed swarms, or auto-stop behavior | Action: Make retirement opt-in through recipe/run metadata, keep candidates blocked while active command-template branches or descendant `pi -p` workers are still running, retire only after observed child actors are terminal and outputs are flushed, prefer graceful control messages before process termination, record retirement events, and never infer retirement for persistent services or backlog implementers
54
54
  - `Persistent implementer workflows are recipe composition`: Backlog implementer scenarios should be launched through reusable component recipes, not one-off scripts or ad hoc shell orchestration | Trigger: Designing implementer swarms, backlog workers, coordinator-assigned task loops, or related recipes | Action: Compose cells such as `coordinator-locker`, subagent launchers, and actor-message utilities; preserve JSON envelope object shape across handoffs; add missing reusable component recipes only when needed; update the actors skill launcher map with supported scenarios
55
55
  - `Modular coordination and separate lock state`: The coordination of multi-agent workflows is split into two cleanly decoupled layers: the active coordinator and the stateful locker. The locker manages task queueing and resource lock leases over Unix FIFO/pipes without project policy. The coordinator script (`scripts/coordinator.mjs`) manages process pools, rooms, and lifecycles, and supports different pluggable mode strategies (`pipeline`, `fanout`, `pool`, `consensus`). | Trigger: Modifying coordination scripts, queues, locking, or parallel worker flows | Action: Keep the locker generic and thin, and implement all orchestration strategy rules inside the multi-mode coordinator.
56
- - `Active branch inbox queues`: Direct branch messages are active, work-triggering inbox queues rather than passive files. During subagent execution, the coordinator automatically claims (`claimed`), injects, and handles (`handled`/`failed`) queued branch-local direct messages to allow interactive/resumable worker workflows. | Trigger: Delivering branch messages, executing subagents, or updating branch queues | Action: Ensure direct messages can continue or wake long-lived branch runners, and keep the FIFO queue status transitions clean and fully tested.
56
+ - `Active branch inbox queues`: Direct branch messages are active, work-triggering inbox queues rather than passive files. During subagent execution, the coordinator atomically claims (`claimed`), assigns missing message IDs, injects, and handles (`handled`/`failed`) queued branch-local direct messages to allow interactive/resumable worker workflows. | Trigger: Delivering branch messages, executing subagents, or updating branch queues | Action: Ensure direct messages can continue or wake long-lived branch runners, guard branch-local inbox append/status rewrites with the branch inbox lock, and keep the FIFO queue status transitions clean and fully tested.
57
57
  - `Recipe library growth is demand-driven`: Packaged recipes should grow from concrete repeated task patterns, not speculative scenario catalogs | Trigger: Adding packaged utilities, pipelines, or component recipes | Action: Prefer existing component composition, keep recipes policy-light with caller-owned prompts/models/paths/knobs, avoid scenario-specific scripts when existing components suffice, and document new reusable launch scenarios in the actors skill only after the recipe exists
58
58
  - `Context sync`: Meaningful implementation or docs changes must reconcile `BACKLOG.md`, `CHANGELOG.md`, README, and docs navigation | Trigger: Closing, narrowing, or discovering work | Action: Run the context validator before final status when practical
59
59
  - `Public path hygiene`: Published docs must not include machine-local absolute paths | Trigger: Adding validation commands, examples, or local instructions to README/AGENTS/docs/changelog | Action: Use `~/.pi/...`, `<repo>/...`, `${SKILL_DIR}/...`, or relative paths
package/BACKLOG.md CHANGED
@@ -9,19 +9,10 @@
9
9
  - Direction:
10
10
  - Evaluate whether room storage/routing should remain built into the tool adapter or move behind a dedicated non-LLM communication actor recipe/script, possibly singleton-scoped. Preserve the same public `room:<run>` address and envelope either way.
11
11
  - Consider reducing direct file-backed state where it improves coherence: model room/roster state as actor-owned data structures served by helper scripts/actors, with files retained only for durable snapshots, recovery, artifacts, or audit logs.
12
- - Avoid full roster rewrite amplification during bursty room activity; branch communication snapshot writes are already debounced while root snapshots stay current.
12
+ - Further storage changes should preserve the current burst/read/concurrency safeguards: branch communication snapshot writes are debounced, root snapshots stay current, roster files are not rewritten during bursts when only `last_seen` changes, room status inspection does not parse full timelines, and branch-local inbox append/status rewrites are lock-guarded.
13
13
  - Exit:
14
14
  - Any backend/storage change preserves existing `spawn` / `message` / `inspect` semantics and room address compatibility.
15
15
 
16
- ### Actor Communication TUI Preview
17
-
18
- - Priority: High.
19
- - Goal: Make actor-to-actor communication more navigable in the terminal UI without exposing large payloads by default.
20
- - Direction:
21
- - Add current-branch and unread filters after branch read-state semantics are real.
22
- - Exit:
23
- - Operators can distinguish unread/current-branch messages while retaining intentional full-body inspection.
24
-
25
16
  ### Graceful Actor Retirement
26
17
 
27
18
  - Priority: Medium.
@@ -29,7 +20,7 @@
29
20
  - Direction:
30
21
  - Build on the existing `retire_when: "children_terminal"` recipe/run metadata contract and observability retirement-candidate detection for ephemeral supervisors.
31
22
  - Treat auto-retirement as opt-in only; never infer it for arbitrary long-lived services, user tools, or persistent backlog implementers.
32
- - Extend candidate detection from current `progress.activeSubagents === 0` gating to full observed child/descendant actor state rather than log text: the supervisor may retire only when all launched child async runs or descendant `pi -p` workers are terminal and required artifacts/outbox events have been flushed.
23
+ - Extend candidate detection beyond current active command/proc-descendant gating to full observed child async-run state rather than log text: the supervisor may retire only when all launched child async runs are terminal and required artifacts/outbox events have been flushed.
33
24
  - Prefer graceful stop (`control.stop` / actor message) before process termination; escalate only after a bounded timeout and record the retirement event in run state.
34
25
  - Preserve manual `cancel` / `kill` semantics and make retirement visible through `inspect` / ambient observability.
35
26
  - Exit:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.10: Legacy Branch Message Claim IDs
4
+
5
+ - `[Branch Messages]` Coordinator claim handling now assigns IDs to older/manual queued branch inbox entries that lack `id`, so injected direct messages can still transition to `handled` or `failed` and do not repeat forever.
6
+ - `[Tests]` Extended direct branch inbox coordinator coverage to include a legacy no-ID message and assert both claimed/handled timestamps are recorded.
7
+ - `[Docs/Context]` Updated actor-message docs, durable project context, package metadata, lockfile metadata, and packaged skill metadata to `0.19.10`.
8
+
9
+ ## 0.19.9: Locked Branch Inbox Mutations
10
+
11
+ - `[Branch Messages]` Added lock-guarded append and status rewrites for branch-local direct-message inbox files so concurrent direct delivery and coordinator claim/handle transitions do not overwrite each other.
12
+ - `[Coordinator]` Made room-swarm branch prompt execution atomically claim queued direct messages before injection, then mark claimed messages as `handled` or `failed` after the child prompt exits.
13
+ - `[Tests]` Added concurrent branch inbox append coverage and asserted coordinator direct-message handling records both `claimed_at` and `handled_at`.
14
+ - `[Docs/Context]` Updated actor-message docs, project context, backlog safeguards, package metadata, lockfile metadata, and packaged skill metadata to `0.19.9`.
15
+
16
+ ## 0.19.8: Efficient Room Status Reads
17
+
18
+ - `[Rooms]` Changed room status inspection to count JSONL entries and read only the last timeline record instead of parsing the full room timeline into actor-envelope objects.
19
+ - `[Inspector]` Preserved the existing `inspect room:<run> view=status` shape while reducing storage/read amplification for large room transcripts.
20
+ - `[Docs/Context]` Updated actor-message docs, backlog safeguards, project context, package metadata, lockfile metadata, and skill metadata for `0.19.8`.
21
+ - `[Tests]` Added regression coverage that room status preserves message count and last-message metadata across longer timelines.
22
+
23
+ ## 0.19.7: Burst-Safe Roster Writes
24
+
25
+ - `[Rooms]` Debounced room roster rewrites when a burst only changes a member's `last_seen`, while still writing semantic roster changes such as role, status, display, caps, claim, or parent immediately.
26
+ - `[Runtime IO]` Added `PI_ACTORS_ROOM_ROSTER_MIN_MS` as the roster-only debounce interval, mirroring the existing communication snapshot debounce approach without changing public `room:<run>` message or inspect semantics.
27
+ - `[Docs/Context]` Updated actor-message docs, project context, and the remaining rooms backlog scope to preserve the new burst-safe roster invariant during future storage/backend changes.
28
+ - `[Tests]` Added regression coverage for roster rewrite debounce and immediate semantic roster updates.
29
+ - `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata to `0.19.7` for the hotfix release.
30
+
31
+ ## 0.19.6: Conservative Retirement Candidates
32
+
33
+ - `[Observability]` Added per-run descendant `pi -p` worker counting and exposes `descendantSubagents` on run observations. Ambient run status still counts active descendant workers, but now retains the per-run attribution needed for supervisor lifecycle decisions.
34
+ - `[Retirement]` Tightened opt-in `retire_when: "children_terminal"` candidate detection so supervisors are not considered retirement-ready while command-template progress or descendant `pi -p` workers are still active.
35
+ - `[Docs/Context]` Updated async-run docs, project context, and the remaining retirement backlog scope to reflect the conservative candidate baseline and the remaining child async-run/output-flush work.
36
+ - `[Tests]` Added regression coverage that blocks retirement candidates with descendant subagents.
37
+ - `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata to `0.19.6` for the hotfix release.
38
+
39
+ ## 0.19.5: Branch Inbox Inspector Filters
40
+
41
+ - `[Actor Inspector]` Added branch-local inbox previews to the compact actor communication table, so queued direct `branch:<run>/<branch>` work is visible alongside room, run inbox, and outbox messages.
42
+ - `[Actor Inspector]` Added `/actors-inspector-filter unread`, `/actors-inspector-filter branch <name>`, and `/actors-inspector-filter current-branch <name>` to focus queued branch inbox work and one branch's room/direct/inbox traffic without exposing full payloads by default.
43
+ - `[Docs/Skills]` Updated README and the packaged actors skill with the new inspector filters and branch-inbox preview behavior.
44
+ - `[Backlog]` Closed the high-priority actor communication TUI preview item now that unread/current-branch navigation is implemented with branch read-state semantics.
45
+ - `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata to `0.19.5` for the hotfix release.
46
+
47
+ ## 0.19.4: User Recipe Collection Suggestions
48
+
49
+ - `[Observability]` Broadened recipe persistence suggestions from direct inline spawns to the normal user workflow: any successful actor run backed by a recipe outside `~/.pi/agent/recipes` now asks the launching agent to offer copying/registering it into the user recipe root when it fits this machine's recurring workflow.
50
+ - `[Runtime]` Preserved the ask-first boundary and suppression for recipes already in the user recipe root, so pi-actors grows operator muscle memory without silently writing user recipe files.
51
+ - `[Docs/Prompt]` Updated README, async-run docs, actors skill, onboarding prompt, and project context to frame `~/.pi/agent/recipes` as the everyday per-machine collection of reusable actor recipes/tools.
52
+ - `[Tests]` Added coverage for successful external recipe suggestions, while keeping user-owned recipe suppression covered.
53
+ - `[Package]` Bumped package metadata, lockfile metadata, and packaged skill metadata to `0.19.4` for the hotfix release.
54
+
3
55
  ## 0.19.3: Spawn Recipe Persistence Suggestions
4
56
 
5
57
  - `[Observability]` Added semi-active recipe persistence suggestions for successful direct `spawn` runs. Inline/ad hoc spawned actors now record `launch_source: "spawn"`, and their successful terminal follow-up asks the agent to offer saving the reusable pattern as a durable recipe/tool under `~/.pi/agent/recipes` without auto-saving.
package/README.md CHANGED
@@ -155,11 +155,13 @@ The terminal actor inspector is hidden by default. When opened without an explic
155
155
  /actors-inspector-toggle 20
156
156
  /actors-inspector-filter room
157
157
  /actors-inspector-filter direct
158
+ /actors-inspector-filter unread
159
+ /actors-inspector-filter branch front
158
160
  /actors-inspector-filter mention checkpoint
159
161
  /actors-inspect 3
160
162
  ```
161
163
 
162
- The table is compact and optimistic by default: bounded route/type/summary/body previews, capped noisy room rows, and an inline roster summary in the form `name/role` that wraps only when needed. Active roster members use the target color; members that sent `actor.leave` remain visible as inactive/muted participants from the current run. `/actors-inspect <number>` opens the selected row as a full-message view; toggle again to return to the table or close it. Actor display names come from room `actor.join` roster metadata or branch addresses, keeping debugger output plain and name-driven.
164
+ The table is compact and optimistic by default: bounded route/type/summary/body previews, capped noisy room rows, branch-local inbox previews, and an inline roster summary in the form `name/role` that wraps only when needed. Active roster members use the target color; members that sent `actor.leave` remain visible as inactive/muted participants from the current run. Use `unread` to focus queued branch inbox work and `branch <name>` / `current-branch <name>` to focus one branch's room/direct/inbox traffic. `/actors-inspect <number>` opens the selected row as a full-message view; toggle again to return to the table or close it. Actor display names come from room `actor.join` roster metadata or branch addresses, keeping debugger output plain and name-driven.
163
165
 
164
166
  ## Registry Model
165
167
 
@@ -239,7 +241,7 @@ Packaged recipes are building blocks. Copy them into `~/.pi/agent/recipes/` or r
239
241
 
240
242
  Use a foreground registered tool when the work is short, bounded, and does not need lifecycle.
241
243
 
242
- Use an async recipe or `spawn` when the work is long-running, service-like, parallel, agentic, artifact-producing, or needs later control. If a directly spawned inline/ad hoc actor completes successfully, pi-actors sends the launching agent a follow-up note to offer saving that pattern as a durable recipe/tool under `~/.pi/agent/recipes`; the agent should ask first and never auto-save.
244
+ Use an async recipe or `spawn` when the work is long-running, service-like, parallel, agentic, artifact-producing, or needs later control. When a directly spawned inline/ad hoc actor or a recipe outside the user recipe root completes successfully, pi-actors sends the launching agent a follow-up note to offer saving that pattern as a durable recipe/tool under `~/.pi/agent/recipes`; the agent should ask first and never auto-save.
243
245
 
244
246
  Use `room:<run>` when multiple actors in the same run need shared context, roster discovery, or group-visible progress.
245
247
 
@@ -94,7 +94,7 @@ Transports differ, but the public contract does not:
94
94
 
95
95
  - `to: run:<id>` routes through the run-local control channel selected by that recipe or runtime adapter.
96
96
  - `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. 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.
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.
98
98
  - `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
99
  - `to: tool:<name>` invokes an executable pi tool by name. Object bodies become tool parameters; primitive bodies are passed as `{ "input": body }`.
100
100
 
@@ -182,7 +182,7 @@ Recipes can declare their conversational surface:
182
182
  }
183
183
  ```
184
184
 
185
- `spawn` creates detached `run:<id>` actors from a recipe file/name or inline command template. Spawn metadata may include explicit `state_dir` and named `artifacts` for terminal follow-ups and inspection.
185
+ `spawn` creates detached `run:<id>` actors from a recipe file/name or inline command template. Spawn metadata may include explicit `state_dir` and named `artifacts` for terminal follow-ups and inspection. Room rosters are durable but burst-safe: repeated messages that only update `last_seen` may be coalesced briefly, while semantic roster changes such as role/status/display still write immediately.
186
186
 
187
187
  ## Inspect
188
188
 
@@ -195,7 +195,7 @@ Recipes can declare their conversational surface:
195
195
  }
196
196
  ```
197
197
 
198
- The implementation supports `status`, `tail`, `messages`, `artifacts`, `files`, `mailbox`, and `communication` for `run:<id>` actors, `status`, `messages`, `previews`, `roster`, and `contacts` for `room:<run>` actors, `status`/`runs` for `coordinator`, `session:<id>`, and `session:all` actors with optional status filtering, and `status`/`schema` for registered `tool:<name>` actors. Room `status` returns compact message/roster counts plus `last_message_at`, `last_message_from`, `last_message_type`, and `last_message_summary` when available. Use `messages` for actor-envelope inspection. `inspect target=coordinator` requires a current coordinator session; use `session:<id>` or `session:all` when the session is intentionally explicit. Direct `run:<id>` and `room:<run>` inspection respects coordinator-session ownership when the current session is known. `inspect` is for decision points and diagnosis only; examples must not teach sleep-then-inspect polling.
198
+ The implementation supports `status`, `tail`, `messages`, `artifacts`, `files`, `mailbox`, and `communication` for `run:<id>` actors, `status`, `messages`, `previews`, `roster`, and `contacts` for `room:<run>` actors, `status`/`runs` for `coordinator`, `session:<id>`, and `session:all` actors with optional status filtering, and `status`/`schema` for registered `tool:<name>` actors. Room `status` returns compact message/roster counts plus `last_message_at`, `last_message_from`, `last_message_type`, and `last_message_summary` when available, without parsing the full timeline into actor envelopes. Use `messages` for actor-envelope inspection. `inspect target=coordinator` requires a current coordinator session; use `session:<id>` or `session:all` when the session is intentionally explicit. Direct `run:<id>` and `room:<run>` inspection respects coordinator-session ownership when the current session is known. `inspect` is for decision points and diagnosis only; examples must not teach sleep-then-inspect polling.
199
199
 
200
200
  ## Runtime Direction
201
201
 
@@ -124,7 +124,7 @@ The core loop is:
124
124
  { "recipe": "music-player.json", "as": "run:music" }
125
125
  ```
126
126
 
127
- 2. Let terminal completion, `command.done`, and script-authored follow-up messages reach the launching coordinator automatically. When a directly spawned inline/ad hoc actor completes successfully, the coordinator follow-up tells the agent to offer recipe persistence only as a question to the operator; it must not auto-save.
127
+ 2. Let terminal completion, `command.done`, and script-authored follow-up messages reach the launching coordinator automatically. When a directly spawned inline/ad hoc actor or a recipe outside `~/.pi/agent/recipes` completes successfully, the coordinator follow-up tells the agent to offer recipe persistence only as a question to the operator; it must not auto-save.
128
128
 
129
129
  3. Respond with explicit run-local messages when needed:
130
130
 
@@ -158,6 +158,8 @@ 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.
162
+
161
163
  Low-level async actions map into the actor surface instead of forming a second public model:
162
164
 
163
165
  - Start → `spawn`
package/index.ts CHANGED
@@ -54,6 +54,8 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
54
54
  | ActorInspectorTui.ActorInspectorPreview["channel"][]
55
55
  | undefined;
56
56
  let actorInspectorMention: string | undefined;
57
+ let actorInspectorBranch: string | undefined;
58
+ let actorInspectorUnreadOnly = false;
57
59
  let actorInspectorRoomLimitPerRun = 12;
58
60
  let selectedInspectorSequence: number | undefined;
59
61
  let recipeWatcherFailureNotified = false;
@@ -90,9 +92,11 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
90
92
  {
91
93
  channels: actorInspectorChannels,
92
94
  currentRunOnly: true,
95
+ branch: actorInspectorBranch,
93
96
  mention: actorInspectorMention,
94
97
  ownerId,
95
98
  roomLimitPerRun: actorInspectorRoomLimitPerRun,
99
+ unreadOnly: actorInspectorUnreadOnly,
96
100
  },
97
101
  );
98
102
  const rows =
@@ -337,7 +341,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
337
341
  });
338
342
  pi.registerCommand("actors-inspector-filter", {
339
343
  description:
340
- "Filter actor inspector rows: all, room, direct, broadcast, mention <text>",
344
+ "Filter actor inspector rows: all, room, direct, broadcast, unread, branch <name>, mention <text>",
341
345
  handler: async (args, ctx) => {
342
346
  const parts = Array.isArray(args)
343
347
  ? args.map(String)
@@ -346,9 +350,23 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
346
350
  if (!mode || mode === "all" || mode === "clear") {
347
351
  actorInspectorChannels = undefined;
348
352
  actorInspectorMention = undefined;
353
+ actorInspectorBranch = undefined;
354
+ actorInspectorUnreadOnly = false;
349
355
  } else if (mode === "room" || mode === "direct" || mode === "broadcast") {
350
356
  actorInspectorChannels = [mode];
351
357
  actorInspectorMention = undefined;
358
+ } else if (mode === "unread") {
359
+ actorInspectorUnreadOnly = true;
360
+ } else if (mode === "branch" || mode === "current-branch") {
361
+ const branch = parts.slice(1).join(" ").trim();
362
+ if (!branch) {
363
+ ctx.ui.notify(
364
+ `Usage: /actors-inspector-filter ${mode} <branch-name>`,
365
+ "warning",
366
+ );
367
+ return;
368
+ }
369
+ actorInspectorBranch = branch;
352
370
  } else if (mode === "mention") {
353
371
  const mention = parts.slice(1).join(" ").trim();
354
372
  if (!mention) {
@@ -362,7 +380,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
362
380
  actorInspectorMention = mention;
363
381
  } else {
364
382
  ctx.ui.notify(
365
- "Usage: /actors-inspector-filter all|room|direct|broadcast|mention <text>",
383
+ "Usage: /actors-inspector-filter all|room|direct|broadcast|unread|branch <name>|mention <text>",
366
384
  "warning",
367
385
  );
368
386
  return;
@@ -13,9 +13,12 @@ import * as Paths from "./paths.ts";
13
13
 
14
14
  export interface ActorInspectorPreview {
15
15
  body_preview?: string;
16
+ branch?: string;
16
17
  channel: "broadcast" | "direct" | "room";
17
18
  from?: string;
18
19
  from_display?: string;
20
+ inbox_status?: string;
21
+ message_id?: string;
19
22
  run: string;
20
23
  sequence?: number;
21
24
  summary?: string;
@@ -50,10 +53,12 @@ export interface ActorInspectorRosterMember {
50
53
 
51
54
  export interface ActorInspectorPreviewReadOptions {
52
55
  ownerId?: string;
56
+ branch?: string;
53
57
  currentRunOnly?: boolean;
54
58
  channels?: ActorInspectorPreview["channel"][];
55
59
  mention?: string;
56
60
  roomLimitPerRun?: number;
61
+ unreadOnly?: boolean;
57
62
  }
58
63
 
59
64
  function asRecord(value: unknown): Record<string, unknown> {
@@ -207,6 +212,39 @@ function readInboxPreviews(
207
212
  .filter((preview): preview is ActorInspectorPreview => Boolean(preview));
208
213
  }
209
214
 
215
+ function readBranchInboxPreviews(
216
+ run: string,
217
+ stateDir: string,
218
+ ): ActorInspectorPreview[] {
219
+ const branchesDir = path.join(stateDir, "branches");
220
+ try {
221
+ return fs
222
+ .readdirSync(branchesDir, { withFileTypes: true })
223
+ .filter((entry) => entry.isDirectory())
224
+ .flatMap((entry) =>
225
+ readJsonLines(path.join(branchesDir, entry.name, "inbox.jsonl"))
226
+ .map((message): ActorInspectorPreview | undefined => {
227
+ const preview = previewFromMessage(
228
+ run,
229
+ message,
230
+ String(message.queued_at ?? message.received_at ?? message.timestamp ?? ""),
231
+ );
232
+ if (!preview) return undefined;
233
+ return {
234
+ ...preview,
235
+ branch: entry.name,
236
+ ...(typeof message.id === "string" ? { message_id: message.id } : {}),
237
+ ...(typeof message.status === "string" ? { inbox_status: message.status } : {}),
238
+ };
239
+ })
240
+ .filter((preview): preview is ActorInspectorPreview => Boolean(preview)),
241
+ );
242
+ } catch (error) {
243
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
244
+ return [];
245
+ }
246
+ }
247
+
210
248
  function readOutboxPreviews(
211
249
  run: string,
212
250
  stateDir: string,
@@ -238,6 +276,21 @@ function matchesOwner(stateDir: string, ownerId: string | undefined): boolean {
238
276
  return ownerId === undefined || getRunOwnerId(stateDir) === ownerId;
239
277
  }
240
278
 
279
+ function isUnreadPreview(preview: ActorInspectorPreview): boolean {
280
+ return preview.inbox_status === "queued" || preview.inbox_status === undefined && preview.branch !== undefined;
281
+ }
282
+
283
+ function matchesBranchFilter(
284
+ preview: ActorInspectorPreview,
285
+ branch: string | undefined,
286
+ ): boolean {
287
+ const name = branch?.trim();
288
+ if (!name) return true;
289
+ if (preview.branch !== undefined) return preview.branch === name;
290
+ const address = `branch:${preview.run}/${name}`;
291
+ return preview.from === address || preview.to === address;
292
+ }
293
+
241
294
  function matchesPreviewFilter(
242
295
  preview: ActorInspectorPreview,
243
296
  options: ActorInspectorPreviewReadOptions,
@@ -245,11 +298,15 @@ function matchesPreviewFilter(
245
298
  if (options.channels?.length && !options.channels.includes(preview.channel)) {
246
299
  return false;
247
300
  }
301
+ if (options.unreadOnly && !isUnreadPreview(preview)) return false;
302
+ if (!matchesBranchFilter(preview, options.branch)) return false;
248
303
  const mention = options.mention?.trim().toLowerCase();
249
304
  if (!mention) return true;
250
305
  return [
306
+ preview.branch,
251
307
  preview.from,
252
308
  preview.from_display,
309
+ preview.inbox_status,
253
310
  preview.to,
254
311
  preview.type,
255
312
  preview.summary,
@@ -297,6 +354,7 @@ export function readActorInspectorPreviews(
297
354
  return [
298
355
  ...readRoomPreviews(entry.name, stateDir),
299
356
  ...readInboxPreviews(entry.name, stateDir),
357
+ ...readBranchInboxPreviews(entry.name, stateDir),
300
358
  ...readOutboxPreviews(entry.name, stateDir),
301
359
  ];
302
360
  })
@@ -10,8 +10,8 @@ import * as path from "node:path";
10
10
 
11
11
  import type { ActorMessage } from "./actor-messages.ts";
12
12
 
13
- const ROOM_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
14
- const ROOM_LOCK_TIMEOUT_MS = 5000;
13
+ const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
14
+ const STATE_LOCK_TIMEOUT_MS = 5000;
15
15
  const DEFAULT_ROOM_MAX_MESSAGES = 10000;
16
16
  const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = 250;
17
17
 
@@ -117,9 +117,9 @@ function sleepSync(ms: number): void {
117
117
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
118
118
  }
119
119
 
120
- function acquireRoomLock(stateDir: string, room: string): () => void {
121
- ensureRoomDir(stateDir, room);
122
- const lockDir = path.join(roomDir(stateDir, room), ".append.lock");
120
+ function acquireStateLock(parentDir: string, name: string, label: string): () => void {
121
+ fs.mkdirSync(parentDir, { recursive: true });
122
+ const lockDir = path.join(parentDir, name);
123
123
  const started = Date.now();
124
124
  while (true) {
125
125
  try {
@@ -132,24 +132,29 @@ function acquireRoomLock(stateDir: string, room: string): () => void {
132
132
  } catch (error) {
133
133
  try {
134
134
  const stat = fs.statSync(lockDir);
135
- if (Date.now() - stat.mtimeMs > ROOM_LOCK_MAX_AGE_MS) {
135
+ if (Date.now() - stat.mtimeMs > STATE_LOCK_MAX_AGE_MS) {
136
136
  fs.rmSync(lockDir, { recursive: true, force: true });
137
137
  continue;
138
138
  }
139
139
  } catch {
140
140
  continue;
141
141
  }
142
- if (Date.now() - started > ROOM_LOCK_TIMEOUT_MS) {
143
- throw new Error(
144
- `Room append lock timed out for ${room} in ${stateDir}.`,
145
- { cause: error },
146
- );
142
+ if (Date.now() - started > STATE_LOCK_TIMEOUT_MS) {
143
+ throw new Error(`${label} lock timed out.`, { cause: error });
147
144
  }
148
145
  sleepSync(10);
149
146
  }
150
147
  }
151
148
  }
152
149
 
150
+ function acquireRoomLock(stateDir: string, room: string): () => void {
151
+ return acquireStateLock(roomDir(stateDir, room), ".append.lock", `Room append ${room}`);
152
+ }
153
+
154
+ function acquireBranchInboxLock(stateDir: string, branch: string): () => void {
155
+ return acquireStateLock(path.dirname(branchInboxFile(stateDir, branch)), ".inbox.lock", `Branch inbox ${branch}`);
156
+ }
157
+
153
158
  function asRecord(value: unknown): Record<string, unknown> {
154
159
  return value && typeof value === "object" && !Array.isArray(value)
155
160
  ? (value as Record<string, unknown>)
@@ -191,6 +196,13 @@ function snapshotMinIntervalMs(): number {
191
196
  );
192
197
  }
193
198
 
199
+ function rosterMinIntervalMs(): number {
200
+ return positiveEnvInt(
201
+ "PI_ACTORS_ROOM_ROSTER_MIN_MS",
202
+ DEFAULT_SNAPSHOT_MIN_INTERVAL_MS,
203
+ );
204
+ }
205
+
194
206
  function compactRoomMessages(stateDir: string, room: string): void {
195
207
  const maxMessages = roomMaxMessages();
196
208
  const file = messagesFile(stateDir, room);
@@ -204,6 +216,31 @@ function compactRoomMessages(stateDir: string, room: string): void {
204
216
  });
205
217
  }
206
218
 
219
+ function readJsonlLineCount(file: string): number {
220
+ const stat = fs.statSync(file);
221
+ if (stat.size === 0) return 0;
222
+ const fd = fs.openSync(file, "r");
223
+ try {
224
+ const chunkSize = 64 * 1024;
225
+ const chunk = Buffer.allocUnsafe(chunkSize);
226
+ let position = 0;
227
+ let count = 0;
228
+ let lastByte: number | undefined;
229
+ while (position < stat.size) {
230
+ const bytesRead = fs.readSync(fd, chunk, 0, Math.min(chunkSize, stat.size - position), position);
231
+ if (bytesRead <= 0) break;
232
+ position += bytesRead;
233
+ for (let index = 0; index < bytesRead; index += 1) {
234
+ if (chunk[index] === 10) count += 1;
235
+ }
236
+ lastByte = chunk[bytesRead - 1];
237
+ }
238
+ return lastByte === 10 ? count : count + 1;
239
+ } finally {
240
+ fs.closeSync(fd);
241
+ }
242
+ }
243
+
207
244
  function readJsonlTailLines(file: string, limit: number): string[] {
208
245
  const lineLimit = Math.max(1, limit);
209
246
  const stat = fs.statSync(file);
@@ -250,15 +287,38 @@ function writeRoomRoster(
250
287
  writeJsonFile(rosterFile(stateDir, room), roster);
251
288
  }
252
289
 
253
- function shouldDebounceSnapshot(file: string): boolean {
290
+ function shouldDebounceFile(file: string, minIntervalMs: number): boolean {
254
291
  try {
255
- return Date.now() - fs.statSync(file).mtimeMs < snapshotMinIntervalMs();
292
+ return Date.now() - fs.statSync(file).mtimeMs < minIntervalMs;
256
293
  } catch (error) {
257
294
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
258
295
  throw error;
259
296
  }
260
297
  }
261
298
 
299
+ function shouldDebounceSnapshot(file: string): boolean {
300
+ return shouldDebounceFile(file, snapshotMinIntervalMs());
301
+ }
302
+
303
+ function comparableRosterMember(member: RoomMember | undefined): string {
304
+ if (!member) return "";
305
+ const { last_seen: _lastSeen, ...semantic } = member;
306
+ return JSON.stringify(semantic);
307
+ }
308
+
309
+ function shouldWriteRoomRosterMember(
310
+ stateDir: string,
311
+ room: string,
312
+ before: RoomMember | undefined,
313
+ after: RoomMember,
314
+ ): boolean {
315
+ if (!before) return true;
316
+ if (comparableRosterMember(before) !== comparableRosterMember(after)) {
317
+ return true;
318
+ }
319
+ return !shouldDebounceFile(rosterFile(stateDir, room), rosterMinIntervalMs());
320
+ }
321
+
262
322
  function updateRosterForMessage(
263
323
  stateDir: string,
264
324
  room: string,
@@ -269,33 +329,33 @@ function updateRosterForMessage(
269
329
  if (!message.from) return roster;
270
330
  const body = asRecord(message.body);
271
331
  const current = roster[message.from];
272
- if (message.type === "actor.leave") {
273
- roster[message.from] = {
274
- address: message.from,
275
- joined_at: current?.joined_at ?? receivedAt,
276
- last_seen: receivedAt,
277
- ...(current?.caps !== undefined ? { caps: current.caps } : {}),
278
- ...(current?.claim !== undefined ? { claim: current.claim } : {}),
279
- ...(current?.display !== undefined ? { display: current.display } : {}),
280
- ...(current?.parent !== undefined ? { parent: current.parent } : {}),
281
- ...(current?.role !== undefined ? { role: current.role } : { role: "actor" }),
282
- status: String(body.status ?? "left"),
283
- };
332
+ const next = message.type === "actor.leave"
333
+ ? {
334
+ address: message.from,
335
+ joined_at: current?.joined_at ?? receivedAt,
336
+ last_seen: receivedAt,
337
+ ...(current?.caps !== undefined ? { caps: current.caps } : {}),
338
+ ...(current?.claim !== undefined ? { claim: current.claim } : {}),
339
+ ...(current?.display !== undefined ? { display: current.display } : {}),
340
+ ...(current?.parent !== undefined ? { parent: current.parent } : {}),
341
+ ...(current?.role !== undefined ? { role: current.role } : { role: "actor" }),
342
+ status: String(body.status ?? "left"),
343
+ }
344
+ : {
345
+ address: message.from,
346
+ joined_at: current?.joined_at ?? receivedAt,
347
+ last_seen: receivedAt,
348
+ ...(body.caps !== undefined ? { caps: body.caps } : current?.caps !== undefined ? { caps: current.caps } : {}),
349
+ ...(body.claim !== undefined ? { claim: body.claim } : current?.claim !== undefined ? { claim: current.claim } : {}),
350
+ ...(body.display !== undefined ? { display: body.display } : current?.display !== undefined ? { display: current.display } : {}),
351
+ ...(body.parent !== undefined ? { parent: body.parent } : current?.parent !== undefined ? { parent: current.parent } : {}),
352
+ ...(body.role !== undefined ? { role: body.role } : current?.role !== undefined ? { role: current.role } : { role: "actor" }),
353
+ status: String(body.status ?? current?.status ?? "present"),
354
+ };
355
+ roster[message.from] = next;
356
+ if (shouldWriteRoomRosterMember(stateDir, room, current, next)) {
284
357
  writeRoomRoster(stateDir, room, roster);
285
- return roster;
286
358
  }
287
- roster[message.from] = {
288
- address: message.from,
289
- joined_at: current?.joined_at ?? receivedAt,
290
- last_seen: receivedAt,
291
- ...(body.caps !== undefined ? { caps: body.caps } : current?.caps !== undefined ? { caps: current.caps } : {}),
292
- ...(body.claim !== undefined ? { claim: body.claim } : current?.claim !== undefined ? { claim: current.claim } : {}),
293
- ...(body.display !== undefined ? { display: body.display } : current?.display !== undefined ? { display: current.display } : {}),
294
- ...(body.parent !== undefined ? { parent: body.parent } : current?.parent !== undefined ? { parent: current.parent } : {}),
295
- ...(body.role !== undefined ? { role: body.role } : current?.role !== undefined ? { role: current.role } : { role: "actor" }),
296
- status: String(body.status ?? current?.status ?? "present"),
297
- };
298
- writeRoomRoster(stateDir, room, roster);
299
359
  return roster;
300
360
  }
301
361
 
@@ -325,12 +385,16 @@ export function appendBranchInboxMessage(
325
385
  ): void {
326
386
  const branch = branchIdFromAddress(address, run);
327
387
  if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
328
- fs.mkdirSync(path.dirname(branchInboxFile(stateDir, branch)), { recursive: true });
329
- fs.writeFileSync(
330
- branchInboxFile(stateDir, branch),
331
- `${JSON.stringify({ ...message, id: randomUUID(), queued_at: new Date().toISOString(), status: "queued" })}\n`,
332
- { flag: "a" },
333
- );
388
+ const releaseLock = acquireBranchInboxLock(stateDir, branch);
389
+ try {
390
+ fs.writeFileSync(
391
+ branchInboxFile(stateDir, branch),
392
+ `${JSON.stringify({ ...message, id: randomUUID(), queued_at: new Date().toISOString(), status: "queued" })}\n`,
393
+ { flag: "a" },
394
+ );
395
+ } finally {
396
+ releaseLock();
397
+ }
334
398
  }
335
399
 
336
400
  export function updateBranchInboxMessageStatus(
@@ -343,18 +407,23 @@ export function updateBranchInboxMessageStatus(
343
407
  ): boolean {
344
408
  const branch = branchIdFromAddress(address, run);
345
409
  if (!branch) throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
346
- const file = branchInboxFile(stateDir, branch);
347
- const messages = readBranchInboxMessages(stateDir, run, address, Number.MAX_SAFE_INTEGER);
348
- let changed = false;
349
- const timestampKey = `${status}_at`;
350
- const updated = messages.map((message) => {
351
- if (message.id !== id) return message;
352
- changed = true;
353
- return { ...message, ...metadata, [timestampKey]: new Date().toISOString(), status };
354
- });
355
- if (!changed) return false;
356
- fs.writeFileSync(file, `${updated.map((message) => JSON.stringify(message)).join("\n")}\n`);
357
- return true;
410
+ const releaseLock = acquireBranchInboxLock(stateDir, branch);
411
+ try {
412
+ const file = branchInboxFile(stateDir, branch);
413
+ const messages = readBranchInboxMessages(stateDir, run, address, Number.MAX_SAFE_INTEGER);
414
+ let changed = false;
415
+ const timestampKey = `${status}_at`;
416
+ const updated = messages.map((message) => {
417
+ if (message.id !== id) return message;
418
+ changed = true;
419
+ return { ...message, ...metadata, [timestampKey]: new Date().toISOString(), status };
420
+ });
421
+ if (!changed) return false;
422
+ fs.writeFileSync(file, `${updated.map((message) => JSON.stringify(message)).join("\n")}\n`);
423
+ return true;
424
+ } finally {
425
+ releaseLock();
426
+ }
358
427
  }
359
428
 
360
429
  export function appendRoomMessage(
@@ -427,8 +496,13 @@ export function readRoomMessagePreviews(
427
496
  }
428
497
 
429
498
  export function getRoomStatus(stateDir: string, room: string): RoomStatus {
430
- const messages = readRoomMessages(stateDir, room, Number.MAX_SAFE_INTEGER);
431
- const last = messages[messages.length - 1];
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
+ }
505
+ const [last] = readRoomMessages(stateDir, room, 1);
432
506
  return {
433
507
  ...(last
434
508
  ? {
@@ -438,7 +512,7 @@ export function getRoomStatus(stateDir: string, room: string): RoomStatus {
438
512
  last_message_type: last.type,
439
513
  }
440
514
  : {}),
441
- message_count: messages.length,
515
+ message_count: messageCount,
442
516
  room,
443
517
  roster_count: Object.keys(readRoomRoster(stateDir, room)).length,
444
518
  };
@@ -23,6 +23,7 @@ export type RunOutboxLevel = "info" | "warning" | "error";
23
23
  export interface RunObservation {
24
24
  activeSubagents?: number;
25
25
  completed?: number;
26
+ descendantSubagents?: number;
26
27
  failures?: number;
27
28
  ownerId?: string;
28
29
  artifacts?: Record<string, string>;
@@ -51,6 +52,7 @@ export interface RunSummary {
51
52
 
52
53
  export interface RunRetirementCandidate {
53
54
  activeSubagents: number;
55
+ descendantSubagents: number;
54
56
  run: string;
55
57
  stateDir: string;
56
58
  }
@@ -94,7 +96,7 @@ const PROC_DESCENDANT_SCAN_TTL_MS = 1000;
94
96
 
95
97
  const procDescendantScanCache = new Map<
96
98
  string,
97
- { count: number; expiresAt: number; signature: string }
99
+ { counts: Map<string, number>; expiresAt: number; signature: string }
98
100
  >();
99
101
 
100
102
  function toNumber(value: unknown): number | undefined {
@@ -182,18 +184,26 @@ export function summarizeRuns(
182
184
  .filter((run): run is RunObservation => Boolean(run))
183
185
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
184
186
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
185
- const runningRuns = runs.filter((run) => run.status === "running");
187
+ const processSubagentsByRun = countRunningSubagentsByRun(stateRoot, ownerId);
188
+ const runsWithDescendants = runs.map((run) => {
189
+ const descendantSubagents = processSubagentsByRun.get(run.run) ?? 0;
190
+ return descendantSubagents > 0 ? { ...run, descendantSubagents } : run;
191
+ });
192
+ const runningRuns = runsWithDescendants.filter((run) => run.status === "running");
186
193
  const running = runningRuns.length;
187
- const done = runs.filter((run) => run.status === "done").length;
188
- const exited = runs.filter((run) => run.status === "exited").length;
189
- const failed = runs.filter((run) => run.status === "failed").length;
190
- const cancelled = runs.filter((run) => run.status === "cancelled").length;
191
- const killed = runs.filter((run) => run.status === "killed").length;
194
+ const done = runsWithDescendants.filter((run) => run.status === "done").length;
195
+ const exited = runsWithDescendants.filter((run) => run.status === "exited").length;
196
+ const failed = runsWithDescendants.filter((run) => run.status === "failed").length;
197
+ const cancelled = runsWithDescendants.filter((run) => run.status === "cancelled").length;
198
+ const killed = runsWithDescendants.filter((run) => run.status === "killed").length;
192
199
  const progressSubagents = runningRuns.reduce(
193
200
  (sum, run) => sum + Math.max(1, Math.floor(run.activeSubagents ?? 0)),
194
201
  0,
195
202
  );
196
- const processSubagents = countRunningSubagents(stateRoot, ownerId);
203
+ const processSubagents = [...processSubagentsByRun.values()].reduce(
204
+ (sum, count) => sum + count,
205
+ 0,
206
+ );
197
207
  const runningSubagents = Math.max(progressSubagents, running + processSubagents);
198
208
  return {
199
209
  cancelled,
@@ -203,8 +213,8 @@ export function summarizeRuns(
203
213
  killed,
204
214
  running,
205
215
  runningSubagents,
206
- runs,
207
- total: runs.length,
216
+ runs: runsWithDescendants,
217
+ total: runsWithDescendants.length,
208
218
  };
209
219
  }
210
220
 
@@ -228,13 +238,13 @@ function getProcCommand(pid: string): string {
228
238
  return (readProcFile(`/proc/${pid}/cmdline`) ?? "").replaceAll("\0", " ");
229
239
  }
230
240
 
231
- function getRunningRunPids(stateRoot: string, ownerId?: string): Set<string> {
232
- const pids = new Set<string>();
241
+ function getRunningRunPidMap(stateRoot: string, ownerId?: string): Map<string, string> {
242
+ const pids = new Map<string, string>();
233
243
  for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
234
244
  if (run.status !== "running") continue;
235
245
  const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
236
246
  const pid = Number(status.pid || 0);
237
- if (pid > 0) pids.add(String(pid));
247
+ if (pid > 0) pids.set(String(pid), run.run);
238
248
  }
239
249
  return pids;
240
250
  }
@@ -278,18 +288,18 @@ function summarizeRunsWithoutSubagents(
278
288
  };
279
289
  }
280
290
 
281
- export function countRunningSubagents(
291
+ export function countRunningSubagentsByRun(
282
292
  stateRoot = Paths.getRunStateRoot(),
283
293
  ownerId?: string,
284
- ): number {
285
- const runPids = getRunningRunPids(stateRoot, ownerId);
286
- if (runPids.size === 0 || !existsSync("/proc")) return 0;
287
- const signature = [...runPids].sort().join(",");
294
+ ): Map<string, number> {
295
+ const runPidMap = getRunningRunPidMap(stateRoot, ownerId);
296
+ if (runPidMap.size === 0 || !existsSync("/proc")) return new Map();
297
+ const signature = [...runPidMap.keys()].sort().join(",");
288
298
  const cacheKey = `${stateRoot}\0${ownerId ?? ""}`;
289
299
  const cached = procDescendantScanCache.get(cacheKey);
290
300
  const now = Date.now();
291
301
  if (cached && cached.signature === signature && cached.expiresAt > now) {
292
- return cached.count;
302
+ return new Map(cached.counts);
293
303
  }
294
304
  const parentByPid = new Map<string, string>();
295
305
  const commandByPid = new Map<string, string>();
@@ -297,7 +307,7 @@ export function countRunningSubagents(
297
307
  try {
298
308
  procEntries = readdirSync("/proc", { withFileTypes: true });
299
309
  } catch {
300
- return 0;
310
+ return new Map();
301
311
  }
302
312
  for (const entry of procEntries) {
303
313
  if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
@@ -306,27 +316,39 @@ export function countRunningSubagents(
306
316
  parentByPid.set(entry.name, ppid);
307
317
  commandByPid.set(entry.name, getProcCommand(entry.name));
308
318
  }
309
- const descendantOfRun = (pid: string): boolean => {
319
+ const runForDescendant = (pid: string): string | undefined => {
310
320
  let current = parentByPid.get(pid);
311
321
  const seen = new Set<string>();
312
322
  while (current && !seen.has(current)) {
313
- if (runPids.has(current)) return true;
323
+ const run = runPidMap.get(current);
324
+ if (run) return run;
314
325
  seen.add(current);
315
326
  current = parentByPid.get(current);
316
327
  }
317
- return false;
328
+ return undefined;
318
329
  };
319
- let count = 0;
330
+ const counts = new Map<string, number>();
320
331
  for (const [pid, command] of commandByPid.entries()) {
321
332
  if (!command.includes("pi -p") && !command.includes("pi\0-p")) continue;
322
- if (descendantOfRun(pid)) count++;
333
+ const run = runForDescendant(pid);
334
+ if (run) counts.set(run, (counts.get(run) ?? 0) + 1);
323
335
  }
324
336
  procDescendantScanCache.set(cacheKey, {
325
- count,
337
+ counts,
326
338
  expiresAt: now + PROC_DESCENDANT_SCAN_TTL_MS,
327
339
  signature,
328
340
  });
329
- return count;
341
+ return new Map(counts);
342
+ }
343
+
344
+ export function countRunningSubagents(
345
+ stateRoot = Paths.getRunStateRoot(),
346
+ ownerId?: string,
347
+ ): number {
348
+ return [...countRunningSubagentsByRun(stateRoot, ownerId).values()].reduce(
349
+ (sum, count) => sum + count,
350
+ 0,
351
+ );
330
352
  }
331
353
 
332
354
  export function renderSubagentStatus(
@@ -352,14 +374,19 @@ export function findRunRetirementCandidates(
352
374
  summary: RunSummary,
353
375
  ): RunRetirementCandidate[] {
354
376
  return summary.runs
355
- .filter((run) =>
356
- run.status === "running" &&
357
- run.retireWhen === "children_terminal" &&
358
- run.stateDir &&
359
- Math.floor(run.activeSubagents ?? 0) <= 0,
360
- )
377
+ .filter((run) => {
378
+ const activeSubagents = Math.max(0, Math.floor(run.activeSubagents ?? 0));
379
+ const descendantSubagents = Math.max(0, Math.floor(run.descendantSubagents ?? 0));
380
+ return (
381
+ run.status === "running" &&
382
+ run.retireWhen === "children_terminal" &&
383
+ run.stateDir &&
384
+ activeSubagents + descendantSubagents <= 0
385
+ );
386
+ })
361
387
  .map((run) => ({
362
388
  activeSubagents: Math.max(0, Math.floor(run.activeSubagents ?? 0)),
389
+ descendantSubagents: Math.max(0, Math.floor(run.descendantSubagents ?? 0)),
363
390
  run: run.run,
364
391
  stateDir: run.stateDir!,
365
392
  }));
@@ -636,17 +663,17 @@ function isUserRecipeFile(file: string | undefined): boolean {
636
663
  export function shouldSuggestRecipePersistence(
637
664
  transition: RunTransition,
638
665
  ): boolean {
639
- return (
640
- transition.to === "done" &&
641
- transition.launchSource === "spawn" &&
642
- !transition.tool &&
643
- !isUserRecipeFile(transition.recipeFile)
644
- );
666
+ if (transition.to !== "done") return false;
667
+ if (isUserRecipeFile(transition.recipeFile)) return false;
668
+ return Boolean(transition.recipeFile) || transition.launchSource === "spawn";
645
669
  }
646
670
 
647
671
  function formatRecipePersistenceSuggestion(transition: RunTransition): string {
648
672
  if (!shouldSuggestRecipePersistence(transition)) return "";
649
- return `\nAgent note: this actor was spawned directly and completed successfully. If this pattern looks reusable, ask the operator whether to save it as a durable recipe/tool under ~/.pi/agent/recipes with register_tool. Do not auto-save without confirmation.`;
673
+ if (transition.recipeFile) {
674
+ return `\nAgent note: this actor completed successfully from recipe ${transition.recipeFile}. If this recipe fits this machine's recurring workflow, ask the operator whether to copy or register it as a durable tool recipe under ~/.pi/agent/recipes. Do not auto-save without confirmation.`;
675
+ }
676
+ return `\nAgent note: this actor was spawned directly and completed successfully. If this pattern fits this machine's recurring workflow, ask the operator whether to save it as a durable recipe/tool under ~/.pi/agent/recipes with register_tool. Do not auto-save without confirmation.`;
650
677
  }
651
678
 
652
679
  export function formatRunTransitionMessage(transition: RunTransition): string {
package/lib/prompts.ts CHANGED
@@ -29,7 +29,7 @@ export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
29
29
  - Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.
30
30
  - Use spawn/message/inspect for actor-level start/send/observe; avoid runtime/FIFO/outbox vocabulary in public guidance.
31
31
  - Run state lives under ~/.pi/agent/tmp/pi-actors/runs; inspect status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.
32
- - Maintain ~/.pi/agent/recipes like MEMORY.md for capabilities: keep useful tools, curate stale ones; packaged recipes are lower-priority components; offer to save successful direct spawn patterns only after confirmation.
32
+ - Maintain ~/.pi/agent/recipes like MEMORY.md for capabilities: keep useful tools, curate stale ones; packaged/ad hoc recipes are lower-priority components; offer to save successful recurring patterns only after confirmation.
33
33
  - Foreground tools/templates fit short work; async recipes/runs fit subagents, services, fanout, media, and long pipelines.
34
34
  - Long fanout = parent async recipe wrapping template(parallel:true) and imports; packaged fanout recipes bubble branch completion messages; grow recurring multi-agent workflows as packaged recipes/pipelines, not ad hoc external scripts.
35
35
  - For deeper pi-actors guidance, inspect installed extension sources/docs/recipes; README and docs are not automatically in context.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.19.3",
3
+ "version": "0.19.10",
4
4
  "private": false,
5
5
  "description": "Actor runtime and orchestrator for agent-managed local processes",
6
6
  "keywords": [
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
- import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
5
 
6
6
  function arg(name, fallback = "") {
7
7
  const prefix = `--${name}=`;
@@ -60,6 +60,9 @@ async function sleep(ms) {
60
60
  await new Promise((resolve) => setTimeout(resolve, ms));
61
61
  }
62
62
 
63
+ const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
64
+ const STATE_LOCK_TIMEOUT_MS = 5000;
65
+
63
66
  async function waitForPath(path, timeoutMs = 5000) {
64
67
  const started = Date.now();
65
68
  while (!existsSync(path)) {
@@ -68,6 +71,37 @@ async function waitForPath(path, timeoutMs = 5000) {
68
71
  }
69
72
  }
70
73
 
74
+ async function acquireStateLock(parentDir, name, label) {
75
+ await mkdir(parentDir, { recursive: true });
76
+ const lockDir = `${parentDir}/${name}`;
77
+ const started = Date.now();
78
+ while (true) {
79
+ try {
80
+ await mkdir(lockDir);
81
+ await writeFile(`${lockDir}/owner.json`, `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`, "utf8");
82
+ return async () => rm(lockDir, { recursive: true, force: true });
83
+ } catch (error) {
84
+ try {
85
+ const current = await stat(lockDir);
86
+ if (Date.now() - current.mtimeMs > STATE_LOCK_MAX_AGE_MS) {
87
+ await rm(lockDir, { recursive: true, force: true });
88
+ continue;
89
+ }
90
+ } catch {
91
+ continue;
92
+ }
93
+ if (Date.now() - started > STATE_LOCK_TIMEOUT_MS) {
94
+ throw new Error(`${label} lock timed out.`, { cause: error });
95
+ }
96
+ await sleep(10);
97
+ }
98
+ }
99
+ }
100
+
101
+ async function acquireBranchInboxLock(runId, branchName) {
102
+ return acquireStateLock(`${runStateDir(runId)}/branches/${branchName}`, ".inbox.lock", `Branch inbox ${branchName}`);
103
+ }
104
+
71
105
  async function writeLockerMessage(locker, message) {
72
106
  if (!locker) return;
73
107
  await waitForPath(locker.controlPath);
@@ -186,46 +220,64 @@ async function synthesize(config, locker) {
186
220
  process.stdout.write(`artifact=${config.artifactPath}\n`);
187
221
  }
188
222
 
223
+ async function readInboxLines(inboxPath) {
224
+ if (!existsSync(inboxPath)) return [];
225
+ const content = await readFile(inboxPath, "utf8");
226
+ return content.split("\n").filter(Boolean).map((line) => JSON.parse(line));
227
+ }
228
+
229
+ async function writeInboxMessages(inboxPath, messages) {
230
+ await writeFile(inboxPath, messages.map((message) => JSON.stringify(message)).join("\n") + "\n", "utf8");
231
+ }
232
+
233
+ async function claimQueuedInboxMessages(runId, branchName) {
234
+ const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
235
+ const releaseLock = await acquireBranchInboxLock(runId, branchName);
236
+ try {
237
+ const messages = await readInboxLines(inboxPath);
238
+ const claimedAt = new Date().toISOString();
239
+ const queuedMessages = [];
240
+ const updated = messages.map((msg, index) => {
241
+ if (msg.status !== "queued" && msg.status) return msg;
242
+ const claimed = {
243
+ ...msg,
244
+ claimed_at: claimedAt,
245
+ id: msg.id || `legacy-${Date.now()}-${index}`,
246
+ status: "claimed",
247
+ };
248
+ queuedMessages.push(claimed);
249
+ return claimed;
250
+ });
251
+ if (queuedMessages.length > 0) await writeInboxMessages(inboxPath, updated);
252
+ return queuedMessages;
253
+ } catch {
254
+ return [];
255
+ } finally {
256
+ await releaseLock();
257
+ }
258
+ }
259
+
189
260
  async function updateInboxMessagesStatus(runId, branchName, ids, status) {
190
261
  const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
262
+ const releaseLock = await acquireBranchInboxLock(runId, branchName);
191
263
  try {
192
- if (!existsSync(inboxPath)) return;
193
- const content = await readFile(inboxPath, "utf8");
194
- const lines = content.split("\n").filter(Boolean);
195
- const updatedLines = [];
196
- for (const line of lines) {
197
- const msg = JSON.parse(line);
198
- if (msg.id && ids.includes(msg.id)) {
199
- msg.status = status;
200
- msg[`${status}_at`] = new Date().toISOString();
201
- }
202
- updatedLines.push(JSON.stringify(msg));
203
- }
204
- await writeFile(inboxPath, updatedLines.join("\n") + "\n", "utf8");
205
- } catch (err) {
264
+ const messages = await readInboxLines(inboxPath);
265
+ const idSet = new Set(ids);
266
+ const updated = messages.map((msg) => {
267
+ if (!msg.id || !idSet.has(msg.id)) return msg;
268
+ return { ...msg, [`${status}_at`]: new Date().toISOString(), status };
269
+ });
270
+ await writeInboxMessages(inboxPath, updated);
271
+ } catch {
206
272
  // Best-effort write
273
+ } finally {
274
+ await releaseLock();
207
275
  }
208
276
  }
209
277
 
210
278
  async function executeParticipantPrompt(role, basePrompt, config) {
211
279
  const branchName = role.name;
212
- const inboxPath = `${runStateDir(config.runId)}/branches/${branchName}/inbox.jsonl`;
213
- const queuedMessages = [];
214
-
215
- try {
216
- if (existsSync(inboxPath)) {
217
- const content = await readFile(inboxPath, "utf8");
218
- const lines = content.split("\n").filter(Boolean);
219
- for (const line of lines) {
220
- const msg = JSON.parse(line);
221
- if (msg.status === "queued" || !msg.status) {
222
- queuedMessages.push(msg);
223
- }
224
- }
225
- }
226
- } catch (err) {
227
- // Best-effort read
228
- }
280
+ const queuedMessages = await claimQueuedInboxMessages(config.runId, branchName);
229
281
 
230
282
  let finalPrompt = basePrompt;
231
283
  const claimedIds = [];
@@ -239,10 +291,6 @@ async function executeParticipantPrompt(role, basePrompt, config) {
239
291
  }
240
292
  inboxSection += "\nPlease acknowledge and address these direct messages in your response.\n";
241
293
  finalPrompt += inboxSection;
242
-
243
- if (claimedIds.length > 0) {
244
- await updateInboxMessagesStatus(config.runId, branchName, claimedIds, "claimed");
245
- }
246
294
  }
247
295
 
248
296
  const result = await runPi(finalPrompt, config.model, config.thinking);
@@ -2,7 +2,7 @@
2
2
  name: actors
3
3
  description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.19.3
5
+ version: 0.19.10
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)
@@ -63,7 +63,7 @@ Rules:
63
63
 
64
64
  - Use `file`/`recipe` for saved recipes; bare names resolve under `~/.pi/agent/recipes`.
65
65
  - Use inline `template` for one-off experiments; promote useful repeats to recipes.
66
- - When a directly spawned inline/ad hoc actor completes successfully and the follow-up suggests persistence, offer to save it as a recipe/tool; ask before writing `~/.pi/agent/recipes`.
66
+ - When a successful actor follow-up suggests persistence, offer to save or register the pattern under `~/.pi/agent/recipes`; ask before writing the user recipe root.
67
67
  - Use stable `as` names when you will inspect or message the actor later.
68
68
  - `async: true` on the recipe is the detached run switch.
69
69
 
@@ -122,10 +122,10 @@ Views:
122
122
  Actor inspector commands:
123
123
 
124
124
  - `/actors-inspector-toggle [rows]`: open/close the compact table or set row count; default is 12 log rows when no size is supplied.
125
- - `/actors-inspector-filter all|room|direct|broadcast|mention <text>`: narrow table previews without changing room/run state.
125
+ - `/actors-inspector-filter all|room|direct|broadcast|unread|branch <name>|current-branch <name>|mention <text>`: narrow table previews without changing room/run state.
126
126
  - `/actors-inspect <number>`: open one visible row as a full-message view.
127
127
 
128
- The table is compact and optimistic by default: bounded body previews, capped noisy room rows, and an inline roster summary in the form `name/role` that wraps only when needed. Active roster members use the target color; members that sent `actor.leave` stay visible as inactive/muted participants from the current run. Actor display names come from `actor.join` bodies (`display`) or branch addresses, keeping debugger output plain and name-driven.
128
+ The table is compact and optimistic by default: bounded body previews, capped noisy room rows, branch-local inbox previews, and an inline roster summary in the form `name/role` that wraps only when needed. Use `unread` for queued branch inbox work and `branch <name>` / `current-branch <name>` for one branch's room/direct/inbox traffic. Active roster members use the target color; members that sent `actor.leave` stay visible as inactive/muted participants from the current run. Actor display names come from `actor.join` bodies (`display`) or branch addresses, keeping debugger output plain and name-driven.
129
129
 
130
130
  Let terminal notifications arrive; avoid sleep-poll loops except during diagnosis.
131
131
 
@@ -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.19.3
5
+ version: 0.19.10
6
6
  ---
7
7
 
8
8
  # Swarm