@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
@@ -14,6 +14,7 @@ profile discovery, loader, role body, chain DSL, or nested delegation.
14
14
  delegate({
15
15
  prompt: "Complete assignment with constraints and expected output",
16
16
  name: "short-name",
17
+ thinking: "low",
17
18
  });
18
19
  ```
19
20
 
@@ -23,12 +24,14 @@ expected result, and stop boundary. Do not rely on a hidden profile to add them.
23
24
 
24
25
  Fresh context is default; `context:"fork"` preserves the native conversation,
25
26
  including prior tool results. A forked child still cannot delegate. Optional
26
- `cwd` and `model` are explicit launch overrides; do not change global auth/model
27
- settings to implement an override.
27
+ `cwd`, `model`, and `thinking` are explicit launch overrides; do not change global
28
+ auth/model settings to implement an override. `thinking` accepts `off`, `minimal`,
29
+ `low`, `medium`, `high`, `xhigh`, or `max`. Omit it to inherit the parent's effort.
30
+ Pi clamps the child's effort to its model capabilities without changing the parent.
28
31
  `model:"provider/model:thinking"` uses Pi's native suffix resolver when the host
29
32
  exports it; a literal model ID wins before suffix parsing. Without that host API,
30
- use a literal ID with inherited thinking or upgrade Pi. Never retry another model
31
- silently.
33
+ use `thinking` with an omitted model or literal ID. If both the field and a native
34
+ suffix supply effort, they must match. Never retry another model silently.
32
35
 
33
36
  Every launch, continuation and guidance call returns a receipt; no tool waits for
34
37
  child completion. Continue useful independent work; when child evidence gates the
@@ -45,6 +48,7 @@ invocation exits after its final turn and stops any remaining children.
45
48
  - `inspect` reads a run by ID; follow returned offsets for complete output.
46
49
  - `continue` takes a complete follow-up `prompt` and keeps the saved child chat.
47
50
  Active work receives guidance; completed work gets an immutable successor.
51
+ Both keep the child's saved effort, independent of later parent changes.
48
52
  - `stop` requests termination. Inspect actual process evidence; request acceptance
49
53
  does not prove actor or command exit.
50
54
 
@@ -93,6 +93,23 @@ export const resultSchema = Type.Object({
93
93
  ),
94
94
  });
95
95
  export type Result = Static<typeof resultSchema>;
96
+ export type AccountingState =
97
+ | { readonly kind: "unrecorded"; readonly terminalEntryId: string | undefined }
98
+ | {
99
+ readonly kind: "verified";
100
+ readonly contributions: Contribution[];
101
+ readonly terminalEntryId: string | undefined;
102
+ }
103
+ | {
104
+ readonly kind: "pending";
105
+ readonly pending: NonNullable<Result["accountingPending"]>;
106
+ readonly error: string | undefined;
107
+ }
108
+ | {
109
+ readonly kind: "unavailable";
110
+ readonly error: string;
111
+ readonly terminalEntryId: string | undefined;
112
+ };
96
113
  export const commandSchema = Type.Object({
97
114
  id: Type.String(),
98
115
  runId: Type.String(),
package/src/agent/host.ts CHANGED
@@ -9,6 +9,25 @@ import { readIntercomLineage } from "../pi-intercom/identity.ts";
9
9
 
10
10
  export { errorMessage as errorText } from "../shared/unknown.ts";
11
11
 
12
+ /** Native fork lifetime owner; structural detection keeps official Pi supported. */
13
+ export interface ManagedHost {
14
+ readonly owner: { readonly pid: number };
15
+ readonly ready: Promise<boolean>;
16
+ }
17
+ export function managedHost(context: SDK.ExtensionContext): ManagedHost | undefined {
18
+ const host: unknown = Reflect.get(context, "managedHost");
19
+ if (typeof host !== "object" || host === null) {
20
+ return undefined;
21
+ }
22
+ const owner: unknown = Reflect.get(host, "owner");
23
+ const pid: unknown =
24
+ typeof owner === "object" && owner !== null ? Reflect.get(owner, "pid") : undefined;
25
+ const ready: unknown = Reflect.get(host, "ready");
26
+ return typeof pid === "number" && Number.isSafeInteger(pid) && ready instanceof Promise
27
+ ? { owner: { pid }, ready: ready.then((accepted) => accepted === true) }
28
+ : undefined;
29
+ }
30
+
12
31
  /** A launcher may still own children while handing off or replacing a worker. */
13
32
  export function retainsChildren(event: object): boolean {
14
33
  const disposition: unknown = Reflect.get(event, "disposition");
@@ -17,7 +17,14 @@ import {
17
17
  signalProcess,
18
18
  } from "./process.ts";
19
19
  import { stopChildren } from "./shutdown.ts";
20
- import { isChild, errorText, rememberSDK, retainsChildren } from "./host.ts";
20
+ import {
21
+ isChild,
22
+ errorText,
23
+ managedHost,
24
+ rememberSDK,
25
+ retainsChildren,
26
+ type ManagedHost,
27
+ } from "./host.ts";
21
28
  import { serveParked } from "./parking.ts";
22
29
  import {
23
30
  resultSchema,
@@ -54,24 +61,20 @@ import { getPiAgentDir } from "../pi-intercom/agent-dir.ts";
54
61
  import { registerCompactMessages, renderCompactResult } from "../shared/compact-cards.ts";
55
62
  import { BackgroundReporter } from "../shared/background-reporting.ts";
56
63
 
57
- /** Native fork lifetime owner (`ctx.managedHost`); read structurally so official Pi stays supported. */
58
- interface ManagedHost {
59
- readonly owner: { readonly pid: number };
60
- readonly ready: Promise<boolean>;
61
- }
62
- function managedHost(context: ExtensionContext): ManagedHost | undefined {
63
- const host: unknown = Reflect.get(context, "managedHost");
64
- if (typeof host !== "object" || host === null) {
65
- return undefined;
66
- }
67
- const owner: unknown = Reflect.get(host, "owner");
68
- const pid: unknown =
69
- typeof owner === "object" && owner !== null ? Reflect.get(owner, "pid") : undefined;
70
- const ready: unknown = Reflect.get(host, "ready");
71
- return typeof pid === "number" && Number.isSafeInteger(pid) && ready instanceof Promise
72
- ? { owner: { pid }, ready: ready.then((accepted) => accepted === true) }
73
- : undefined;
64
+ interface AttachmentBinding {
65
+ readonly context: ExtensionContext;
66
+ readonly owner: string;
67
+ readonly base: string;
68
+ readonly host: ManagedHost | undefined;
74
69
  }
70
+ type AttachmentState =
71
+ | { readonly kind: "detached" }
72
+ | { readonly kind: "waiting"; readonly binding: AttachmentBinding }
73
+ | {
74
+ readonly kind: "attaching" | "attached";
75
+ readonly binding: AttachmentBinding;
76
+ readonly generation: string;
77
+ };
75
78
 
76
79
  /** Legacy results are optional; their reconciliation reports an invalid configured root. */
77
80
  function legacyInput(): string[] {
@@ -82,14 +85,8 @@ function legacyInput(): string[] {
82
85
  }
83
86
  }
84
87
  class AgentRuntime {
85
- private ctx?: ExtensionContext | undefined;
88
+ private attachment: AttachmentState = { kind: "detached" };
86
89
  private readonly reporter = new BackgroundReporter(() => this.ctx);
87
- // A managed launcher owns children across worker replacement; otherwise this process does.
88
- private host?: ManagedHost | undefined;
89
- private accepted = false;
90
- private base = "";
91
- private owner = "";
92
- private generation = "";
93
90
  // Polling remains only while change hints are unavailable or a reconciliation failed.
94
91
  private timer?: NodeJS.Timeout;
95
92
  private woken?: NodeJS.Immediate | undefined;
@@ -99,7 +96,6 @@ class AgentRuntime {
99
96
  // Input stamps the last reconciliation saw; a one-second probe recovers dropped hints.
100
97
  private observed = new Map<string, string>();
101
98
  private probe?: NodeJS.Timeout;
102
- private closing = false;
103
99
  private reset: Promise<void> = Promise.resolve();
104
100
  private readonly shutdown = new AbortController();
105
101
  private readonly child = isChild();
@@ -109,11 +105,28 @@ class AgentRuntime {
109
105
  this.pi = pi;
110
106
  this.watches = new CompletionWatches(this.reporter, this.wake);
111
107
  }
108
+ private get binding(): AttachmentBinding {
109
+ if (this.attachment.kind === "detached") {
110
+ throw new Error("Agent runtime is not attached.");
111
+ }
112
+ return this.attachment.binding;
113
+ }
114
+ private get ctx(): ExtensionContext | undefined {
115
+ return this.attachment.kind === "attaching" || this.attachment.kind === "attached"
116
+ ? this.attachment.binding.context
117
+ : undefined;
118
+ }
119
+ private get base(): string {
120
+ return this.binding.base;
121
+ }
122
+ private get owner(): string {
123
+ return this.binding.owner;
124
+ }
112
125
  private checkMain(): void {
113
126
  if (isChild()) {
114
127
  throw new Error("Only a main session may delegate or control agent runs.");
115
128
  }
116
- if (this.closing || !this.ctx || this.base.length === 0) {
129
+ if (this.shutdown.signal.aborted || this.attachment.kind !== "attached") {
117
130
  throw new Error("Agent runtime is not attached.");
118
131
  }
119
132
  }
@@ -133,17 +146,18 @@ class AgentRuntime {
133
146
  };
134
147
  private readonly scan = (): void => {
135
148
  clearTimeout(this.timer);
136
- if (this.closing || !this.ctx || this.child) {
149
+ if (this.shutdown.signal.aborted || this.attachment.kind !== "attached" || this.child) {
137
150
  return;
138
151
  }
152
+ const { context } = this.attachment.binding;
139
153
  const inputs = () => [this.base, ...legacyInput(), ...this.awaited];
140
154
  const before = new Map(inputs().map((file) => [file, pathStamp(file)]));
141
155
  let reconciled = true;
142
156
  try {
143
- const { notices, awaited } = notifications(this.ctx, this.base, this.owner);
157
+ const { notices, awaited } = notifications(context, this.base, this.owner);
144
158
  this.awaited = awaited;
145
- notices.unshift(...reconcileLegacy(this.ctx, this.reporter));
146
- deliver(this.pi, this.ctx, notices);
159
+ notices.unshift(...reconcileLegacy(context, this.reporter));
160
+ deliver(this.pi, context, notices);
147
161
  this.reporter.recover("completion-scan");
148
162
  } catch (error) {
149
163
  reconciled = false;
@@ -172,23 +186,34 @@ class AgentRuntime {
172
186
  this.awaited = new Set();
173
187
  }
174
188
  private start(context: ExtensionContext): Promise<void> | undefined {
189
+ if (this.shutdown.signal.aborted) {
190
+ return;
191
+ }
175
192
  this.reporter.start();
176
- this.host = managedHost(context);
177
- if (this.host === undefined || this.accepted || this.child) {
178
- this.reset = this.reset.then(() => this.attach(context));
193
+ const owner = context.sessionManager.getSessionId();
194
+ const host = managedHost(context);
195
+ const binding = { context, owner, base: ownerDirectory(this.agentDir, owner), host };
196
+ if (
197
+ host === undefined ||
198
+ this.attachment.kind === "attached" ||
199
+ this.attachment.kind === "attaching" ||
200
+ this.child
201
+ ) {
202
+ this.reset = this.reset.then(() => this.attach(binding));
179
203
  return this.reset;
180
204
  }
181
205
  // Attach and publish only after the launcher retires rollback: an unready replacement
182
206
  // must neither adopt children nor append completions to a journal it may abandon.
183
207
  // Readiness follows session_start, so never await it here.
184
- this.owner = context.sessionManager.getSessionId();
185
- this.base = ownerDirectory(this.agentDir, this.owner);
186
- this.host.ready
187
- .then(async (accepted) => {
188
- if (accepted && !this.closing) {
189
- this.accepted = true;
190
- await this.start(context);
208
+ const waiting: AttachmentState = { kind: "waiting", binding };
209
+ this.attachment = waiting;
210
+ host.ready
211
+ .then((accepted) => {
212
+ if (accepted && !this.shutdown.signal.aborted && this.attachment === waiting) {
213
+ this.reset = this.reset.then(() => this.attach(binding));
214
+ return this.reset;
191
215
  }
216
+ return null;
192
217
  })
193
218
  .catch((error: unknown) => {
194
219
  this.reporter.report(undefined, `Agent runtime attachment: ${errorText(error)}`);
@@ -196,37 +221,36 @@ class AgentRuntime {
196
221
  return undefined;
197
222
  }
198
223
  private ownerIdentity(): Omit<ProcessIdentity, "token"> {
199
- return ownIdentity(this.host?.owner.pid);
224
+ return ownIdentity(this.binding.host?.owner.pid);
200
225
  }
201
- private async attach(context: ExtensionContext): Promise<void> {
226
+ private async attach(binding: AttachmentBinding): Promise<void> {
202
227
  this.unwatch();
203
- this.ctx = context;
228
+ const attaching: AttachmentState = { kind: "attaching", binding, generation: randomUUID() };
229
+ this.attachment = attaching;
204
230
  if (this.child) {
231
+ this.attachment = { ...attaching, kind: "attached" };
205
232
  return;
206
233
  }
207
- this.owner = context.sessionManager.getSessionId();
208
- this.base = ownerDirectory(this.agentDir, this.owner);
209
- this.generation = randomUUID();
210
- await fs.promises.mkdir(this.base, { recursive: true, mode: 0o700 });
211
- write(path.join(this.base, "attachment.json"), {
212
- ...this.ownerIdentity(),
213
- generation: this.generation,
234
+ await fs.promises.mkdir(binding.base, { recursive: true, mode: 0o700 });
235
+ write(path.join(binding.base, "attachment.json"), {
236
+ ...ownIdentity(binding.host?.owner.pid),
237
+ generation: attaching.generation,
214
238
  attached: true,
215
239
  });
216
- if (this.closing) {
240
+ if (this.shutdown.signal.aborted) {
217
241
  return;
218
242
  }
243
+ this.attachment = { ...attaching, kind: "attached" };
219
244
  // Recovery and watch setup stay off the awaited startup path.
220
245
  this.timer = setTimeout(this.scan, 200).unref();
221
246
  this.probe = setInterval(this.recover, 1000).unref();
222
247
  }
223
248
  private async dispose(retain: boolean): Promise<void> {
224
249
  this.reporter.shutdown();
225
- this.closing = true;
226
250
  this.unwatch();
227
251
  this.shutdown.abort();
228
252
  await this.reset;
229
- if (!retain && this.base.length > 0) {
253
+ if (!retain && !this.child && this.attachment.kind !== "detached") {
230
254
  const file = path.join(this.base, "attachment.json");
231
255
  const attachment = read(file, attachmentSchema);
232
256
  if (attachment !== undefined && this.releases(attachment)) {
@@ -240,16 +264,20 @@ class AgentRuntime {
240
264
  );
241
265
  }
242
266
  }
243
- this.ctx = undefined;
267
+ this.attachment = { kind: "detached" };
244
268
  }
245
269
  private releases(attachment: Attachment): boolean {
246
- if (attachment.generation === this.generation) {
247
- return true;
270
+ const state = this.attachment;
271
+ switch (state.kind) {
272
+ case "detached":
273
+ return false;
274
+ case "waiting":
275
+ // An unready replacement can release the launcher's existing generation on quit.
276
+ return attachment.attached && attachment.pid === state.binding.host?.owner.pid;
277
+ case "attaching":
278
+ case "attached":
279
+ return attachment.generation === state.generation;
248
280
  }
249
- // An unready replacement never attached, but exiting still releases the launcher-owned children.
250
- return (
251
- this.generation.length === 0 && attachment.attached && attachment.pid === this.host?.owner.pid
252
- );
253
281
  }
254
282
  private stop(launch: Launch) {
255
283
  const base = this.base;
@@ -433,6 +461,7 @@ class AgentRuntime {
433
461
  this.pi.on("turn_end", (event, context) => {
434
462
  // The public pending flag covers user input; the preview also includes custom messages.
435
463
  if (
464
+ this.attachment.kind === "attached" &&
436
465
  settle(this.owner, context.hasPendingMessages() || event.context.pendingMessages.length > 0)
437
466
  ) {
438
467
  // Released admissions may have been dropped from the queue rather than published.
@@ -459,7 +488,7 @@ class AgentRuntime {
459
488
  name: "delegate",
460
489
  label: "Delegate",
461
490
  description:
462
- "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.",
491
+ "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.",
463
492
  parameters: delegateSchema,
464
493
  exposure: this.child ? "hidden" : "direct",
465
494
  renderResult: renderCompactResult,
@@ -8,6 +8,16 @@ import type { DelegateInput } from "./tool-schemas.ts";
8
8
  import { loadSDK, packageRoot, runtimeEntry } from "./host.ts";
9
9
  import { runDirectory, write } from "./storage.ts";
10
10
 
11
+ function selectedThinking(
12
+ requested: ThinkingLevel | undefined,
13
+ inherited: ThinkingLevel,
14
+ suffix?: ThinkingLevel,
15
+ ): ThinkingLevel {
16
+ if (requested !== undefined && suffix !== undefined && requested !== suffix) {
17
+ throw new Error("thinking conflicts with the model's thinking suffix.");
18
+ }
19
+ return suffix ?? requested ?? inherited;
20
+ }
11
21
  function selectedModel(
12
22
  input: DelegateInput,
13
23
  context: ExtensionContext,
@@ -18,7 +28,7 @@ function selectedModel(
18
28
  if (!context.model) {
19
29
  throw new Error("No selected model; no fallback was used.");
20
30
  }
21
- return { model: context.model, thinking };
31
+ return { model: context.model, thinking: selectedThinking(input.thinking, thinking) };
22
32
  }
23
33
  const slash = input.model.indexOf("/");
24
34
  if (slash < 1) {
@@ -29,7 +39,7 @@ function selectedModel(
29
39
  input.model.slice(slash + 1),
30
40
  );
31
41
  if (model) {
32
- return { model, thinking };
42
+ return { model, thinking: selectedThinking(input.thinking, thinking) };
33
43
  }
34
44
  if (typeof sdk.resolveModelReference !== "function") {
35
45
  throw new Error(
@@ -49,7 +59,10 @@ function selectedModel(
49
59
  if (resolved.status !== "resolved") {
50
60
  throw new Error(`Model ${input.model} is unavailable; no fallback was used.`);
51
61
  }
52
- return { model: resolved.model, thinking: resolved.thinkingLevel ?? thinking };
62
+ return {
63
+ model: resolved.model,
64
+ thinking: selectedThinking(input.thinking, thinking, resolved.thinkingLevel),
65
+ };
53
66
  }
54
67
  function targetTrust(
55
68
  sdk: Awaited<ReturnType<typeof loadSDK>>,
@@ -6,7 +6,7 @@ import { readOutputPage } from "../shared/output-page.ts";
6
6
  import { journalStamp } from "../shared/native-journal.ts";
7
7
  import type { Launch, Result } from "./contracts.ts";
8
8
  import { launchAt, list, runDirectory } from "./storage.ts";
9
- import { readCanonicalResult, readResult } from "./usage.ts";
9
+ import { accountingState, readCanonicalResult, readResult } from "./usage.ts";
10
10
 
11
11
  function isStringSet(value: unknown): value is Set<string> {
12
12
  if (!(value instanceof Set)) {
@@ -160,7 +160,7 @@ function unchangedPending(pending: Loaded | undefined): boolean {
160
160
  function load(base: string, id: string, owner: string): Loaded {
161
161
  const launch = launchAt(base, id, owner);
162
162
  const result = readCanonicalResult(base, launch);
163
- if (result?.accountingPending === undefined) {
163
+ if (accountingState(result).kind !== "pending") {
164
164
  return { launch, result };
165
165
  }
166
166
  // Only pending runs need this pre-read stamp to observe concurrent publication.
@@ -169,7 +169,7 @@ function load(base: string, id: string, owner: string): Loaded {
169
169
  return { launch, result: readResult(base, launch), journal };
170
170
  }
171
171
  function cacheableResult(loaded: Loaded): boolean {
172
- return loaded.result?.accountingPending === undefined || loaded.journal !== undefined;
172
+ return accountingState(loaded.result).kind !== "pending" || loaded.journal !== undefined;
173
173
  }
174
174
  interface Scan {
175
175
  readonly base: string;
@@ -198,7 +198,7 @@ function completedRun({ base, owner, known, settled }: Scan, id: string): Comple
198
198
  const order = { endedAt: loaded.result.endedAt, createdAt: loaded.launch.createdAt };
199
199
  // Pending accounting retries on child changes, even after the completion receipt.
200
200
  // Ordinary settled history retains its launch/result-only idle probes.
201
- const pending = loaded.result.accountingPending === undefined ? undefined : loaded;
201
+ const pending = accountingState(loaded.result).kind === "pending" ? loaded : undefined;
202
202
  if (launch !== undefined && result !== undefined && cacheableResult(loaded)) {
203
203
  settled.set(id, { launch, result, pending, ...order });
204
204
  }
@@ -216,7 +216,7 @@ function completed(base: string, owner: string, awaited: Set<string>): Completed
216
216
  const run = completedRun(scan, id);
217
217
  if (run === undefined) {
218
218
  awaited.add(path.join(runDirectory(base, id), "result.json"));
219
- } else if (run.loaded?.result?.accountingPending !== undefined) {
219
+ } else if (run.loaded && accountingState(run.loaded.result).kind === "pending") {
220
220
  awaited.add(run.loaded.launch.sessionFile);
221
221
  }
222
222
  return run ?? [];
@@ -29,7 +29,14 @@ import type { NativeHistory } from "../pi-intercom/last-message.ts";
29
29
  type SDK = Awaited<ReturnType<typeof loadSDK>>;
30
30
  type Restore = NonNullable<SDK["readWorkingSession"]>;
31
31
  export type Runtime = Awaited<ReturnType<SDK["createAgentSessionRuntime"]>>;
32
+ export type ParkOutcome = "resident" | "exit-parked" | "exit-failed";
32
33
  type SessionManager = Parameters<SDK["createAgentSessionRuntime"]>[1]["sessionManager"];
34
+ type OpenSource =
35
+ | { readonly kind: "journal" }
36
+ | { readonly kind: "parked"; readonly state: string; readonly saved: WorkingState };
37
+ type RestorePhase =
38
+ | { readonly kind: "unopened" }
39
+ | { readonly kind: "opening" | "adopted"; readonly source: OpenSource };
33
40
  interface WorkingHold {
34
41
  readonly state: WorkingState;
35
42
  readonly sleepReady: boolean;
@@ -156,10 +163,7 @@ export function parkedState(
156
163
  }
157
164
  /** One actor generation's idle parking and exact restore; commands stay with the runner. */
158
165
  export class Parking {
159
- saved?: WorkingState;
160
- /** Set before entering either native open, whose effects may outlive an exception. */
161
- opened = false;
162
- private restored?: string;
166
+ private restore: RestorePhase = { kind: "unopened" };
163
167
  private since = 0;
164
168
  private leaf?: string | null;
165
169
  private shell?: string | undefined;
@@ -179,36 +183,48 @@ export class Parking {
179
183
  private get file(): string {
180
184
  return path.join(this.dir, "parked.json");
181
185
  }
186
+ get saved(): WorkingState | undefined {
187
+ return this.restore.kind !== "unopened" && this.restore.source.kind === "parked"
188
+ ? this.restore.source.saved
189
+ : undefined;
190
+ }
191
+ /** Set before entering either native open, whose effects may outlive an exception. */
192
+ get opened(): boolean {
193
+ return this.restore.kind !== "unopened";
194
+ }
182
195
  /** Parked authority is exact state or nothing; never a journal-only reopen. */
183
196
  open(): SessionManager {
184
197
  const { sdk, launch } = this;
185
198
  const { openWorkingSession: restore } = sdk;
186
199
  const parked = parkedState(this.dir, launch, restore && sdk.readWorkingSession);
187
- this.opened = true;
200
+ this.restore = {
201
+ kind: "opening",
202
+ source: parked ? { kind: "parked", ...parked } : { kind: "journal" },
203
+ };
188
204
  if (!parked || !restore) {
189
205
  return sdk.SessionManager.open(launch.sessionFile, undefined, launch.cwd);
190
206
  }
191
- this.saved = parked.saved;
192
- this.restored = parked.state;
193
207
  return restore(parked.saved);
194
208
  }
195
209
  /** The ready restored generation owns this state now; a later park writes a new cut. */
196
210
  adopt(): void {
197
- if (this.restored !== undefined) {
211
+ if (this.restore.kind !== "opening") {
212
+ return;
213
+ }
214
+ const { source } = this.restore;
215
+ if (source.kind === "parked") {
198
216
  fs.rmSync(this.file);
199
- fs.rmSync(this.restored, { force: true });
217
+ fs.rmSync(source.state, { force: true });
200
218
  }
219
+ this.restore = { kind: "adopted", source };
201
220
  }
202
- /** True once this generation has exited its runtime with or without parked authority. */
203
- async park(runtime: Runtime, actor: Resident): Promise<boolean> {
221
+ /** Both exit outcomes have disposed the runtime; only one publishes parked authority. */
222
+ async park(runtime: Runtime, actor: Resident): Promise<ParkOutcome> {
204
223
  if (!actor.pi || !this.due(runtime.session, actor.busy) || this.hasShell()) {
205
- return false;
224
+ return "resident";
206
225
  }
207
226
  const hold = await this.prepare(runtime, actor.pi, actor);
208
- if (hold) {
209
- await this.commit(runtime, hold, actor.errors);
210
- }
211
- return hold !== undefined;
227
+ return hold ? this.commit(runtime, hold, actor.errors) : "resident";
212
228
  }
213
229
  private hasShell(): boolean {
214
230
  const witness = this.shell === undefined ? undefined : shell(this.shell, this.token);
@@ -259,7 +275,11 @@ export class Parking {
259
275
  return undefined;
260
276
  }
261
277
  /** Commit: native strict exit, then capture; failure leaves no parked authority. */
262
- private async commit(runtime: Runtime, hold: WorkingHold, errors: string[]): Promise<void> {
278
+ private async commit(
279
+ runtime: Runtime,
280
+ hold: WorkingHold,
281
+ errors: string[],
282
+ ): Promise<Exclude<ParkOutcome, "resident">> {
263
283
  const save = this.sdk.writeWorkingSession;
264
284
  if (save && finalizing(runtime)) {
265
285
  runtime.setFinalization((final) => this.capture(final, save), { strictShutdown: true });
@@ -275,7 +295,9 @@ export class Parking {
275
295
  if (read(this.file, parkedSchema)?.token !== this.token) {
276
296
  // Truthful boundary: retained input stays queued and nothing restarts implicitly.
277
297
  write(path.join(this.dir, "park-failed.json"), { token: this.token, errors });
298
+ return "exit-failed";
278
299
  }
300
+ return "exit-parked";
279
301
  }
280
302
  private async capture(
281
303
  session: AgentSession,
@@ -8,12 +8,13 @@ import {
8
8
  type Command,
9
9
  type Launch,
10
10
  type Result,
11
+ type AccountingState,
11
12
  } from "./contracts.ts";
12
13
  import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
13
14
  import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
14
15
  import { errorText, loadSDK } from "./host.ts";
15
- import { Parking, type Runtime } from "./parking.ts";
16
- import { UsageIndex } from "./usage.ts";
16
+ import { Parking, type ParkOutcome, type Runtime } from "./parking.ts";
17
+ import { accountingFields, UsageIndex } from "./usage.ts";
17
18
  import { initializeCwd } from "./cwd.ts";
18
19
  import { childRuntime } from "./child-runtime.ts";
19
20
  import { ActorObservation, type ObservationStats } from "./observation.ts";
@@ -56,7 +57,7 @@ class Actor {
56
57
  private stopping = false;
57
58
  private stopDeadline?: NodeJS.Timeout;
58
59
  private nextParentCheck = 0;
59
- private parking = false;
60
+ private parking: ParkOutcome = "resident";
60
61
  private readonly parent = new ParentCheck();
61
62
  // Raised when durable commands may need admission: change hints and stop.
62
63
  private readonly wake = new Wake();
@@ -110,10 +111,7 @@ class Actor {
110
111
  owner: launch.owner,
111
112
  status: completionStatus(this.stopping, message, error),
112
113
  endedAt: Date.now(),
113
- ...(accounting.accountingError === undefined && terminal?.id !== undefined
114
- ? { terminalEntryId: terminal.id }
115
- : {}),
116
- ...accounting,
114
+ ...accountingFields(accounting),
117
115
  ...(failure !== undefined ? { error: failure } : {}),
118
116
  };
119
117
  fs.writeFileSync(path.join(runDirectory(this.base, launch.id), "output.txt"), output, {
@@ -121,20 +119,21 @@ class Actor {
121
119
  });
122
120
  write(path.join(runDirectory(this.base, launch.id), "result.json"), result);
123
121
  }
124
- private usage(
125
- launch: Launch,
126
- baseline: ReadonlySet<string>,
127
- terminal?: string,
128
- ): Pick<Result, "contributions" | "accountingError" | "accountingPending"> {
122
+ private usage(launch: Launch, baseline: ReadonlySet<string>, terminal?: string): AccountingState {
129
123
  try {
130
- return { contributions: this.accounting.read(launch, baseline, terminal) };
131
- } catch (error) {
132
124
  return {
133
- accountingError: errorText(error),
134
- ...(terminal !== undefined
135
- ? { accountingPending: { terminalCandidate: terminal, baseline: [...baseline] } }
136
- : {}),
125
+ kind: "verified",
126
+ contributions: this.accounting.read(launch, baseline, terminal),
127
+ terminalEntryId: terminal,
137
128
  };
129
+ } catch (error) {
130
+ return terminal === undefined
131
+ ? { kind: "unavailable", error: errorText(error), terminalEntryId: undefined }
132
+ : {
133
+ kind: "pending",
134
+ error: errorText(error),
135
+ pending: { terminalCandidate: terminal, baseline: [...baseline] },
136
+ };
138
137
  }
139
138
  }
140
139
  private output(message: AssistantMessage | undefined): string {
@@ -384,7 +383,7 @@ class Actor {
384
383
  } finally {
385
384
  clearInterval(watch);
386
385
  }
387
- return this.parking;
386
+ return this.parking !== "resident";
388
387
  }
389
388
  private failure(error: unknown, unopened?: string): void {
390
389
  const launch = this.current ?? this.initial;
@@ -494,7 +493,7 @@ class Actor {
494
493
  process.off("SIGTERM", this.stop);
495
494
  process.off("SIGINT", this.stop);
496
495
  const observation: ObservationStats | undefined = this.observation?.close();
497
- if (!this.parking) {
496
+ if (this.parking === "resident") {
498
497
  await runtime?.dispose();
499
498
  }
500
499
  if (this.stopping) {
@@ -506,7 +505,7 @@ class Actor {
506
505
  errors: this.errors,
507
506
  ...(observation === undefined ? {} : { observation }),
508
507
  });
509
- if (this.parking) {
508
+ if (this.parking !== "resident") {
510
509
  // Parked authority requires actual exit; no leftover extension handle may keep it alive.
511
510
  process.exit();
512
511
  }
@@ -1,6 +1,7 @@
1
1
  import type { Static } from "typebox";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "./typebox.ts";
4
+ import { thinkingSchema } from "./contracts.ts";
4
5
 
5
6
  // Main-session tool parameters only; the detached runner must not load pi-ai values.
6
7
  export const delegateSchema = Type.Object(
@@ -15,6 +16,7 @@ export const delegateSchema = Type.Object(
15
16
  "Exact provider/model ID (:thinking and any case need host support); no fuzzy match or fallback.",
16
17
  }),
17
18
  ),
19
+ thinking: Type.Optional(thinkingSchema),
18
20
  context: Type.Optional(StringEnum(["fresh", "fork"] as const)),
19
21
  },
20
22
  { additionalProperties: false },