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