@fitchmultz/pi-subagents 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/README.md +36 -5
  3. package/dist/agent/child-runtime.js +80 -0
  4. package/dist/agent/contracts.js +15 -24
  5. package/dist/agent/host.js +3 -16
  6. package/dist/agent/index.js +212 -192
  7. package/dist/agent/journal-tail.js +115 -156
  8. package/dist/agent/launch.js +20 -8
  9. package/dist/agent/legacy.js +65 -23
  10. package/dist/agent/notifications.js +138 -47
  11. package/dist/agent/observation-state.js +246 -0
  12. package/dist/agent/observation-view.js +258 -0
  13. package/dist/agent/observation.js +350 -0
  14. package/dist/agent/observe-card.js +243 -0
  15. package/dist/agent/observe-chrome.js +85 -0
  16. package/dist/agent/observe-client.js +285 -0
  17. package/dist/agent/observe-layout.js +65 -0
  18. package/dist/agent/observe-reading.js +84 -0
  19. package/dist/agent/observe-screen.js +400 -96
  20. package/dist/agent/observe-transcript.js +382 -0
  21. package/dist/agent/observe.js +250 -43
  22. package/dist/agent/parking.js +253 -0
  23. package/dist/agent/process.js +228 -28
  24. package/dist/agent/receipts.js +92 -20
  25. package/dist/agent/runner.js +229 -126
  26. package/dist/agent/shell.js +2 -1
  27. package/dist/agent/storage.js +47 -12
  28. package/dist/agent/tool-schemas.js +20 -0
  29. package/dist/agent/typebox.js +10 -5
  30. package/dist/agent/usage.js +0 -115
  31. package/dist/agent/view.js +46 -16
  32. package/dist/agent/wake.js +99 -0
  33. package/dist/build-info.js +1 -1
  34. package/dist/intercom-client.d.ts +26 -0
  35. package/dist/intercom-client.js +45 -0
  36. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  37. package/dist/pi-intercom/bridge-session.js +2 -1
  38. package/dist/pi-intercom/broker/broker.js +62 -43
  39. package/dist/pi-intercom/broker/client-last.d.ts +24 -0
  40. package/dist/pi-intercom/broker/client-last.js +42 -5
  41. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  42. package/dist/pi-intercom/broker/client-park.js +21 -0
  43. package/dist/pi-intercom/broker/client.d.ts +59 -0
  44. package/dist/pi-intercom/broker/client.js +55 -29
  45. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  46. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  47. package/dist/pi-intercom/broker/framing.js +10 -7
  48. package/dist/pi-intercom/broker/guard.js +23 -0
  49. package/dist/pi-intercom/broker/last.d.ts +30 -0
  50. package/dist/pi-intercom/broker/last.js +87 -40
  51. package/dist/pi-intercom/broker/parked.js +107 -0
  52. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  53. package/dist/pi-intercom/broker/paths.js +35 -12
  54. package/dist/pi-intercom/broker/pid.js +5 -1
  55. package/dist/pi-intercom/broker/server-connection.js +20 -1
  56. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  57. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  58. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  59. package/dist/pi-intercom/connection.js +71 -20
  60. package/dist/pi-intercom/identity.js +6 -5
  61. package/dist/pi-intercom/inbound-delivery.js +16 -60
  62. package/dist/pi-intercom/inbound-journal.js +83 -6
  63. package/dist/pi-intercom/inbound-record.js +18 -1
  64. package/dist/pi-intercom/inbound.js +22 -37
  65. package/dist/pi-intercom/index.js +41 -0
  66. package/dist/pi-intercom/last-message.js +4 -16
  67. package/dist/pi-intercom/lifecycle.js +88 -3
  68. package/dist/pi-intercom/outbound.js +5 -15
  69. package/dist/pi-intercom/reply-tracker.js +7 -6
  70. package/dist/pi-intercom/reply-wait.js +2 -2
  71. package/dist/pi-intercom/session-targets.d.ts +24 -0
  72. package/dist/pi-intercom/tool-registration.js +4 -1
  73. package/dist/pi-intercom/tool-results.js +5 -7
  74. package/dist/pi-intercom/tool-schemas.js +12 -26
  75. package/dist/pi-intercom/types.d.ts +212 -0
  76. package/dist/pi-intercom/types.js +54 -3
  77. package/dist/pi-intercom/validation.d.ts +12 -0
  78. package/dist/pi-intercom/validation.js +3 -0
  79. package/dist/shared/bounded-json.js +151 -0
  80. package/dist/shared/compact-cards.js +66 -0
  81. package/dist/shared/journal-frames.js +3 -6
  82. package/dist/shared/json-projection.js +267 -129
  83. package/dist/shared/native-journal.js +123 -56
  84. package/dist/shared/parking.js +12 -0
  85. package/dist/shared/post-exit-stdio-guard.js +120 -7
  86. package/dist/shared/tui-action.js +29 -0
  87. package/dist/shared/unknown.d.ts +9 -0
  88. package/docs/code-quality.md +5 -3
  89. package/docs/development.md +42 -3
  90. package/docs/intercom-bridge.md +9 -4
  91. package/docs/intercom.md +228 -7
  92. package/docs/performance.md +118 -14
  93. package/docs/reference.md +168 -20
  94. package/docs/retired-history-maintenance.md +252 -0
  95. package/package.json +28 -15
  96. package/scripts/archive-retired-history.mjs +464 -0
  97. package/scripts/build.mjs +48 -28
  98. package/scripts/measure-size.mjs +1 -1
  99. package/scripts/native-package-smoke.mjs +153 -12
  100. package/scripts/package-smoke.mjs +80 -11
  101. package/scripts/prepare.mjs +2 -2
  102. package/scripts/real-pi-live.mjs +377 -0
  103. package/scripts/real-pi-processes.mjs +6 -1
  104. package/scripts/real-pi-smoke.mjs +6 -282
  105. package/scripts/run-tests.mjs +4 -2
  106. package/scripts/typescript.mjs +35 -0
  107. package/skills/pi-intercom/SKILL.md +2 -1
  108. package/skills/pi-subagents/SKILL.md +23 -5
  109. package/src/agent/child-runtime.ts +105 -0
  110. package/src/agent/contracts.ts +18 -34
  111. package/src/agent/host.ts +39 -20
  112. package/src/agent/index.ts +234 -207
  113. package/src/agent/journal-tail.ts +141 -153
  114. package/src/agent/launch.ts +33 -9
  115. package/src/agent/legacy.ts +85 -31
  116. package/src/agent/notifications.ts +192 -67
  117. package/src/agent/observation-state.ts +277 -0
  118. package/src/agent/observation-view.ts +267 -0
  119. package/src/agent/observation.ts +409 -0
  120. package/src/agent/observe-card.ts +304 -0
  121. package/src/agent/observe-chrome.ts +170 -0
  122. package/src/agent/observe-client.ts +318 -0
  123. package/src/agent/observe-layout.ts +79 -0
  124. package/src/agent/observe-reading.ts +97 -0
  125. package/src/agent/observe-screen.ts +443 -129
  126. package/src/agent/observe-transcript.ts +434 -0
  127. package/src/agent/observe.ts +304 -71
  128. package/src/agent/parking.ts +341 -0
  129. package/src/agent/process.ts +280 -37
  130. package/src/agent/receipts.ts +114 -24
  131. package/src/agent/runner.ts +227 -149
  132. package/src/agent/shell.ts +2 -1
  133. package/src/agent/storage.ts +44 -11
  134. package/src/agent/tool-schemas.ts +33 -0
  135. package/src/agent/typebox.ts +12 -7
  136. package/src/agent/usage.ts +0 -130
  137. package/src/agent/view.ts +70 -20
  138. package/src/agent/wake.ts +107 -0
  139. package/src/intercom-client.ts +108 -0
  140. package/src/pi-intercom/bridge-session.ts +2 -2
  141. package/src/pi-intercom/broker/broker.ts +64 -53
  142. package/src/pi-intercom/broker/client-last.ts +65 -12
  143. package/src/pi-intercom/broker/client-park.ts +23 -0
  144. package/src/pi-intercom/broker/client.ts +63 -45
  145. package/src/pi-intercom/broker/framing.ts +11 -8
  146. package/src/pi-intercom/broker/guard.ts +33 -0
  147. package/src/pi-intercom/broker/last.ts +131 -43
  148. package/src/pi-intercom/broker/parked.ts +130 -0
  149. package/src/pi-intercom/broker/paths.ts +39 -12
  150. package/src/pi-intercom/broker/pid.ts +6 -1
  151. package/src/pi-intercom/broker/server-connection.ts +27 -2
  152. package/src/pi-intercom/connection.ts +103 -39
  153. package/src/pi-intercom/identity.ts +8 -5
  154. package/src/pi-intercom/inbound-delivery.ts +20 -66
  155. package/src/pi-intercom/inbound-journal.ts +103 -7
  156. package/src/pi-intercom/inbound-record.ts +21 -4
  157. package/src/pi-intercom/inbound.ts +32 -49
  158. package/src/pi-intercom/index.ts +44 -0
  159. package/src/pi-intercom/last-message.ts +11 -20
  160. package/src/pi-intercom/lifecycle.ts +117 -14
  161. package/src/pi-intercom/outbound.ts +7 -16
  162. package/src/pi-intercom/reply-tracker.ts +8 -6
  163. package/src/pi-intercom/reply-wait.ts +3 -2
  164. package/src/pi-intercom/runtime-types.ts +0 -1
  165. package/src/pi-intercom/tool-registration.ts +4 -1
  166. package/src/pi-intercom/tool-results.ts +10 -9
  167. package/src/pi-intercom/tool-schemas.ts +12 -44
  168. package/src/pi-intercom/types.ts +115 -4
  169. package/src/pi-intercom/validation.ts +4 -0
  170. package/src/shared/bounded-json.ts +162 -0
  171. package/src/shared/compact-cards.ts +118 -0
  172. package/src/shared/journal-frames.ts +3 -19
  173. package/src/shared/json-projection.ts +267 -147
  174. package/src/shared/native-journal.ts +157 -75
  175. package/src/shared/parking.ts +22 -0
  176. package/src/shared/post-exit-stdio-guard.ts +165 -7
  177. package/src/shared/tui-action.ts +39 -0
  178. package/tsconfig.client.json +8 -0
  179. package/tsconfig.json +1 -0
  180. package/src/shared/stream-json.d.ts +0 -10
@@ -1,26 +1,29 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { setTimeout as delay } from "node:timers/promises";
4
- import type {
5
- AgentSession,
6
- CreateAgentSessionRuntimeFactory,
7
- ExtensionAPI,
8
- ExtensionContext,
9
- } from "@earendil-works/pi-coding-agent";
3
+ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
4
  import type { AssistantMessage } from "@earendil-works/pi-ai";
11
- import {
12
- attachmentSchema,
13
- commandSchema,
14
- type Command,
15
- type Launch,
16
- type Result,
17
- } from "./contracts.ts";
18
- import { launchAt, read, runDirectory, write } from "./storage.ts";
19
- import { alive, ownIdentity, shellCommand } from "./process.ts";
5
+ import { commandSchema, type Command, type Launch, type Result } from "./contracts.ts";
6
+ import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
7
+ import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
20
8
  import { errorText, loadSDK } from "./host.ts";
9
+ import { Parking, type Runtime } from "./parking.ts";
21
10
  import { childUsage } from "./usage.ts";
22
- import { initializeCwd, localCwdOwner } from "./cwd.ts";
11
+ import { initializeCwd } from "./cwd.ts";
12
+ import { childRuntime } from "./child-runtime.ts";
13
+ import { ActorObservation, type ObservationStats } from "./observation.ts";
14
+ import { DirectoryWatch, Wake } from "./wake.ts";
23
15
 
16
+ function commandIdentity(file: string): string | undefined {
17
+ try {
18
+ const stat = fs.statSync(file, { bigint: true });
19
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
20
+ } catch (error) {
21
+ if (missing(error)) {
22
+ return;
23
+ }
24
+ throw error;
25
+ }
26
+ }
24
27
  function completionStatus(
25
28
  stopping: boolean,
26
29
  message: AssistantMessage | undefined,
@@ -34,32 +37,24 @@ function completionStatus(
34
37
  }
35
38
  return "completed";
36
39
  }
37
- function guard(pi: ExtensionAPI, directory: string, token: string): void {
38
- pi.on("tool_call", (event) => {
39
- if (event.toolName === "delegate" || event.toolName === "agent_runs") {
40
- return { block: true, reason: "Only a main session may delegate or control children." };
41
- }
42
- const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
43
- if (
44
- event.toolName === "bash" &&
45
- typeof event.input.command === "string" &&
46
- owner &&
47
- (owner.source === "builtin" || localCwdOwner(owner))
48
- ) {
49
- // Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
50
- const input = event.input;
51
- input.command = shellCommand(event.input.command, directory, token);
52
- }
53
- return;
54
- });
55
- }
56
40
  class Actor {
57
41
  private active?: Promise<void>;
58
42
  private stopping = false;
43
+ private stopDeadline?: NodeJS.Timeout;
44
+ private nextParentCheck = 0;
45
+ private parking = false;
46
+ private readonly parent = new ParentCheck();
47
+ // Raised when durable commands may need admission: change hints and stop.
48
+ private readonly wake = new Wake();
49
+ // A deferred run waits for an idle session, which has no command-file event.
50
+ private retry = false;
59
51
  private current?: Launch;
60
52
  private readonly errors: string[] = [];
61
53
  private readonly started = new Set<string>();
54
+ // Actor-local validation metadata only; never retain command prompts or receipt authority.
55
+ private readonly validated = new Map<string, { identity: string; id: string }>();
62
56
  private session?: AgentSession;
57
+ private observation?: ActorObservation;
63
58
  private context?: { pi: ExtensionAPI; ctx: ExtensionContext };
64
59
  private readonly base: string;
65
60
  private readonly initial: Launch;
@@ -134,69 +129,38 @@ class Actor {
134
129
  .join("\n") ?? ""
135
130
  );
136
131
  }
137
- private createRuntime(
138
- sdk: Awaited<ReturnType<typeof loadSDK>>,
139
- ): CreateAgentSessionRuntimeFactory {
140
- return async ({ sessionManager }) => {
141
- const launch = this.initial;
142
- if (!fs.existsSync(launch.sessionFile)) {
143
- throw new Error("Missing saved native session; refusing to create another.");
144
- }
145
- const settingsManager = sdk.SettingsManager.create(launch.cwd, launch.agentDir, {
146
- projectTrusted: launch.trusted,
147
- });
148
- const services = await sdk.createAgentSessionServices({
149
- cwd: launch.cwd,
150
- agentDir: launch.agentDir,
151
- settingsManager,
152
- resourceLoaderOptions: {
153
- additionalExtensionPaths: [launch.entry],
154
- extensionFactories: [
155
- {
156
- name: "codemode",
157
- builtin: true,
158
- factory: sdk.createCodemodeExtension({ mode: "on" }),
159
- },
160
- { name: "tool_search", builtin: true, factory: sdk.createToolSearchExtension() },
161
- { name: "mcp", builtin: true, factory: sdk.createMcpExtension() },
162
- (pi) => {
163
- guard(pi, this.dir, this.token);
164
- pi.on("session_start", (_event, ctx) => {
165
- this.context = { pi, ctx };
166
- });
167
- },
168
- ],
169
- },
170
- });
171
- const diagnostics = services.resourceLoader.getExtensions().errors;
172
- if (diagnostics.length > 0) {
173
- throw new Error(`Child extension load failed: ${JSON.stringify(diagnostics)}`);
174
- }
175
- const model = services.modelRuntime.getModel(launch.provider, launch.model);
176
- if (!model) {
177
- throw new Error(
178
- `Saved model ${launch.provider}/${launch.model} is unavailable; no fallback was used.`,
179
- );
180
- }
181
- return {
182
- ...(await sdk.createAgentSessionFromServices({
183
- services,
184
- sessionManager,
185
- model,
186
- thinkingLevel: launch.thinking,
187
- })),
188
- services,
189
- diagnostics: services.diagnostics,
190
- };
191
- };
192
- }
193
132
  private readonly stop = (): void => {
194
- write(path.join(this.dir, "stop-acknowledged.json"), { at: Date.now(), id: this.current?.id });
133
+ if (this.stopping) {
134
+ return;
135
+ }
195
136
  this.stopping = true;
137
+ this.wake.raise();
138
+ write(path.join(this.dir, `stopping-${this.token}.json`), { at: Date.now() });
139
+ write(path.join(this.dir, "stop-acknowledged.json"), { at: Date.now(), id: this.current?.id });
196
140
  this.session?.abort().catch((error: unknown) => {
197
141
  this.errors.push(errorText(error));
198
142
  });
143
+ this.stopShells("SIGTERM");
144
+ // Parent death leaves no parent-side escalation timer to stop an uncooperative tool.
145
+ this.stopDeadline = setTimeout(() => {
146
+ this.stopShells("SIGKILL");
147
+ process.exit(1);
148
+ }, 3000);
149
+ this.stopDeadline.unref();
199
150
  };
151
+ private stopShells(signal: NodeJS.Signals): void {
152
+ try {
153
+ for (const identity of shells(this.base, this.initial, this.token)) {
154
+ try {
155
+ signalProcess(identity, signal);
156
+ } catch (error) {
157
+ this.errors.push(errorText(error));
158
+ }
159
+ }
160
+ } catch (error) {
161
+ this.errors.push(errorText(error));
162
+ }
163
+ }
200
164
  private run(launch: Launch): void {
201
165
  const session = this.session;
202
166
  if (!session) {
@@ -204,6 +168,7 @@ class Actor {
204
168
  }
205
169
  this.current = launch;
206
170
  const baseline = new Set(session.sessionManager.getEntries().map((entry) => entry.id));
171
+ this.observation?.working(true);
207
172
  this.active = (async () => {
208
173
  try {
209
174
  if (
@@ -221,6 +186,7 @@ class Actor {
221
186
  } finally {
222
187
  this.active = undefined;
223
188
  this.current = undefined;
189
+ this.observation?.working(false);
224
190
  }
225
191
  })();
226
192
  }
@@ -256,6 +222,7 @@ class Actor {
256
222
  command.action === "run" &&
257
223
  (this.stopping || this.active !== undefined || !session.isIdle)
258
224
  ) {
225
+ this.retry = true;
259
226
  return;
260
227
  }
261
228
  this.started.add(command.id);
@@ -306,6 +273,24 @@ class Actor {
306
273
  timestamp: Date.now(),
307
274
  });
308
275
  }
276
+ private pendingCommand(file: string): Command | undefined {
277
+ const identity = commandIdentity(file);
278
+ const cached = this.validated.get(file);
279
+ if (
280
+ identity !== undefined &&
281
+ cached?.identity === identity &&
282
+ fs.existsSync(path.join(this.dir, "admissions", `${cached.id}.json`))
283
+ ) {
284
+ return;
285
+ }
286
+ this.validated.delete(file);
287
+ const command = read(file, commandSchema);
288
+ // A replacement during validation must not inherit the old body's validated identity.
289
+ if (command && identity !== undefined && commandIdentity(file) === identity) {
290
+ this.validated.set(file, { identity, id: command.id });
291
+ }
292
+ return command;
293
+ }
309
294
  private pending(): Command[] {
310
295
  const dir = path.join(this.dir, "commands");
311
296
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -313,23 +298,79 @@ class Actor {
313
298
  .readdirSync(dir)
314
299
  .filter((file) => file.endsWith(".json"))
315
300
  .flatMap((file) => {
316
- const command = read(path.join(dir, file), commandSchema);
301
+ const command = this.pendingCommand(path.join(dir, file));
317
302
  return command ? [command] : [];
318
303
  })
319
304
  .sort((a, b) => a.createdAt - b.createdAt);
320
305
  }
321
306
  private shouldExit(): boolean {
322
- if (this.active !== undefined) {
323
- return false;
307
+ const now = performance.now();
308
+ if (!this.stopping && now >= this.nextParentCheck) {
309
+ this.nextParentCheck = now + 1000;
310
+ // One observation at a time; a slow one keeps the regular schedule.
311
+ const file = path.join(this.base, "attachment.json");
312
+ this.parent.check(file, this.stop).catch((error: unknown) => {
313
+ this.errors.push(errorText(error));
314
+ });
324
315
  }
325
- if (this.stopping) {
326
- return true;
316
+ return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
317
+ }
318
+ /** Durable command readiness, not elapsed time, defines admission. */
319
+ private async admitCommands(runtime: Runtime, parked: Parking): Promise<void> {
320
+ // Queued commands create this directory; otherwise pending() does and the next pass arms.
321
+ const directory = path.join(this.dir, "commands");
322
+ const commands = new DirectoryWatch(directory, () => true, this.wake.raise);
323
+ try {
324
+ while (!this.shouldExit()) {
325
+ // Each liveness tick also rechecks the directory for change hints the OS dropped.
326
+ const watch = commands.ensure();
327
+ if ((this.wake.take() || this.retry || watch !== "watching") && !this.stopping) {
328
+ this.retry = false;
329
+ this.pending().forEach((command) => this.accept(command));
330
+ }
331
+ // oxlint-disable-next-line no-await-in-loop
332
+ if (await this.park(runtime, parked)) {
333
+ return;
334
+ }
335
+ const polling = this.retry || this.stopping || watch === "unwatched";
336
+ const tick = Math.max(1, this.nextParentCheck - performance.now());
337
+ // oxlint-disable-next-line no-await-in-loop
338
+ await this.wake.sleep(polling ? 100 : tick);
339
+ }
340
+ } finally {
341
+ // A verdict settling after the loop ends must not act.
342
+ this.parent.cancel();
343
+ commands.close();
327
344
  }
328
- if (this.session && (!this.session.isIdle || this.session.pendingMessageCount > 0)) {
329
- return false;
345
+ }
346
+ /** Parks a final-idle actor; the parent/stop owner stays live while preparation awaits. */
347
+ private async park(runtime: Runtime, parked: Parking): Promise<boolean> {
348
+ const owner = new AbortController();
349
+ const watch = setInterval(() => {
350
+ if (this.shouldExit() || this.stopping) {
351
+ owner.abort();
352
+ }
353
+ }, 100);
354
+ try {
355
+ this.parking = await parked.park(runtime, {
356
+ signal: owner.signal,
357
+ pi: this.context?.pi,
358
+ busy: this.stopping || this.active !== undefined || this.observation?.watched === true,
359
+ // Raced commands and viewers keep this actor resident; the loop admits after release.
360
+ quiet: () => {
361
+ const queued = this.pending().some((command) => this.commandLaunch(command));
362
+ if (queued) {
363
+ this.wake.raise();
364
+ }
365
+ const idle = this.active === undefined && !this.stopping;
366
+ return !queued && idle && this.observation?.watched !== true;
367
+ },
368
+ errors: this.errors,
369
+ });
370
+ } finally {
371
+ clearInterval(watch);
330
372
  }
331
- const attachment = read(path.join(this.base, "attachment.json"), attachmentSchema);
332
- return attachment === undefined || !attachment.attached || !alive(attachment);
373
+ return this.parking;
333
374
  }
334
375
  private failure(error: unknown): void {
335
376
  const launch = this.current ?? this.initial;
@@ -347,64 +388,101 @@ class Actor {
347
388
  }
348
389
  process.exitCode = 1;
349
390
  }
391
+ private async start(sdk: Awaited<ReturnType<typeof loadSDK>>, parked: Parking): Promise<Runtime> {
392
+ const sessionManager = parked.open();
393
+ const factory = childRuntime(sdk, this.initial, {
394
+ directory: this.dir,
395
+ token: this.token,
396
+ saved: parked.saved,
397
+ started: (pi, ctx) => {
398
+ this.context = { pi, ctx };
399
+ },
400
+ tool: (event) => this.observation?.tool(event),
401
+ });
402
+ const runtime = await sdk.createAgentSessionRuntime(factory, {
403
+ cwd: this.initial.cwd,
404
+ agentDir: this.initial.agentDir,
405
+ sessionManager,
406
+ });
407
+ try {
408
+ await this.ready(runtime, parked);
409
+ } catch (error) {
410
+ // A runtime that never became ready is still disposed, so the failed actor exits.
411
+ await runtime.dispose();
412
+ throw error;
413
+ }
414
+ return runtime;
415
+ }
416
+ private async ready(runtime: Runtime, parked: Parking): Promise<void> {
417
+ this.session = runtime.session;
418
+ await this.session.bindExtensions({
419
+ mode: "print",
420
+ onError: (error) => {
421
+ this.errors.push(error.error);
422
+ },
423
+ });
424
+ if (!this.context) {
425
+ throw new Error("Child extension context is unavailable.");
426
+ }
427
+ initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
428
+ if (this.initial.name !== undefined && !parked.saved) {
429
+ this.session.sessionManager.appendSessionInfo(this.initial.name);
430
+ }
431
+ // Optional live view, bound before readiness; its faults never change native execution.
432
+ this.observation = await ActorObservation.open(this.session, {
433
+ token: this.token,
434
+ rootId: this.initial.rootId,
435
+ sessionId: this.session.sessionManager.getSessionId(),
436
+ });
437
+ write(path.join(this.dir, "actor.lock", "process.json"), {
438
+ ...ownIdentity(),
439
+ token: this.token,
440
+ });
441
+ write(path.join(this.dir, "ready.json"), {
442
+ pid: process.pid,
443
+ token: this.token,
444
+ sessionId: this.session.sessionManager.getSessionId(),
445
+ at: Date.now(),
446
+ });
447
+ parked.adopt();
448
+ }
350
449
  async serve(): Promise<void> {
351
450
  const sdk = await loadSDK(this.initial.sdk);
352
- let runtime: Awaited<ReturnType<typeof sdk.createAgentSessionRuntime>> | undefined;
451
+ const parked = new Parking(this.base, this.initial, this.token, sdk);
452
+ let runtime: Runtime | undefined;
353
453
  try {
354
- runtime = await sdk.createAgentSessionRuntime(this.createRuntime(sdk), {
355
- cwd: this.initial.cwd,
356
- agentDir: this.initial.agentDir,
357
- sessionManager: sdk.SessionManager.open(
358
- this.initial.sessionFile,
359
- undefined,
360
- this.initial.cwd,
361
- ),
362
- });
363
- this.session = runtime.session;
364
- await this.session.bindExtensions({
365
- mode: "print",
366
- onError: (error) => {
367
- this.errors.push(error.error);
368
- },
369
- });
370
- if (!this.context) {
371
- throw new Error("Child extension context is unavailable.");
372
- }
373
- initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
374
- if (this.initial.name !== undefined) {
375
- this.session.sessionManager.appendSessionInfo(this.initial.name);
376
- }
377
- write(path.join(this.dir, "actor.lock", "process.json"), {
378
- ...ownIdentity(),
379
- token: this.token,
380
- });
381
- write(path.join(this.dir, "ready.json"), {
382
- pid: process.pid,
383
- token: this.token,
384
- sessionId: this.session.sessionManager.getSessionId(),
385
- at: Date.now(),
386
- });
454
+ runtime = await this.start(sdk, parked);
387
455
  process.on("SIGTERM", this.stop);
388
456
  process.on("SIGINT", this.stop);
389
- do {
390
- this.pending().forEach((command) => this.accept(command));
391
- // Durable command readiness, not elapsed time, defines admission.
392
- // oxlint-disable-next-line no-await-in-loop
393
- await delay(100);
394
- } while (!this.shouldExit());
457
+ await this.admitCommands(runtime, parked);
395
458
  await this.active;
396
459
  } catch (error) {
397
460
  this.failure(error);
398
461
  } finally {
399
- process.off("SIGTERM", this.stop);
400
- process.off("SIGINT", this.stop);
462
+ await this.finish(runtime);
463
+ }
464
+ }
465
+ private async finish(runtime: Runtime | undefined): Promise<void> {
466
+ process.off("SIGTERM", this.stop);
467
+ process.off("SIGINT", this.stop);
468
+ const observation: ObservationStats | undefined = this.observation?.close();
469
+ if (!this.parking) {
401
470
  await runtime?.dispose();
402
- write(path.join(this.dir, `shutdown-${this.token}.json`), {
403
- at: Date.now(),
404
- errors: this.errors,
405
- });
406
- // Keep the process claim until a launcher proves this exact actor is gone.
407
471
  }
472
+ if (this.stopping) {
473
+ this.stopShells("SIGKILL");
474
+ }
475
+ clearTimeout(this.stopDeadline);
476
+ write(path.join(this.dir, `shutdown-${this.token}.json`), {
477
+ at: Date.now(),
478
+ errors: this.errors,
479
+ ...(observation === undefined ? {} : { observation }),
480
+ });
481
+ if (this.parking) {
482
+ // Parked authority requires actual exit; no leftover extension handle may keep it alive.
483
+ process.exit();
484
+ }
485
+ // Keep the process claim until a launcher proves this exact actor is gone.
408
486
  }
409
487
  }
410
488
  const base = process.argv.at(2);
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import * as path from "node:path";
4
4
  import * as fs from "node:fs";
5
- import { readChildProcessIdentity } from "../shared/post-exit-stdio-guard.ts";
5
+ import { readChildProcessIdentity, readProcessClock } from "../shared/post-exit-stdio-guard.ts";
6
6
  import { read, write } from "./storage.ts";
7
7
  import { processSchema } from "./contracts.ts";
8
8
  import { verifyProcess } from "./process.ts";
@@ -50,6 +50,7 @@ write(
50
50
  pid,
51
51
  identity,
52
52
  token,
53
+ clock: readProcessClock(),
53
54
  },
54
55
  true,
55
56
  );
@@ -6,6 +6,7 @@ import type { Projection } from "../shared/json-projection.ts";
6
6
  import { Assert } from "./typebox.ts";
7
7
  import type { Static, TSchema } from "typebox";
8
8
  import { launchSchema, successorSchema, type Launch, type Command } from "./contracts.ts";
9
+ import { hasErrorCode } from "../shared/unknown.ts";
9
10
 
10
11
  export function missing(error: unknown): boolean {
11
12
  return error instanceof Error && "code" in error && error.code === "ENOENT";
@@ -25,28 +26,43 @@ export function read<T extends TSchema>(
25
26
  throw error;
26
27
  }
27
28
  Assert(schema, value);
28
- // TypeBox's generic Static<T> is intentionally the asserted schema's type.
29
- // oxlint-disable-next-line typescript/no-unsafe-return
30
29
  return value;
31
30
  }
31
+ /** Publish complete bytes without replacing a winner, even where Android denies hard links. */
32
+ function publishExclusive(temp: string, file: string): boolean {
33
+ try {
34
+ fs.linkSync(temp, file);
35
+ return false;
36
+ } catch (error) {
37
+ if (!["EACCES", "EPERM", "ENOTSUP"].some((code) => hasErrorCode(error, code))) {
38
+ throw error;
39
+ }
40
+ // The immutable, private backing file must outlive its atomic no-clobber symlink.
41
+ fs.symlinkSync(path.basename(temp), file);
42
+ return true;
43
+ }
44
+ }
32
45
  export function write(file: string, value: unknown, exclusive = false): void {
33
46
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
34
- const temp = `${file}.${randomUUID()}.tmp`;
47
+ const temp = `${file}.${randomUUID()}.${exclusive ? "data" : "tmp"}`;
35
48
  const fd = fs.openSync(temp, "wx", 0o600);
49
+ let retained = false;
36
50
  try {
37
- fs.writeFileSync(fd, `${JSON.stringify(value)}\n`);
38
- fs.fsyncSync(fd);
39
- } finally {
40
- fs.closeSync(fd);
41
- }
42
- try {
51
+ try {
52
+ fs.writeFileSync(fd, `${JSON.stringify(value)}\n`);
53
+ fs.fsyncSync(fd);
54
+ } finally {
55
+ fs.closeSync(fd);
56
+ }
43
57
  if (exclusive) {
44
- fs.linkSync(temp, file);
58
+ retained = publishExclusive(temp, file);
45
59
  } else {
46
60
  fs.renameSync(temp, file);
47
61
  }
48
62
  } finally {
49
- fs.rmSync(temp, { force: true });
63
+ if (!retained) {
64
+ fs.rmSync(temp, { force: true });
65
+ }
50
66
  }
51
67
  }
52
68
  export function safeId(id: string): string {
@@ -68,6 +84,23 @@ export function launchAt(base: string, id: string, owner: string): Launch {
68
84
  }
69
85
  return launch;
70
86
  }
87
+ /** Claims the single successor of a saved run; undefined when another caller already has. */
88
+ export function successor(base: string, launch: Launch, prompt: string): Launch | undefined {
89
+ const id = randomUUID();
90
+ const next: Launch = { ...launch, id, predecessor: launch.id, prompt, createdAt: Date.now() };
91
+ fs.mkdirSync(runDirectory(base, id), { mode: 0o700 });
92
+ write(path.join(runDirectory(base, id), "launch.json"), next, true);
93
+ try {
94
+ write(path.join(runDirectory(base, launch.id), "successor.json"), { id }, true);
95
+ } catch (error) {
96
+ fs.rmSync(runDirectory(base, id), { recursive: true });
97
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) {
98
+ throw error;
99
+ }
100
+ return undefined;
101
+ }
102
+ return next;
103
+ }
71
104
  export function latest(base: string, initial: Launch): Launch {
72
105
  const seen = new Set<string>();
73
106
  let launch = initial;
@@ -0,0 +1,33 @@
1
+ import type { Static } from "typebox";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type } from "./typebox.ts";
4
+
5
+ // Main-session tool parameters only; the detached runner must not load pi-ai values.
6
+ export const delegateSchema = Type.Object(
7
+ {
8
+ prompt: Type.String({ minLength: 1 }),
9
+ name: Type.Optional(Type.String({ minLength: 1 })),
10
+ cwd: Type.Optional(Type.String({ minLength: 1 })),
11
+ model: Type.Optional(
12
+ Type.String({
13
+ minLength: 1,
14
+ description:
15
+ "Exact provider/model ID (:thinking and any case need host support); no fuzzy match or fallback.",
16
+ }),
17
+ ),
18
+ context: Type.Optional(StringEnum(["fresh", "fork"] as const)),
19
+ },
20
+ { additionalProperties: false },
21
+ );
22
+ export type DelegateInput = Static<typeof delegateSchema>;
23
+ export const runsSchema = Type.Object(
24
+ {
25
+ action: StringEnum(["list", "inspect", "continue", "stop"] as const),
26
+ id: Type.Optional(Type.String()),
27
+ prompt: Type.Optional(Type.String({ minLength: 1 })),
28
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
29
+ length: Type.Optional(Type.Integer({ minimum: 4, maximum: 262144 })),
30
+ },
31
+ { additionalProperties: false },
32
+ );
33
+ export type RunsInput = Static<typeof runsSchema>;
@@ -3,15 +3,20 @@ import * as path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import type * as NativeTypebox from "typebox";
5
5
  import type * as NativeValue from "typebox/value";
6
- import { packageRoot } from "./host.ts";
6
+ import { isChild, packageRoot } from "./host.ts";
7
7
 
8
- const require = createRequire(path.join(packageRoot(), "dist/index.js"));
9
- // The detached runner needs the selected host's dependencies, not Jiti aliases.
8
+ // Pi aliases these imports to its own instance (including bundled virtual modules).
9
+ // Only the detached runner needs absolute imports from the selected host package.
10
+ const require = isChild() ? createRequire(path.join(packageRoot(), "dist/index.js")) : undefined;
10
11
  // oxlint-disable-next-line typescript/no-unsafe-assignment
11
- const typebox: typeof NativeTypebox = await import(pathToFileURL(require.resolve("typebox")).href);
12
+ const typebox: typeof NativeTypebox =
13
+ require === undefined
14
+ ? await import("typebox")
15
+ : await import(pathToFileURL(require.resolve("typebox")).href);
12
16
  // oxlint-disable-next-line typescript/no-unsafe-assignment
13
- const value: typeof NativeValue = await import(
14
- pathToFileURL(require.resolve("typebox/value")).href
15
- );
17
+ const value: typeof NativeValue =
18
+ require === undefined
19
+ ? await import("typebox/value")
20
+ : await import(pathToFileURL(require.resolve("typebox/value")).href);
16
21
  export const { Type } = typebox;
17
22
  export const Assert: typeof NativeValue.Assert = value.Assert;