@fitchmultz/pi-subagents 0.52.17 → 0.52.19

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 (49) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -0
  3. package/dist/agent/host.js +12 -0
  4. package/dist/agent/index.js +70 -56
  5. package/dist/agent/launch.js +12 -3
  6. package/dist/agent/notifications.js +5 -5
  7. package/dist/agent/parking.js +26 -15
  8. package/dist/agent/runner.js +18 -16
  9. package/dist/agent/tool-schemas.js +2 -0
  10. package/dist/agent/usage.js +42 -3
  11. package/dist/agent/view.js +13 -13
  12. package/dist/agent/wake.js +17 -15
  13. package/dist/build-info.js +1 -1
  14. package/dist/pi-intercom/broker/broker.js +2 -2
  15. package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
  16. package/dist/pi-intercom/broker/snapshots.js +56 -25
  17. package/dist/pi-intercom/broker/spawn-claim.js +89 -0
  18. package/dist/pi-intercom/broker/spawn.js +31 -78
  19. package/dist/pi-intercom/inbound-delivery.js +2 -6
  20. package/dist/pi-intercom/lifecycle.js +26 -26
  21. package/dist/pi-intercom/reply-tracker.js +47 -55
  22. package/dist/pi-intercom/session-events.js +2 -0
  23. package/dist/shared/post-exit-stdio-guard.js +44 -37
  24. package/docs/development.md +25 -0
  25. package/docs/intercom.md +19 -2
  26. package/docs/reference.md +16 -5
  27. package/package.json +1 -1
  28. package/scripts/build.mjs +41 -14
  29. package/skills/pi-subagents/SKILL.md +8 -4
  30. package/src/agent/contracts.ts +17 -0
  31. package/src/agent/host.ts +19 -0
  32. package/src/agent/index.ts +91 -62
  33. package/src/agent/launch.ts +16 -3
  34. package/src/agent/notifications.ts +5 -5
  35. package/src/agent/parking.ts +39 -17
  36. package/src/agent/runner.ts +20 -21
  37. package/src/agent/tool-schemas.ts +2 -0
  38. package/src/agent/usage.ts +48 -3
  39. package/src/agent/view.ts +15 -13
  40. package/src/agent/wake.ts +19 -15
  41. package/src/pi-intercom/broker/broker.ts +2 -1
  42. package/src/pi-intercom/broker/snapshots.ts +82 -37
  43. package/src/pi-intercom/broker/spawn-claim.ts +98 -0
  44. package/src/pi-intercom/broker/spawn.ts +42 -82
  45. package/src/pi-intercom/inbound-delivery.ts +2 -6
  46. package/src/pi-intercom/lifecycle.ts +38 -27
  47. package/src/pi-intercom/reply-tracker.ts +50 -58
  48. package/src/pi-intercom/session-events.ts +3 -1
  49. package/src/shared/post-exit-stdio-guard.ts +48 -40
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## 0.52.19 - 2026-11-07
6
+
7
+ <!-- npm-release:unreleased:start -->
8
+
9
+ - Make post-exit pipe cleanup terminal across waiting, draining and closed phases so later pipe events cannot restart smoke child cleanup.
10
+ - Distinguish parked and failed actor exit outcomes; both have disposed the native runtime, exit the actor and permit no second ordinary disposal.
11
+ - Bind native intercom context identity to inactive, live or invalidated lifecycle state; invalidated transport cleanup retains the session ID until cleared, separate from transport connection and native tool activity.
12
+ - 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.
13
+ - 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.
14
+ - Represent child accounting as unrecorded, verified, pending or unavailable while keeping execution status and saved result formats unchanged.
15
+ - Make directory-watch availability and the coalesced wake's single-owner contract explicit without changing polling fallback.
16
+ - Preserve stranded last-good builds until a complete replacement publishes, and report retained recovery paths when a later build fails.
17
+ - Give prepared topic transactions action-specific payloads while preserving validate-before-commit and stale-restore normalization.
18
+
19
+ <!-- npm-release:unreleased:end -->
20
+
21
+ - fix: preserve live and uncertain broker startup ownership (#342) ([f6147dd](https://github.com/fitchmultz/pi-subagents/commit/f6147dd7a31775e8499a6fd77b4185d6d918d646))
22
+
23
+ ### What's Changed
24
+
25
+ - fix: preserve live and uncertain broker startup ownership by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/342
26
+ - refactor: make post-exit pipe cleanup terminal by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/346
27
+ - refactor: distinguish parked and failed actor exit outcomes by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/348
28
+ - refactor: bind intercom context identity to lifecycle state by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/351
29
+ - refactor: bind parent attachment to explicit lifecycle phases by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/349
30
+ - fix: preserve last-good builds across interrupted publication by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/343
31
+ - refactor: make child accounting outcomes explicit by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/352
32
+ - refactor: make directory watch availability explicit by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/350
33
+ - refactor: make prepared topic transactions explicit by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/353
34
+ - Add explicit child thinking to delegate by @tribble in https://github.com/fitchmultz/pi-subagents/pull/338
35
+
36
+ **Full Changelog**: https://github.com/fitchmultz/pi-subagents/compare/v0.52.18...v0.52.19
37
+
38
+ ## 0.52.18 - 2026-11-06
39
+
40
+ <!-- npm-release:unreleased:start -->
41
+
42
+ - Keep replied or retired asks out of reply attribution when their queued input reaches a later native turn.
43
+ - Preserve unanswered queued asks through abort recovery without sending a premature recipient-failure reply.
44
+ - Preserve live or uncertain broker startup ownership; reclaim only exited spawn owners and release only the acquired claim.
45
+
46
+ <!-- npm-release:unreleased:end -->
47
+
48
+ - Keep retired asks out of native reply attribution (#341) ([27a93f9](https://github.com/fitchmultz/pi-subagents/commit/27a93f9bc52aaa9e454ffc593017b66b01461bc4))
49
+
50
+ ### What's Changed
51
+
52
+ - Keep retired asks out of native reply attribution by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/341
53
+
54
+ **Full Changelog**: https://github.com/fitchmultz/pi-subagents/compare/v0.52.17...v0.52.18
55
+
5
56
  ## 0.52.17 - 2026-11-05
6
57
 
7
58
  <!-- npm-release:unreleased:start -->
package/README.md CHANGED
@@ -14,6 +14,7 @@ delegate({
14
14
  prompt:
15
15
  "Inspect the authentication changes. Return concrete defects with file and line evidence. Do not edit files.",
16
16
  name: "auth-review",
17
+ thinking: "low",
17
18
  });
18
19
  ```
19
20
 
@@ -22,6 +23,9 @@ arrives later as a notification. Do other work or end the turn; do not poll.
22
23
  `context: "fresh"` is the default; `context: "fork"` preserves the native
23
24
  conversation, including prior tool results. There is no hidden role prompt,
24
25
  report-writing suffix, profile selection, or model fallback catalog.
26
+ Optional `thinking` sets the child's effort without changing the parent. Omit it
27
+ to inherit parent effort. Pi clamps the request to the child's model capabilities.
28
+ Guidance and saved continuations keep the child's saved effort.
25
29
 
26
30
  ```js
27
31
  agent_runs({ action: "list" });
@@ -12,6 +12,18 @@ import { findPackageJSON } from "node:module";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
  import { readIntercomLineage } from "../pi-intercom/identity.js";
14
14
  export { errorMessage as errorText } from "../shared/unknown.js";
15
+ export function managedHost(context) {
16
+ const host = Reflect.get(context, "managedHost");
17
+ if (typeof host !== "object" || host === null) {
18
+ return undefined;
19
+ }
20
+ const owner = Reflect.get(host, "owner");
21
+ const pid = typeof owner === "object" && owner !== null ? Reflect.get(owner, "pid") : undefined;
22
+ const ready = Reflect.get(host, "ready");
23
+ return typeof pid === "number" && Number.isSafeInteger(pid) && ready instanceof Promise
24
+ ? { owner: { pid }, ready: ready.then((accepted) => accepted === true) }
25
+ : undefined;
26
+ }
15
27
  /** A launcher may still own children while handing off or replacing a worker. */
16
28
  export function retainsChildren(event) {
17
29
  const disposition = Reflect.get(event, "disposition");
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import * as loadedSDK from "@earendil-works/pi-coding-agent";
5
5
  import { actor, alive, ensureActor, ownIdentity, parkFailure, requestStop, signalProcess, } from "./process.js";
6
6
  import { stopChildren } from "./shutdown.js";
7
- import { isChild, errorText, rememberSDK, retainsChildren } from "./host.js";
7
+ import { isChild, errorText, managedHost, rememberSDK, retainsChildren, } from "./host.js";
8
8
  import { serveParked } from "./parking.js";
9
9
  import { resultSchema, attachmentSchema, admissionSchema, commandSchema, } from "./contracts.js";
10
10
  import { delegateSchema, runsSchema } from "./tool-schemas.js";
@@ -20,18 +20,6 @@ import { executionCwd } from "./cwd.js";
20
20
  import { getPiAgentDir } from "../pi-intercom/agent-dir.js";
21
21
  import { registerCompactMessages, renderCompactResult } from "../shared/compact-cards.js";
22
22
  import { BackgroundReporter } from "../shared/background-reporting.js";
23
- function managedHost(context) {
24
- const host = Reflect.get(context, "managedHost");
25
- if (typeof host !== "object" || host === null) {
26
- return undefined;
27
- }
28
- const owner = Reflect.get(host, "owner");
29
- const pid = typeof owner === "object" && owner !== null ? Reflect.get(owner, "pid") : undefined;
30
- const ready = Reflect.get(host, "ready");
31
- return typeof pid === "number" && Number.isSafeInteger(pid) && ready instanceof Promise
32
- ? { owner: { pid }, ready: ready.then((accepted) => accepted === true) }
33
- : undefined;
34
- }
35
23
  /** Legacy results are optional; their reconciliation reports an invalid configured root. */
36
24
  function legacyInput() {
37
25
  try {
@@ -42,14 +30,8 @@ function legacyInput() {
42
30
  }
43
31
  }
44
32
  class AgentRuntime {
45
- ctx;
33
+ attachment = { kind: "detached" };
46
34
  reporter = new BackgroundReporter(() => this.ctx);
47
- // A managed launcher owns children across worker replacement; otherwise this process does.
48
- host;
49
- accepted = false;
50
- base = "";
51
- owner = "";
52
- generation = "";
53
35
  // Polling remains only while change hints are unavailable or a reconciliation failed.
54
36
  timer;
55
37
  woken;
@@ -59,7 +41,6 @@ class AgentRuntime {
59
41
  // Input stamps the last reconciliation saw; a one-second probe recovers dropped hints.
60
42
  observed = new Map();
61
43
  probe;
62
- closing = false;
63
44
  reset = Promise.resolve();
64
45
  shutdown = new AbortController();
65
46
  child = isChild();
@@ -69,11 +50,28 @@ class AgentRuntime {
69
50
  this.pi = pi;
70
51
  this.watches = new CompletionWatches(this.reporter, this.wake);
71
52
  }
53
+ get binding() {
54
+ if (this.attachment.kind === "detached") {
55
+ throw new Error("Agent runtime is not attached.");
56
+ }
57
+ return this.attachment.binding;
58
+ }
59
+ get ctx() {
60
+ return this.attachment.kind === "attaching" || this.attachment.kind === "attached"
61
+ ? this.attachment.binding.context
62
+ : undefined;
63
+ }
64
+ get base() {
65
+ return this.binding.base;
66
+ }
67
+ get owner() {
68
+ return this.binding.owner;
69
+ }
72
70
  checkMain() {
73
71
  if (isChild()) {
74
72
  throw new Error("Only a main session may delegate or control agent runs.");
75
73
  }
76
- if (this.closing || !this.ctx || this.base.length === 0) {
74
+ if (this.shutdown.signal.aborted || this.attachment.kind !== "attached") {
77
75
  throw new Error("Agent runtime is not attached.");
78
76
  }
79
77
  }
@@ -93,17 +91,18 @@ class AgentRuntime {
93
91
  };
94
92
  scan = () => {
95
93
  clearTimeout(this.timer);
96
- if (this.closing || !this.ctx || this.child) {
94
+ if (this.shutdown.signal.aborted || this.attachment.kind !== "attached" || this.child) {
97
95
  return;
98
96
  }
97
+ const { context } = this.attachment.binding;
99
98
  const inputs = () => [this.base, ...legacyInput(), ...this.awaited];
100
99
  const before = new Map(inputs().map((file) => [file, pathStamp(file)]));
101
100
  let reconciled = true;
102
101
  try {
103
- const { notices, awaited } = notifications(this.ctx, this.base, this.owner);
102
+ const { notices, awaited } = notifications(context, this.base, this.owner);
104
103
  this.awaited = awaited;
105
- notices.unshift(...reconcileLegacy(this.ctx, this.reporter));
106
- deliver(this.pi, this.ctx, notices);
104
+ notices.unshift(...reconcileLegacy(context, this.reporter));
105
+ deliver(this.pi, context, notices);
107
106
  this.reporter.recover("completion-scan");
108
107
  }
109
108
  catch (error) {
@@ -131,23 +130,32 @@ class AgentRuntime {
131
130
  this.awaited = new Set();
132
131
  }
133
132
  start(context) {
133
+ if (this.shutdown.signal.aborted) {
134
+ return;
135
+ }
134
136
  this.reporter.start();
135
- this.host = managedHost(context);
136
- if (this.host === undefined || this.accepted || this.child) {
137
- this.reset = this.reset.then(() => this.attach(context));
137
+ const owner = context.sessionManager.getSessionId();
138
+ const host = managedHost(context);
139
+ const binding = { context, owner, base: ownerDirectory(this.agentDir, owner), host };
140
+ if (host === undefined ||
141
+ this.attachment.kind === "attached" ||
142
+ this.attachment.kind === "attaching" ||
143
+ this.child) {
144
+ this.reset = this.reset.then(() => this.attach(binding));
138
145
  return this.reset;
139
146
  }
140
147
  // Attach and publish only after the launcher retires rollback: an unready replacement
141
148
  // must neither adopt children nor append completions to a journal it may abandon.
142
149
  // Readiness follows session_start, so never await it here.
143
- this.owner = context.sessionManager.getSessionId();
144
- this.base = ownerDirectory(this.agentDir, this.owner);
145
- this.host.ready
146
- .then(async (accepted) => {
147
- if (accepted && !this.closing) {
148
- this.accepted = true;
149
- await this.start(context);
150
+ const waiting = { kind: "waiting", binding };
151
+ this.attachment = waiting;
152
+ host.ready
153
+ .then((accepted) => {
154
+ if (accepted && !this.shutdown.signal.aborted && this.attachment === waiting) {
155
+ this.reset = this.reset.then(() => this.attach(binding));
156
+ return this.reset;
150
157
  }
158
+ return null;
151
159
  })
152
160
  .catch((error) => {
153
161
  this.reporter.report(undefined, `Agent runtime attachment: ${errorText(error)}`);
@@ -155,37 +163,36 @@ class AgentRuntime {
155
163
  return undefined;
156
164
  }
157
165
  ownerIdentity() {
158
- return ownIdentity(this.host?.owner.pid);
166
+ return ownIdentity(this.binding.host?.owner.pid);
159
167
  }
160
- async attach(context) {
168
+ async attach(binding) {
161
169
  this.unwatch();
162
- this.ctx = context;
170
+ const attaching = { kind: "attaching", binding, generation: randomUUID() };
171
+ this.attachment = attaching;
163
172
  if (this.child) {
173
+ this.attachment = { ...attaching, kind: "attached" };
164
174
  return;
165
175
  }
166
- this.owner = context.sessionManager.getSessionId();
167
- this.base = ownerDirectory(this.agentDir, this.owner);
168
- this.generation = randomUUID();
169
- await fs.promises.mkdir(this.base, { recursive: true, mode: 0o700 });
170
- write(path.join(this.base, "attachment.json"), {
171
- ...this.ownerIdentity(),
172
- generation: this.generation,
176
+ await fs.promises.mkdir(binding.base, { recursive: true, mode: 0o700 });
177
+ write(path.join(binding.base, "attachment.json"), {
178
+ ...ownIdentity(binding.host?.owner.pid),
179
+ generation: attaching.generation,
173
180
  attached: true,
174
181
  });
175
- if (this.closing) {
182
+ if (this.shutdown.signal.aborted) {
176
183
  return;
177
184
  }
185
+ this.attachment = { ...attaching, kind: "attached" };
178
186
  // Recovery and watch setup stay off the awaited startup path.
179
187
  this.timer = setTimeout(this.scan, 200).unref();
180
188
  this.probe = setInterval(this.recover, 1000).unref();
181
189
  }
182
190
  async dispose(retain) {
183
191
  this.reporter.shutdown();
184
- this.closing = true;
185
192
  this.unwatch();
186
193
  this.shutdown.abort();
187
194
  await this.reset;
188
- if (!retain && this.base.length > 0) {
195
+ if (!retain && !this.child && this.attachment.kind !== "detached") {
189
196
  const file = path.join(this.base, "attachment.json");
190
197
  const attachment = read(file, attachmentSchema);
191
198
  if (attachment !== undefined && this.releases(attachment)) {
@@ -197,14 +204,20 @@ class AgentRuntime {
197
204
  await stopChildren(this.base, this.owner, (message) => this.reporter.report(undefined, message));
198
205
  }
199
206
  }
200
- this.ctx = undefined;
207
+ this.attachment = { kind: "detached" };
201
208
  }
202
209
  releases(attachment) {
203
- if (attachment.generation === this.generation) {
204
- return true;
210
+ const state = this.attachment;
211
+ switch (state.kind) {
212
+ case "detached":
213
+ return false;
214
+ case "waiting":
215
+ // An unready replacement can release the launcher's existing generation on quit.
216
+ return attachment.attached && attachment.pid === state.binding.host?.owner.pid;
217
+ case "attaching":
218
+ case "attached":
219
+ return attachment.generation === state.generation;
205
220
  }
206
- // An unready replacement never attached, but exiting still releases the launcher-owned children.
207
- return (this.generation.length === 0 && attachment.attached && attachment.pid === this.host?.owner.pid);
208
221
  }
209
222
  stop(launch) {
210
223
  const base = this.base;
@@ -368,7 +381,8 @@ class AgentRuntime {
368
381
  this.pi.on("session_start", (_event, context) => this.start(context));
369
382
  this.pi.on("turn_end", (event, context) => {
370
383
  // The public pending flag covers user input; the preview also includes custom messages.
371
- if (settle(this.owner, context.hasPendingMessages() || event.context.pendingMessages.length > 0)) {
384
+ if (this.attachment.kind === "attached" &&
385
+ settle(this.owner, context.hasPendingMessages() || event.context.pendingMessages.length > 0)) {
372
386
  // Released admissions may have been dropped from the queue rather than published.
373
387
  this.wake();
374
388
  }
@@ -389,7 +403,7 @@ class AgentRuntime {
389
403
  this.pi.registerTool({
390
404
  name: "delegate",
391
405
  label: "Delegate",
392
- description: "Run a child on exactly this prompt; main sessions only. Fork copies the full conversation; default is fresh. Returns a receipt; completion is notified later: do other work or end the turn, don't poll.",
406
+ description: "Run a child on exactly this prompt; main sessions only. Fork copies the full conversation; default is fresh. Optional thinking overrides inherited effort for this child. Returns a receipt; completion is notified later: do other work or end the turn, don't poll.",
393
407
  parameters: delegateSchema,
394
408
  exposure: this.child ? "hidden" : "direct",
395
409
  renderResult: renderCompactResult,
@@ -3,12 +3,18 @@ import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { loadSDK, packageRoot, runtimeEntry } from "./host.js";
5
5
  import { runDirectory, write } from "./storage.js";
6
+ function selectedThinking(requested, inherited, suffix) {
7
+ if (requested !== undefined && suffix !== undefined && requested !== suffix) {
8
+ throw new Error("thinking conflicts with the model's thinking suffix.");
9
+ }
10
+ return suffix ?? requested ?? inherited;
11
+ }
6
12
  function selectedModel(input, context, thinking, sdk) {
7
13
  if (input.model === undefined) {
8
14
  if (!context.model) {
9
15
  throw new Error("No selected model; no fallback was used.");
10
16
  }
11
- return { model: context.model, thinking };
17
+ return { model: context.model, thinking: selectedThinking(input.thinking, thinking) };
12
18
  }
13
19
  const slash = input.model.indexOf("/");
14
20
  if (slash < 1) {
@@ -16,7 +22,7 @@ function selectedModel(input, context, thinking, sdk) {
16
22
  }
17
23
  const model = context.modelRegistry.find(input.model.slice(0, slash), input.model.slice(slash + 1));
18
24
  if (model) {
19
- return { model, thinking };
25
+ return { model, thinking: selectedThinking(input.thinking, thinking) };
20
26
  }
21
27
  if (typeof sdk.resolveModelReference !== "function") {
22
28
  throw new Error(`Model ${input.model} is unavailable; no fallback was used. Check the provider/model ID. This host supports literal IDs only. If you intended a thinking suffix, update Pi for native thinking-suffix support.`);
@@ -30,7 +36,10 @@ function selectedModel(input, context, thinking, sdk) {
30
36
  if (resolved.status !== "resolved") {
31
37
  throw new Error(`Model ${input.model} is unavailable; no fallback was used.`);
32
38
  }
33
- return { model: resolved.model, thinking: resolved.thinkingLevel ?? thinking };
39
+ return {
40
+ model: resolved.model,
41
+ thinking: selectedThinking(input.thinking, thinking, resolved.thinkingLevel),
42
+ };
34
43
  }
35
44
  function targetTrust(sdk, cwd, context, agentDir) {
36
45
  if (fs.realpathSync(cwd) === fs.realpathSync(context.cwd)) {
@@ -4,7 +4,7 @@ import { completionReceipts } from "./receipts.js";
4
4
  import { readOutputPage } from "../shared/output-page.js";
5
5
  import { journalStamp } from "../shared/native-journal.js";
6
6
  import { launchAt, list, runDirectory } from "./storage.js";
7
- import { readCanonicalResult, readResult } from "./usage.js";
7
+ import { accountingState, readCanonicalResult, readResult } from "./usage.js";
8
8
  function isStringSet(value) {
9
9
  if (!(value instanceof Set)) {
10
10
  return false;
@@ -126,7 +126,7 @@ function unchangedPending(pending) {
126
126
  function load(base, id, owner) {
127
127
  const launch = launchAt(base, id, owner);
128
128
  const result = readCanonicalResult(base, launch);
129
- if (result?.accountingPending === undefined) {
129
+ if (accountingState(result).kind !== "pending") {
130
130
  return { launch, result };
131
131
  }
132
132
  // Only pending runs need this pre-read stamp to observe concurrent publication.
@@ -135,7 +135,7 @@ function load(base, id, owner) {
135
135
  return { launch, result: readResult(base, launch), journal };
136
136
  }
137
137
  function cacheableResult(loaded) {
138
- return loaded.result?.accountingPending === undefined || loaded.journal !== undefined;
138
+ return accountingState(loaded.result).kind !== "pending" || loaded.journal !== undefined;
139
139
  }
140
140
  function completedRun({ base, owner, known, settled }, id) {
141
141
  const directory = runDirectory(base, id);
@@ -156,7 +156,7 @@ function completedRun({ base, owner, known, settled }, id) {
156
156
  const order = { endedAt: loaded.result.endedAt, createdAt: loaded.launch.createdAt };
157
157
  // Pending accounting retries on child changes, even after the completion receipt.
158
158
  // Ordinary settled history retains its launch/result-only idle probes.
159
- const pending = loaded.result.accountingPending === undefined ? undefined : loaded;
159
+ const pending = accountingState(loaded.result).kind === "pending" ? loaded : undefined;
160
160
  if (launch !== undefined && result !== undefined && cacheableResult(loaded)) {
161
161
  settled.set(id, { launch, result, pending, ...order });
162
162
  }
@@ -175,7 +175,7 @@ function completed(base, owner, awaited) {
175
175
  if (run === undefined) {
176
176
  awaited.add(path.join(runDirectory(base, id), "result.json"));
177
177
  }
178
- else if (run.loaded?.result?.accountingPending !== undefined) {
178
+ else if (run.loaded && accountingState(run.loaded.result).kind === "pending") {
179
179
  awaited.add(run.loaded.launch.sessionFile);
180
180
  }
181
181
  return run ?? [];
@@ -85,10 +85,7 @@ export function parkedState(dir, launch, restore) {
85
85
  }
86
86
  /** One actor generation's idle parking and exact restore; commands stay with the runner. */
87
87
  export class Parking {
88
- saved;
89
- /** Set before entering either native open, whose effects may outlive an exception. */
90
- opened = false;
91
- restored;
88
+ restore = { kind: "unopened" };
92
89
  since = 0;
93
90
  leaf;
94
91
  shell;
@@ -108,36 +105,48 @@ export class Parking {
108
105
  get file() {
109
106
  return path.join(this.dir, "parked.json");
110
107
  }
108
+ get saved() {
109
+ return this.restore.kind !== "unopened" && this.restore.source.kind === "parked"
110
+ ? this.restore.source.saved
111
+ : undefined;
112
+ }
113
+ /** Set before entering either native open, whose effects may outlive an exception. */
114
+ get opened() {
115
+ return this.restore.kind !== "unopened";
116
+ }
111
117
  /** Parked authority is exact state or nothing; never a journal-only reopen. */
112
118
  open() {
113
119
  const { sdk, launch } = this;
114
120
  const { openWorkingSession: restore } = sdk;
115
121
  const parked = parkedState(this.dir, launch, restore && sdk.readWorkingSession);
116
- this.opened = true;
122
+ this.restore = {
123
+ kind: "opening",
124
+ source: parked ? { kind: "parked", ...parked } : { kind: "journal" },
125
+ };
117
126
  if (!parked || !restore) {
118
127
  return sdk.SessionManager.open(launch.sessionFile, undefined, launch.cwd);
119
128
  }
120
- this.saved = parked.saved;
121
- this.restored = parked.state;
122
129
  return restore(parked.saved);
123
130
  }
124
131
  /** The ready restored generation owns this state now; a later park writes a new cut. */
125
132
  adopt() {
126
- if (this.restored !== undefined) {
133
+ if (this.restore.kind !== "opening") {
134
+ return;
135
+ }
136
+ const { source } = this.restore;
137
+ if (source.kind === "parked") {
127
138
  fs.rmSync(this.file);
128
- fs.rmSync(this.restored, { force: true });
139
+ fs.rmSync(source.state, { force: true });
129
140
  }
141
+ this.restore = { kind: "adopted", source };
130
142
  }
131
- /** True once this generation has exited its runtime with or without parked authority. */
143
+ /** Both exit outcomes have disposed the runtime; only one publishes parked authority. */
132
144
  async park(runtime, actor) {
133
145
  if (!actor.pi || !this.due(runtime.session, actor.busy) || this.hasShell()) {
134
- return false;
146
+ return "resident";
135
147
  }
136
148
  const hold = await this.prepare(runtime, actor.pi, actor);
137
- if (hold) {
138
- await this.commit(runtime, hold, actor.errors);
139
- }
140
- return hold !== undefined;
149
+ return hold ? this.commit(runtime, hold, actor.errors) : "resident";
141
150
  }
142
151
  hasShell() {
143
152
  const witness = this.shell === undefined ? undefined : shell(this.shell, this.token);
@@ -201,7 +210,9 @@ export class Parking {
201
210
  if (read(this.file, parkedSchema)?.token !== this.token) {
202
211
  // Truthful boundary: retained input stays queued and nothing restarts implicitly.
203
212
  write(path.join(this.dir, "park-failed.json"), { token: this.token, errors });
213
+ return "exit-failed";
204
214
  }
215
+ return "exit-parked";
205
216
  }
206
217
  async capture(session, save) {
207
218
  if (!parkable(session)) {
@@ -5,7 +5,7 @@ import { launchAt, missing, read, runDirectory, write } from "./storage.js";
5
5
  import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.js";
6
6
  import { errorText, loadSDK } from "./host.js";
7
7
  import { Parking } from "./parking.js";
8
- import { UsageIndex } from "./usage.js";
8
+ import { accountingFields, UsageIndex } from "./usage.js";
9
9
  import { initializeCwd } from "./cwd.js";
10
10
  import { childRuntime } from "./child-runtime.js";
11
11
  import { ActorObservation } from "./observation.js";
@@ -40,7 +40,7 @@ class Actor {
40
40
  stopping = false;
41
41
  stopDeadline;
42
42
  nextParentCheck = 0;
43
- parking = false;
43
+ parking = "resident";
44
44
  parent = new ParentCheck();
45
45
  // Raised when durable commands may need admission: change hints and stop.
46
46
  wake = new Wake();
@@ -91,10 +91,7 @@ class Actor {
91
91
  owner: launch.owner,
92
92
  status: completionStatus(this.stopping, message, error),
93
93
  endedAt: Date.now(),
94
- ...(accounting.accountingError === undefined && terminal?.id !== undefined
95
- ? { terminalEntryId: terminal.id }
96
- : {}),
97
- ...accounting,
94
+ ...accountingFields(accounting),
98
95
  ...(failure !== undefined ? { error: failure } : {}),
99
96
  };
100
97
  fs.writeFileSync(path.join(runDirectory(this.base, launch.id), "output.txt"), output, {
@@ -104,16 +101,21 @@ class Actor {
104
101
  }
105
102
  usage(launch, baseline, terminal) {
106
103
  try {
107
- return { contributions: this.accounting.read(launch, baseline, terminal) };
108
- }
109
- catch (error) {
110
104
  return {
111
- accountingError: errorText(error),
112
- ...(terminal !== undefined
113
- ? { accountingPending: { terminalCandidate: terminal, baseline: [...baseline] } }
114
- : {}),
105
+ kind: "verified",
106
+ contributions: this.accounting.read(launch, baseline, terminal),
107
+ terminalEntryId: terminal,
115
108
  };
116
109
  }
110
+ catch (error) {
111
+ return terminal === undefined
112
+ ? { kind: "unavailable", error: errorText(error), terminalEntryId: undefined }
113
+ : {
114
+ kind: "pending",
115
+ error: errorText(error),
116
+ pending: { terminalCandidate: terminal, baseline: [...baseline] },
117
+ };
118
+ }
117
119
  }
118
120
  output(message) {
119
121
  return (message?.content
@@ -352,7 +354,7 @@ class Actor {
352
354
  finally {
353
355
  clearInterval(watch);
354
356
  }
355
- return this.parking;
357
+ return this.parking !== "resident";
356
358
  }
357
359
  failure(error, unopened) {
358
360
  const launch = this.current ?? this.initial;
@@ -465,7 +467,7 @@ class Actor {
465
467
  process.off("SIGTERM", this.stop);
466
468
  process.off("SIGINT", this.stop);
467
469
  const observation = this.observation?.close();
468
- if (!this.parking) {
470
+ if (this.parking === "resident") {
469
471
  await runtime?.dispose();
470
472
  }
471
473
  if (this.stopping) {
@@ -477,7 +479,7 @@ class Actor {
477
479
  errors: this.errors,
478
480
  ...(observation === undefined ? {} : { observation }),
479
481
  });
480
- if (this.parking) {
482
+ if (this.parking !== "resident") {
481
483
  // Parked authority requires actual exit; no leftover extension handle may keep it alive.
482
484
  process.exit();
483
485
  }
@@ -1,5 +1,6 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type } from "./typebox.js";
3
+ import { thinkingSchema } from "./contracts.js";
3
4
  // Main-session tool parameters only; the detached runner must not load pi-ai values.
4
5
  export const delegateSchema = Type.Object({
5
6
  prompt: Type.String({ minLength: 1 }),
@@ -9,6 +10,7 @@ export const delegateSchema = Type.Object({
9
10
  minLength: 1,
10
11
  description: "Exact provider/model ID (:thinking and any case need host support); no fuzzy match or fallback.",
11
12
  })),
13
+ thinking: Type.Optional(thinkingSchema),
12
14
  context: Type.Optional(StringEnum(["fresh", "fork"])),
13
15
  }, { additionalProperties: false });
14
16
  export const runsSchema = Type.Object({