@fitchmultz/pi-subagents 0.52.20 → 0.52.22

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/dist/agent/contracts.js +12 -3
  3. package/dist/agent/index.js +9 -9
  4. package/dist/agent/legacy.js +5 -5
  5. package/dist/agent/notifications.js +21 -14
  6. package/dist/agent/parent-check.js +38 -0
  7. package/dist/agent/process.js +45 -52
  8. package/dist/agent/runner.js +59 -64
  9. package/dist/agent/storage.js +16 -4
  10. package/dist/agent/view.js +1 -3
  11. package/dist/build-info.js +1 -1
  12. package/dist/pi-intercom/bridge-wake-state.js +31 -6
  13. package/dist/pi-intercom/bridge-wake.js +4 -10
  14. package/dist/pi-intercom/broker/broker.js +4 -26
  15. package/dist/pi-intercom/broker/client-park.d.ts +1 -1
  16. package/dist/pi-intercom/broker/client-park.js +22 -6
  17. package/dist/pi-intercom/broker/client.d.ts +3 -2
  18. package/dist/pi-intercom/broker/client.js +88 -62
  19. package/dist/pi-intercom/broker/connection.d.ts +3 -5
  20. package/dist/pi-intercom/broker/connection.js +48 -33
  21. package/dist/pi-intercom/broker/framing.d.ts +1 -1
  22. package/dist/pi-intercom/broker/framing.js +19 -12
  23. package/dist/pi-intercom/broker/replace-deliveries.js +38 -0
  24. package/dist/pi-intercom/connection.js +5 -8
  25. package/dist/pi-intercom/inbound-journal.js +5 -2
  26. package/dist/shared/journal-frames.js +19 -24
  27. package/docs/development.md +6 -0
  28. package/package.json +1 -1
  29. package/src/agent/contracts.ts +12 -3
  30. package/src/agent/index.ts +10 -10
  31. package/src/agent/legacy.ts +5 -5
  32. package/src/agent/notifications.ts +35 -15
  33. package/src/agent/parent-check.ts +40 -0
  34. package/src/agent/process.ts +53 -55
  35. package/src/agent/runner.ts +65 -65
  36. package/src/agent/storage.ts +18 -5
  37. package/src/agent/view.ts +1 -4
  38. package/src/pi-intercom/bridge-wake-state.ts +39 -8
  39. package/src/pi-intercom/bridge-wake.ts +4 -9
  40. package/src/pi-intercom/broker/broker.ts +5 -37
  41. package/src/pi-intercom/broker/client-park.ts +27 -6
  42. package/src/pi-intercom/broker/client.ts +91 -61
  43. package/src/pi-intercom/broker/connection.ts +58 -39
  44. package/src/pi-intercom/broker/framing.ts +24 -12
  45. package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
  46. package/src/pi-intercom/connection.ts +9 -9
  47. package/src/pi-intercom/inbound-journal.ts +5 -2
  48. package/src/shared/journal-frames.ts +22 -23
package/CHANGELOG.md CHANGED
@@ -2,9 +2,55 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## 0.52.22 - 2026-11-07
6
+
7
+ <!-- npm-release:unreleased:start -->
8
+
9
+ - Bind each durable wake reservation to one POST and its required settlement, preserving retryable settlement failures and crash uncertainty.
10
+
11
+ <!-- npm-release:unreleased:end -->
12
+
13
+ - refactor: bind wake reservations to one settled attempt (#366) ([9bd60f9](https://github.com/fitchmultz/pi-subagents/commit/9bd60f92a4630ca9300ed9c4c06bc48758077a40))
14
+
15
+ ### What's Changed
16
+
17
+ - refactor: bind wake reservations to one settled attempt by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/366
18
+
19
+ **Full Changelog**: https://github.com/fitchmultz/pi-subagents/compare/v0.52.21...v0.52.22
20
+
21
+ ## 0.52.21 - 2026-11-07
22
+
23
+ <!-- npm-release:unreleased:start -->
24
+
25
+ - Join broker socket acquisition during disconnect, share repeated close work, and make registration and parking acknowledgement ownership explicit.
26
+ - Make socket framing failures terminal and represent JSONL record parsing as blank, reading, or invalid without changing publication policies.
27
+ - Represent actor work as idle or running with its exact launch and task; validate command payloads by action.
28
+ - Name absent, launcher-only, live, exited, and uncertain actor claims while keeping shell evidence and signal-time birth checks separate.
29
+ - Give broker-staged replacement messages one timer owner and name recipient-side supersession before native handoff.
30
+ - Share explicit completion-delivery decisions between canonical and legacy results while keeping native file and memory receipts distinct.
31
+
32
+ <!-- npm-release:unreleased:end -->
33
+
34
+ - refactor: give staged replacement delivery one owner (#358) ([f292627](https://github.com/fitchmultz/pi-subagents/commit/f292627a357a5eb99cc9ad2e7a1e9b9622eaa082))
35
+
36
+ ### What's Changed
37
+
38
+ - refactor: give staged replacement delivery one owner by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/358
39
+ - refactor: make actor work and command payload states explicit by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/345
40
+ - refactor: share completion delivery decisions by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/357
41
+ - refactor: make framing and JSONL record states explicit by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/359
42
+ - refactor: share process claim inspection by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/364
43
+ - refactor: own socket acquisition through joined disconnect by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/362
44
+
45
+ **Full Changelog**: https://github.com/fitchmultz/pi-subagents/compare/v0.52.20...v0.52.21
46
+
5
47
  ## 0.52.20 - 2026-11-07
6
48
 
7
49
  <!-- npm-release:unreleased:start -->
50
+
51
+ - Give bridge wake and presence schedulers explicit timer, running, and stopped ownership while retaining durable wake policy.
52
+ - Centralize broker request settlement and close multipart snapshot readers after completion or failure.
53
+
8
54
  <!-- npm-release:unreleased:end -->
9
55
 
10
56
  - refactor: centralize request settlement and snapshot phases (#354) ([335c7e2](https://github.com/fitchmultz/pi-subagents/commit/335c7e269936467672f7e8d70cc67bf875a0a1c6))
@@ -26,10 +72,8 @@
26
72
  - Bind parent attachment to explicit detached, waiting, attaching and attached phases; managed readiness attaches only its own still-current waiting binding and shutdown joins admitted publication before releasing the matching generation.
27
73
  - Add optional `delegate.thinking` to select child effort independently of the parent on all supported hosts, including official Pi 1.5.0. Omission keeps inherited effort. Native model suffixes must agree with an explicit field. Pi still clamps effort to model capabilities; guidance and saved continuations retain the child's saved effort.
28
74
  - Represent child accounting as unrecorded, verified, pending or unavailable while keeping execution status and saved result formats unchanged.
29
- - Give bridge wake and presence schedulers explicit timer, running, and stopped ownership while retaining durable wake policy.
30
75
  - Make directory-watch availability and the coalesced wake's single-owner contract explicit without changing polling fallback.
31
76
  - Preserve stranded last-good builds until a complete replacement publishes, and report retained recovery paths when a later build fails.
32
- - Centralize broker request settlement and close multipart snapshot readers after completion or failure.
33
77
  - Give prepared topic transactions action-specific payloads while preserving validate-before-commit and stale-restore normalization.
34
78
 
35
79
  <!-- npm-release:unreleased:end -->
@@ -80,14 +80,23 @@ export const resultSchema = Type.Object({
80
80
  baseline: Type.Array(Type.String()),
81
81
  })),
82
82
  });
83
- export const commandSchema = Type.Object({
83
+ export const commandMetadataSchema = Type.Object({
84
84
  id: Type.String(),
85
85
  runId: Type.String(),
86
86
  action: Type.Union([Type.Literal("run"), Type.Literal("steer"), Type.Literal("stop")]),
87
- prompt: Type.Optional(Type.String()),
88
- actorToken: Type.Optional(Type.String()),
89
87
  createdAt: Type.Number(),
90
88
  });
89
+ const commandFields = Type.Omit(commandMetadataSchema, ["action"]).properties;
90
+ export const commandSchema = Type.Union([
91
+ Type.Object({ ...commandFields, action: Type.Literal("run") }),
92
+ Type.Object({ ...commandFields, action: Type.Literal("steer"), prompt: Type.String() }),
93
+ Type.Object({
94
+ ...commandFields,
95
+ action: Type.Literal("stop"),
96
+ // Historical unbound Stops parse but cannot target any current actor.
97
+ actorToken: Type.Optional(Type.String()),
98
+ }),
99
+ ]);
91
100
  export const successorSchema = Type.Object({ id: Type.String() });
92
101
  /** Authority to restore exact working state, valid only after the named actor has exited. */
93
102
  /** Exact parked authority: one exited generation's state bytes for this session and leaf. */
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import * as loadedSDK from "@earendil-works/pi-coding-agent";
5
- import { actor, alive, ensureActor, ownIdentity, parkFailure, requestStop, signalProcess, } from "./process.js";
5
+ import { inspectActorClaim, possiblyLive, ensureActor, ownIdentity, parkFailure, requestStop, signalProcess, } from "./process.js";
6
6
  import { stopChildren } from "./shutdown.js";
7
7
  import { isChild, errorText, managedHost, rememberSDK, retainsChildren, } from "./host.js";
8
8
  import { serveParked } from "./parking.js";
@@ -261,7 +261,7 @@ class AgentRuntime {
261
261
  executionCwd: executionCwd(this.pi, context),
262
262
  });
263
263
  this.checkMain();
264
- enqueue(this.base, launch, "run");
264
+ enqueue(this.base, launch, { action: "run" });
265
265
  await ensureActor(this.base, launch, this.shutdown.signal);
266
266
  return renderReceipt(this.base, launch, this.ctx, {
267
267
  receipt: "launch",
@@ -286,10 +286,10 @@ class AgentRuntime {
286
286
  return;
287
287
  }
288
288
  async retryContinuation(launch, input) {
289
- const identity = actor(this.base, launch);
289
+ const claim = inspectActorClaim(this.base, launch);
290
290
  const command = read(path.join(conversation(this.base, launch), "commands", `${launch.id}.json`), commandSchema);
291
291
  if (input.prompt !== launch.prompt ||
292
- (identity && alive(identity)) ||
292
+ possiblyLive(claim) ||
293
293
  command?.id !== launch.id ||
294
294
  command.runId !== launch.id ||
295
295
  command.action !== "run") {
@@ -310,9 +310,9 @@ class AgentRuntime {
310
310
  if (completed === "pending") {
311
311
  return this.retryContinuation(launch, input);
312
312
  }
313
- const identity = actor(this.base, launch);
314
- if (!completed && identity && alive(identity)) {
315
- const command = enqueue(this.base, launch, "steer", { prompt: input.prompt });
313
+ const claim = completed ? undefined : inspectActorClaim(this.base, launch);
314
+ if (possiblyLive(claim)) {
315
+ const command = enqueue(this.base, launch, { action: "steer", prompt: input.prompt });
316
316
  return renderReceipt(this.base, launch, this.ctx, {
317
317
  receipt: "guidance",
318
318
  command,
@@ -327,7 +327,7 @@ class AgentRuntime {
327
327
  });
328
328
  }
329
329
  // Retain successor claims on ambiguous after-launch failures: no replay.
330
- enqueue(this.base, next, "run");
330
+ enqueue(this.base, next, { action: "run" });
331
331
  await ensureActor(this.base, next, this.shutdown.signal);
332
332
  return renderReceipt(this.base, next, this.ctx, { receipt: "continuation" });
333
333
  }
@@ -344,7 +344,7 @@ class AgentRuntime {
344
344
  });
345
345
  if (created) {
346
346
  // The durable adoption claim survives ambiguous after-launch failures.
347
- enqueue(this.base, launch, "run");
347
+ enqueue(this.base, launch, { action: "run" });
348
348
  await ensureActor(this.base, launch, this.shutdown.signal);
349
349
  }
350
350
  return renderReceipt(this.base, launch, this.ctx, {
@@ -5,7 +5,7 @@ import { journalStamp } from "../shared/native-journal.js";
5
5
  import { receiptId } from "./receipts.js";
6
6
  import { resolveTempRootDir } from "../shared/types.js";
7
7
  import { isRecord } from "../shared/unknown.js";
8
- import { release, isHeld } from "./notifications.js";
8
+ import { release, completionDelivery } from "./notifications.js";
9
9
  import { missing } from "./storage.js";
10
10
  // Retain only foreign classifications, never their saved output, for each live native manager.
11
11
  const ignoredInputs = new WeakMap();
@@ -102,19 +102,19 @@ function published(ctx, input) {
102
102
  }
103
103
  function notification(ctx, input) {
104
104
  const identity = `legacy:${input.id}:${input.key}`;
105
- const where = published(ctx, input);
106
- if (where !== undefined) {
105
+ const state = completionDelivery(input.owner, identity, published(ctx, input));
106
+ if (state.kind === "published") {
107
107
  release(input.owner, identity);
108
108
  // Retire only an unchanged recovery input whose receipt is LF-published in the session file.
109
109
  // An in-memory receipt stops redelivery but never removes the saved input.
110
- if (where === "file" &&
110
+ if (state.where === "file" &&
111
111
  journalStamp(fs.lstatSync(input.file, { bigint: true })) === input.stamp &&
112
112
  fs.readFileSync(input.file, "utf8") === input.raw) {
113
113
  fs.unlinkSync(input.file);
114
114
  }
115
115
  return;
116
116
  }
117
- if (isHeld(input.owner, identity)) {
117
+ if (state.kind === "held") {
118
118
  return;
119
119
  }
120
120
  return {
@@ -25,18 +25,19 @@ function queuedAdmissions() {
25
25
  }
26
26
  const admissions = queuedAdmissions();
27
27
  function hold(owner, id) {
28
- const key = `${owner}/${id}`;
29
- if (admissions.has(key)) {
30
- return false;
31
- }
32
- admissions.add(key);
33
- return true;
28
+ admissions.add(`${owner}/${id}`);
34
29
  }
35
30
  export function release(owner, id) {
36
31
  admissions.delete(`${owner}/${id}`);
37
32
  }
38
- export function isHeld(owner, id) {
39
- return admissions.has(`${owner}/${id}`);
33
+ /** Canonical result readers establish availability before deciding delivery. */
34
+ function deliveryDecision(receipt, held) {
35
+ return receipt === undefined
36
+ ? { kind: held ? "held" : "deliverable" }
37
+ : { kind: "published", where: receipt };
38
+ }
39
+ export function completionDelivery(owner, id, receipt) {
40
+ return deliveryDecision(receipt, admissions.has(`${owner}/${id}`));
40
41
  }
41
42
  /** Returns whether any admission was released for reconciliation. */
42
43
  export function settle(owner, pending) {
@@ -197,14 +198,20 @@ export function notifications(ctx, base, owner) {
197
198
  const awaited = new Set();
198
199
  const runs = completed(base, owner, awaited);
199
200
  const receipts = runs.length === 0 ? undefined : completionReceipts(ctx.sessionManager, owner);
201
+ const where = ctx.sessionManager.getSessionFile() === undefined ? "memory" : "file";
200
202
  for (const run of runs) {
201
- const key = `${owner}/${run.id}`;
202
- if (receipts?.get(run.id) !== undefined) {
203
- admissions.delete(key);
204
- continue;
203
+ const state = completionDelivery(owner, run.id, receipts?.get(run.id) === undefined ? undefined : where);
204
+ switch (state.kind) {
205
+ case "published":
206
+ release(owner, run.id);
207
+ continue;
208
+ case "held":
209
+ continue;
210
+ case "deliverable":
211
+ break;
205
212
  }
206
- const loaded = admissions.has(key) ? undefined : (run.loaded ?? load(base, run.id, owner));
207
- if (loaded?.result !== undefined) {
213
+ const loaded = run.loaded ?? load(base, run.id, owner);
214
+ if (loaded.result !== undefined) {
208
215
  notices.push(notification(base, loaded.launch, loaded.result));
209
216
  }
210
217
  }
@@ -0,0 +1,38 @@
1
+ import { attachmentSchema } from "./contracts.js";
2
+ import { observeAlive, published } from "./process.js";
3
+ import { read } from "./storage.js";
4
+ /** A parent liveness verdict acts only while its check and attachment generation are current. */
5
+ export class ParentCheck {
6
+ current;
7
+ check(file, detached) {
8
+ if (this.current !== undefined) {
9
+ return Promise.resolve();
10
+ }
11
+ const attachment = published(file, read(file, attachmentSchema));
12
+ if (attachment === undefined || !attachment.attached) {
13
+ detached();
14
+ return Promise.resolve();
15
+ }
16
+ const check = {};
17
+ this.current = check;
18
+ const settle = async () => {
19
+ try {
20
+ const [live] = await observeAlive([attachment]);
21
+ if (this.current === check &&
22
+ live !== true &&
23
+ read(file, attachmentSchema)?.generation === attachment.generation) {
24
+ detached();
25
+ }
26
+ }
27
+ finally {
28
+ if (this.current === check) {
29
+ this.current = undefined;
30
+ }
31
+ }
32
+ };
33
+ return settle();
34
+ }
35
+ cancel() {
36
+ this.current = undefined;
37
+ }
38
+ }
@@ -7,7 +7,7 @@ import { setTimeout as delay } from "node:timers/promises";
7
7
  import { isChildProcessReplaced, observeChildProcessReplaced, readChildProcessIdentity, readProcessClock, trySignalChildTree, } from "../shared/post-exit-stdio-guard.js";
8
8
  import { agentObservationSocketPath } from "../pi-intercom/broker/paths.js";
9
9
  import { hasErrorCode } from "../shared/unknown.js";
10
- import { attachmentSchema, parkedSchema, processSchema, wakeFailureSchema, } from "./contracts.js";
10
+ import { parkedSchema, processSchema, wakeFailureSchema, } from "./contracts.js";
11
11
  import { conversation, enqueue, read, write } from "./storage.js";
12
12
  import { errorText } from "./host.js";
13
13
  /** Owner records follow their process's birth; stat after read only moves the bound later. */
@@ -27,10 +27,19 @@ function probe(identity) {
27
27
  }
28
28
  return identity.identity === "unverified" ? true : undefined;
29
29
  }
30
+ function presence(identity) {
31
+ const probed = probe(identity);
32
+ if (probed !== undefined) {
33
+ return probed ? "uncertain" : "exited";
34
+ }
35
+ if (readChildProcessIdentity(identity.pid) === identity.identity) {
36
+ return "live";
37
+ }
38
+ return replaced(identity) ? "exited" : "uncertain";
39
+ }
30
40
  export function alive(identity) {
31
- // Only a proven different birth disproves a live PID; ambiguous identities stay possibly owned.
32
- return (probe(identity) ??
33
- (readChildProcessIdentity(identity.pid) === identity.identity || !replaced(identity)));
41
+ // Uncertain identity remains possibly owned; only death/replacement authorizes reuse.
42
+ return presence(identity) !== "exited";
34
43
  }
35
44
  /** Routine `alive` without waiting on `ps`; signals still use the synchronous proof. */
36
45
  export async function observeAlive(identities) {
@@ -42,50 +51,6 @@ export async function observeAlive(identities) {
42
51
  }
43
52
  return verdicts;
44
53
  }
45
- /**
46
- * A child's routine parent check, one observation at a time. A dead parent detaches the child
47
- * only while its check is current and the attachment generation is unchanged: a newer parent
48
- * may adopt the child meanwhile.
49
- */
50
- export class ParentCheck {
51
- current;
52
- /**
53
- * Reads the attachment synchronously, so an unreadable one still fails the caller at once.
54
- * Missing or released attachments detach immediately; a check in flight makes this a no-op.
55
- */
56
- check(file, detached) {
57
- if (this.current !== undefined) {
58
- return Promise.resolve();
59
- }
60
- const attachment = published(file, read(file, attachmentSchema));
61
- if (attachment === undefined || !attachment.attached) {
62
- detached();
63
- return Promise.resolve();
64
- }
65
- const check = {};
66
- this.current = check;
67
- const settle = async () => {
68
- try {
69
- const [live] = await observeAlive([attachment]);
70
- if (this.current === check &&
71
- live !== true &&
72
- read(file, attachmentSchema)?.generation === attachment.generation) {
73
- detached();
74
- }
75
- }
76
- finally {
77
- if (this.current === check) {
78
- this.current = undefined;
79
- }
80
- }
81
- };
82
- return settle();
83
- }
84
- /** A check settling later no longer acts. */
85
- cancel() {
86
- this.current = undefined;
87
- }
88
- }
89
54
  function replaced(identity) {
90
55
  return (identity.identity !== "unverified" &&
91
56
  isChildProcessReplaced(identity.pid, identity.identity, identity.published, identity.clock));
@@ -103,6 +68,33 @@ export function actor(base, launch) {
103
68
  const file = path.join(conversation(base, launch), "actor.lock", "process.json");
104
69
  return published(file, read(file, processSchema));
105
70
  }
71
+ export function possiblyLive(claim) {
72
+ return (claim?.kind === "actor-live" || (claim?.kind === "uncertain" && claim.process !== undefined));
73
+ }
74
+ /** Launcher claims do not prove actor identity; shell ownership is checked separately. */
75
+ export function inspectActorClaim(base, launch) {
76
+ const process = actor(base, launch);
77
+ if (process !== undefined) {
78
+ switch (presence(process)) {
79
+ case "live":
80
+ return { kind: "actor-live", process };
81
+ case "exited":
82
+ return { kind: "actor-exited", process };
83
+ case "uncertain":
84
+ return { kind: "uncertain", process };
85
+ }
86
+ }
87
+ const lock = path.join(conversation(base, launch), "actor.lock");
88
+ if (!fs.existsSync(lock)) {
89
+ return { kind: "absent", process: undefined };
90
+ }
91
+ return {
92
+ kind: read(path.join(lock, "claim.json"), processSchema) === undefined
93
+ ? "uncertain"
94
+ : "launcher-only",
95
+ process: undefined,
96
+ };
97
+ }
106
98
  function childEnvironment(launch) {
107
99
  const env = { ...process.env };
108
100
  for (const key of Object.keys(env)) {
@@ -248,8 +240,9 @@ export async function ensureActor(base, launch, signal) {
248
240
  await awaitStopping(dir, actor(base, launch), signal);
249
241
  signal?.throwIfAborted();
250
242
  // Another parallel tool may replace the claim while this invocation yields.
251
- const existing = actor(base, launch);
252
- if (existing && alive(existing)) {
243
+ const claim = inspectActorClaim(base, launch);
244
+ const existing = claim.process;
245
+ if (possiblyLive(claim)) {
253
246
  return;
254
247
  }
255
248
  parkFailure(base, launch, existing);
@@ -372,12 +365,12 @@ export function requestStop(base, launch, onError) {
372
365
  return false;
373
366
  }
374
367
  };
375
- const identity = actor(base, launch);
368
+ const identity = inspectActorClaim(base, launch).process;
376
369
  const targets = identity ? [identity, ...shells(base, launch, identity.token)] : [];
377
370
  // Interactive Stop validates the whole set; shutdown reports and skips only ambiguous targets.
378
371
  const live = targets.filter((target) => attempt(() => verifyProcess(target)));
379
372
  const command = identity && live.includes(identity)
380
- ? enqueue(base, launch, "stop", { actorToken: identity.token })
373
+ ? enqueue(base, launch, { action: "stop", actorToken: identity.token })
381
374
  : undefined;
382
375
  if (identity && live.length > 0) {
383
376
  write(path.join(conversation(base, launch), `stopping-${identity.token}.json`), {
@@ -1,8 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { commandSchema, parkedSchema, } from "./contracts.js";
4
- import { launchAt, missing, read, runDirectory, write } from "./storage.js";
5
- import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.js";
4
+ import { commandIdentity, launchAt, read, runDirectory, write } from "./storage.js";
5
+ import { ownIdentity, shells, signalProcess } from "./process.js";
6
+ import { ParentCheck } from "./parent-check.js";
6
7
  import { errorText, loadSDK } from "./host.js";
7
8
  import { Parking } from "./parking.js";
8
9
  import { accountingFields, UsageIndex } from "./usage.js";
@@ -10,18 +11,6 @@ import { initializeCwd } from "./cwd.js";
10
11
  import { childRuntime } from "./child-runtime.js";
11
12
  import { ActorObservation } from "./observation.js";
12
13
  import { DirectoryWatch, Wake } from "./wake.js";
13
- function commandIdentity(file) {
14
- try {
15
- const stat = fs.statSync(file, { bigint: true });
16
- return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
17
- }
18
- catch (error) {
19
- if (missing(error)) {
20
- return;
21
- }
22
- throw error;
23
- }
24
- }
25
14
  function completionStatus(stopping, message, error) {
26
15
  if (stopping || message?.stopReason === "aborted") {
27
16
  return "stopped";
@@ -36,7 +25,9 @@ const failureReason = (error, message) => error ??
36
25
  message?.errorMessage ??
37
26
  (message ? undefined : "Native run produced no terminal assistant message.");
38
27
  class Actor {
39
- active;
28
+ work = {
29
+ kind: "idle",
30
+ };
40
31
  stopping = false;
41
32
  stopDeadline;
42
33
  nextParentCheck = 0;
@@ -46,7 +37,6 @@ class Actor {
46
37
  wake = new Wake();
47
38
  // A deferred run waits for an idle session, which has no command-file event.
48
39
  retry = false;
49
- current;
50
40
  errors = [];
51
41
  started = new Set();
52
42
  // Actor-local validation metadata only; never retain command prompts or receipt authority.
@@ -130,7 +120,10 @@ class Actor {
130
120
  this.stopping = true;
131
121
  this.wake.raise();
132
122
  write(path.join(this.dir, `stopping-${this.token}.json`), { at: Date.now() });
133
- write(path.join(this.dir, "stop-acknowledged.json"), { at: Date.now(), id: this.current?.id });
123
+ write(path.join(this.dir, "stop-acknowledged.json"), {
124
+ at: Date.now(),
125
+ id: this.work.kind === "running" ? this.work.launch.id : undefined,
126
+ });
134
127
  this.session?.abort().catch((error) => {
135
128
  this.errors.push(errorText(error));
136
129
  });
@@ -162,29 +155,29 @@ class Actor {
162
155
  if (!session) {
163
156
  throw new Error("Native child session is not ready.");
164
157
  }
165
- this.current = launch;
166
158
  const baseline = new Set(session.sessionManager.getEntries().map((entry) => entry.id));
159
+ const task = Promise.resolve().then(() => this.executeRun(launch, session, baseline));
160
+ this.work = { kind: "running", launch, task };
167
161
  this.observation?.working(true);
168
- this.active = (async () => {
169
- try {
170
- if (launch.provider !== this.initial.provider ||
171
- launch.model !== this.initial.model ||
172
- launch.thinking !== this.initial.thinking) {
173
- throw new Error("Continuation model snapshot changed unexpectedly.");
174
- }
175
- await session.prompt(launch.prompt, { expandPromptTemplates: false });
176
- await session.waitForIdle();
177
- this.saveResult(launch, baseline);
178
- }
179
- catch (error) {
180
- this.saveResult(launch, baseline, errorText(error));
181
- }
182
- finally {
183
- this.active = undefined;
184
- this.current = undefined;
185
- this.observation?.working(false);
162
+ }
163
+ async executeRun(launch, session, baseline) {
164
+ try {
165
+ if (launch.provider !== this.initial.provider ||
166
+ launch.model !== this.initial.model ||
167
+ launch.thinking !== this.initial.thinking) {
168
+ throw new Error("Continuation model snapshot changed unexpectedly.");
186
169
  }
187
- })();
170
+ await session.prompt(launch.prompt, { expandPromptTemplates: false });
171
+ await session.waitForIdle();
172
+ this.saveResult(launch, baseline);
173
+ }
174
+ catch (error) {
175
+ this.saveResult(launch, baseline, errorText(error));
176
+ }
177
+ finally {
178
+ this.work = { kind: "idle" };
179
+ this.observation?.working(false);
180
+ }
188
181
  }
189
182
  commandLaunch(command) {
190
183
  const receipt = path.join(this.dir, "admissions", `${command.id}.json`);
@@ -213,7 +206,7 @@ class Actor {
213
206
  return;
214
207
  }
215
208
  if (command.action === "run" &&
216
- (this.stopping || this.active !== undefined || !session.isIdle)) {
209
+ (this.stopping || this.work.kind === "running" || !session.isIdle)) {
217
210
  this.retry = true;
218
211
  return;
219
212
  }
@@ -233,31 +226,31 @@ class Actor {
233
226
  this.applyCommand(command, launch, session);
234
227
  }
235
228
  staleCommand(command, launch, session) {
236
- // Unbound legacy Stops cannot safely target this generation either.
237
- if (command.action === "stop") {
238
- return command.actorToken !== this.token;
229
+ switch (command.action) {
230
+ case "stop":
231
+ return command.actorToken !== this.token;
232
+ case "steer":
233
+ return this.work.kind !== "running" || session.isIdle || this.work.launch.id !== launch.id;
234
+ case "run":
235
+ return false;
239
236
  }
240
- return (command.action === "steer" &&
241
- (this.active === undefined || session.isIdle || this.current?.id !== launch.id));
242
237
  }
243
238
  applyCommand(command, launch, session) {
244
- if (command.action === "stop") {
245
- this.stop();
246
- return;
247
- }
248
- if (command.action === "run") {
249
- this.run(launch);
250
- return;
239
+ switch (command.action) {
240
+ case "stop":
241
+ this.stop();
242
+ return;
243
+ case "run":
244
+ this.run(launch);
245
+ return;
246
+ case "steer":
247
+ // Synchronous native queue admission cannot turn into an idle prompt after an await.
248
+ session.agent.steer({
249
+ role: "user",
250
+ content: [{ type: "text", text: command.prompt }],
251
+ timestamp: Date.now(),
252
+ });
251
253
  }
252
- if (command.prompt === undefined) {
253
- throw new Error("Missing guidance.");
254
- }
255
- // Synchronous native queue admission cannot turn into an idle prompt after an await.
256
- session.agent.steer({
257
- role: "user",
258
- content: [{ type: "text", text: command.prompt }],
259
- timestamp: Date.now(),
260
- });
261
254
  }
262
255
  pendingCommand(file) {
263
256
  const identity = commandIdentity(file);
@@ -297,7 +290,7 @@ class Actor {
297
290
  this.errors.push(errorText(error));
298
291
  });
299
292
  }
300
- return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
293
+ return this.stopping && this.work.kind === "idle" && (this.session?.isIdle ?? true);
301
294
  }
302
295
  /** Durable command readiness, not elapsed time, defines admission. */
303
296
  async admitCommands(runtime, parked) {
@@ -338,14 +331,14 @@ class Actor {
338
331
  this.parking = await parked.park(runtime, {
339
332
  signal: owner.signal,
340
333
  pi: this.context?.pi,
341
- busy: this.stopping || this.active !== undefined || this.observation?.watched === true,
334
+ busy: this.stopping || this.work.kind === "running" || this.observation?.watched === true,
342
335
  // Raced commands and viewers keep this actor resident; the loop admits after release.
343
336
  quiet: () => {
344
337
  const queued = this.pending().some((command) => this.commandLaunch(command));
345
338
  if (queued) {
346
339
  this.wake.raise();
347
340
  }
348
- const idle = this.active === undefined && !this.stopping;
341
+ const idle = this.work.kind === "idle" && !this.stopping;
349
342
  return !queued && idle && this.observation?.watched !== true;
350
343
  },
351
344
  errors: this.errors,
@@ -357,7 +350,7 @@ class Actor {
357
350
  return this.parking !== "resident";
358
351
  }
359
352
  failure(error, unopened) {
360
- const launch = this.current ?? this.initial;
353
+ const launch = this.work.kind === "running" ? this.work.launch : this.initial;
361
354
  const result = {
362
355
  id: launch.id,
363
356
  owner: launch.owner,
@@ -454,7 +447,9 @@ class Actor {
454
447
  process.on("SIGTERM", this.stop);
455
448
  process.on("SIGINT", this.stop);
456
449
  await this.admitCommands(runtime, parked);
457
- await this.active;
450
+ if (this.work.kind === "running") {
451
+ await this.work.task;
452
+ }
458
453
  }
459
454
  catch (error) {
460
455
  this.failure(error, parked?.opened === true ? undefined : parkedToken);