@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,7 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { setTimeout as delay } from "node:timers/promises";
5
4
  import type {
6
5
  ExtensionAPI,
7
6
  ExtensionContext,
@@ -12,23 +11,24 @@ import {
12
11
  alive,
13
12
  ensureActor,
14
13
  ownIdentity,
14
+ parkFailure,
15
+ requestStop,
15
16
  signalProcess,
16
- verifyProcess,
17
- shells,
17
+ stopChildren,
18
18
  } from "./process.ts";
19
19
  import { isChild, errorText } from "./host.ts";
20
+ import { serveParked } from "./parking.ts";
20
21
  import {
21
- delegateSchema,
22
- runsSchema,
23
22
  resultSchema,
24
23
  attachmentSchema,
25
24
  admissionSchema,
26
25
  commandSchema,
26
+ type Attachment,
27
27
  type Launch,
28
+ type ProcessIdentity,
28
29
  type Result,
29
- type DelegateInput,
30
- type RunsInput,
31
30
  } from "./contracts.ts";
31
+ import { delegateSchema, runsSchema, type DelegateInput, type RunsInput } from "./tool-schemas.ts";
32
32
  import {
33
33
  enqueue,
34
34
  latest,
@@ -38,23 +38,68 @@ import {
38
38
  runDirectory,
39
39
  ownerDirectory,
40
40
  conversation,
41
+ successor,
41
42
  write,
42
43
  } from "./storage.ts";
43
- import { hold, notify, settle, release } from "./notifications.ts";
44
+ import { notifications, deliver, settle } from "./notifications.ts";
44
45
  import { prepareLaunch } from "./launch.ts";
45
- import { toolResult, projection, render, summaries } from "./view.ts";
46
- import { registerUsage } from "./usage.ts";
47
- import { notifyLegacy } from "./legacy.ts";
46
+ import { renderReceipt, renderInventory, renderInspection } from "./view.ts";
47
+ import { legacyDirectory, legacyNotifications } from "./legacy.ts";
48
+ import { DirectoryWatch, pathStamp, type WatchState } from "./wake.ts";
48
49
  import { savedContinuation } from "./saved.ts";
49
50
  import { executionCwd } from "./cwd.ts";
50
51
  import { getPiAgentDir } from "../pi-intercom/agent-dir.ts";
52
+ import { registerCompactMessages, renderCompactResult } from "../shared/compact-cards.ts";
51
53
 
54
+ /** Native fork lifetime owner (`ctx.managedHost`); read structurally so official Pi stays supported. */
55
+ interface ManagedHost {
56
+ readonly owner: { readonly pid: number };
57
+ readonly ready: Promise<boolean>;
58
+ }
59
+ /** `handoff` and `provisional` mean another worker under the same launcher may still own these children. */
60
+ function retainsChildren(event: object): boolean {
61
+ const disposition: unknown = Reflect.get(event, "disposition");
62
+ return disposition === "handoff" || disposition === "provisional";
63
+ }
64
+ function managedHost(context: ExtensionContext): ManagedHost | undefined {
65
+ const host: unknown = Reflect.get(context, "managedHost");
66
+ if (typeof host !== "object" || host === null) {
67
+ return undefined;
68
+ }
69
+ const owner: unknown = Reflect.get(host, "owner");
70
+ const pid: unknown =
71
+ typeof owner === "object" && owner !== null ? Reflect.get(owner, "pid") : undefined;
72
+ const ready: unknown = Reflect.get(host, "ready");
73
+ return typeof pid === "number" && Number.isSafeInteger(pid) && ready instanceof Promise
74
+ ? { owner: { pid }, ready: ready.then((accepted) => accepted === true) }
75
+ : undefined;
76
+ }
77
+
78
+ /** Legacy results are optional; their reconciliation reports an invalid configured root. */
79
+ function legacyInput(): string[] {
80
+ try {
81
+ return [legacyDirectory()];
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
52
86
  class AgentRuntime {
53
87
  private ctx?: ExtensionContext;
88
+ // A managed launcher owns children across worker replacement; otherwise this process does.
89
+ private host?: ManagedHost;
90
+ private accepted = false;
54
91
  private base = "";
55
92
  private owner = "";
56
93
  private generation = "";
94
+ // Polling remains only while change hints are unavailable or a reconciliation failed.
57
95
  private timer?: NodeJS.Timeout;
96
+ private woken?: NodeJS.Immediate;
97
+ // Change hints for the owner directory, legacy results and every awaited canonical file.
98
+ private readonly watches = new Map<string, DirectoryWatch>();
99
+ private awaited: ReadonlySet<string> = new Set();
100
+ // Input stamps the last reconciliation saw; a one-second probe recovers dropped hints.
101
+ private observed = new Map<string, string>();
102
+ private probe?: NodeJS.Timeout;
58
103
  private closing = false;
59
104
  private reset: Promise<void> = Promise.resolve();
60
105
  private readonly shutdown = new AbortController();
@@ -72,23 +117,121 @@ class AgentRuntime {
72
117
  throw new Error("Agent runtime is not attached.");
73
118
  }
74
119
  }
120
+ private readonly wake = (): void => {
121
+ this.woken ??= setImmediate(() => {
122
+ this.woken = undefined;
123
+ this.scan();
124
+ }).unref();
125
+ };
126
+ private readonly recover = (): void => {
127
+ for (const [file, seen] of this.observed) {
128
+ if (pathStamp(file) !== seen) {
129
+ this.wake();
130
+ return;
131
+ }
132
+ }
133
+ };
75
134
  private readonly scan = (): void => {
135
+ clearTimeout(this.timer);
76
136
  if (this.closing || !this.ctx || this.child) {
77
137
  return;
78
138
  }
139
+ const inputs = () => [this.base, ...legacyInput(), ...this.awaited];
140
+ const before = new Map(inputs().map((file) => [file, pathStamp(file)]));
141
+ let reconciled = true;
79
142
  try {
80
- notify(this.pi, this.ctx, this.base, this.owner);
81
- notifyLegacy(this.pi, this.ctx);
143
+ const { notices, awaited } = notifications(this.ctx, this.base, this.owner);
144
+ this.awaited = awaited;
145
+ try {
146
+ notices.unshift(...legacyNotifications(this.ctx));
147
+ } catch (error) {
148
+ console.error(`Legacy completion reconciliation: ${errorText(error)}`);
149
+ }
150
+ deliver(this.pi, this.ctx, notices);
82
151
  } catch (error) {
152
+ reconciled = false;
83
153
  console.error(`Agent completion reconciliation: ${errorText(error)}`);
84
154
  }
155
+ // Newly awaited files were absent when read, or are rechecked once by the probe.
156
+ this.observed = new Map(inputs().map((file) => [file, before.get(file) ?? ""]));
157
+ const next = this.watch();
158
+ if (!reconciled || next === "poll") {
159
+ this.timer = setTimeout(this.scan, 200).unref();
160
+ } else if (next === "rescan") {
161
+ // Publications before arming had no hint; reconcile once more after it.
162
+ this.wake();
163
+ }
85
164
  };
86
- private start(context: ExtensionContext): Promise<void> {
87
- this.reset = this.reset.then(() => this.attach(context));
88
- return this.reset;
165
+ /** Watches every directory that can publish an awaited input; legacy results may never exist. */
166
+ private watch(): "poll" | "rescan" | "hints" {
167
+ const legacy = legacyInput();
168
+ const directories = [
169
+ ...new Set([this.base, ...legacy, ...[...this.awaited].map((file) => path.dirname(file))]),
170
+ ];
171
+ for (const [directory, watch] of this.watches) {
172
+ if (!directories.includes(directory)) {
173
+ watch.close();
174
+ this.watches.delete(directory);
175
+ }
176
+ }
177
+ const states = directories.map((directory): [string, WatchState] => {
178
+ let watch = this.watches.get(directory);
179
+ if (watch === undefined) {
180
+ const every = directory === this.base || legacy.includes(directory);
181
+ watch = new DirectoryWatch(
182
+ directory,
183
+ (file) => every || file === null || this.awaited.has(path.join(directory, file)),
184
+ this.wake,
185
+ );
186
+ this.watches.set(directory, watch);
187
+ }
188
+ return [directory, watch.ensure()];
189
+ });
190
+ if (states.some(([directory, state]) => state === "unwatched" && !legacy.includes(directory))) {
191
+ return "poll";
192
+ }
193
+ return states.some(([, state]) => state === "armed") ? "rescan" : "hints";
194
+ }
195
+ private unwatch(): void {
196
+ clearTimeout(this.timer);
197
+ clearInterval(this.probe);
198
+ this.observed.clear();
199
+ clearImmediate(this.woken);
200
+ this.woken = undefined;
201
+ for (const watch of this.watches.values()) {
202
+ watch.close();
203
+ }
204
+ this.watches.clear();
205
+ this.awaited = new Set();
206
+ }
207
+ private start(context: ExtensionContext): Promise<void> | undefined {
208
+ this.host = managedHost(context);
209
+ if (this.host === undefined || this.accepted || this.child) {
210
+ this.reset = this.reset.then(() => this.attach(context));
211
+ return this.reset;
212
+ }
213
+ // Attach and publish only after the launcher retires rollback: an unready replacement
214
+ // must neither adopt children nor append completions to a journal it may abandon.
215
+ // Readiness follows session_start, so never await it here.
216
+ this.owner = context.sessionManager.getSessionId();
217
+ this.base = ownerDirectory(this.agentDir, this.owner);
218
+ this.host.ready
219
+ .then(async (accepted) => {
220
+ if (accepted && !this.closing) {
221
+ this.accepted = true;
222
+ await this.start(context);
223
+ }
224
+ })
225
+ .catch((error: unknown) => {
226
+ console.error(`Agent runtime attachment: ${errorText(error)}`);
227
+ });
228
+ return undefined;
229
+ }
230
+ private ownerIdentity(): Omit<ProcessIdentity, "token"> {
231
+ return ownIdentity(this.host?.owner.pid);
89
232
  }
90
233
  private async attach(context: ExtensionContext): Promise<void> {
91
- clearInterval(this.timer);
234
+ this.unwatch();
92
235
  this.ctx = context;
93
236
  if (this.child) {
94
237
  return;
@@ -98,55 +241,54 @@ class AgentRuntime {
98
241
  this.generation = randomUUID();
99
242
  await fs.promises.mkdir(this.base, { recursive: true, mode: 0o700 });
100
243
  write(path.join(this.base, "attachment.json"), {
101
- ...ownIdentity(),
244
+ ...this.ownerIdentity(),
102
245
  generation: this.generation,
103
246
  attached: true,
104
247
  });
105
248
  if (this.closing) {
106
249
  return;
107
250
  }
108
- this.timer = setInterval(this.scan, 200);
109
- this.timer.unref();
110
- this.scan();
251
+ // Recovery and watch setup stay off the awaited startup path.
252
+ this.timer = setTimeout(this.scan, 200).unref();
253
+ this.probe = setInterval(this.recover, 1000).unref();
111
254
  }
112
- private async dispose(): Promise<void> {
255
+ private async dispose(retain: boolean): Promise<void> {
113
256
  this.closing = true;
114
- clearInterval(this.timer);
257
+ this.unwatch();
115
258
  this.shutdown.abort();
116
259
  await this.reset;
117
- if (this.base.length > 0) {
260
+ if (!retain && this.base.length > 0) {
118
261
  const file = path.join(this.base, "attachment.json");
119
- if (read(file, attachmentSchema)?.generation === this.generation) {
120
- write(file, { ...ownIdentity(), generation: this.generation, attached: false });
262
+ const attachment = read(file, attachmentSchema);
263
+ if (attachment !== undefined && this.releases(attachment)) {
264
+ write(file, {
265
+ ...this.ownerIdentity(),
266
+ generation: attachment.generation,
267
+ attached: false,
268
+ });
269
+ await stopChildren(this.base, this.owner);
121
270
  }
122
271
  }
123
272
  this.ctx = undefined;
124
273
  }
274
+ private releases(attachment: Attachment): boolean {
275
+ if (attachment.generation === this.generation) {
276
+ return true;
277
+ }
278
+ // An unready replacement never attached, but exiting still releases the launcher-owned children.
279
+ return (
280
+ this.generation.length === 0 && attachment.attached && attachment.pid === this.host?.owner.pid
281
+ );
282
+ }
125
283
  private stop(launch: Launch) {
126
284
  const base = this.base;
127
- const identity = actor(base, launch);
128
- const targets = identity ? [identity, ...shells(base, launch, identity.token)] : [];
129
- // Verify all targets before admitting a request or signaling any process.
130
- const live = targets.filter(verifyProcess);
285
+ const { targets: live, command: id } = requestStop(base, launch);
131
286
  if (live.length === 0) {
132
- return {
133
- ...projection(base, launch, this.ctx),
287
+ return renderReceipt(base, launch, this.ctx, {
134
288
  stopAcknowledged: false,
135
289
  note: "No matching live actor or owned shell; process exit code remains unknown.",
136
- };
137
- }
138
- const id =
139
- identity && verifyProcess(identity)
140
- ? enqueue(base, launch, "stop", { actorToken: identity.token })
141
- : undefined;
142
- if (identity) {
143
- write(path.join(conversation(base, launch), `stopping-${identity.token}.json`), {
144
- at: Date.now(),
145
290
  });
146
291
  }
147
- live.forEach((target) => {
148
- signalProcess(target, "SIGTERM");
149
- });
150
292
  const escalation = setTimeout(() => {
151
293
  try {
152
294
  live.forEach((target) => {
@@ -157,65 +299,11 @@ class AgentRuntime {
157
299
  }
158
300
  }, 3000);
159
301
  escalation.unref();
160
- return {
161
- ...projection(base, launch, this.ctx),
302
+ return renderReceipt(base, launch, this.ctx, {
162
303
  stopRequest: id,
163
304
  stopAcknowledged: false,
164
305
  note: "Stop requested; neither native admission nor process exit is implied.",
165
- };
166
- }
167
- private interrupted(launch: Launch, signal: AbortSignal | undefined, owned: boolean) {
168
- if (this.shutdown.signal.aborted) {
169
- return toolResult({
170
- ...projection(this.base, launch, this.ctx),
171
- wait: "detached",
172
- note: "Parent runtime disposed; producer was not stopped.",
173
- });
174
- }
175
- if (signal?.aborted === true) {
176
- if (owned) {
177
- this.stop(launch);
178
- }
179
- return toolResult({
180
- ...projection(this.base, launch, this.ctx),
181
- wait: "cancelled",
182
- note: owned
183
- ? "Owned foreground work was asked to stop; exit is unconfirmed."
184
- : "Only the wait was cancelled; producer was not stopped.",
185
- });
186
- }
187
- const identity = actor(this.base, launch);
188
- if (identity && !alive(identity)) {
189
- return toolResult({
190
- ...projection(this.base, launch, this.ctx),
191
- wait: "interrupted",
192
- note: "Actor is not live; no completion or exit code was invented.",
193
- });
194
- }
195
- return;
196
- }
197
- private async wait(launch: Launch, signal: AbortSignal | undefined, owned: boolean) {
198
- const held = hold(this.owner, launch.id);
199
- for (;;) {
200
- const result = read(
201
- path.join(runDirectory(this.base, launch.id), "result.json"),
202
- resultSchema,
203
- );
204
- if (result) {
205
- hold(this.owner, launch.id);
206
- return render(this.base, launch, this.ctx, { receivedResult: true });
207
- }
208
- const interruption = this.interrupted(launch, signal, owned);
209
- if (interruption) {
210
- if (held) {
211
- release(this.owner, launch.id);
212
- }
213
- return interruption;
214
- }
215
- // Cancellation of an observer never removes canonical producer state.
216
- // oxlint-disable-next-line no-await-in-loop
217
- await delay(50);
218
- }
306
+ });
219
307
  }
220
308
  private async delegate(
221
309
  input: DelegateInput,
@@ -238,44 +326,17 @@ class AgentRuntime {
238
326
  });
239
327
  this.checkMain();
240
328
  enqueue(this.base, launch, "run");
241
- await ensureActor(this.base, launch);
242
- if (input.async === false) {
243
- return this.wait(launch, signal, true);
244
- }
245
- return toolResult({
246
- ...projection(this.base, launch, this.ctx),
329
+ await ensureActor(this.base, launch, this.shutdown.signal);
330
+ return renderReceipt(this.base, launch, this.ctx, {
247
331
  receipt: "launch",
248
- note: "Launch accepted, not actual completion or native receipt publication.",
332
+ note: "Launch accepted, not completion or native receipt publication. Completion arrives as a notification; do other work or end the turn instead of polling.",
249
333
  });
250
334
  }
251
335
  private list(input: RunsInput) {
252
336
  const all = list(this.base)
253
337
  .map((id) => launchAt(this.base, id, this.owner))
254
338
  .sort((a, b) => a.createdAt - b.createdAt);
255
- const offset = input.offset ?? 0;
256
- const page = summaries(this.base, all.slice(offset, offset + 50), this.ctx);
257
- return toolResult({
258
- runs: page,
259
- offset,
260
- nextOffset: offset + page.length < all.length ? offset + page.length : undefined,
261
- total: all.length,
262
- });
263
- }
264
- private successor(launch: Launch, prompt: string): Launch | undefined {
265
- const id = randomUUID();
266
- const next: Launch = { ...launch, id, predecessor: launch.id, prompt, createdAt: Date.now() };
267
- fs.mkdirSync(runDirectory(this.base, id), { mode: 0o700 });
268
- write(path.join(runDirectory(this.base, id), "launch.json"), next, true);
269
- try {
270
- write(path.join(runDirectory(this.base, launch.id), "successor.json"), { id }, true);
271
- } catch (error) {
272
- fs.rmSync(runDirectory(this.base, id), { recursive: true });
273
- if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) {
274
- throw error;
275
- }
276
- return undefined;
277
- }
278
- return next;
339
+ return renderInventory(this.base, all, this.ctx, input);
279
340
  }
280
341
  private continuationResult(launch: Launch): Result | "pending" | undefined {
281
342
  const result = read(path.join(runDirectory(this.base, launch.id), "result.json"), resultSchema);
@@ -291,11 +352,7 @@ class AgentRuntime {
291
352
  }
292
353
  return;
293
354
  }
294
- private async retryContinuation(
295
- launch: Launch,
296
- input: RunsInput,
297
- signal: AbortSignal | undefined,
298
- ) {
355
+ private async retryContinuation(launch: Launch, input: RunsInput) {
299
356
  const identity = actor(this.base, launch);
300
357
  const command = read(
301
358
  path.join(conversation(this.base, launch), "commands", `${launch.id}.json`),
@@ -309,17 +366,14 @@ class AgentRuntime {
309
366
  command.action !== "run"
310
367
  ) {
311
368
  throw new Error(
312
- `Run ${launch.id} is awaiting native admission. Retry requires the exact saved prompt, its existing queued Run command, and no live actor. Missing commands or ambiguous process claims require inspection; wait for admission or a canonical result before sending another prompt. No new continuation was launched.`,
369
+ `Run ${launch.id} is awaiting native admission. Retry requires the exact saved prompt, its existing queued Run command, and no live actor. Missing commands or ambiguous process claims require inspection. Admission or a canonical result arrives without polling: end the turn or do other work until the completion notification, then inspect once before sending another prompt. No new continuation was launched.`,
313
370
  );
314
371
  }
315
372
  // Only an unadmitted durable command is retryable; keep all process/shell claim guards.
316
- await ensureActor(this.base, launch);
317
- if (input.async === false) {
318
- return this.wait(launch, signal, true);
319
- }
320
- return toolResult({ ...projection(this.base, launch, this.ctx), receipt: "continuation" });
373
+ await ensureActor(this.base, launch, this.shutdown.signal);
374
+ return renderReceipt(this.base, launch, this.ctx, { receipt: "continuation" });
321
375
  }
322
- private async continue(launch: Launch, input: RunsInput, signal: AbortSignal | undefined) {
376
+ private async continue(launch: Launch, input: RunsInput) {
323
377
  if (input.prompt === undefined) {
324
378
  throw new Error("continue requires the entire new prompt.");
325
379
  }
@@ -328,65 +382,30 @@ class AgentRuntime {
328
382
  }
329
383
  const completed = this.continuationResult(launch);
330
384
  if (completed === "pending") {
331
- return this.retryContinuation(launch, input, signal);
385
+ return this.retryContinuation(launch, input);
332
386
  }
333
387
  const identity = actor(this.base, launch);
334
388
  if (!completed && identity && alive(identity)) {
335
389
  const command = enqueue(this.base, launch, "steer", { prompt: input.prompt });
336
- if (input.async === false) {
337
- return this.guidance(launch, command, signal);
338
- }
339
- return toolResult({
340
- ...projection(this.base, launch, this.ctx),
390
+ return renderReceipt(this.base, launch, this.ctx, {
341
391
  receipt: "guidance",
342
392
  command,
343
393
  note: "Guidance queued, not actual completion or model consumption.",
344
394
  });
345
395
  }
346
- const next = this.successor(launch, input.prompt);
396
+ parkFailure(this.base, launch);
397
+ const next = successor(this.base, launch, input.prompt);
347
398
  if (!next) {
348
- return toolResult({
349
- ...projection(this.base, latest(this.base, launch), this.ctx),
399
+ return renderReceipt(this.base, latest(this.base, launch), this.ctx, {
350
400
  receipt: "existing-successor",
351
401
  });
352
402
  }
353
403
  // Retain successor claims on ambiguous after-launch failures: no replay.
354
404
  enqueue(this.base, next, "run");
355
- await ensureActor(this.base, next);
356
- if (input.async === false) {
357
- return this.wait(next, signal, true);
358
- }
359
- return toolResult({ ...projection(this.base, next, this.ctx), receipt: "continuation" });
405
+ await ensureActor(this.base, next, this.shutdown.signal);
406
+ return renderReceipt(this.base, next, this.ctx, { receipt: "continuation" });
360
407
  }
361
- private async guidance(launch: Launch, command: string, signal: AbortSignal | undefined) {
362
- const file = path.join(conversation(this.base, launch), "admissions", `${command}.json`);
363
- for (;;) {
364
- const admission = read(file, admissionSchema);
365
- if (admission) {
366
- if (admission.state === "stale") {
367
- return toolResult({
368
- ...projection(this.base, launch, this.ctx),
369
- command,
370
- guidance: "stale",
371
- note: "The native turn settled before guidance admission. Retry the entire prompt as a continuation; no new work was started.",
372
- });
373
- }
374
- return this.wait(launch, signal, false);
375
- }
376
- const interruption = this.interrupted(launch, signal, false);
377
- if (interruption) {
378
- return interruption;
379
- }
380
- // Observe the actor's actual admission, not a timer-based model-consumption claim.
381
- // oxlint-disable-next-line no-await-in-loop
382
- await delay(50);
383
- }
384
- }
385
- private async continueSaved(
386
- input: RunsInput & { id: string },
387
- signal: AbortSignal | undefined,
388
- context: ExtensionContext,
389
- ) {
408
+ private async continueSaved(input: RunsInput & { id: string }, context: ExtensionContext) {
390
409
  if (input.prompt === undefined) {
391
410
  throw new Error("continue requires the entire new prompt.");
392
411
  }
@@ -400,17 +419,13 @@ class AgentRuntime {
400
419
  if (created) {
401
420
  // The durable adoption claim survives ambiguous after-launch failures.
402
421
  enqueue(this.base, launch, "run");
403
- await ensureActor(this.base, launch);
404
- }
405
- if (input.async === false) {
406
- return this.wait(launch, signal, created);
422
+ await ensureActor(this.base, launch, this.shutdown.signal);
407
423
  }
408
- return toolResult({
409
- ...projection(this.base, launch, this.ctx),
424
+ return renderReceipt(this.base, launch, this.ctx, {
410
425
  receipt: created ? "continuation" : "existing-successor",
411
426
  });
412
427
  }
413
- private async runs(input: RunsInput, signal: AbortSignal | undefined, context: ExtensionContext) {
428
+ private async runs(input: RunsInput, context: ExtensionContext) {
414
429
  this.checkMain();
415
430
  await this.reset;
416
431
  if (input.action === "list") {
@@ -424,31 +439,41 @@ class AgentRuntime {
424
439
  input.action === "continue" &&
425
440
  !fs.existsSync(path.join(runDirectory(this.base, input.id), "launch.json"))
426
441
  ) {
427
- return this.continueSaved({ ...input, id: input.id }, signal, context);
442
+ return this.continueSaved({ ...input, id: input.id }, context);
428
443
  }
429
444
  const original = launchAt(this.base, input.id, this.owner);
430
445
  if (input.action === "inspect") {
431
- return render(this.base, original, this.ctx, input);
446
+ return renderInspection(this.base, original, this.ctx, input);
432
447
  }
433
448
  const launch = latest(this.base, original);
434
449
  if (input.action === "stop") {
435
- return toolResult(this.stop(launch));
450
+ return this.stop(launch);
436
451
  }
437
- if (input.action === "wait") {
438
- return this.wait(launch, signal, false);
439
- }
440
- return this.continue(launch, input, signal);
452
+ return this.continue(launch, input);
441
453
  }
442
454
  register(): void {
443
- registerUsage(this.pi);
455
+ registerCompactMessages(this.pi, ["agent-completion", "subagent-notify"]);
456
+ serveParked(
457
+ this.pi,
458
+ () => (this.child || !this.ctx ? undefined : { base: this.base, owner: this.owner }),
459
+ this.shutdown.signal,
460
+ );
444
461
  this.pi.on("session_start", (_event, context) => this.start(context));
445
- this.pi.on("turn_end", (event) => {
446
- settle(this.owner, event.context.pendingMessages.length > 0);
462
+ this.pi.on("turn_end", (event, context) => {
463
+ // The public pending flag covers user input; the preview also includes custom messages.
464
+ if (
465
+ settle(this.owner, context.hasPendingMessages() || event.context.pendingMessages.length > 0)
466
+ ) {
467
+ // Released admissions may have been dropped from the queue rather than published.
468
+ this.wake();
469
+ }
447
470
  });
448
471
  this.pi.on("agent_settled", () => {
449
472
  this.scan();
450
473
  });
451
- this.pi.on("session_shutdown", () => this.dispose());
474
+ this.pi.on("session_shutdown", (event) =>
475
+ this.dispose(event.reason === "reload" || retainsChildren(event)),
476
+ );
452
477
  if (this.child) {
453
478
  this.pi.on("tool_call", (event) => {
454
479
  if (event.toolName === "delegate" || event.toolName === "agent_runs") {
@@ -463,20 +488,22 @@ class AgentRuntime {
463
488
  name: "delegate",
464
489
  label: "Delegate",
465
490
  description:
466
- "Launch one child with exactly the supplied prompt. Fresh context by default; fork preserves the full native conversation. Main sessions only. Returns an asynchronous launch receipt unless async:false.",
491
+ "Run a child on exactly this prompt; main sessions only. Fork copies the full conversation; default is fresh. Returns a receipt; completion is notified later: do other work or end the turn, don't poll.",
467
492
  parameters: delegateSchema,
468
493
  exposure: this.child ? "hidden" : "direct",
494
+ renderResult: renderCompactResult,
469
495
  execute,
470
496
  });
471
497
  const runs: ToolDefinition<typeof runsSchema>["execute"] = (...args) =>
472
- this.runs(args[1], args[2], args[4]);
498
+ this.runs(args[1], args[4]);
473
499
  this.pi.registerTool({
474
500
  name: "agent_runs",
475
501
  label: "Agent runs",
476
502
  description:
477
- "List, inspect, continue, wait for, or stop this native main session's durable child runs. Inspect is paged with byte offsets; full outputs and native journals remain available. Wait cancellation does not stop existing work. Continue requires the complete new prompt and reuses the saved conversation.",
503
+ "Manage your child runs without waiting; completions are notified. Inspect pages by byte offset, never interrupting. Continue takes the complete new prompt: it steers a live child or resumes the saved conversation.",
478
504
  parameters: runsSchema,
479
505
  exposure: this.child ? "hidden" : "direct",
506
+ renderResult: renderCompactResult,
480
507
  execute: runs,
481
508
  });
482
509
  }