@jwilger/pi-development-system 0.10.5 → 0.11.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 (36) hide show
  1. package/agents/architect.md +66 -0
  2. package/agents/coder.md +57 -0
  3. package/agents/researcher.md +60 -0
  4. package/agents/reviewer.md +53 -0
  5. package/agents/tasker.md +58 -0
  6. package/agents/writer.md +39 -0
  7. package/extensions/development-system.ts +2 -0
  8. package/package.json +5 -3
  9. package/src/core/test-runner.ts +1 -1
  10. package/src/gates/red-first-guard.ts +4 -1
  11. package/src/subagents/VENDORED.md +38 -0
  12. package/src/subagents/index.ts +544 -0
  13. package/src/subagents/orch/inherited-tools.ts +85 -0
  14. package/src/subagents/orch/mailbox.ts +262 -0
  15. package/src/subagents/orch/manager.ts +765 -0
  16. package/src/subagents/orch/paths.ts +74 -0
  17. package/src/subagents/orch/prompt.ts +25 -0
  18. package/src/subagents/orch/runtime.ts +784 -0
  19. package/src/subagents/orch/tools.ts +166 -0
  20. package/src/subagents/orch/transcript.ts +187 -0
  21. package/src/subagents/prefs/agent-import.ts +246 -0
  22. package/src/subagents/prefs/config.ts +1029 -0
  23. package/src/subagents/prefs/import-discovery.ts +644 -0
  24. package/src/subagents/prefs/import-instructions.ts +157 -0
  25. package/src/subagents/prefs/models.ts +78 -0
  26. package/src/subagents/prefs/settings.ts +371 -0
  27. package/src/subagents/types.ts +200 -0
  28. package/src/subagents/ui/agent-navigation-editor.ts +210 -0
  29. package/src/subagents/ui/dialog.ts +651 -0
  30. package/src/subagents/ui/import-picker.ts +232 -0
  31. package/src/subagents/ui/live-agent-view.ts +572 -0
  32. package/src/subagents/ui/model-picker.ts +452 -0
  33. package/src/subagents/ui/settings-ui.ts +329 -0
  34. package/src/subagents/ui/status-ui.ts +545 -0
  35. package/src/subagents/ui/thread-tree.ts +135 -0
  36. package/src/subagents/ui/ui.ts +1507 -0
@@ -0,0 +1,765 @@
1
+ // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type {
4
+ AgentDriver,
5
+ AgentType,
6
+ DriverOptions,
7
+ ManagerOptions,
8
+ SavedThread,
9
+ SavedThreadView,
10
+ ThreadService,
11
+ ThreadView,
12
+ TranscriptListener,
13
+ TranscriptObservation,
14
+ TranscriptSnapshot,
15
+ } from "../types.ts";
16
+ import { immutableTranscriptValue } from "./transcript.ts";
17
+ import { canonicalPath, inheritContext, isDescendant, parentPath } from "./paths.ts";
18
+ import { DEFAULT_MANAGER_SETTINGS, type ManagerSettings } from "../prefs/settings.ts";
19
+
20
+ interface Record {
21
+ view: ThreadView;
22
+ definition: AgentType;
23
+ inherited: AgentMessage[];
24
+ contextReady?: Promise<void>;
25
+ contextPending?: boolean;
26
+ startup: AbortController;
27
+ driver?: AgentDriver;
28
+ initializing?: Promise<AgentDriver>;
29
+ run?: Promise<void>;
30
+ started?: Promise<void>;
31
+ sessionLeafId?: string | null;
32
+ /** Last authoritative cumulative usage applied from the current driver. */
33
+ usageCursor?: { input: number; output: number };
34
+ /** In-progress message usage. Shown live, never persisted. */
35
+ liveInputTokens?: number;
36
+ liveOutputTokens?: number;
37
+ pauseRequested: boolean;
38
+ stopRequested: boolean;
39
+ observationRevision?: number;
40
+ driverGeneration?: number;
41
+ transcriptSource?: TranscriptSnapshot;
42
+ transcriptListeners?: Set<TranscriptListener>;
43
+ detachTranscript?: () => void;
44
+ observationError?: string;
45
+ }
46
+ const active = (view: Pick<ThreadView, "state">) =>
47
+ view.state === "starting" || view.state === "running";
48
+ const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error));
49
+
50
+ function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
51
+ if (signal.aborted) {
52
+ void work.catch(() => {});
53
+ return Promise.reject(signal.reason);
54
+ }
55
+ return new Promise((resolve, reject) => {
56
+ const cancel = () => reject(signal.reason ?? new Error("Startup cancelled"));
57
+ signal.addEventListener("abort", cancel, { once: true });
58
+ work.then(
59
+ (value) => {
60
+ signal.removeEventListener("abort", cancel);
61
+ resolve(value);
62
+ },
63
+ (error) => {
64
+ signal.removeEventListener("abort", cancel);
65
+ reject(error);
66
+ },
67
+ );
68
+ });
69
+ }
70
+
71
+ /** Runtime-neutral thread ownership, context inheritance, lifecycle and durable registry. */
72
+ export class ThreadManager {
73
+ private records = new Map<string, Record>();
74
+ private disposed = false;
75
+ private epoch = 0;
76
+ constructor(private options: ManagerOptions) {}
77
+
78
+ /** New limits affect future launches/resumes, never cancel existing work. */
79
+ setLimits(limits: ManagerSettings): void {
80
+ this.options = { ...this.options, ...limits };
81
+ }
82
+
83
+ list(): ThreadView[] {
84
+ return [...this.records.values()].map((record) => this.view(record));
85
+ }
86
+
87
+ /** Bind authority once; models and the user UI share this small composable service. */
88
+ scope(caller: string): ThreadService {
89
+ caller = canonicalPath(caller);
90
+ return {
91
+ list: () =>
92
+ this.list().filter(
93
+ (thread) =>
94
+ caller === "/root" || thread.path === caller || isDescendant(thread.path, caller),
95
+ ),
96
+ get: (path) => this.get(path, caller),
97
+ output: (path) => this.output(path, caller),
98
+ transcript: (path) => this.transcript(caller, path),
99
+ observeTranscript: (path, listener) => this.observeTranscript(caller, path, listener),
100
+ spawn: (args, signal) => this.spawn(caller, args, signal),
101
+ steer: (path, message) => this.steer(caller, path, message),
102
+ wait: (path, timeoutMs, signal) => this.wait(caller, path, timeoutMs, signal),
103
+ update: (message) => this.update(caller, message),
104
+ pause: (reason) => this.pause(caller, reason),
105
+ stop: (path) => this.stop(caller, path),
106
+ };
107
+ }
108
+ get(path: string, caller = "/root"): ThreadView {
109
+ path = canonicalPath(path, caller);
110
+ this.assertAccess(caller, path, true);
111
+ if (path === "/root")
112
+ return {
113
+ path,
114
+ parent: null,
115
+ owner: path,
116
+ type: "main",
117
+ state: "running",
118
+ task: "Main Pi session",
119
+ status: "main",
120
+ createdAt: 0,
121
+ updatedAt: Date.now(),
122
+ };
123
+ return this.view(this.record(path));
124
+ }
125
+ output(path: string, caller = "/root"): string {
126
+ const view = this.get(path, caller);
127
+ if (view.state === "paused") return ""; // No answer handback from a paused turn.
128
+ return view.output ?? "";
129
+ }
130
+ saved(): SavedThread[] {
131
+ return [...this.records.values()].map((record) => {
132
+ const current = this.view(record);
133
+ // Explicit durable schema: adding UI fields must not silently change session storage.
134
+ const view: SavedThreadView = {
135
+ path: current.path,
136
+ parent: current.parent,
137
+ owner: current.owner,
138
+ type: current.type,
139
+ color: current.color,
140
+ icon: current.icon,
141
+ state: current.state,
142
+ task: current.task,
143
+ status: current.status,
144
+ output: current.output,
145
+ error: current.error,
146
+ createdAt: current.createdAt,
147
+ sessionFile: current.sessionFile,
148
+ // Settled totals only. startedAt and partial usage must not churn persistence.
149
+ elapsedMs: record.view.elapsedMs ?? 0,
150
+ inputTokens: record.view.inputTokens ?? 0,
151
+ outputTokens: record.view.outputTokens ?? 0,
152
+ };
153
+ const sessionLeafId = this.sessionLeafId(record);
154
+ if (sessionLeafId !== undefined) view.sessionLeafId = sessionLeafId;
155
+ if (active(view)) view.status = view.state === "running" ? "Working" : "Starting";
156
+ return {
157
+ view,
158
+ definition: structuredClone(record.definition),
159
+ ...(!view.sessionFile && !record.contextPending
160
+ ? { inherited: structuredClone(record.inherited) }
161
+ : {}),
162
+ };
163
+ });
164
+ }
165
+ restore(saved: SavedThread[]): void {
166
+ if (this.records.size) throw new Error("Restore requires an empty thread registry");
167
+ const restored = new Map<string, Record>();
168
+ for (const item of saved) {
169
+ const savedView = structuredClone(item.view);
170
+ const path = canonicalPath(savedView.path);
171
+ if (
172
+ path === "/root" ||
173
+ restored.has(path) ||
174
+ !["starting", "running", "paused", "completed", "failed", "stopped"].includes(
175
+ savedView.state,
176
+ )
177
+ )
178
+ throw new Error("Invalid saved thread registry");
179
+ const { sessionLeafId, ...storedView } = savedView;
180
+ const view: ThreadView = {
181
+ ...storedView,
182
+ icon: storedView.icon ?? item.definition.icon,
183
+ updatedAt: Date.now(),
184
+ elapsedMs: storedView.elapsedMs ?? 0,
185
+ inputTokens: storedView.inputTokens ?? 0,
186
+ outputTokens: storedView.outputTokens ?? 0,
187
+ };
188
+ delete view.startedAt;
189
+ if (view.parent !== parentPath(path)) throw new Error(`Invalid saved parent for ${path}`);
190
+ if (active(view)) {
191
+ view.state = "paused";
192
+ view.status = "Interrupted by reload; send input to resume";
193
+ delete view.output;
194
+ }
195
+ restored.set(path, {
196
+ view,
197
+ definition: structuredClone(item.definition),
198
+ inherited: structuredClone(item.inherited ?? []),
199
+ // A reservation saved before its lexical parent opened has no snapshot yet.
200
+ contextPending: !view.sessionFile && item.inherited === undefined && view.parent !== null,
201
+ startup: new AbortController(),
202
+ ...(Object.prototype.hasOwnProperty.call(savedView, "sessionLeafId")
203
+ ? { sessionLeafId }
204
+ : {}),
205
+ pauseRequested: false,
206
+ stopRequested: false,
207
+ });
208
+ }
209
+ for (const record of restored.values()) {
210
+ if (record.view.parent && record.view.parent !== "/root" && !restored.has(record.view.parent))
211
+ throw new Error(`Missing saved parent ${record.view.parent}`);
212
+ }
213
+ this.records = restored;
214
+ }
215
+
216
+ async spawn(
217
+ caller: string,
218
+ args: { path: string; type: string; task: string; wait?: boolean },
219
+ signal?: AbortSignal,
220
+ ): Promise<ThreadView> {
221
+ this.assertLive();
222
+ caller = canonicalPath(caller);
223
+ if (
224
+ caller !== "/root" &&
225
+ (!active(this.record(caller).view) || this.record(caller).stopRequested)
226
+ )
227
+ throw new Error("Only a working agent may spawn children");
228
+ signal?.throwIfAborted();
229
+ const path = canonicalPath(args.path, caller);
230
+ this.assertAccess(caller, path);
231
+ if (path === "/root" || this.records.has(path))
232
+ throw new Error(`Thread ${path} already exists; use agent_steer to resume it`);
233
+ const parent = parentPath(path);
234
+ if (parent && parent !== "/root") this.record(parent);
235
+ if (caller !== "/root" && parent !== caller)
236
+ throw new Error("An agent may spawn only its immediate children");
237
+ if (!args.task.trim()) throw new Error("Task must not be empty");
238
+ const segments = path.slice(1).split("/");
239
+ // Independent roots still represent agents launched by the main L1 conversation.
240
+ const level = segments.length + (segments[0] === "root" ? 0 : 1);
241
+ const maxLevels = this.options.maxLevels ?? DEFAULT_MANAGER_SETTINGS.maxLevels;
242
+ if (
243
+ this.options.maxLevels === undefined && this.options.maxDepth !== undefined
244
+ ? segments.length - 1 > this.options.maxDepth
245
+ : level > maxLevels
246
+ )
247
+ throw new Error("Agent depth limit reached");
248
+ if (this.records.size >= (this.options.maxThreads ?? DEFAULT_MANAGER_SETTINGS.maxThreads))
249
+ throw new Error("Total thread limit reached");
250
+ this.assertCapacity();
251
+ const type = structuredClone(this.options.getType(args.type));
252
+ const parentRecord = parent && parent !== "/root" ? this.record(parent) : undefined;
253
+ this.assertStartableAncestors(path);
254
+ // Reservation is synchronous: subtree cancellation sees children even during lazy parent reopen.
255
+ const inherited =
256
+ parent === "/root"
257
+ ? inheritContext(this.options.rootSnapshot())
258
+ : parentRecord?.driver
259
+ ? inheritContext(parentRecord.driver.snapshot())
260
+ : [];
261
+ const now = Date.now();
262
+ const record: Record = {
263
+ view: {
264
+ path,
265
+ parent,
266
+ owner: parent ?? caller,
267
+ type: type.name,
268
+ color: type.color,
269
+ icon: type.icon,
270
+ state: "starting",
271
+ task: args.task,
272
+ status: "Starting",
273
+ createdAt: now,
274
+ updatedAt: now,
275
+ elapsedMs: 0,
276
+ inputTokens: 0,
277
+ outputTokens: 0,
278
+ },
279
+ definition: type,
280
+ inherited,
281
+ contextPending: !!(parentRecord && !parentRecord.driver),
282
+ startup: new AbortController(),
283
+ pauseRequested: false,
284
+ stopRequested: false,
285
+ };
286
+ this.records.set(path, record);
287
+ if (record.contextPending) this.prepareInheritedContext(record);
288
+ this.start(record, args.task);
289
+ return args.wait === false ? this.view(record) : this.wait(caller, path, undefined, signal);
290
+ }
291
+
292
+ async steer(caller: string, path: string, message: string): Promise<ThreadView> {
293
+ this.assertLive();
294
+ caller = canonicalPath(caller);
295
+ path = canonicalPath(path, caller);
296
+ this.assertAccess(caller, path);
297
+ if (!message.trim()) throw new Error("Steering message must not be empty");
298
+ const record = this.record(path);
299
+ for (;;) {
300
+ this.assertLive();
301
+ this.assertCallerMaySteer(caller);
302
+ if (!active(record.view)) this.assertStartableAncestors(path);
303
+ // A detached spawn reserves immediately, but its original task must reach prompt() before steering.
304
+ if (record.view.state === "starting") {
305
+ if (!record.started) throw new Error("Thread start invariant violated");
306
+ await record.started;
307
+ continue;
308
+ }
309
+ if (record.stopRequested && active(record.view))
310
+ throw new Error("Thread is stopping; wait for stopped state before resuming");
311
+ if (record.view.state === "running") {
312
+ await record.driver!.steer(message);
313
+ this.touch(record);
314
+ return this.view(record);
315
+ }
316
+ this.assertCapacity();
317
+ this.start(record, message);
318
+ return this.view(record);
319
+ }
320
+ }
321
+ async wait(
322
+ caller: string,
323
+ path: string,
324
+ timeoutMs?: number,
325
+ signal?: AbortSignal,
326
+ ): Promise<ThreadView> {
327
+ path = canonicalPath(path, caller);
328
+ this.assertAccess(caller, path);
329
+ const record = this.record(path);
330
+ if (!active(record.view)) return this.view(record);
331
+ if (
332
+ timeoutMs !== undefined &&
333
+ (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 3_600_000)
334
+ )
335
+ throw new Error("timeoutMs must be between 0 and 3600000");
336
+ if (signal?.aborted) throw new Error("Waiting cancelled; thread remains running");
337
+ await new Promise<void>((resolve, reject) => {
338
+ let timer: ReturnType<typeof setTimeout> | undefined;
339
+ const finish = (error?: Error) => {
340
+ if (timer) clearTimeout(timer);
341
+ signal?.removeEventListener("abort", cancel);
342
+ error ? reject(error) : resolve();
343
+ };
344
+ const cancel = () => finish(new Error("Waiting cancelled; thread remains running"));
345
+ signal?.addEventListener("abort", cancel, { once: true });
346
+ if (timeoutMs !== undefined) timer = setTimeout(() => finish(), timeoutMs);
347
+ record.run!.then(
348
+ () => finish(),
349
+ (error) => finish(new Error(errorText(error))),
350
+ );
351
+ });
352
+ return this.view(record);
353
+ }
354
+ update(caller: string, message: string): ThreadView {
355
+ const record = this.record(caller);
356
+ if (!active(record.view)) throw new Error("Only a working child may report progress");
357
+ if (!message.trim() || message.length > 8000)
358
+ throw new Error("Progress message must contain 1–8000 characters");
359
+ record.view.status = message;
360
+ this.touch(record);
361
+ this.options.onEvent?.({
362
+ kind: "update",
363
+ thread: this.view(record),
364
+ message,
365
+ recipient: record.view.parent ?? record.view.owner,
366
+ });
367
+ return this.view(record);
368
+ }
369
+ pause(caller: string, reason: string): ThreadView {
370
+ const record = this.record(caller);
371
+ if (!active(record.view)) throw new Error("Only a working child may pause itself");
372
+ record.pauseRequested = true;
373
+ record.view.status = reason.trim() || "Awaiting further input";
374
+ this.touch(record);
375
+ // Remains running until the SDK settles. No interim final answer leaks through wait().
376
+ return this.view(record);
377
+ }
378
+ async stop(caller: string, path: string): Promise<ThreadView> {
379
+ path = canonicalPath(path, caller);
380
+ this.assertAccess(caller, path);
381
+ const record = this.record(path);
382
+ const targets = [...this.records.values()].filter(
383
+ (item) => item === record || (isDescendant(item.view.path, path) && active(item.view)),
384
+ );
385
+ for (const item of targets) {
386
+ item.stopRequested = true;
387
+ item.startup.abort();
388
+ }
389
+ await Promise.all(
390
+ targets.map(async (item) => {
391
+ if (item.driver) await item.driver.abort();
392
+ await item.run;
393
+ item.view.state = "stopped";
394
+ item.view.status = "Stopped; session retained";
395
+ delete item.view.output;
396
+ this.touch(item);
397
+ }),
398
+ );
399
+ return this.view(record);
400
+ }
401
+ async transcript(caller: string, path: string): Promise<string> {
402
+ path = canonicalPath(path, caller);
403
+ this.assertAccess(caller, path, true);
404
+ const driver = await this.ensureDriver(this.record(path), true);
405
+ return JSON.stringify(driver.snapshot(), null, 2);
406
+ }
407
+ async observeTranscript(
408
+ caller: string,
409
+ path: string,
410
+ listener: TranscriptListener,
411
+ ): Promise<TranscriptObservation> {
412
+ this.assertLive();
413
+ path = canonicalPath(path, caller);
414
+ this.assertAccess(caller, path, true);
415
+ if (path === "/root") throw new Error("Main is not a child transcript observation target");
416
+ const record = this.record(path);
417
+ // Idle/restored history may be lazily loaded, but attachment never prompts/resumes a turn.
418
+ if (!active(record.view)) await this.ensureDriver(record, true);
419
+ this.assertLive();
420
+ const listeners = (record.transcriptListeners ??= new Set());
421
+ const registered: TranscriptListener = (snapshot) => listener(snapshot);
422
+ listeners.add(registered);
423
+ if (record.driver) this.attachTranscript(record, record.driver);
424
+ else {
425
+ // A starting child gets a placeholder immediately; existing startup supplies the live source.
426
+ void this.ensureDriver(record, true).catch((error) => {
427
+ if (this.disposed || !listeners.has(registered)) return;
428
+ record.observationError = errorText(error);
429
+ this.publishTranscript(record);
430
+ });
431
+ }
432
+ return {
433
+ snapshot: this.transcriptSnapshot(record),
434
+ unsubscribe: () => {
435
+ listeners.delete(registered);
436
+ if (!listeners.size) {
437
+ record.detachTranscript?.();
438
+ record.detachTranscript = undefined;
439
+ record.transcriptSource = undefined;
440
+ }
441
+ },
442
+ };
443
+ }
444
+ async deliver(path: string, content: string): Promise<void> {
445
+ // Restored parents may be idle and unopened; reports still belong in their retained transcript.
446
+ const driver = await this.ensureDriver(this.record(path), true);
447
+ await driver.sendUpdate(content);
448
+ }
449
+ async shutdown(): Promise<void> {
450
+ this.disposed = true;
451
+ this.epoch++;
452
+ const records = [...this.records.values()];
453
+ for (const record of records) {
454
+ record.detachTranscript?.();
455
+ record.detachTranscript = undefined;
456
+ record.transcriptSource = undefined;
457
+ record.transcriptListeners?.clear();
458
+ record.stopRequested = true;
459
+ record.startup.abort();
460
+ }
461
+ await Promise.all(
462
+ records.map(async (record) => {
463
+ await record.driver?.abort();
464
+ await record.initializing?.catch(() => {});
465
+ await record.run;
466
+ record.driver?.dispose();
467
+ }),
468
+ );
469
+ }
470
+
471
+ private start(record: Record, message: string): void {
472
+ this.freezeElapsed(record);
473
+ record.pauseRequested = false;
474
+ record.stopRequested = false;
475
+ record.startup = new AbortController();
476
+ record.view.state = "starting";
477
+ record.view.status = "Starting";
478
+ record.view.startedAt = Date.now();
479
+ delete record.view.output;
480
+ delete record.view.error;
481
+ const epoch = this.epoch;
482
+ let markStarted!: () => void;
483
+ record.started = new Promise((resolve) => {
484
+ markStarted = resolve;
485
+ });
486
+ record.run = Promise.resolve().then(async () => {
487
+ try {
488
+ const contextReady = this.prepareInheritedContext(record);
489
+ if (contextReady) await abortable(contextReady, record.startup.signal);
490
+ record.startup.signal.throwIfAborted();
491
+ const driver = await this.ensureDriver(record);
492
+ if (record.stopRequested || this.disposed) {
493
+ record.view.state = "stopped";
494
+ return;
495
+ }
496
+ const task = driver.prompt(message);
497
+ record.view.state = "running";
498
+ record.view.status = "Working";
499
+ this.touch(record);
500
+ markStarted();
501
+ await task;
502
+ record.view.state = record.stopRequested
503
+ ? "stopped"
504
+ : record.pauseRequested
505
+ ? "paused"
506
+ : "completed";
507
+ if (record.view.state === "completed") {
508
+ record.view.output = driver.output();
509
+ record.view.status = "Completed; session retained";
510
+ } else if (record.view.state === "stopped")
511
+ record.view.status = "Stopped; session retained";
512
+ } catch (error) {
513
+ record.view.state = record.stopRequested ? "stopped" : "failed";
514
+ record.view.error = errorText(error);
515
+ record.view.status = record.stopRequested ? "Stopped; session retained" : record.view.error;
516
+ } finally {
517
+ this.freezeElapsed(record);
518
+ record.liveInputTokens = 0;
519
+ record.liveOutputTokens = 0;
520
+ markStarted(); // Failed/cancelled initialization must also release callers waiting to steer.
521
+ this.touch(record);
522
+ if (!this.disposed && epoch === this.epoch)
523
+ this.options.onEvent?.({
524
+ kind: "settled",
525
+ thread: this.view(record),
526
+ recipient: record.view.parent ?? record.view.owner,
527
+ });
528
+ }
529
+ });
530
+ this.touch(record);
531
+ }
532
+ private ensureDriver(record: Record, readOnly = false): Promise<AgentDriver> {
533
+ this.assertLive();
534
+ if (record.driver) return Promise.resolve(record.driver);
535
+ if (!record.initializing) {
536
+ const signal =
537
+ readOnly && !active(record.view) ? new AbortController().signal : record.startup.signal;
538
+ const creation = Promise.resolve()
539
+ .then(async () => {
540
+ // Inspection and delivery can initialize a reserved child before start().
541
+ const contextReady = this.prepareInheritedContext(record, readOnly);
542
+ if (contextReady) await abortable(contextReady, signal);
543
+ if (!record.view.sessionFile && record.view.parent && record.view.parent !== "/root") {
544
+ const parentRecord = this.record(record.view.parent);
545
+ if (!parentRecord.driver) await this.ensureDriver(parentRecord, readOnly);
546
+ }
547
+ const parentSessionFile = this.lexicalParentSessionFile(record);
548
+ const options: DriverOptions = {
549
+ path: record.view.path,
550
+ type: record.definition,
551
+ inherited: record.inherited,
552
+ tools: this.options.toolsFor(record.view.path),
553
+ parentPath: record.view.parent,
554
+ sessionFile: record.view.sessionFile,
555
+ signal,
556
+ shouldPause: () => record.pauseRequested,
557
+ onEvent: (event) => {
558
+ if (event.kind === "usage") {
559
+ this.applyUsage(record, event);
560
+ return;
561
+ }
562
+ if (event.kind === "checkpoint") {
563
+ this.touch(record);
564
+ return;
565
+ }
566
+ if (active(record.view) && !record.pauseRequested) {
567
+ record.view.status = event.text;
568
+ this.touch(record);
569
+ }
570
+ },
571
+ ...(parentSessionFile ? { parentSessionFile } : {}),
572
+ ...(record.sessionLeafId !== undefined ? { sessionLeafId: record.sessionLeafId } : {}),
573
+ };
574
+ return this.options.createDriver(options);
575
+ })
576
+ .then(async (driver) => {
577
+ if (this.disposed || signal.aborted) {
578
+ await driver.abort();
579
+ driver.dispose();
580
+ throw new Error("Driver startup cancelled");
581
+ }
582
+ record.driver = driver;
583
+ record.driverGeneration = (record.driverGeneration ?? 0) + 1;
584
+ record.observationError = undefined;
585
+ if (record.transcriptListeners?.size) this.attachTranscript(record, driver);
586
+ record.usageCursor = { input: 0, output: 0 };
587
+ record.liveInputTokens = 0;
588
+ record.liveOutputTokens = 0;
589
+ record.view.sessionFile = driver.sessionFile;
590
+ if (driver.sessionLeafId !== undefined) record.sessionLeafId = driver.sessionLeafId;
591
+ this.touch(record);
592
+ return driver;
593
+ });
594
+ let initializing!: Promise<AgentDriver>;
595
+ initializing = abortable(creation, signal).catch((error) => {
596
+ if (record.initializing === initializing) record.initializing = undefined;
597
+ throw error;
598
+ });
599
+ record.initializing = initializing;
600
+ }
601
+ return record.initializing;
602
+ }
603
+ private attachTranscript(record: Record, driver: AgentDriver): void {
604
+ if (record.detachTranscript || !record.transcriptListeners?.size) return;
605
+ const observation = driver.observeTranscript?.((snapshot) => {
606
+ if (this.disposed || record.driver !== driver || !record.transcriptListeners?.size) return;
607
+ record.transcriptSource = snapshot;
608
+ this.publishTranscript(record);
609
+ });
610
+ if (observation) {
611
+ record.transcriptSource = observation.snapshot;
612
+ record.detachTranscript = observation.unsubscribe;
613
+ }
614
+ }
615
+ private transcriptSnapshot(record: Record): TranscriptSnapshot {
616
+ const source = record.transcriptSource;
617
+ return Object.freeze({
618
+ revision: record.observationRevision ?? 0,
619
+ generation: record.driverGeneration ?? 0,
620
+ messages:
621
+ source?.messages ?? immutableTranscriptValue(record.driver?.snapshot() ?? record.inherited),
622
+ assistant: source?.assistant ?? null,
623
+ tools: source?.tools ?? Object.freeze([]),
624
+ inheritedCount: source?.inheritedCount ?? record.inherited.length,
625
+ thread: immutableTranscriptValue(this.view(record)),
626
+ ...(record.observationError || source?.error
627
+ ? { error: record.observationError ?? source?.error }
628
+ : {}),
629
+ });
630
+ }
631
+ private publishTranscript(record: Record): void {
632
+ if (this.disposed || !record.transcriptListeners?.size) return;
633
+ record.observationRevision = (record.observationRevision ?? 0) + 1;
634
+ const snapshot = this.transcriptSnapshot(record);
635
+ for (const listener of record.transcriptListeners) {
636
+ try {
637
+ listener(snapshot);
638
+ } catch {
639
+ /* UI failure must not affect execution. */
640
+ }
641
+ }
642
+ }
643
+ private touch(record: Record): void {
644
+ record.view.updatedAt = Date.now();
645
+ this.publishTranscript(record);
646
+ if (!this.disposed) this.options.onEvent?.({ kind: "change", thread: this.view(record) });
647
+ }
648
+ /** Fold driver-cumulative usage into persisted totals. Partials only refresh the live view. */
649
+ private applyUsage(
650
+ record: Record,
651
+ event: { inputTokens: number; outputTokens: number; partial?: boolean },
652
+ ): void {
653
+ const cursor = record.usageCursor ?? { input: 0, output: 0 };
654
+ if (event.partial) {
655
+ const input = Math.max(0, event.inputTokens - cursor.input);
656
+ const output = Math.max(0, event.outputTokens - cursor.output);
657
+ if (input === record.liveInputTokens && output === record.liveOutputTokens) return;
658
+ record.liveInputTokens = input;
659
+ record.liveOutputTokens = output;
660
+ if (!this.disposed) this.options.onEvent?.({ kind: "metrics", thread: this.view(record) });
661
+ return;
662
+ }
663
+ record.view.inputTokens =
664
+ (record.view.inputTokens ?? 0) + Math.max(0, event.inputTokens - cursor.input);
665
+ record.view.outputTokens =
666
+ (record.view.outputTokens ?? 0) + Math.max(0, event.outputTokens - cursor.output);
667
+ record.usageCursor = { input: event.inputTokens, output: event.outputTokens };
668
+ record.liveInputTokens = 0;
669
+ record.liveOutputTokens = 0;
670
+ this.touch(record);
671
+ }
672
+ private freezeElapsed(record: Record): void {
673
+ const startedAt = record.view.startedAt;
674
+ if (startedAt === undefined) return;
675
+ record.view.elapsedMs = (record.view.elapsedMs ?? 0) + Math.max(0, Date.now() - startedAt);
676
+ delete record.view.startedAt;
677
+ }
678
+ private prepareInheritedContext(record: Record, readOnly = false): Promise<void> | undefined {
679
+ if (!record.contextPending) return record.contextReady;
680
+ if (record.contextReady) return record.contextReady;
681
+ if (!record.view.parent || record.view.parent === "/root") {
682
+ record.inherited = inheritContext(this.options.rootSnapshot());
683
+ record.contextPending = false;
684
+ return undefined;
685
+ }
686
+ const parentRecord = this.record(record.view.parent);
687
+ let ready!: Promise<void>;
688
+ ready = this.ensureDriver(parentRecord, readOnly)
689
+ .then((driver) => {
690
+ record.inherited = inheritContext(driver.snapshot());
691
+ record.contextPending = false;
692
+ if (record.contextReady === ready) record.contextReady = undefined;
693
+ this.touch(record);
694
+ })
695
+ .catch((error) => {
696
+ if (record.contextReady === ready) record.contextReady = undefined;
697
+ throw error;
698
+ });
699
+ record.contextReady = ready;
700
+ void ready.catch(() => {});
701
+ return ready;
702
+ }
703
+ private sessionLeafId(record: Record): string | null | undefined {
704
+ const leaf = record.driver?.sessionLeafId;
705
+ return leaf !== undefined ? leaf : record.sessionLeafId;
706
+ }
707
+ /** Live persisted file, else the saved file of a restored parent that has not been opened. */
708
+ private lexicalParentSessionFile(record: Record): string | undefined {
709
+ const parent = record.view.parent;
710
+ if (!parent || parent === "/root") return undefined;
711
+ const parentRecord = this.records.get(parent);
712
+ if (!parentRecord) return undefined;
713
+ return parentRecord.driver?.sessionFile ?? parentRecord.view.sessionFile;
714
+ }
715
+ private view(record: Record): ThreadView {
716
+ const view = structuredClone({
717
+ ...record.view,
718
+ sessionFile: record.driver ? record.driver.sessionFile : record.view.sessionFile,
719
+ });
720
+ if (record.liveInputTokens) view.inputTokens = (view.inputTokens ?? 0) + record.liveInputTokens;
721
+ if (record.liveOutputTokens)
722
+ view.outputTokens = (view.outputTokens ?? 0) + record.liveOutputTokens;
723
+ return view;
724
+ }
725
+ private record(path: string): Record {
726
+ const record = this.records.get(path);
727
+ if (!record) throw new Error(`Unknown thread ${path}`);
728
+ return record;
729
+ }
730
+ private assertLive(): void {
731
+ if (this.disposed) throw new Error("Thread manager has shut down");
732
+ }
733
+ private assertCallerMaySteer(caller: string): void {
734
+ if (caller !== "/root" && this.record(caller).stopRequested)
735
+ throw new Error("Stopping agents may not steer descendants");
736
+ }
737
+ private assertStartableAncestors(path: string): void {
738
+ for (
739
+ let ancestor = parentPath(path);
740
+ ancestor && ancestor !== "/root";
741
+ ancestor = parentPath(ancestor)
742
+ ) {
743
+ const record = this.record(ancestor);
744
+ if (record.stopRequested || record.view.state === "stopped") {
745
+ throw new Error(`Cannot continue under stopped ancestor ${ancestor}; resume it first`);
746
+ }
747
+ }
748
+ }
749
+ private assertCapacity(): void {
750
+ if (
751
+ [...this.records.values()].filter((record) => active(record.view)).length >=
752
+ (this.options.maxConcurrent ?? DEFAULT_MANAGER_SETTINGS.maxConcurrent)
753
+ )
754
+ throw new Error("Concurrent thread limit reached; wait for a thread to settle");
755
+ }
756
+ private assertAccess(caller: string, path: string, self = false): void {
757
+ caller = canonicalPath(caller);
758
+ if (caller === "/root" && path !== "/root") return;
759
+ if (self && path === caller) return;
760
+ if (!isDescendant(path, caller))
761
+ throw new Error(
762
+ `${caller} may address only its descendants (not siblings, unrelated trees or ancestors)`,
763
+ );
764
+ }
765
+ }