@llblab/pi-actors 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/tools.js CHANGED
@@ -235,9 +235,9 @@ function compactCommunicationSnapshot(snapshot) {
235
235
  return "\n(no communication snapshot)";
236
236
  return `\nself=${snapshot.self} root=${snapshot.root} rooms=${snapshot.rooms.length} updated_at=${snapshot.updated_at}`;
237
237
  }
238
- function compactBranchInbox(messages) {
238
+ function compactInboxMessages(messages, emptyLabel) {
239
239
  if (messages.length === 0)
240
- return "\n(no branch inbox messages)";
240
+ return `\n(no ${emptyLabel} messages)`;
241
241
  return `\n${messages
242
242
  .map((message) => [
243
243
  ...(message.id ? [`id=${String(message.id)}`] : []),
@@ -246,12 +246,19 @@ function compactBranchInbox(messages) {
246
246
  `from=${String(message.from ?? "")}`,
247
247
  `to=${String(message.to ?? "")}`,
248
248
  ...(message.queued_at ? [`queued_at=${String(message.queued_at)}`] : []),
249
+ ...(message.sent_at ? [`sent_at=${String(message.sent_at)}`] : []),
249
250
  ...(message.claimed_at ? [`claimed_at=${String(message.claimed_at)}`] : []),
250
251
  ...(message.handled_at ? [`handled_at=${String(message.handled_at)}`] : []),
251
252
  ...(message.failed_at ? [`failed_at=${String(message.failed_at)}`] : []),
252
253
  ].join(" "))
253
254
  .join("\n")}`;
254
255
  }
256
+ function compactBranchInbox(messages) {
257
+ return compactInboxMessages(messages, "branch inbox");
258
+ }
259
+ function compactRunMailbox(run, mailbox, messages) {
260
+ return `\nrun=${run} accepts=${Array.isArray(mailbox.accepts) ? mailbox.accepts.join(",") : ""} emits=${Array.isArray(mailbox.emits) ? mailbox.emits.join(",") : ""}${compactInboxMessages(messages, "run inbox")}`;
261
+ }
255
262
  function compactActorFiles(status) {
256
263
  const run = String(status.run ?? "<unknown>");
257
264
  const artifacts = asRecord(status.artifacts);
@@ -726,14 +733,16 @@ export function createInspectToolDefinition(deps = {}) {
726
733
  case "mailbox": {
727
734
  const status = assertRunAccessibleToContext(runId, ctx);
728
735
  const mailbox = asRecord(status.mailbox);
736
+ const messages = AsyncRuns.readRunInboxMessages(runId, Number(input.lines || 40));
737
+ const details = { mailbox, messages };
729
738
  return {
730
739
  content: [
731
740
  {
732
741
  type: "text",
733
- text: maybeJsonText(mailbox, input.verbose === true, `\nrun=${String(status.run ?? runId)} accepts=${Array.isArray(mailbox.accepts) ? mailbox.accepts.join(",") : ""} emits=${Array.isArray(mailbox.emits) ? mailbox.emits.join(",") : ""}`),
742
+ text: maybeJsonText(details, input.verbose === true, compactRunMailbox(String(status.run ?? runId), mailbox, messages)),
734
743
  },
735
744
  ],
736
- details: { mailbox },
745
+ details,
737
746
  };
738
747
  }
739
748
  case "communication": {
@@ -44,7 +44,7 @@ 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.
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`. Live notification is a separate advisory wake layer: actors may subscribe to `wake.jsonl` changes through a cross-platform file notifier and still reconcile canonical mailbox files if a wake is missed. 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
48
 
49
49
  Package-specific endpoints may still exist, but the envelope stays the same.
50
50
 
@@ -106,7 +106,9 @@ Transport is not public API unless a recipe explicitly documents a custom endpoi
106
106
 
107
107
  The task room is the discovery and shared-context layer for actors whose spawn-tree positions do not give them each other's addresses. The spawn tree remains the lifecycle/provenance structure; the task room describes the group communication graph. Direct messages and room messages can share the same semantic `type` such as `chat.message`; the route (`to: branch:*` versus `to: room:*`) determines whether delivery is private or group-wide.
108
108
 
109
- Use direct branch messages only when the receiving branch is backed by a worker or recipe that reads the parent run mailbox or branch inbox and dispatches branch-targeted envelopes. Room roster contacts are discovery hints, not a guarantee that an independent prompt process is subscribed to its branch address. The current branch inbox records queued FIFO work; the intended next step is runner-side claiming/handling so direct messages become prompt work for the recipient branch, while room messages remain shared transcript entries. A direct message may ask the recipient to inspect room history when broader shared context is needed. For ad hoc or transcript-driven swarms without such a runner, prefer room-visible replies and mentions so every participant can inspect the shared timeline.
109
+ Use direct branch messages only when the receiving branch is backed by a worker or recipe that reads the parent run mailbox or branch inbox and dispatches branch-targeted envelopes. Room roster contacts are discovery hints, not a guarantee that an independent prompt process is subscribed to its branch address. The current branch inbox records queued mailbox work; runner-side claiming/handling turns direct messages into prompt work for the recipient branch, while room messages remain shared transcript entries. A direct message may ask the recipient to inspect room history when broader shared context is needed. For ad hoc or transcript-driven swarms without such a runner, prefer room-visible replies and mentions so every participant can inspect the shared timeline.
110
+
111
+ Direct branch delivery is prompt steering for worker-backed branches, not a coordinator follow-up. Packaged coordinator flows claim queued branch inbox records immediately before launching the branch's next prompt, append a bounded "direct messages for you" section to that prompt, and then mark the claimed records `handled` or `failed` based on the prompt result. Generic one-shot `pi -p` children do not receive this injection automatically; a recipe must own the runner loop or use the packaged coordinator path for direct messages to become next-prompt work.
110
112
 
111
113
  Selected-recipient multicast stays route-based: send one `to: room:<run>` envelope with `metadata.recipients` set to same-run branch addresses. The room timeline keeps the original room-visible envelope, and the runtime also forwards branch-targeted copies to each listed recipient. This is not a subroom; it is a shared transcript plus explicit direct delivery for actors whose worker protocol consumes branch envelopes.
112
114
 
@@ -197,7 +199,7 @@ Recipes can declare their conversational surface:
197
199
  }
198
200
  ```
199
201
 
200
- 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.
202
+ 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. Run mailbox inspection shows recipe-declared mailbox metadata plus recent durable run inbox entries; branch mailbox inspection shows branch-local queued/claimed/handled records. 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.
201
203
 
202
204
  ## Runtime Direction
203
205
 
@@ -156,7 +156,7 @@ The actor-level surface is:
156
156
 
157
157
  - `spawn`: start a detached `run:<id>` actor from `file`, `recipe`, or inline `template`.
158
158
  - `message`: send one typed envelope to `run:<id>`, `branch:<run>/<branch>`, `room:<run>`, `tool:<name>`, `coordinator`, or `session:<id>`.
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.
159
+ - `inspect`: intentionally read owned `run:<id>` status, tail, messages, artifacts, files, mailbox metadata plus recent run inbox entries, 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
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
 
@@ -183,10 +183,12 @@ Some recipes expose a run-local control channel. When present, a caller can send
183
183
  }
184
184
  ```
185
185
 
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.
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. In packaged coordinator flows, queued branch inbox records are claimed immediately before the branch's next prompt is launched, appended as direct prompt-steering context, and then marked `handled` or `failed` from the prompt result. This path is not a follow-up notification; it is a runner-owned prompt queue. 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
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
+ Runtime wake notifications are now modeled separately from durable queues. Message handling records canonical state in file-backed mailbox/event files before attempting optional live endpoint delivery. Runs may expose a mailbox-only control endpoint when durable inbox plus wake notification is the intended delivery path; FIFO and named-pipe endpoints remain compatibility/fast-wake paths rather than the durable queue itself. Successful FIFO or named-pipe delivery marks the run inbox entry `sent`; mailbox-only delivery leaves the entry queued for the runtime to claim. `wake.jsonl` is an advisory doorbell that lets a live runtime subscribe through file-system notifications plus explicit initial, wake-triggered, and polling reconciliation callbacks. A missed wake must not lose work because actors can re-read the canonical mailbox state. Runtime loops that consume the file-backed mailbox should claim queued run inbox entries, then mark them `handled` or `failed`; the helper path uses a small lock so concurrent reconciliation callbacks do not process the same entry twice.
191
+
190
192
  ## Coordinator Notifications
191
193
 
192
194
  The launching coordinator should not busy-poll long-running async runs. The extension watches run state directories and delivers terminal `done`/`failed`/unhandled `killed`/`exited` transitions plus script-authored `notify`/`followup` actor messages back to the owning session. This gives the top-level async task a completion signal on the happy path while still letting recipe-local messages bubble up when scripts need finer-grained notifications. Terminal follow-ups include recipe-level named `artifacts` when declared. The generic runner also emits compact `command.done` actor messages for completed leaf commands; recipe authors declare that capability in `mailbox.emits` rather than configuring a separate delivery policy. Failures and in-flight parallel branch completions can bubble as follow-ups, while successful final leaf completions stay diagnostic to avoid flooding long sequential pipelines. Branch-level `command.done` follow-ups omit artifact manifests because the top-level terminal follow-up carries them once. Intentional `control.stop`, `control.kill`, and recipe-local stop commands stay out of follow-up context because the initiating message already returns synchronously. If a follow-up asks for direction, answer with `message` rather than starting a polling loop. Use explicit `inspect` only when a delivered follow-up requests inspection, a real decision depends on state, or a suspected stuck run needs diagnosis — never merely because a timeout elapsed.
@@ -136,12 +136,12 @@ Files:
136
136
 
137
137
  Purpose: start a local or URL audio source as an async run so the agent can continue working while playback runs in the background. The running script exposes a run-local mailbox, so addressed `message` calls can control playback without a second recipe.
138
138
 
139
- Requirements: Linux, macOS, or WSL, Node.js, and one of `mpv`, `ffplay`, `cvlc`, or SoX `play`. Native Windows is not supported by the standard wrapper; use WSL or a platform-specific recipe transport.
139
+ Requirements: Node.js and one playback backend. Supported backends are `mpv`, macOS `afplay`, `ffplay`, `cvlc`, SoX `play`, or `wmp` on native Windows through the legacy Windows Media Player COM control exposed by `powershell.exe`. The `wmp` backend validates `wmplayer.exe` under `Program Files/Windows Media Player` or `Program Files (x86)/Windows Media Player`; it does not target the newer UWP/Store Media Player. Playback format support depends on the selected player; the actor control path itself uses the portable mailbox/wake runtime layer.
140
140
 
141
141
  The required `source` arg accepts:
142
142
 
143
143
  - A single local file or URL.
144
- - A directory containing audio files; the wrapper scans `.mp3`, `.ogg`, `.wav`, `.flac`, and `.m4a` files.
144
+ - A directory containing audio files; the wrapper scans `.aac`, `.aif`, `.aiff`, `.flac`, `.m4a`, `.mp3`, `.ogg`, and `.wav` files.
145
145
  - An `.m3u`, `.m3u8`, or `.txt` playlist file.
146
146
  - A `|`-separated inline list of local files or URLs.
147
147
 
@@ -158,7 +158,7 @@ Register playback:
158
158
  register_tool name=music_player \
159
159
  description="Start async music player playback through the Node.js wrapper" \
160
160
  template="music-player.json" \
161
- args="source:string,loop:bool=true,volume:int=70,player:enum(auto,mpv,ffplay,cvlc,play)=auto"
161
+ args="source:string,loop:bool=true,volume:int=70,player:enum(auto,mpv,afplay,ffplay,cvlc,play,wmp)=auto"
162
162
  ```
163
163
 
164
164
  Start playback:
@@ -185,7 +185,14 @@ The wrapper also accepts control commands directly when a caller already has the
185
185
  scripts/music-player.mjs next ~/.pi/agent/tmp/pi-actors/runs/music
186
186
  ```
187
187
 
188
- Message body is adapted to the recipe's run-local control channel. The script writes `status.txt`, `player.json`, and track-change actor messages in the same state dir. Track-change messages stay diagnostic by default; interactive recipes should define a small command vocabulary for addressed messages, emit semantic actor messages for decision points, and let the coordinator react to messages rather than sleep-polling state.
188
+ Message body is queued in the recipe's run-local mailbox and reconciled by the player loop. The loop treats `wake.jsonl` and `fs.watch` as advisory signals, then verifies the durable inbox signature before taking the inbox lock so unchanged mailboxes are not reread on every tick. On Unix-like hosts, child players run in their own process group so pause/resume/next/stop controls can signal the playback subtree directly. The script writes `status.txt`, `player.json`, and track-change actor messages in the same state dir. Track-change messages stay diagnostic by default; interactive recipes should define a small command vocabulary for addressed messages, emit semantic actor messages for decision points, and let the coordinator react to messages rather than sleep-polling state.
189
+
190
+ Cross-platform smoke checklist:
191
+
192
+ - Linux: install one backend such as `mpv` or `ffplay`; start `music_player source="~/Music" run_id=music`, send `pause`, `play`, `next`, and `stop`, then inspect `run:music` status/mailbox.
193
+ - macOS: verify `player=auto` selects `afplay` when no preferred CLI backend is installed, then run the same addressed message controls.
194
+ - Native Windows: verify `player=wmp` detects `wmplayer.exe`, starts playback through Windows Media Player COM, handles `pause`/`play`/`next`/`previous`/`stop`, and leaves handled mailbox records visible through `inspect target=run:music view=mailbox`.
195
+ - All hosts: confirm missed wake resilience by checking that queued mailbox commands are eventually claimed without relying on a transport-specific endpoint.
189
196
 
190
197
  ## Safety Notes
191
198
 
@@ -9,6 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
  import * as path from "node:path";
10
10
 
11
11
  import type { ActorMessage } from "./actor-messages.ts";
12
+ import { notifyRuntimeWake } from "./runtime-notifier.ts";
12
13
 
13
14
  const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
14
15
  const STATE_LOCK_TIMEOUT_MS = 5000;
@@ -181,6 +182,19 @@ function writeJsonFile(file: string, value: unknown): void {
181
182
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
182
183
  }
183
184
 
185
+ function notifyActorWake(
186
+ stateDir: string,
187
+ actor: string,
188
+ reason: string,
189
+ metadata: Record<string, unknown> = {},
190
+ ): void {
191
+ try {
192
+ notifyRuntimeWake(stateDir, { actor, metadata, reason });
193
+ } catch {
194
+ // Runtime wakes are advisory; durable room/inbox state remains canonical.
195
+ }
196
+ }
197
+
184
198
  function positiveEnvInt(name: string, fallback: number): number {
185
199
  const value = Number(process.env[name] ?? fallback);
186
200
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
@@ -422,6 +436,10 @@ export function appendBranchInboxMessage(
422
436
  `${JSON.stringify({ ...message, id: randomUUID(), queued_at: new Date().toISOString(), status: "queued" })}\n`,
423
437
  { flag: "a" },
424
438
  );
439
+ notifyActorWake(stateDir, address, "branch.message", {
440
+ ...(message.from ? { from: message.from } : {}),
441
+ type: message.type,
442
+ });
425
443
  } finally {
426
444
  releaseLock();
427
445
  }
@@ -451,6 +469,7 @@ export function updateBranchInboxMessageStatus(
451
469
  if (!changed) return false;
452
470
  const compacted = compactBranchInboxMessages(updated);
453
471
  fs.writeFileSync(file, `${compacted.map((message) => JSON.stringify(message)).join("\n")}\n`);
472
+ notifyActorWake(stateDir, address, "branch.inbox.status", { id, status });
454
473
  return true;
455
474
  } finally {
456
475
  releaseLock();
@@ -472,6 +491,10 @@ export function appendRoomMessage(
472
491
  const run = runFromRoomAddress(message.to);
473
492
  if (run) {
474
493
  writeCommunicationSnapshot(stateDir, run);
494
+ notifyActorWake(stateDir, message.to, "room.message", {
495
+ ...(message.from ? { from: message.from } : {}),
496
+ type: message.type,
497
+ });
475
498
  if (message.from && branchIdFromAddress(message.from, run)) {
476
499
  writeBranchCommunicationSnapshotDebounced(stateDir, run, message.from);
477
500
  }
package/lib/async-runs.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Owns detached run state, observation, log tailing, listing, and cancellation safety
5
5
  */
6
6
 
7
+ import { randomUUID } from "node:crypto";
7
8
  import { spawn, spawnSync } from "node:child_process";
8
9
  import {
9
10
  closeSync,
@@ -33,14 +34,16 @@ import { writeJsonAtomic } from "./file-state.ts";
33
34
  import * as Paths from "./paths.ts";
34
35
  import * as RecipeReferences from "./recipe-references.ts";
35
36
  import * as RecipeUsage from "./recipe-usage.ts";
37
+ import { notifyRuntimeWake } from "./runtime-notifier.ts";
36
38
 
37
39
  const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
40
+ const RUN_INBOX_LOCK_TIMEOUT_MS = 5000;
38
41
 
39
42
  export type AsyncRunLaunchSource = "spawn" | "tool";
40
43
 
41
44
  export interface AsyncRunControlEndpoint {
42
45
  path: string;
43
- type: "fifo" | "named-pipe";
46
+ type: "fifo" | "mailbox" | "named-pipe";
44
47
  }
45
48
 
46
49
  export interface AsyncRunStartParams {
@@ -662,6 +665,193 @@ export function readRunEvents(runOrDir: string, lines = 40): RunOutboxEvent[] {
662
665
  .filter((event): event is RunOutboxEvent => Boolean(event));
663
666
  }
664
667
 
668
+ export type RunInboxStatus =
669
+ | "queued"
670
+ | "sent"
671
+ | "claimed"
672
+ | "handled"
673
+ | "failed";
674
+
675
+ export type RunInboxMessage = Record<string, unknown> & {
676
+ id?: string;
677
+ status?: RunInboxStatus | string;
678
+ };
679
+
680
+ export interface ProcessRunInboxResult {
681
+ claimed: number;
682
+ failed: number;
683
+ handled: number;
684
+ }
685
+
686
+ function runInboxFile(stateDir: string): string {
687
+ return join(stateDir, "inbox.jsonl");
688
+ }
689
+
690
+ function sleepSync(ms: number): void {
691
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
692
+ }
693
+
694
+ function acquireRunInboxLock(stateDir: string): () => void {
695
+ const lockDir = join(stateDir, ".inbox.lock");
696
+ const started = Date.now();
697
+ while (true) {
698
+ try {
699
+ mkdirSync(lockDir, { recursive: false });
700
+ writeFileSync(
701
+ join(lockDir, "owner.json"),
702
+ `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
703
+ "utf8",
704
+ );
705
+ return () => rmSync(lockDir, { recursive: true, force: true });
706
+ } catch (error) {
707
+ try {
708
+ const stat = statSync(lockDir);
709
+ if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS) {
710
+ rmSync(lockDir, { recursive: true, force: true });
711
+ continue;
712
+ }
713
+ } catch {
714
+ continue;
715
+ }
716
+ if (Date.now() - started > RUN_INBOX_LOCK_TIMEOUT_MS) {
717
+ throw new Error("Run inbox lock timed out.", { cause: error });
718
+ }
719
+ sleepSync(10);
720
+ }
721
+ }
722
+ }
723
+
724
+ function parseRunInboxLine(line: string): RunInboxMessage | undefined {
725
+ try {
726
+ return JSON.parse(line) as RunInboxMessage;
727
+ } catch {
728
+ return undefined;
729
+ }
730
+ }
731
+
732
+ function readRunInboxMessagesFromStateDir(stateDir: string): RunInboxMessage[] {
733
+ const file = runInboxFile(stateDir);
734
+ if (!existsSync(file)) return [];
735
+ return readFileSync(file, "utf8")
736
+ .split("\n")
737
+ .filter((line) => line.trim())
738
+ .map(parseRunInboxLine)
739
+ .filter((message): message is RunInboxMessage => Boolean(message));
740
+ }
741
+
742
+ function writeRunInboxMessages(
743
+ stateDir: string,
744
+ messages: RunInboxMessage[],
745
+ ): void {
746
+ writeFileSync(
747
+ runInboxFile(stateDir),
748
+ messages.length
749
+ ? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
750
+ : "",
751
+ "utf8",
752
+ );
753
+ }
754
+
755
+ export function readRunInboxMessages(
756
+ runOrDir: string,
757
+ lines = 40,
758
+ ): RunInboxMessage[] {
759
+ const status = getRunStatus(runOrDir);
760
+ const stateDir = String(status.state_dir);
761
+ return tailLines(runInboxFile(stateDir), lines)
762
+ .map(parseRunInboxLine)
763
+ .filter((message): message is RunInboxMessage => Boolean(message));
764
+ }
765
+
766
+ export function updateRunInboxMessageStatus(
767
+ runOrDir: string,
768
+ id: string,
769
+ nextStatus: RunInboxStatus,
770
+ metadata: Record<string, unknown> = {},
771
+ ): boolean {
772
+ const status = getRunStatus(runOrDir);
773
+ const stateDir = String(status.state_dir);
774
+ const releaseLock = acquireRunInboxLock(stateDir);
775
+ try {
776
+ const messages = readRunInboxMessagesFromStateDir(stateDir);
777
+ const timestampKey = `${nextStatus}_at`;
778
+ let changed = false;
779
+ const updated = messages.map((message) => {
780
+ if (message.id !== id) return message;
781
+ changed = true;
782
+ return {
783
+ ...message,
784
+ ...metadata,
785
+ [timestampKey]: new Date().toISOString(),
786
+ status: nextStatus,
787
+ };
788
+ });
789
+ if (changed) writeRunInboxMessages(stateDir, updated);
790
+ return changed;
791
+ } finally {
792
+ releaseLock();
793
+ }
794
+ }
795
+
796
+ export function claimRunInboxMessage(
797
+ runOrDir: string,
798
+ owner = "runtime",
799
+ statuses: string[] = ["queued"],
800
+ ): RunInboxMessage | undefined {
801
+ const status = getRunStatus(runOrDir);
802
+ const stateDir = String(status.state_dir);
803
+ const releaseLock = acquireRunInboxLock(stateDir);
804
+ try {
805
+ const messages = readRunInboxMessagesFromStateDir(stateDir);
806
+ const index = messages.findIndex((message) =>
807
+ statuses.includes(String(message.status ?? "queued")),
808
+ );
809
+ if (index < 0) return undefined;
810
+ const claimed = {
811
+ ...messages[index],
812
+ claimed_at: new Date().toISOString(),
813
+ claimed_by: owner,
814
+ id: typeof messages[index].id === "string" ? messages[index].id : randomUUID(),
815
+ status: "claimed",
816
+ } satisfies RunInboxMessage;
817
+ messages[index] = claimed;
818
+ writeRunInboxMessages(stateDir, messages);
819
+ return claimed;
820
+ } finally {
821
+ releaseLock();
822
+ }
823
+ }
824
+
825
+ export async function processRunInboxMessages(
826
+ runOrDir: string,
827
+ handler: (message: RunInboxMessage) => Promise<void> | void,
828
+ options: { limit?: number; owner?: string; statuses?: string[] } = {},
829
+ ): Promise<ProcessRunInboxResult> {
830
+ const result: ProcessRunInboxResult = { claimed: 0, failed: 0, handled: 0 };
831
+ const limit = Math.max(1, Number(options.limit ?? 1));
832
+ const owner = options.owner ?? "runtime";
833
+ for (let index = 0; index < limit; index += 1) {
834
+ const message = claimRunInboxMessage(runOrDir, owner, options.statuses);
835
+ if (!message?.id) break;
836
+ result.claimed += 1;
837
+ try {
838
+ await handler(message);
839
+ if (updateRunInboxMessageStatus(runOrDir, message.id, "handled")) {
840
+ result.handled += 1;
841
+ }
842
+ } catch (error) {
843
+ if (
844
+ updateRunInboxMessageStatus(runOrDir, message.id, "failed", {
845
+ error: error instanceof Error ? error.message : String(error),
846
+ })
847
+ ) {
848
+ result.failed += 1;
849
+ }
850
+ }
851
+ }
852
+ return result;
853
+ }
854
+
665
855
  export function appendRunOutboxEvent(
666
856
  runOrDir: string,
667
857
  event: {
@@ -725,7 +915,9 @@ function getRunControlEndpoint(
725
915
  if (control && typeof control === "object" && !Array.isArray(control)) {
726
916
  const record = control as Record<string, unknown>;
727
917
  if (
728
- (record.type === "fifo" || record.type === "named-pipe") &&
918
+ (record.type === "fifo" ||
919
+ record.type === "mailbox" ||
920
+ record.type === "named-pipe") &&
729
921
  typeof record.path === "string" &&
730
922
  record.path.trim()
731
923
  ) {
@@ -735,10 +927,36 @@ function getRunControlEndpoint(
735
927
  return { path: join(stateDir, "control.fifo"), type: "fifo" };
736
928
  }
737
929
 
930
+ function appendRunInboxMessage(stateDir: string, message: string): string {
931
+ const id = randomUUID();
932
+ const ts = new Date().toISOString();
933
+ let record: Record<string, unknown>;
934
+ try {
935
+ const parsed = JSON.parse(message) as unknown;
936
+ record = parsed && typeof parsed === "object" && !Array.isArray(parsed)
937
+ ? (parsed as Record<string, unknown>)
938
+ : { body: parsed, type: "run.message" };
939
+ } catch {
940
+ record = { body: message, type: "run.message" };
941
+ }
942
+ const releaseLock = acquireRunInboxLock(stateDir);
943
+ try {
944
+ writeFileSync(
945
+ runInboxFile(stateDir),
946
+ `${JSON.stringify({ ...record, id, queued_at: ts, received_at: ts, status: "queued" })}\n`,
947
+ { flag: "a" },
948
+ );
949
+ } finally {
950
+ releaseLock();
951
+ }
952
+ return id;
953
+ }
954
+
738
955
  function writeRunMessageReceipt(
739
956
  stateDir: string,
740
957
  message: string,
741
958
  bytes: number,
959
+ inboxId: string,
742
960
  ): void {
743
961
  const trimmedMessage = message.trim().toLowerCase();
744
962
  const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
@@ -747,19 +965,10 @@ function writeRunMessageReceipt(
747
965
  const ts = new Date().toISOString();
748
966
  writeFileSync(
749
967
  join(stateDir, "events.jsonl"),
750
- `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`,
968
+ `${JSON.stringify({ bytes, event: "run.message", inbox_id: inboxId, terminal: terminalMessage || undefined, ts })}\n`,
751
969
  { flag: "a" },
752
970
  );
753
- try {
754
- const envelope = JSON.parse(message) as Record<string, unknown>;
755
- writeFileSync(
756
- join(stateDir, "inbox.jsonl"),
757
- `${JSON.stringify({ ...envelope, received_at: ts })}\n`,
758
- { flag: "a" },
759
- );
760
- } catch {
761
- // Plain control lines are already represented in events.jsonl.
762
- }
971
+ updateRunInboxMessageStatus(stateDir, inboxId, "sent", { bytes });
763
972
  if (terminalMessage) {
764
973
  markTerminalHandled(stateDir, {
765
974
  event: "run.message",
@@ -787,6 +996,25 @@ function sendRunMessageToFifo(
787
996
  }
788
997
  }
789
998
 
999
+ function notifyRunMessageWake(
1000
+ stateDir: string,
1001
+ run: string,
1002
+ bytes: number,
1003
+ endpoint: AsyncRunControlEndpoint,
1004
+ inboxId: string,
1005
+ ): Record<string, unknown> | undefined {
1006
+ try {
1007
+ const event = notifyRuntimeWake(stateDir, {
1008
+ actor: `run:${run}`,
1009
+ metadata: { bytes, control_type: endpoint.type, inbox_id: inboxId },
1010
+ reason: "run.message",
1011
+ });
1012
+ return { wake: "wake.jsonl", wake_id: event.id };
1013
+ } catch {
1014
+ return undefined;
1015
+ }
1016
+ }
1017
+
790
1018
  function sendRunMessageToNamedPipe(
791
1019
  endpoint: AsyncRunControlEndpoint,
792
1020
  payload: string,
@@ -832,7 +1060,23 @@ export async function sendRunMessage(
832
1060
  throw new Error(`Run pid owner mismatch: ${run}`);
833
1061
  const endpoint = getRunControlEndpoint(status, stateDir);
834
1062
  const payload = message.endsWith("\n") ? message : `${message}\n`;
1063
+ const payloadBytes = Buffer.byteLength(payload);
1064
+ const inboxId = appendRunInboxMessage(stateDir, message);
1065
+ const wake = notifyRunMessageWake(stateDir, run, payloadBytes, endpoint, inboxId);
835
1066
  const runtimePlatform = options.platform ?? process.platform;
1067
+ if (endpoint.type === "mailbox") {
1068
+ return {
1069
+ ...(wake ?? {}),
1070
+ bytes: payloadBytes,
1071
+ control: "inbox.jsonl",
1072
+ control_path: endpoint.path,
1073
+ control_type: endpoint.type,
1074
+ queued: true,
1075
+ run,
1076
+ sent: true,
1077
+ state_dir: stateDir,
1078
+ };
1079
+ }
836
1080
  try {
837
1081
  if (endpoint.type === "fifo") {
838
1082
  if (runtimePlatform === "win32") {
@@ -841,8 +1085,9 @@ export async function sendRunMessage(
841
1085
  );
842
1086
  }
843
1087
  const bytes = sendRunMessageToFifo(endpoint, payload);
844
- writeRunMessageReceipt(stateDir, message, bytes);
1088
+ writeRunMessageReceipt(stateDir, message, bytes, inboxId);
845
1089
  return {
1090
+ ...(wake ?? {}),
846
1091
  bytes,
847
1092
  control: "control.fifo",
848
1093
  control_path: endpoint.path,
@@ -857,8 +1102,9 @@ export async function sendRunMessage(
857
1102
  payload,
858
1103
  options.namedPipeSend,
859
1104
  );
860
- writeRunMessageReceipt(stateDir, message, bytes);
1105
+ writeRunMessageReceipt(stateDir, message, bytes, inboxId);
861
1106
  return {
1107
+ ...(wake ?? {}),
862
1108
  bytes,
863
1109
  control: endpoint.path,
864
1110
  control_path: endpoint.path,