@fitchmultz/pi-subagents 0.50.0 → 0.51.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 (169) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.md +36 -5
  3. package/dist/agent/contracts.js +6 -24
  4. package/dist/agent/host.js +3 -16
  5. package/dist/agent/index.js +207 -173
  6. package/dist/agent/journal-tail.js +115 -156
  7. package/dist/agent/launch.js +20 -8
  8. package/dist/agent/legacy.js +65 -23
  9. package/dist/agent/notifications.js +138 -47
  10. package/dist/agent/observation-state.js +246 -0
  11. package/dist/agent/observation-view.js +258 -0
  12. package/dist/agent/observation.js +346 -0
  13. package/dist/agent/observe-card.js +243 -0
  14. package/dist/agent/observe-chrome.js +85 -0
  15. package/dist/agent/observe-client.js +285 -0
  16. package/dist/agent/observe-layout.js +65 -0
  17. package/dist/agent/observe-reading.js +84 -0
  18. package/dist/agent/observe-screen.js +400 -96
  19. package/dist/agent/observe-transcript.js +382 -0
  20. package/dist/agent/observe.js +250 -43
  21. package/dist/agent/process.js +198 -25
  22. package/dist/agent/receipts.js +92 -20
  23. package/dist/agent/runner.js +125 -20
  24. package/dist/agent/shell.js +2 -1
  25. package/dist/agent/storage.js +29 -12
  26. package/dist/agent/tool-schemas.js +20 -0
  27. package/dist/agent/typebox.js +10 -5
  28. package/dist/agent/usage.js +0 -115
  29. package/dist/agent/view.js +46 -16
  30. package/dist/agent/wake.js +99 -0
  31. package/dist/build-info.js +1 -1
  32. package/dist/intercom-client.d.ts +26 -0
  33. package/dist/intercom-client.js +45 -0
  34. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  35. package/dist/pi-intercom/bridge-session.js +2 -1
  36. package/dist/pi-intercom/broker/broker.js +27 -21
  37. package/dist/pi-intercom/broker/client-last.d.ts +13 -0
  38. package/dist/pi-intercom/broker/client-last.js +17 -5
  39. package/dist/pi-intercom/broker/client.d.ts +60 -0
  40. package/dist/pi-intercom/broker/client.js +47 -16
  41. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  42. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  43. package/dist/pi-intercom/broker/framing.js +10 -7
  44. package/dist/pi-intercom/broker/guard.js +23 -0
  45. package/dist/pi-intercom/broker/last.d.ts +26 -0
  46. package/dist/pi-intercom/broker/last.js +57 -32
  47. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  48. package/dist/pi-intercom/broker/paths.js +35 -12
  49. package/dist/pi-intercom/broker/pid.js +5 -1
  50. package/dist/pi-intercom/broker/server-connection.js +19 -0
  51. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  52. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  53. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  54. package/dist/pi-intercom/connection.js +10 -2
  55. package/dist/pi-intercom/identity.js +6 -5
  56. package/dist/pi-intercom/inbound-delivery.js +16 -60
  57. package/dist/pi-intercom/inbound-journal.js +83 -6
  58. package/dist/pi-intercom/inbound-record.js +18 -1
  59. package/dist/pi-intercom/inbound.js +12 -38
  60. package/dist/pi-intercom/index.js +3 -0
  61. package/dist/pi-intercom/last-message.js +4 -16
  62. package/dist/pi-intercom/lifecycle.js +88 -3
  63. package/dist/pi-intercom/outbound.js +5 -15
  64. package/dist/pi-intercom/reply-tracker.js +7 -6
  65. package/dist/pi-intercom/reply-wait.js +2 -2
  66. package/dist/pi-intercom/session-targets.d.ts +24 -0
  67. package/dist/pi-intercom/tool-registration.js +4 -1
  68. package/dist/pi-intercom/tool-results.js +5 -7
  69. package/dist/pi-intercom/tool-schemas.js +12 -26
  70. package/dist/pi-intercom/types.d.ts +212 -0
  71. package/dist/pi-intercom/types.js +54 -3
  72. package/dist/pi-intercom/validation.d.ts +12 -0
  73. package/dist/pi-intercom/validation.js +3 -0
  74. package/dist/shared/bounded-json.js +151 -0
  75. package/dist/shared/compact-cards.js +66 -0
  76. package/dist/shared/journal-frames.js +3 -6
  77. package/dist/shared/json-projection.js +267 -129
  78. package/dist/shared/native-journal.js +123 -56
  79. package/dist/shared/post-exit-stdio-guard.js +120 -7
  80. package/dist/shared/tui-action.js +29 -0
  81. package/dist/shared/unknown.d.ts +9 -0
  82. package/docs/code-quality.md +2 -0
  83. package/docs/development.md +40 -1
  84. package/docs/intercom-bridge.md +9 -4
  85. package/docs/intercom.md +226 -6
  86. package/docs/performance.md +87 -11
  87. package/docs/reference.md +157 -20
  88. package/docs/retired-history-maintenance.md +252 -0
  89. package/package.json +28 -15
  90. package/scripts/archive-retired-history.mjs +464 -0
  91. package/scripts/build.mjs +48 -28
  92. package/scripts/measure-size.mjs +1 -1
  93. package/scripts/native-package-smoke.mjs +153 -12
  94. package/scripts/package-smoke.mjs +80 -11
  95. package/scripts/prepare.mjs +2 -2
  96. package/scripts/real-pi-live.mjs +377 -0
  97. package/scripts/real-pi-processes.mjs +6 -1
  98. package/scripts/real-pi-smoke.mjs +6 -282
  99. package/scripts/run-tests.mjs +4 -2
  100. package/scripts/typescript.mjs +35 -0
  101. package/skills/pi-intercom/SKILL.md +2 -1
  102. package/skills/pi-subagents/SKILL.md +23 -5
  103. package/src/agent/contracts.ts +8 -34
  104. package/src/agent/host.ts +19 -20
  105. package/src/agent/index.ts +224 -190
  106. package/src/agent/journal-tail.ts +141 -153
  107. package/src/agent/launch.ts +33 -9
  108. package/src/agent/legacy.ts +85 -31
  109. package/src/agent/notifications.ts +192 -67
  110. package/src/agent/observation-state.ts +277 -0
  111. package/src/agent/observation-view.ts +267 -0
  112. package/src/agent/observation.ts +405 -0
  113. package/src/agent/observe-card.ts +304 -0
  114. package/src/agent/observe-chrome.ts +170 -0
  115. package/src/agent/observe-client.ts +318 -0
  116. package/src/agent/observe-layout.ts +79 -0
  117. package/src/agent/observe-reading.ts +97 -0
  118. package/src/agent/observe-screen.ts +443 -129
  119. package/src/agent/observe-transcript.ts +434 -0
  120. package/src/agent/observe.ts +304 -71
  121. package/src/agent/process.ts +240 -34
  122. package/src/agent/receipts.ts +114 -24
  123. package/src/agent/runner.ts +125 -27
  124. package/src/agent/shell.ts +2 -1
  125. package/src/agent/storage.ts +27 -11
  126. package/src/agent/tool-schemas.ts +33 -0
  127. package/src/agent/typebox.ts +12 -7
  128. package/src/agent/usage.ts +0 -130
  129. package/src/agent/view.ts +70 -20
  130. package/src/agent/wake.ts +107 -0
  131. package/src/intercom-client.ts +108 -0
  132. package/src/pi-intercom/bridge-session.ts +2 -2
  133. package/src/pi-intercom/broker/broker.ts +27 -23
  134. package/src/pi-intercom/broker/client-last.ts +25 -12
  135. package/src/pi-intercom/broker/client.ts +55 -33
  136. package/src/pi-intercom/broker/framing.ts +11 -8
  137. package/src/pi-intercom/broker/guard.ts +33 -0
  138. package/src/pi-intercom/broker/last.ts +86 -33
  139. package/src/pi-intercom/broker/paths.ts +39 -12
  140. package/src/pi-intercom/broker/pid.ts +6 -1
  141. package/src/pi-intercom/broker/server-connection.ts +25 -0
  142. package/src/pi-intercom/connection.ts +21 -10
  143. package/src/pi-intercom/identity.ts +8 -5
  144. package/src/pi-intercom/inbound-delivery.ts +20 -66
  145. package/src/pi-intercom/inbound-journal.ts +103 -7
  146. package/src/pi-intercom/inbound-record.ts +21 -4
  147. package/src/pi-intercom/inbound.ts +18 -49
  148. package/src/pi-intercom/index.ts +3 -0
  149. package/src/pi-intercom/last-message.ts +10 -19
  150. package/src/pi-intercom/lifecycle.ts +117 -14
  151. package/src/pi-intercom/outbound.ts +7 -16
  152. package/src/pi-intercom/reply-tracker.ts +8 -6
  153. package/src/pi-intercom/reply-wait.ts +3 -2
  154. package/src/pi-intercom/runtime-types.ts +0 -1
  155. package/src/pi-intercom/tool-registration.ts +4 -1
  156. package/src/pi-intercom/tool-results.ts +10 -9
  157. package/src/pi-intercom/tool-schemas.ts +12 -44
  158. package/src/pi-intercom/types.ts +115 -4
  159. package/src/pi-intercom/validation.ts +4 -0
  160. package/src/shared/bounded-json.ts +162 -0
  161. package/src/shared/compact-cards.ts +118 -0
  162. package/src/shared/journal-frames.ts +3 -19
  163. package/src/shared/json-projection.ts +267 -147
  164. package/src/shared/native-journal.ts +157 -75
  165. package/src/shared/post-exit-stdio-guard.ts +165 -7
  166. package/src/shared/tui-action.ts +39 -0
  167. package/tsconfig.client.json +8 -0
  168. package/tsconfig.json +1 -0
  169. package/src/shared/stream-json.d.ts +0 -10
@@ -1,6 +1,5 @@
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
3
  import type {
5
4
  AgentSession,
6
5
  CreateAgentSessionRuntimeFactory,
@@ -8,19 +7,27 @@ import type {
8
7
  ExtensionContext,
9
8
  } from "@earendil-works/pi-coding-agent";
10
9
  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";
10
+ import { commandSchema, type Command, type Launch, type Result } from "./contracts.ts";
11
+ import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
12
+ import { ownIdentity, ParentCheck, shellCommand, shells, signalProcess } from "./process.ts";
20
13
  import { errorText, loadSDK } from "./host.ts";
21
14
  import { childUsage } from "./usage.ts";
22
15
  import { initializeCwd, localCwdOwner } from "./cwd.ts";
16
+ import { ActorObservation, type ObservationStats } from "./observation.ts";
17
+ import type { ToolEvent } from "./observation-state.ts";
18
+ import { DirectoryWatch, Wake } from "./wake.ts";
23
19
 
20
+ function commandIdentity(file: string): string | undefined {
21
+ try {
22
+ const stat = fs.statSync(file, { bigint: true });
23
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
24
+ } catch (error) {
25
+ if (missing(error)) {
26
+ return;
27
+ }
28
+ throw error;
29
+ }
30
+ }
24
31
  function completionStatus(
25
32
  stopping: boolean,
26
33
  message: AssistantMessage | undefined,
@@ -56,10 +63,20 @@ function guard(pi: ExtensionAPI, directory: string, token: string): void {
56
63
  class Actor {
57
64
  private active?: Promise<void>;
58
65
  private stopping = false;
66
+ private stopDeadline?: NodeJS.Timeout;
67
+ private nextParentCheck = 0;
68
+ private readonly parent = new ParentCheck();
69
+ // Raised when durable commands may need admission: change hints and stop.
70
+ private readonly wake = new Wake();
71
+ // A deferred run waits for an idle session, which has no command-file event.
72
+ private retry = false;
59
73
  private current?: Launch;
60
74
  private readonly errors: string[] = [];
61
75
  private readonly started = new Set<string>();
76
+ // Actor-local validation metadata only; never retain command prompts or receipt authority.
77
+ private readonly validated = new Map<string, { identity: string; id: string }>();
62
78
  private session?: AgentSession;
79
+ private observation?: ActorObservation;
63
80
  private context?: { pi: ExtensionAPI; ctx: ExtensionContext };
64
81
  private readonly base: string;
65
82
  private readonly initial: Launch;
@@ -164,6 +181,11 @@ class Actor {
164
181
  pi.on("session_start", (_event, ctx) => {
165
182
  this.context = { pi, ctx };
166
183
  });
184
+ // Sparse tool lifecycle keeps the live view's tools even while nobody watches.
185
+ const tool = (event: ToolEvent): void => this.observation?.tool(event);
186
+ pi.on("tool_execution_start", tool);
187
+ pi.on("tool_execution_update", tool);
188
+ pi.on("tool_execution_end", tool);
167
189
  },
168
190
  ],
169
191
  },
@@ -191,12 +213,37 @@ class Actor {
191
213
  };
192
214
  }
193
215
  private readonly stop = (): void => {
194
- write(path.join(this.dir, "stop-acknowledged.json"), { at: Date.now(), id: this.current?.id });
216
+ if (this.stopping) {
217
+ return;
218
+ }
195
219
  this.stopping = true;
220
+ this.wake.raise();
221
+ write(path.join(this.dir, `stopping-${this.token}.json`), { at: Date.now() });
222
+ write(path.join(this.dir, "stop-acknowledged.json"), { at: Date.now(), id: this.current?.id });
196
223
  this.session?.abort().catch((error: unknown) => {
197
224
  this.errors.push(errorText(error));
198
225
  });
226
+ this.stopShells("SIGTERM");
227
+ // Parent death leaves no parent-side escalation timer to stop an uncooperative tool.
228
+ this.stopDeadline = setTimeout(() => {
229
+ this.stopShells("SIGKILL");
230
+ process.exit(1);
231
+ }, 3000);
232
+ this.stopDeadline.unref();
199
233
  };
234
+ private stopShells(signal: NodeJS.Signals): void {
235
+ try {
236
+ for (const identity of shells(this.base, this.initial, this.token)) {
237
+ try {
238
+ signalProcess(identity, signal);
239
+ } catch (error) {
240
+ this.errors.push(errorText(error));
241
+ }
242
+ }
243
+ } catch (error) {
244
+ this.errors.push(errorText(error));
245
+ }
246
+ }
200
247
  private run(launch: Launch): void {
201
248
  const session = this.session;
202
249
  if (!session) {
@@ -204,6 +251,7 @@ class Actor {
204
251
  }
205
252
  this.current = launch;
206
253
  const baseline = new Set(session.sessionManager.getEntries().map((entry) => entry.id));
254
+ this.observation?.working(true);
207
255
  this.active = (async () => {
208
256
  try {
209
257
  if (
@@ -221,6 +269,7 @@ class Actor {
221
269
  } finally {
222
270
  this.active = undefined;
223
271
  this.current = undefined;
272
+ this.observation?.working(false);
224
273
  }
225
274
  })();
226
275
  }
@@ -256,6 +305,7 @@ class Actor {
256
305
  command.action === "run" &&
257
306
  (this.stopping || this.active !== undefined || !session.isIdle)
258
307
  ) {
308
+ this.retry = true;
259
309
  return;
260
310
  }
261
311
  this.started.add(command.id);
@@ -306,6 +356,24 @@ class Actor {
306
356
  timestamp: Date.now(),
307
357
  });
308
358
  }
359
+ private pendingCommand(file: string): Command | undefined {
360
+ const identity = commandIdentity(file);
361
+ const cached = this.validated.get(file);
362
+ if (
363
+ identity !== undefined &&
364
+ cached?.identity === identity &&
365
+ fs.existsSync(path.join(this.dir, "admissions", `${cached.id}.json`))
366
+ ) {
367
+ return;
368
+ }
369
+ this.validated.delete(file);
370
+ const command = read(file, commandSchema);
371
+ // A replacement during validation must not inherit the old body's validated identity.
372
+ if (command && identity !== undefined && commandIdentity(file) === identity) {
373
+ this.validated.set(file, { identity, id: command.id });
374
+ }
375
+ return command;
376
+ }
309
377
  private pending(): Command[] {
310
378
  const dir = path.join(this.dir, "commands");
311
379
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -313,23 +381,46 @@ class Actor {
313
381
  .readdirSync(dir)
314
382
  .filter((file) => file.endsWith(".json"))
315
383
  .flatMap((file) => {
316
- const command = read(path.join(dir, file), commandSchema);
384
+ const command = this.pendingCommand(path.join(dir, file));
317
385
  return command ? [command] : [];
318
386
  })
319
387
  .sort((a, b) => a.createdAt - b.createdAt);
320
388
  }
321
389
  private shouldExit(): boolean {
322
- if (this.active !== undefined) {
323
- return false;
324
- }
325
- if (this.stopping) {
326
- return true;
390
+ const now = performance.now();
391
+ if (!this.stopping && now >= this.nextParentCheck) {
392
+ this.nextParentCheck = now + 1000;
393
+ // One observation at a time; a slow one keeps the regular schedule.
394
+ const file = path.join(this.base, "attachment.json");
395
+ this.parent.check(file, this.stop).catch((error: unknown) => {
396
+ this.errors.push(errorText(error));
397
+ });
327
398
  }
328
- if (this.session && (!this.session.isIdle || this.session.pendingMessageCount > 0)) {
329
- return false;
399
+ return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
400
+ }
401
+ /** Durable command readiness, not elapsed time, defines admission. */
402
+ private async admitCommands(): Promise<void> {
403
+ // Queued commands create this directory; otherwise pending() does and the next pass arms.
404
+ const directory = path.join(this.dir, "commands");
405
+ const commands = new DirectoryWatch(directory, () => true, this.wake.raise);
406
+ try {
407
+ while (!this.shouldExit()) {
408
+ // Each liveness tick also rechecks the directory for change hints the OS dropped.
409
+ const watch = commands.ensure();
410
+ if ((this.wake.take() || this.retry || watch !== "watching") && !this.stopping) {
411
+ this.retry = false;
412
+ this.pending().forEach((command) => this.accept(command));
413
+ }
414
+ const polling = this.retry || this.stopping || watch === "unwatched";
415
+ const tick = Math.max(1, this.nextParentCheck - performance.now());
416
+ // oxlint-disable-next-line no-await-in-loop
417
+ await this.wake.sleep(polling ? 100 : tick);
418
+ }
419
+ } finally {
420
+ // A verdict settling after the loop ends must not act.
421
+ this.parent.cancel();
422
+ commands.close();
330
423
  }
331
- const attachment = read(path.join(this.base, "attachment.json"), attachmentSchema);
332
- return attachment === undefined || !attachment.attached || !alive(attachment);
333
424
  }
334
425
  private failure(error: unknown): void {
335
426
  const launch = this.current ?? this.initial;
@@ -374,6 +465,12 @@ class Actor {
374
465
  if (this.initial.name !== undefined) {
375
466
  this.session.sessionManager.appendSessionInfo(this.initial.name);
376
467
  }
468
+ // Optional live view, bound before readiness; its faults never change native execution.
469
+ this.observation = await ActorObservation.open(this.session, {
470
+ token: this.token,
471
+ rootId: this.initial.rootId,
472
+ sessionId: this.session.sessionManager.getSessionId(),
473
+ });
377
474
  write(path.join(this.dir, "actor.lock", "process.json"), {
378
475
  ...ownIdentity(),
379
476
  token: this.token,
@@ -386,22 +483,23 @@ class Actor {
386
483
  });
387
484
  process.on("SIGTERM", this.stop);
388
485
  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());
486
+ await this.admitCommands();
395
487
  await this.active;
396
488
  } catch (error) {
397
489
  this.failure(error);
398
490
  } finally {
399
491
  process.off("SIGTERM", this.stop);
400
492
  process.off("SIGINT", this.stop);
493
+ const observation: ObservationStats | undefined = this.observation?.close();
401
494
  await runtime?.dispose();
495
+ if (this.stopping) {
496
+ this.stopShells("SIGKILL");
497
+ }
498
+ clearTimeout(this.stopDeadline);
402
499
  write(path.join(this.dir, `shutdown-${this.token}.json`), {
403
500
  at: Date.now(),
404
501
  errors: this.errors,
502
+ ...(observation === undefined ? {} : { observation }),
405
503
  });
406
504
  // Keep the process claim until a launcher proves this exact actor is gone.
407
505
  }
@@ -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 {
@@ -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;
@@ -1,12 +1,8 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { Usage } from "@earendil-works/pi-ai";
3
1
  import * as path from "node:path";
4
2
  import * as fs from "node:fs";
5
3
  import { journalStamp } from "../shared/native-journal.ts";
6
- import { isDeepStrictEqual } from "node:util";
7
4
  import { nativeProjection } from "../shared/journal-projections.ts";
8
5
  import { scanJournal } from "../shared/journal-frames.ts";
9
- import type { Projection } from "../shared/json-projection.ts";
10
6
  import { isRecord } from "../shared/unknown.ts";
11
7
  import { Assert } from "./typebox.ts";
12
8
  import {
@@ -18,132 +14,6 @@ import {
18
14
  } from "./contracts.ts";
19
15
  import { read, runDirectory, write } from "./storage.ts";
20
16
 
21
- export function sumUsage(values: readonly Contribution[]): Usage {
22
- const total: Usage = {
23
- input: 0,
24
- output: 0,
25
- cacheRead: 0,
26
- cacheWrite: 0,
27
- totalTokens: 0,
28
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
29
- };
30
- for (const { usage } of values) {
31
- for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
32
- total[key] += usage[key];
33
- }
34
- for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
35
- total.cost[key] += usage.cost[key];
36
- }
37
- for (const key of ["reasoning", "cacheWrite1h"] as const) {
38
- if (usage[key] !== undefined) {
39
- total[key] = (total[key] ?? 0) + usage[key];
40
- }
41
- }
42
- }
43
- return total;
44
- }
45
- function intents(details: unknown): Contribution[] | undefined {
46
- if (!isRecord(details) || details.agentUsage === undefined) {
47
- return;
48
- }
49
- const values: unknown = details.agentUsage;
50
- Assert(contributionsSchema, values);
51
- return values;
52
- }
53
- function publishedContributions(entry: unknown): Contribution[] {
54
- if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) {
55
- return [];
56
- }
57
- const message = entry.message;
58
- if (
59
- message.role !== "toolResult" ||
60
- (message.toolName !== "delegate" && message.toolName !== "agent_runs")
61
- ) {
62
- return [];
63
- }
64
- const values = intents(message.details);
65
- if (values === undefined || !isDeepStrictEqual(sumUsage(values), message.usage)) {
66
- return [];
67
- }
68
- return values;
69
- }
70
- const receiptProjection: Projection = (keys) => {
71
- if (keys.length === 0 || keys[0] === "type") {
72
- return true;
73
- }
74
- if (keys[0] !== "message") {
75
- return false;
76
- }
77
- if (keys.length === 1 || ["role", "toolName", "usage"].includes(String(keys[1]))) {
78
- return true;
79
- }
80
- return keys[1] === "details" && (keys.length === 2 || keys[2] === "agentUsage");
81
- };
82
- function priorUsage(ctx: ExtensionContext): Map<string, Contribution> {
83
- const values = new Map<string, Contribution>();
84
- const file = ctx.sessionManager.getSessionFile();
85
- if (file !== undefined) {
86
- scanJournal(
87
- file,
88
- receiptProjection,
89
- ({ value: entry }) => {
90
- for (const value of publishedContributions(entry)) {
91
- values.set(value.id, value);
92
- }
93
- },
94
- { policy: "live", requireNewline: true },
95
- );
96
- }
97
- for (const entry of ctx.sessionManager.getEntries()) {
98
- for (const value of publishedContributions(entry)) {
99
- values.set(value.id, value);
100
- }
101
- }
102
- return values;
103
- }
104
- function uncharged(
105
- values: readonly Contribution[],
106
- prior: Map<string, Contribution>,
107
- ): Contribution[] {
108
- const pending: Contribution[] = [];
109
- for (const value of values) {
110
- const earlier = prior.get(value.id);
111
- if (earlier && !isDeepStrictEqual(earlier, value)) {
112
- throw new Error(`Conflicting native usage contribution ${value.id}`);
113
- }
114
- if (!earlier) {
115
- pending.push(value);
116
- prior.set(value.id, value);
117
- }
118
- }
119
- return pending;
120
- }
121
- export function registerUsage(pi: ExtensionAPI): void {
122
- pi.on("message_end", (event, ctx) => {
123
- const message = event.message;
124
- if (
125
- message.role !== "toolResult" ||
126
- (message.toolName !== "delegate" && message.toolName !== "agent_runs")
127
- ) {
128
- return;
129
- }
130
- const values = intents(message.details);
131
- if (values === undefined || !isRecord(message.details)) {
132
- return;
133
- }
134
- const pending = uncharged(values, priorUsage(ctx));
135
- const { usage: _usage, ...rest } = message;
136
- // Finalized native tool messages are serialized even for concurrent tools.
137
- // Preparing or discarding a result cannot reserve a charge.
138
- return {
139
- message: {
140
- ...rest,
141
- details: { ...message.details, agentUsage: pending },
142
- ...(pending.length > 0 ? { usage: sumUsage(pending) } : {}),
143
- },
144
- };
145
- });
146
- }
147
17
  function billing(entry: Record<string, unknown>) {
148
18
  if (entry.checkpoint === true) {
149
19
  return;