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