@hheei/pi-subagents 0.1.2

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/dist/agent-resolver.d.ts +37 -0
  4. package/dist/agent-resolver.js +232 -0
  5. package/dist/child-bridge.d.ts +41 -0
  6. package/dist/child-bridge.js +214 -0
  7. package/dist/child-nudge.d.ts +24 -0
  8. package/dist/child-nudge.js +102 -0
  9. package/dist/commands.d.ts +6 -0
  10. package/dist/commands.js +155 -0
  11. package/dist/connector.d.ts +53 -0
  12. package/dist/connector.js +283 -0
  13. package/dist/diagnostics.d.ts +10 -0
  14. package/dist/diagnostics.js +20 -0
  15. package/dist/domain.d.ts +192 -0
  16. package/dist/domain.js +63 -0
  17. package/dist/extension.d.ts +2 -0
  18. package/dist/extension.js +145 -0
  19. package/dist/host-adapter.d.ts +76 -0
  20. package/dist/host-adapter.js +323 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/json-lines.d.ts +14 -0
  24. package/dist/json-lines.js +94 -0
  25. package/dist/launch-spec.d.ts +47 -0
  26. package/dist/launch-spec.js +149 -0
  27. package/dist/manager.d.ts +101 -0
  28. package/dist/manager.js +802 -0
  29. package/dist/pause-gate.d.ts +14 -0
  30. package/dist/pause-gate.js +64 -0
  31. package/dist/protocol.d.ts +141 -0
  32. package/dist/protocol.js +168 -0
  33. package/dist/registry.d.ts +41 -0
  34. package/dist/registry.js +575 -0
  35. package/dist/rpc-adapter.d.ts +40 -0
  36. package/dist/rpc-adapter.js +194 -0
  37. package/dist/rpc-events.d.ts +8 -0
  38. package/dist/rpc-events.js +29 -0
  39. package/dist/runner-entry.d.ts +24 -0
  40. package/dist/runner-entry.js +166 -0
  41. package/dist/runner.d.ts +52 -0
  42. package/dist/runner.js +930 -0
  43. package/dist/runtime.d.ts +22 -0
  44. package/dist/runtime.js +253 -0
  45. package/dist/session-bootstrap.d.ts +69 -0
  46. package/dist/session-bootstrap.js +195 -0
  47. package/dist/state.d.ts +15 -0
  48. package/dist/state.js +168 -0
  49. package/dist/tools.d.ts +9 -0
  50. package/dist/tools.js +158 -0
  51. package/dist/widget.d.ts +16 -0
  52. package/dist/widget.js +158 -0
  53. package/package.json +53 -0
@@ -0,0 +1,802 @@
1
+ import { isRecord } from "./domain.js";
2
+ import { buildLaunchSpec, withBridgeToken } from "./launch-spec.js";
3
+ import { isIdlePiState } from "./rpc-events.js";
4
+ import { planSessionPlacement } from "./session-bootstrap.js";
5
+ import { createStateProjector } from "./state.js";
6
+ function failure(operation, reason, childId, state, sideEffects = [], safeToRetry = true) {
7
+ return {
8
+ operation,
9
+ ...(childId === undefined ? {} : { childId }),
10
+ reason,
11
+ sideEffects,
12
+ ...(state === undefined ? {} : { state }),
13
+ safeToRetry,
14
+ };
15
+ }
16
+ function publicChild(record, live) {
17
+ return {
18
+ id: record.subagentId,
19
+ agent: record.launchConfig.agent.name,
20
+ ...(record.launchConfig.agent.displayName === undefined
21
+ ? {}
22
+ : { displayName: record.launchConfig.agent.displayName }),
23
+ state: record.state,
24
+ mode: record.mode,
25
+ cwd: record.cwd,
26
+ sessionId: record.sessionId,
27
+ ...(record.latestSummary === undefined ? {} : { summary: record.latestSummary }),
28
+ ...(record.usage === undefined ? {} : { usage: record.usage }),
29
+ ...(record.interrupted === undefined ? {} : { interrupted: record.interrupted }),
30
+ freshness: live ? "live" : "last_known",
31
+ interactive: record.launchConfig.interactive,
32
+ model: record.launchConfig.model,
33
+ thinking: record.launchConfig.thinking,
34
+ createdAt: record.createdAt,
35
+ updatedAt: record.updatedAt,
36
+ };
37
+ }
38
+ function withDeadline(operation, timeoutMs) {
39
+ return new Promise((resolve, reject) => {
40
+ const timer = setTimeout(() => reject(new Error(`Operation deadline exceeded (${timeoutMs}ms)`)), timeoutMs);
41
+ void operation.then((value) => {
42
+ clearTimeout(timer);
43
+ resolve(value);
44
+ }, (error) => {
45
+ clearTimeout(timer);
46
+ reject(error);
47
+ });
48
+ });
49
+ }
50
+ async function waitForRunnerDisconnect(runner, timeoutMs, signal) {
51
+ const deadline = Date.now() + timeoutMs;
52
+ while (runner.connected) {
53
+ if (signal?.aborted)
54
+ throw new Error("Runner shutdown wait was aborted");
55
+ if (Date.now() >= deadline)
56
+ throw new Error("Runner did not disconnect after shutdown");
57
+ await new Promise((resolve) => setTimeout(resolve, 10));
58
+ }
59
+ }
60
+ function sessionContainsUserInput(entries, expected) {
61
+ return entries.some((entry) => {
62
+ const value = isRecord(entry) ? entry : undefined;
63
+ const message = isRecord(value?.message) ? value.message : undefined;
64
+ if (message?.role !== "user")
65
+ return false;
66
+ if (typeof message.content === "string")
67
+ return message.content === expected;
68
+ if (!Array.isArray(message.content))
69
+ return false;
70
+ const text = message.content
71
+ .map((part) => {
72
+ const content = isRecord(part) ? part : undefined;
73
+ return content?.type === "text" && typeof content.text === "string" ? content.text : "";
74
+ })
75
+ .join("");
76
+ return text === expected;
77
+ });
78
+ }
79
+ function throwIfAborted(signal) {
80
+ if (signal?.aborted)
81
+ throw new Error("Attach was cancelled");
82
+ }
83
+ function hostCleanupFailed(result) {
84
+ return result.timedOut || result.exitCode !== 0;
85
+ }
86
+ export class SubagentManager {
87
+ #deps;
88
+ #chains = new Map();
89
+ #runners = new Map();
90
+ #projectors = new Map();
91
+ #stopping = new Set();
92
+ #frozen = new Set();
93
+ #attachCancels = new Map();
94
+ #hostAttachments = new Map();
95
+ #hostWatches = new Map();
96
+ #tuiQuitExpected = new Set();
97
+ #listeners = new Set();
98
+ constructor(deps) {
99
+ this.#deps = deps;
100
+ }
101
+ /** Presentation-only: widget and similar projections re-read list() after this. */
102
+ onChange(listener) {
103
+ this.#listeners.add(listener);
104
+ return () => {
105
+ this.#listeners.delete(listener);
106
+ };
107
+ }
108
+ #notify() {
109
+ for (const listener of this.#listeners) {
110
+ try {
111
+ listener();
112
+ }
113
+ catch (error) {
114
+ console.error(`pi-subagents: onChange listener failed: ${error instanceof Error ? error.message : String(error)}`);
115
+ }
116
+ }
117
+ }
118
+ /** Reattaches active persisted children without replaying any historical input. */
119
+ async recover() {
120
+ const connect = this.#deps.connect;
121
+ if (connect === undefined)
122
+ return { recovered: [], failures: [] };
123
+ const records = (await this.#deps.registry.list()).filter((record) => record.intent === "active" && record.state !== "done" && record.state !== "stopped");
124
+ const outcomes = await Promise.all(records.map(async (record) => {
125
+ try {
126
+ const runner = await withDeadline(connect(record), this.#deps.deadlineMs ?? 30_000);
127
+ this.#attach(record, runner);
128
+ const response = await runner.request("get_entries").catch(() => undefined);
129
+ const entries = isRecord(response) && Array.isArray(response.entries) ? response.entries : [];
130
+ const projector = this.#projectors.get(record.subagentId);
131
+ projector?.rebuild(entries);
132
+ const snapshot = projector?.snapshot();
133
+ await this.#update(record.subagentId, (current) => {
134
+ const interrupted = snapshot?.interrupted ??
135
+ (current.unacknowledgedInput === undefined
136
+ ? current.interrupted
137
+ : (current.interrupted ?? "Parent recovered; pending input was not replayed"));
138
+ return {
139
+ ...current,
140
+ ...(snapshot === undefined
141
+ ? {}
142
+ : {
143
+ state: snapshot.state,
144
+ ...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
145
+ usage: snapshot.usage,
146
+ }),
147
+ ...(interrupted === undefined ? {} : { interrupted }),
148
+ };
149
+ });
150
+ return { childId: record.subagentId };
151
+ }
152
+ catch (error) {
153
+ return {
154
+ childId: record.subagentId,
155
+ reason: error instanceof Error ? error.message : String(error),
156
+ };
157
+ }
158
+ }));
159
+ this.#notify();
160
+ return {
161
+ recovered: outcomes
162
+ .filter((outcome) => !("reason" in outcome))
163
+ .map((outcome) => outcome.childId),
164
+ failures: outcomes.filter((outcome) => "reason" in outcome),
165
+ };
166
+ }
167
+ async spawn(input) {
168
+ if (input.task.trim() === "")
169
+ return failure("spawn", "Task must not be empty");
170
+ try {
171
+ const config = await withDeadline(this.#deps.resolve(input), this.#deps.deadlineMs ?? 30_000);
172
+ const record = await withDeadline(this.#deps.bootstrap({
173
+ parentSessionId: this.#deps.parentSessionId,
174
+ task: input.task,
175
+ launchConfig: config,
176
+ }), this.#deps.deadlineMs ?? 30_000);
177
+ this.#notify();
178
+ const runner = await withDeadline(this.#deps.launch(record), this.#deps.deadlineMs ?? 30_000);
179
+ this.#attach(record, runner);
180
+ try {
181
+ await withDeadline(runner.request("prompt", { message: input.task }), this.#deps.deadlineMs ?? 30_000);
182
+ }
183
+ catch (error) {
184
+ let failureState = "starting";
185
+ try {
186
+ await this.#update(record.subagentId, (current) => ({
187
+ ...current,
188
+ state: "failed",
189
+ interrupted: "Initial task delivery was not confirmed",
190
+ unacknowledgedInput: input.task,
191
+ }));
192
+ failureState = "failed";
193
+ }
194
+ catch (updateError) {
195
+ console.error(`pi-subagents: failed to persist initial delivery uncertainty for ${record.subagentId}: ${updateError instanceof Error ? updateError.message : String(updateError)}`);
196
+ }
197
+ return failure("spawn", error instanceof Error ? error.message : String(error), record.subagentId, failureState, [
198
+ "registry record persisted",
199
+ "runner launched",
200
+ "initial task delivery was not confirmed",
201
+ ], false);
202
+ }
203
+ await this.#update(record.subagentId, (current) => {
204
+ const { latestSummary: _latestSummary, ...rest } = current;
205
+ return { ...rest, state: "running" };
206
+ });
207
+ return {
208
+ child: publicChild((await this.#deps.registry.get(record.subagentId)) ?? record, true),
209
+ };
210
+ }
211
+ catch (error) {
212
+ return failure("spawn", error instanceof Error ? error.message : String(error), undefined, undefined, [], true);
213
+ }
214
+ }
215
+ send(id, message, mode = "auto", signal) {
216
+ return this.#mutate(id, async () => {
217
+ const record = await this.#deps.registry.get(id);
218
+ if (record === undefined)
219
+ return failure("send", "Unknown child", id);
220
+ if (this.#frozen.has(id) || record.mode === "tui")
221
+ return failure("send", "Child input is frozen for attach", id, record.state);
222
+ if (record.intent === "stopped" ||
223
+ record.state === "done" ||
224
+ record.state === "failed" ||
225
+ record.state === "stopped")
226
+ return failure("send", "Child is not accepting input", id, record.state);
227
+ const runner = this.#runners.get(id);
228
+ if (runner === undefined)
229
+ return failure("send", "Child runtime is not live", id, record.state, [], true);
230
+ const state = record.state;
231
+ const operation = mode === "auto"
232
+ ? state === "running"
233
+ ? "steer"
234
+ : state === "idle"
235
+ ? "follow_up"
236
+ : undefined
237
+ : mode;
238
+ if (operation === undefined)
239
+ return failure("send", "Cannot infer send mode from unknown state", id, state);
240
+ const pending = await this.#update(id, (current) => ({
241
+ ...current,
242
+ unacknowledgedInput: message,
243
+ interrupted: "Input delivery was accepted for dispatch but is not yet confirmed in the session",
244
+ }));
245
+ try {
246
+ await withDeadline(runner.request(operation, { message }, signal === undefined ? {} : { signal }), this.#deps.deadlineMs ?? 30_000);
247
+ }
248
+ catch (error) {
249
+ return failure("send", error instanceof Error ? error.message : String(error), id, pending.state, ["input persisted as unacknowledged before dispatch"], false);
250
+ }
251
+ return publicChild((await this.#deps.registry.get(id)) ?? record, true);
252
+ });
253
+ }
254
+ async get(id) {
255
+ const record = await this.#deps.registry.get(id);
256
+ const runner = this.#runners.get(id);
257
+ return record === undefined
258
+ ? failure("get", "Unknown child", id)
259
+ : publicChild(record, runner?.connected === true);
260
+ }
261
+ async list() {
262
+ const records = await this.#deps.registry.list();
263
+ return records.map((record) => publicChild(record, this.#runners.get(record.subagentId)?.connected === true));
264
+ }
265
+ stop(id, signal) {
266
+ this.#attachCancels.get(id)?.abort();
267
+ this.#stopHostWatch(id);
268
+ return this.#mutate(id, async () => {
269
+ if (this.#stopping.has(id))
270
+ return this.get(id);
271
+ this.#stopping.add(id);
272
+ let record;
273
+ try {
274
+ record = await this.#deps.registry.get(id);
275
+ }
276
+ catch (error) {
277
+ this.#stopping.delete(id);
278
+ return failure("stop", error instanceof Error ? error.message : String(error), id, undefined, [], true);
279
+ }
280
+ if (record === undefined) {
281
+ this.#stopping.delete(id);
282
+ return failure("stop", "Unknown child", id);
283
+ }
284
+ let stopped;
285
+ try {
286
+ stopped = await this.#update(id, (current) => ({
287
+ ...current,
288
+ intent: "stopped",
289
+ state: "stopped",
290
+ }));
291
+ }
292
+ catch (error) {
293
+ this.#stopping.delete(id);
294
+ return failure("stop", error instanceof Error ? error.message : String(error), id, record.state, [], true);
295
+ }
296
+ const runner = this.#runners.get(id);
297
+ if (runner === undefined && stopped.runtime !== undefined) {
298
+ this.#stopping.delete(id);
299
+ return failure("stop", "Runtime termination could not be confirmed because no live controller is attached", id, "stopped", ["stopped intent persisted", "runtime metadata retained"], true);
300
+ }
301
+ try {
302
+ if (runner !== undefined) {
303
+ await withDeadline(runner.request("shutdown", undefined, signal === undefined ? {} : { signal }), this.#deps.deadlineMs ?? 30_000);
304
+ await waitForRunnerDisconnect(runner, this.#deps.deadlineMs ?? 30_000, signal);
305
+ runner.close();
306
+ this.#runners.delete(id);
307
+ }
308
+ await this.#releaseHost(id);
309
+ this.#frozen.delete(id);
310
+ const runtimeIdentity = stopped.runtime?.runtimeIdentity;
311
+ if (runtimeIdentity !== undefined) {
312
+ this.#deps.tokens?.forget(runtimeIdentity);
313
+ const current = await this.#deps.registry.get(id);
314
+ if (current !== undefined) {
315
+ stopped = await this.#deps.registry.update(id, current.revision, (value) => {
316
+ const { runtime: _runtime, ...withoutRuntime } = value;
317
+ return withoutRuntime;
318
+ }, runtimeIdentity);
319
+ }
320
+ }
321
+ return publicChild(stopped, false);
322
+ }
323
+ catch (error) {
324
+ return failure("stop", error instanceof Error ? error.message : String(error), id, "stopped", ["stopped intent persisted", "runtime termination may be incomplete"], true);
325
+ }
326
+ finally {
327
+ this.#stopping.delete(id);
328
+ }
329
+ });
330
+ }
331
+ ownsHostAttachment(identity) {
332
+ for (const attachment of this.#hostAttachments.values()) {
333
+ if (attachment.identity.host === identity.host &&
334
+ attachment.identity.attachmentId === identity.attachmentId)
335
+ return true;
336
+ }
337
+ return false;
338
+ }
339
+ attach(id, options = {}) {
340
+ const cancel = new AbortController();
341
+ this.#attachCancels.set(id, cancel);
342
+ if (options.signal?.aborted)
343
+ cancel.abort();
344
+ else
345
+ options.signal?.addEventListener("abort", () => cancel.abort(), { once: true });
346
+ this.#frozen.add(id);
347
+ return this.#mutate(id, () => this.#runAttach(id, options.preferredHost, cancel.signal)).finally(() => {
348
+ this.#attachCancels.delete(id);
349
+ });
350
+ }
351
+ restoreRpc(id) {
352
+ return this.detach(id, { origin: "manual", closeHost: true, operation: "restore_rpc" });
353
+ }
354
+ detach(id, options) {
355
+ return this.#mutate(id, () => this.#runDetach(id, options));
356
+ }
357
+ inspectHost(id) {
358
+ return this.#mutate(id, async () => {
359
+ const attachment = this.#hostAttachments.get(id);
360
+ if (attachment === undefined) {
361
+ return failure("inspect_host", "No host attachment is owned", id);
362
+ }
363
+ const observed = await attachment.observe();
364
+ if (!observed.known || observed.alive) {
365
+ return { alive: observed.alive, known: observed.known, detail: observed.detail };
366
+ }
367
+ return this.#runDetach(id, {
368
+ origin: this.#tuiQuitExpected.has(id) ? "tui_exit" : "tui_crash",
369
+ closeHost: true,
370
+ });
371
+ });
372
+ }
373
+ async #runAttach(id, preferredHost, signal) {
374
+ const failBeforeClose = async (reason, record, sideEffects = []) => {
375
+ this.#frozen.delete(id);
376
+ return failure("attach", reason, id, record?.state, sideEffects, true);
377
+ };
378
+ try {
379
+ throwIfAborted(signal);
380
+ const record = await this.#deps.registry.get(id);
381
+ if (record === undefined)
382
+ return failBeforeClose("Unknown child", undefined);
383
+ if (record.intent === "stopped" || record.state === "stopped" || record.state === "done")
384
+ return failBeforeClose("Child is not attachable", record);
385
+ if (record.mode === "tui")
386
+ return failBeforeClose("Child is already attached as TUI", record);
387
+ const runner = this.#runners.get(id);
388
+ if (runner === undefined || !runner.connected)
389
+ return failBeforeClose("Child runtime is not live", record);
390
+ const hostApi = this.#deps.attachHost;
391
+ if (hostApi === undefined)
392
+ return failBeforeClose("No presentation host is configured", record);
393
+ const token = record.runtime === undefined
394
+ ? undefined
395
+ : this.#deps.tokens?.get(record.runtime.runtimeIdentity);
396
+ if (token === undefined)
397
+ return failBeforeClose("Bridge token is unavailable for TUI launch", record);
398
+ throwIfAborted(signal);
399
+ const selection = await hostApi.select(preferredHost);
400
+ if (!selection.available)
401
+ return failBeforeClose(selection.reason, record, ["RPC writer retained"]);
402
+ throwIfAborted(signal);
403
+ const state = await withDeadline(runner.request("get_state", undefined, { signal }), this.#deps.deadlineMs ?? 30_000);
404
+ if (!isIdlePiState(state)) {
405
+ try {
406
+ await withDeadline(runner.request("pause", undefined, { signal }), this.#deps.deadlineMs ?? 30_000);
407
+ }
408
+ catch (error) {
409
+ try {
410
+ await runner.request("cancel_pause");
411
+ }
412
+ catch {
413
+ // Keep RPC; attach still fails below.
414
+ }
415
+ return failBeforeClose(error instanceof Error && error.message === "Attach was cancelled"
416
+ ? error.message
417
+ : `Child did not pause before attach deadline: ${error instanceof Error ? error.message : String(error)}`, record, ["RPC writer retained"]);
418
+ }
419
+ }
420
+ throwIfAborted(signal);
421
+ const placement = await planSessionPlacement({
422
+ sessionId: record.sessionId,
423
+ cwd: record.cwd,
424
+ sessionDir: record.launchConfig.sessionDir,
425
+ persistence: record.persistence,
426
+ ...(record.sessionPath === undefined ? {} : { sessionPath: record.sessionPath }),
427
+ });
428
+ if (placement.persistence !== "flushed" || placement.sessionPath === undefined)
429
+ return failBeforeClose("Session has not been flushed; attach refused", record, [
430
+ "RPC writer retained",
431
+ ]);
432
+ const sessionPath = placement.sessionPath;
433
+ const flushed = sessionPath === record.sessionPath && record.persistence === "flushed"
434
+ ? record
435
+ : await this.#update(id, (current) => ({
436
+ ...current,
437
+ persistence: "flushed",
438
+ sessionPath,
439
+ launchConfig: { ...current.launchConfig, sessionPath },
440
+ }));
441
+ throwIfAborted(signal);
442
+ await withDeadline(runner.request("close_writer", undefined, { signal }), this.#deps.deadlineMs ?? 30_000);
443
+ const writerClosed = true;
444
+ try {
445
+ throwIfAborted(signal);
446
+ const launch = buildLaunchSpec({
447
+ config: {
448
+ ...flushed.launchConfig,
449
+ sessionPath,
450
+ },
451
+ invocation: flushed.launchConfig.invocation,
452
+ mode: "tui",
453
+ persistence: "flushed",
454
+ bridge: {
455
+ parentSessionId: flushed.parentSessionId,
456
+ subagentId: flushed.subagentId,
457
+ runtimeIdentity: flushed.runtime?.runtimeIdentity ?? "missing",
458
+ endpoint: flushed.runtime?.endpoint ?? "",
459
+ },
460
+ });
461
+ const attachment = await selection.adapter.attach({
462
+ ...launch,
463
+ env: withBridgeToken(launch.env, token),
464
+ });
465
+ if (signal.aborted) {
466
+ this.#hostAttachments.set(id, attachment);
467
+ const restored = await this.#restoreRpcAfterHost(id, runner);
468
+ this.#frozen.delete(id);
469
+ return failure("attach", "Attach was cancelled", id, flushed.state, [restored], false);
470
+ }
471
+ const observed = await attachment.observe();
472
+ if (attachment.launch.timedOut && !observed.known) {
473
+ this.#hostAttachments.set(id, attachment);
474
+ this.#watchHost(id);
475
+ await this.#update(id, (current) => ({ ...current, mode: "tui" }));
476
+ this.#frozen.delete(id);
477
+ return failure("attach", "Host timed out and TUI writer state is unknown; not starting another writer", id, flushed.state, ["RPC writer closed", "TUI writer state unknown"], false);
478
+ }
479
+ if (attachment.launch.timedOut && !observed.alive) {
480
+ this.#hostAttachments.set(id, attachment);
481
+ const restored = await this.#restoreRpcAfterHost(id, runner);
482
+ this.#frozen.delete(id);
483
+ return failure("attach", "Host timed out and TUI writer was not confirmed", id, flushed.state, [restored, "pending input was not replayed"], false);
484
+ }
485
+ if (attachment.launch.timedOut && observed.alive) {
486
+ this.#hostAttachments.set(id, attachment);
487
+ this.#watchHost(id);
488
+ await this.#update(id, (current) => ({ ...current, mode: "tui" }));
489
+ return failure("attach", "Host timed out after TUI started; not starting another writer", id, "idle", ["TUI writer may be live", "RPC writer closed"], false);
490
+ }
491
+ this.#hostAttachments.set(id, attachment);
492
+ this.#watchHost(id);
493
+ const next = await this.#update(id, (current) => ({
494
+ ...current,
495
+ mode: "tui",
496
+ state: "idle",
497
+ }));
498
+ return {
499
+ child: publicChild(next, true),
500
+ host: selection.selectedHost,
501
+ attachmentId: attachment.identity.attachmentId,
502
+ };
503
+ }
504
+ catch (error) {
505
+ if (!writerClosed)
506
+ throw error;
507
+ const restored = await this.#restoreRpcAfterHost(id, runner);
508
+ this.#frozen.delete(id);
509
+ return failure("attach", error instanceof Error ? error.message : String(error), id, flushed.state, [restored, "pending input was not replayed"], false);
510
+ }
511
+ }
512
+ catch (error) {
513
+ this.#frozen.delete(id);
514
+ return failure("attach", error instanceof Error ? error.message : String(error), id, undefined, ["RPC writer retained"], true);
515
+ }
516
+ }
517
+ async #restoreRpcAfterHost(id, runner) {
518
+ try {
519
+ await this.#releaseHost(id);
520
+ }
521
+ catch (error) {
522
+ return `RPC not restored: ${error instanceof Error ? error.message : String(error)}`;
523
+ }
524
+ return this.#restoreRpc(id, runner);
525
+ }
526
+ async #restoreRpc(id, runner) {
527
+ try {
528
+ await withDeadline(runner.request("start_rpc"), this.#deps.deadlineMs ?? 30_000);
529
+ await this.#update(id, (current) => ({ ...current, mode: "rpc" }));
530
+ return "RPC writer restored; waiting for input";
531
+ }
532
+ catch (error) {
533
+ return `RPC restore failed: ${error instanceof Error ? error.message : String(error)}`;
534
+ }
535
+ }
536
+ async #releaseHost(id) {
537
+ this.#stopHostWatch(id);
538
+ const attachment = this.#hostAttachments.get(id);
539
+ if (attachment === undefined)
540
+ return;
541
+ const result = await attachment.cleanup();
542
+ if (hostCleanupFailed(result)) {
543
+ throw new Error(result.stderr || result.stdout || "Host cleanup failed");
544
+ }
545
+ const observed = await attachment.observe();
546
+ if (!observed.known || observed.alive) {
547
+ throw new Error(observed.known
548
+ ? `TUI writer still alive after cleanup: ${observed.detail}`
549
+ : `TUI writer state unknown after cleanup: ${observed.detail}`);
550
+ }
551
+ this.#hostAttachments.delete(id);
552
+ }
553
+ #stopHostWatch(id) {
554
+ const watch = this.#hostWatches.get(id);
555
+ if (watch === undefined)
556
+ return;
557
+ watch.abort();
558
+ this.#hostWatches.delete(id);
559
+ }
560
+ #watchHost(id) {
561
+ this.#stopHostWatch(id);
562
+ const attachment = this.#hostAttachments.get(id);
563
+ if (attachment === undefined)
564
+ return;
565
+ const stop = new AbortController();
566
+ this.#hostWatches.set(id, stop);
567
+ const intervalMs = this.#deps.watchIntervalMs ?? 1_000;
568
+ const tick = () => {
569
+ if (stop.signal.aborted)
570
+ return;
571
+ if (this.#hostAttachments.get(id) !== attachment)
572
+ return;
573
+ void attachment.observe().then((observed) => {
574
+ if (stop.signal.aborted)
575
+ return;
576
+ if (this.#hostAttachments.get(id) !== attachment)
577
+ return;
578
+ if (!observed.known || observed.alive)
579
+ return;
580
+ const origin = this.#tuiQuitExpected.has(id) ? "tui_exit" : "tui_crash";
581
+ this.#tuiQuitExpected.delete(id);
582
+ void this.detach(id, { origin, closeHost: true }).catch((error) => {
583
+ console.error(`pi-subagents: detach after TUI exit failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
584
+ });
585
+ });
586
+ };
587
+ const timer = setInterval(tick, intervalMs);
588
+ stop.signal.addEventListener("abort", () => {
589
+ clearInterval(timer);
590
+ }, { once: true });
591
+ tick();
592
+ }
593
+ async #runDetach(id, options) {
594
+ const operation = options.operation ?? "detach";
595
+ const record = await this.#deps.registry.get(id);
596
+ if (record === undefined)
597
+ return failure(operation, "Unknown child", id);
598
+ if (record.intent === "stopped" || record.state === "stopped") {
599
+ this.#stopHostWatch(id);
600
+ return failure(operation, "Stopped child is not restored", id, "stopped", [], false);
601
+ }
602
+ if (record.mode !== "tui") {
603
+ return publicChild(record, this.#runners.get(id)?.connected === true);
604
+ }
605
+ this.#stopHostWatch(id);
606
+ if (options.closeHost === false) {
607
+ this.#hostAttachments.delete(id);
608
+ }
609
+ else {
610
+ try {
611
+ await this.#releaseHost(id);
612
+ }
613
+ catch (error) {
614
+ this.#frozen.delete(id);
615
+ return failure(operation, error instanceof Error ? error.message : String(error), id, record.state, ["TUI writer was not confirmed gone"], false);
616
+ }
617
+ }
618
+ const runner = this.#runners.get(id);
619
+ if (runner === undefined || !runner.connected) {
620
+ this.#frozen.delete(id);
621
+ return failure(operation, "Child runtime is not live", id);
622
+ }
623
+ const restored = await this.#restoreRpc(id, runner);
624
+ this.#frozen.delete(id);
625
+ const next = await this.#deps.registry.get(id);
626
+ if (next === undefined)
627
+ return failure(operation, "Unknown child", id);
628
+ if (restored.startsWith("RPC restore failed")) {
629
+ return failure(operation, restored, id, next.state, [restored], false);
630
+ }
631
+ if (options.origin === "tui_crash") {
632
+ await this.#update(id, (current) => ({
633
+ ...current,
634
+ interrupted: "TUI exited abnormally; RPC restored waiting for input",
635
+ }));
636
+ await this.contactParent(id, "tui_crash", "Child TUI exited abnormally. RPC was restored and is waiting for input. Pending work was not replayed.");
637
+ }
638
+ return publicChild((await this.#deps.registry.get(id)) ?? next, true);
639
+ }
640
+ async contactParent(id, reason, message) {
641
+ const record = await this.#deps.registry.get(id);
642
+ if (record === undefined)
643
+ return failure("contact_parent", "Unknown child", id);
644
+ if (this.#deps.channel === undefined)
645
+ return failure("contact_parent", "Parent channel unavailable", id, record.state, [], true);
646
+ try {
647
+ await this.#deps.channel.deliver({
648
+ parentSessionId: record.parentSessionId,
649
+ childId: id,
650
+ agent: record.launchConfig.agent.displayName ?? record.launchConfig.agent.name,
651
+ task: record.initialTask,
652
+ status: record.state,
653
+ reason,
654
+ message,
655
+ });
656
+ return { delivered: true };
657
+ }
658
+ catch (error) {
659
+ return failure("contact_parent", error instanceof Error ? error.message : String(error), id, record.state, [], true);
660
+ }
661
+ }
662
+ closeLocalConnections() {
663
+ for (const id of [...this.#hostWatches.keys()])
664
+ this.#stopHostWatch(id);
665
+ for (const runner of this.#runners.values())
666
+ runner.close();
667
+ this.#runners.clear();
668
+ this.#deps.tokens?.clear();
669
+ this.#notify();
670
+ }
671
+ async #observe(id, event, runner) {
672
+ if (this.#runners.get(id) !== runner)
673
+ return;
674
+ const value = isRecord(event) ? event : undefined;
675
+ if (value === undefined)
676
+ return;
677
+ if (value.type === "subagent_report") {
678
+ const report = isRecord(value.report) ? value.report : undefined;
679
+ if (report?.type === "pi_subagent_report" &&
680
+ typeof report.parentSessionId === "string" &&
681
+ typeof report.childId === "string" &&
682
+ typeof report.runtimeIdentity === "string" &&
683
+ typeof report.reason === "string" &&
684
+ typeof report.message === "string") {
685
+ const record = await this.#deps.registry.get(id);
686
+ if (record === undefined ||
687
+ record.intent === "stopped" ||
688
+ report.parentSessionId !== record.parentSessionId ||
689
+ report.childId !== id ||
690
+ report.runtimeIdentity !== record.runtime?.runtimeIdentity)
691
+ return;
692
+ await this.contactParent(id, report.reason, report.message);
693
+ }
694
+ return;
695
+ }
696
+ if (value.type === "child_lifecycle") {
697
+ const record = await this.#deps.registry.get(id);
698
+ if (record === undefined ||
699
+ record.intent === "stopped" ||
700
+ typeof value.kind !== "string" ||
701
+ typeof value.parentSessionId !== "string" ||
702
+ typeof value.childId !== "string" ||
703
+ typeof value.runtimeIdentity !== "string" ||
704
+ value.parentSessionId !== record.parentSessionId ||
705
+ value.childId !== id ||
706
+ value.runtimeIdentity !== record.runtime?.runtimeIdentity)
707
+ return;
708
+ if (value.kind === "tui_quit") {
709
+ this.#tuiQuitExpected.add(id);
710
+ return;
711
+ }
712
+ if (value.kind === "user_interrupt") {
713
+ const message = typeof value.message === "string" && value.message !== ""
714
+ ? value.message
715
+ : "Task is unfinished and waiting for user intent.";
716
+ await this.#update(id, (current) => ({
717
+ ...current,
718
+ interrupted: message,
719
+ }));
720
+ await this.contactParent(id, "user_interrupt", message);
721
+ return;
722
+ }
723
+ if (value.kind === "left_session") {
724
+ await this.#runDetach(id, { origin: "session_switch", closeHost: false });
725
+ }
726
+ return;
727
+ }
728
+ const projector = this.#projectors.get(id);
729
+ if (projector === undefined)
730
+ return;
731
+ projector.applyEvent(event);
732
+ let confirmedInput;
733
+ if (value.type === "agent_end" || value.type === "runner_events_dropped") {
734
+ const response = await runner.request("get_entries").catch(() => undefined);
735
+ const entries = isRecord(response) && Array.isArray(response.entries) ? response.entries : [];
736
+ projector.rebuild(entries);
737
+ if (value.type === "agent_end") {
738
+ const record = await this.#deps.registry.get(id);
739
+ if (record?.unacknowledgedInput !== undefined &&
740
+ sessionContainsUserInput(entries, record.unacknowledgedInput))
741
+ confirmedInput = record.unacknowledgedInput;
742
+ }
743
+ else {
744
+ const piState = await runner.request("get_state").catch(() => undefined);
745
+ if (isIdlePiState(piState))
746
+ projector.applyEvent({ type: "agent_settled" });
747
+ else if (isRecord(piState) &&
748
+ (piState.isStreaming === true || piState.isCompacting === true)) {
749
+ projector.applyEvent({ type: "agent_start" });
750
+ }
751
+ }
752
+ }
753
+ if (value.type === "runner_exit") {
754
+ this.#runners.delete(id);
755
+ runner.close();
756
+ }
757
+ const current = await this.#deps.registry.get(id);
758
+ if (current === undefined || current.intent === "stopped")
759
+ return;
760
+ const snapshot = projector.snapshot();
761
+ await this.#update(id, (record) => {
762
+ const base = confirmedInput === undefined
763
+ ? record
764
+ : (({ unacknowledgedInput: _input, interrupted: _interrupted, ...rest }) => rest)(record);
765
+ return {
766
+ ...base,
767
+ state: snapshot.state,
768
+ ...(snapshot.summary === undefined ? {} : { latestSummary: snapshot.summary }),
769
+ usage: snapshot.usage,
770
+ ...(snapshot.interrupted === undefined ? {} : { interrupted: snapshot.interrupted }),
771
+ };
772
+ });
773
+ }
774
+ #attach(record, runner) {
775
+ this.#runners.get(record.subagentId)?.close();
776
+ this.#runners.set(record.subagentId, runner);
777
+ this.#projectors.set(record.subagentId, createStateProjector(record.state));
778
+ runner.onEvent((event) => {
779
+ void this.#mutate(record.subagentId, () => this.#observe(record.subagentId, event, runner)).catch((error) => {
780
+ console.error(`pi-subagents: failed to project event for ${record.subagentId}: ${error instanceof Error ? error.message : String(error)}`);
781
+ });
782
+ });
783
+ }
784
+ async #update(id, updater) {
785
+ const current = await this.#deps.registry.get(id);
786
+ if (current === undefined)
787
+ throw new Error("Child disappeared");
788
+ const next = await this.#deps.registry.update(id, current.revision, updater);
789
+ this.#notify();
790
+ return next;
791
+ }
792
+ #mutate(id, action) {
793
+ const previous = this.#chains.get(id) ?? Promise.resolve();
794
+ const next = previous.catch(() => undefined).then(action);
795
+ this.#chains.set(id, next);
796
+ void next.finally(() => {
797
+ if (this.#chains.get(id) === next)
798
+ this.#chains.delete(id);
799
+ });
800
+ return next;
801
+ }
802
+ }