@fitchmultz/pi-subagents 0.52.18 → 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 (43) hide show
  1. package/CHANGELOG.md +34 -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/lifecycle.js +26 -26
  20. package/dist/shared/post-exit-stdio-guard.js +44 -37
  21. package/docs/development.md +25 -0
  22. package/docs/intercom.md +13 -0
  23. package/docs/reference.md +16 -5
  24. package/package.json +1 -1
  25. package/scripts/build.mjs +41 -14
  26. package/skills/pi-subagents/SKILL.md +8 -4
  27. package/src/agent/contracts.ts +17 -0
  28. package/src/agent/host.ts +19 -0
  29. package/src/agent/index.ts +91 -62
  30. package/src/agent/launch.ts +16 -3
  31. package/src/agent/notifications.ts +5 -5
  32. package/src/agent/parking.ts +39 -17
  33. package/src/agent/runner.ts +20 -21
  34. package/src/agent/tool-schemas.ts +2 -0
  35. package/src/agent/usage.ts +48 -3
  36. package/src/agent/view.ts +15 -13
  37. package/src/agent/wake.ts +19 -15
  38. package/src/pi-intercom/broker/broker.ts +2 -1
  39. package/src/pi-intercom/broker/snapshots.ts +82 -37
  40. package/src/pi-intercom/broker/spawn-claim.ts +98 -0
  41. package/src/pi-intercom/broker/spawn.ts +42 -82
  42. package/src/pi-intercom/lifecycle.ts +38 -27
  43. package/src/shared/post-exit-stdio-guard.ts +48 -40
@@ -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 },
@@ -8,12 +8,49 @@ import { Assert } from "./typebox.ts";
8
8
  import {
9
9
  contributionsSchema,
10
10
  resultSchema,
11
+ type AccountingState,
11
12
  type Contribution,
12
13
  type Launch,
13
14
  type Result,
14
15
  } from "./contracts.ts";
15
16
  import { read, runDirectory, write } from "./storage.ts";
16
17
 
18
+ /** Pending repair remains authoritative until its native terminal is published and validated. */
19
+ export function accountingState(result: Result | undefined): AccountingState {
20
+ if (result?.accountingPending !== undefined) {
21
+ return { kind: "pending", pending: result.accountingPending, error: result.accountingError };
22
+ }
23
+ const terminalEntryId = result?.terminalEntryId;
24
+ if (result?.contributions !== undefined) {
25
+ return { kind: "verified", contributions: result.contributions, terminalEntryId };
26
+ }
27
+ return result?.accountingError === undefined
28
+ ? { kind: "unrecorded", terminalEntryId }
29
+ : { kind: "unavailable", error: result.accountingError, terminalEntryId };
30
+ }
31
+
32
+ export function accountingFields(
33
+ state: AccountingState,
34
+ ): Pick<Result, "contributions" | "accountingPending" | "accountingError" | "terminalEntryId"> {
35
+ const terminal =
36
+ state.kind !== "pending" && state.terminalEntryId !== undefined
37
+ ? { terminalEntryId: state.terminalEntryId }
38
+ : {};
39
+ switch (state.kind) {
40
+ case "unrecorded":
41
+ return terminal;
42
+ case "verified":
43
+ return { contributions: state.contributions, ...terminal };
44
+ case "pending":
45
+ return {
46
+ accountingPending: state.pending,
47
+ ...(state.error === undefined ? {} : { accountingError: state.error }),
48
+ };
49
+ case "unavailable":
50
+ return { accountingError: state.error, ...terminal };
51
+ }
52
+ }
53
+
17
54
  /** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
18
55
  function billing(entry: UnknownRecord): UnknownRecord | "missing" | undefined {
19
56
  if (entry.checkpoint === true) {
@@ -171,10 +208,11 @@ export function readResult(base: string, launch: Launch): Result | undefined {
171
208
  if (!result) {
172
209
  return;
173
210
  }
174
- const pending = result.accountingPending;
175
- if (pending === undefined) {
211
+ const accounting = accountingState(result);
212
+ if (accounting.kind !== "pending") {
176
213
  return result;
177
214
  }
215
+ const { pending } = accounting;
178
216
  let contributions;
179
217
  try {
180
218
  contributions = childUsage(launch, new Set(pending.baseline), pending.terminalCandidate);
@@ -183,7 +221,14 @@ export function readResult(base: string, launch: Launch): Result | undefined {
183
221
  return result;
184
222
  }
185
223
  const { accountingPending: _pending, accountingError: _error, ...execution } = result;
186
- const repaired = { ...execution, terminalEntryId: pending.terminalCandidate, contributions };
224
+ const repaired = {
225
+ ...execution,
226
+ ...accountingFields({
227
+ kind: "verified",
228
+ terminalEntryId: pending.terminalCandidate,
229
+ contributions,
230
+ }),
231
+ };
187
232
  write(path.join(runDirectory(base, launch.id), "result.json"), repaired);
188
233
  return repaired;
189
234
  }