@jwilger/pi-development-system 0.48.0 → 0.50.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.
@@ -9,7 +9,6 @@ import { applyPromptSection } from "../src/context/system-prompt.ts";
9
9
  import { DEFAULT_VERIFIER_MAX, registerTurnVerifier } from "../src/context/turn-verifier.ts";
10
10
  import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
11
11
  import { defaultMatrix } from "../src/core/models.ts";
12
- import { detectProfiles } from "../src/core/profile.ts";
13
12
  import { createApprovalStore } from "../src/gates/approvals.ts";
14
13
  import { registerCommitGuard } from "../src/gates/commit-guard.ts";
15
14
  import { registerGitGuard } from "../src/gates/git-guard.ts";
@@ -24,6 +23,7 @@ import { createReviewRecordTool, createReviewStartTool } from "../src/review/rev
24
23
  import { registerCiCommand } from "../src/state/ci-command.ts";
25
24
  import { loadConfig } from "../src/state/config.ts";
26
25
  import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
26
+ import { detectProfiles } from "../src/state/profile-detect.ts";
27
27
  import { createRouteTaskTool } from "../src/state/route-task-tool.ts";
28
28
  import { createSessionState } from "../src/state/session-state.ts";
29
29
  import { registerTestEvidence } from "../src/state/test-evidence.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -1,12 +1,11 @@
1
- import { access } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { isParseError, type ParseError, type Profile, parseError } from "./types.ts";
1
+ import { type ParseError, type Profile, parseError } from "./types.ts";
4
2
 
5
3
  export type { Profile };
6
4
 
7
5
  const PROFILES: readonly Profile[] = ["rust", "typescript"];
8
6
 
9
- const MARKERS: ReadonlyArray<{ profile: Profile; files: readonly string[] }> = [
7
+ /** Files whose presence marks a repo as using a language profile. */
8
+ export const MARKERS: ReadonlyArray<{ profile: Profile; files: readonly string[] }> = [
10
9
  { profile: "rust", files: ["Cargo.toml"] },
11
10
  { profile: "typescript", files: ["package.json", "tsconfig.json"] },
12
11
  ];
@@ -24,26 +23,3 @@ export function parseProfiles(names: readonly string[]): Profile[] | ParseError
24
23
  }
25
24
  return out;
26
25
  }
27
-
28
- const exists = (path: string): Promise<boolean> =>
29
- access(path).then(
30
- () => true,
31
- () => false,
32
- );
33
-
34
- /** Profiles that apply to `repoRoot`; a non-empty `override` (valid names only) replaces detection. */
35
- export async function detectProfiles(
36
- repoRoot: string,
37
- override: readonly string[] = [],
38
- ): Promise<Profile[]> {
39
- if (override.length > 0) {
40
- const parsed = parseProfiles(override);
41
- if (!isParseError(parsed)) return parsed;
42
- }
43
- const found: Profile[] = [];
44
- for (const { profile, files } of MARKERS) {
45
- const hits = await Promise.all(files.map((f) => exists(join(repoRoot, f))));
46
- if (hits.some(Boolean)) found.push(profile);
47
- }
48
- return found;
49
- }
@@ -72,8 +72,9 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
72
72
 
73
73
  /** Exit code of a bash tool result: structured `exit_code`, else pi's status line, else error/not. */
74
74
  export function exitCodeOf(result: ResultLike): number {
75
- // `npm test` in a package without a test script exits 1 but ran no tests, so it is not a RED.
76
- if (NO_TEST_SCRIPT.test(result.text)) return 0;
75
+ // `npm test` in a package without a test script exits 1 but ran no tests, so it is not a RED;
76
+ // but with --workspaces one package may lack the script while another fails for real.
77
+ if (NO_TEST_SCRIPT.test(result.text) && !FAILURE_MARKERS.test(result.text)) return 0;
77
78
  const reported = reportedExit(result);
78
79
  if (reported !== 0) return reported;
79
80
  // `npm test | tail` exits 0 whatever the tests did, even in the structured status; trust the output.
@@ -0,0 +1,27 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { MARKERS, parseProfiles } from "../core/profile.ts";
4
+ import { isParseError, type Profile } from "../core/types.ts";
5
+
6
+ const exists = (path: string): Promise<boolean> =>
7
+ access(path).then(
8
+ () => true,
9
+ () => false,
10
+ );
11
+
12
+ /** Profiles that apply to `repoRoot`; a non-empty `override` (valid names only) replaces detection. */
13
+ export async function detectProfiles(
14
+ repoRoot: string,
15
+ override: readonly string[] = [],
16
+ ): Promise<Profile[]> {
17
+ if (override.length > 0) {
18
+ const parsed = parseProfiles(override);
19
+ if (!isParseError(parsed)) return parsed;
20
+ }
21
+ const found: Profile[] = [];
22
+ for (const { profile, files } of MARKERS) {
23
+ const hits = await Promise.all(files.map((f) => exists(join(repoRoot, f))));
24
+ if (hits.some(Boolean)) found.push(profile);
25
+ }
26
+ return found;
27
+ }
@@ -1,5 +1,7 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { parseSpawnOverrides } from "../../core/spawn-overrides.ts";
3
+ import { isParseError } from "../../core/types.ts";
4
+ import { DEFAULT_MANAGER_SETTINGS, type ManagerSettings } from "../prefs/settings.ts";
3
5
  import type {
4
6
  AgentDriver,
5
7
  AgentType,
@@ -13,21 +15,18 @@ import type {
13
15
  TranscriptObservation,
14
16
  TranscriptSnapshot,
15
17
  } from "../types.ts";
16
- import { immutableTranscriptValue } from "./transcript.ts";
17
- import { parseSpawnOverrides } from "../../core/spawn-overrides.ts";
18
- import { isParseError } from "../../core/types.ts";
19
18
  import { canonicalPath, inheritContext, isDescendant, parentPath } from "./paths.ts";
20
- import { DEFAULT_MANAGER_SETTINGS, type ManagerSettings } from "../prefs/settings.ts";
19
+ import { immutableTranscriptValue } from "./transcript.ts";
21
20
 
22
21
  interface Record {
23
22
  view: ThreadView;
24
23
  definition: AgentType;
25
24
  inherited: AgentMessage[];
26
- contextReady?: Promise<void>;
25
+ contextReady?: Promise<void> | undefined;
27
26
  contextPending?: boolean;
28
27
  startup: AbortController;
29
28
  driver?: AgentDriver;
30
- initializing?: Promise<AgentDriver>;
29
+ initializing?: Promise<AgentDriver> | undefined;
31
30
  run?: Promise<void>;
32
31
  started?: Promise<void>;
33
32
  sessionLeafId?: string | null;
@@ -40,18 +39,26 @@ interface Record {
40
39
  stopRequested: boolean;
41
40
  observationRevision?: number;
42
41
  driverGeneration?: number;
43
- transcriptSource?: TranscriptSnapshot;
42
+ transcriptSource?: TranscriptSnapshot | undefined;
44
43
  transcriptListeners?: Set<TranscriptListener>;
45
- detachTranscript?: () => void;
46
- observationError?: string;
44
+ detachTranscript?: (() => void) | undefined;
45
+ observationError?: string | undefined;
47
46
  }
48
47
  const active = (view: Pick<ThreadView, "state">) =>
49
48
  view.state === "starting" || view.state === "running";
49
+ const settledState = (record: {
50
+ stopRequested: boolean;
51
+ pauseRequested: boolean;
52
+ }): ThreadView["state"] => {
53
+ if (record.stopRequested) return "stopped";
54
+ return record.pauseRequested ? "paused" : "completed";
55
+ };
56
+ const swallow = (): undefined => undefined;
50
57
  const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error));
51
58
 
52
59
  function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
53
60
  if (signal.aborted) {
54
- void work.catch(() => {});
61
+ work.catch(swallow);
55
62
  return Promise.reject(signal.reason);
56
63
  }
57
64
  return new Promise((resolve, reject) => {
@@ -75,7 +82,10 @@ export class ThreadManager {
75
82
  private records = new Map<string, Record>();
76
83
  private disposed = false;
77
84
  private epoch = 0;
78
- constructor(private options: ManagerOptions) {}
85
+ private options: ManagerOptions;
86
+ constructor(options: ManagerOptions) {
87
+ this.options = options;
88
+ }
79
89
 
80
90
  /** New limits affect future launches/resumes, never cancel existing work. */
81
91
  setLimits(limits: ManagerSettings): void {
@@ -158,14 +168,14 @@ export class ThreadManager {
158
168
  return {
159
169
  view,
160
170
  definition: structuredClone(record.definition),
161
- ...(!view.sessionFile && !record.contextPending
171
+ ...(!(view.sessionFile || record.contextPending)
162
172
  ? { inherited: structuredClone(record.inherited) }
163
173
  : {}),
164
174
  };
165
175
  });
166
176
  }
167
177
  restore(saved: SavedThread[]): void {
168
- if (this.records.size) throw new Error("Restore requires an empty thread registry");
178
+ if (this.records.size > 0) throw new Error("Restore requires an empty thread registry");
169
179
  const restored = new Map<string, Record>();
170
180
  for (const item of saved) {
171
181
  const savedView = structuredClone(item.view);
@@ -187,12 +197,12 @@ export class ThreadManager {
187
197
  inputTokens: storedView.inputTokens ?? 0,
188
198
  outputTokens: storedView.outputTokens ?? 0,
189
199
  };
190
- delete view.startedAt;
200
+ view.startedAt = undefined;
191
201
  if (view.parent !== parentPath(path)) throw new Error(`Invalid saved parent for ${path}`);
192
202
  if (active(view)) {
193
203
  view.state = "paused";
194
204
  view.status = "Interrupted by reload; send input to resume";
195
- delete view.output;
205
+ view.output = undefined;
196
206
  }
197
207
  restored.set(path, {
198
208
  view,
@@ -201,9 +211,7 @@ export class ThreadManager {
201
211
  // A reservation saved before its lexical parent opened has no snapshot yet.
202
212
  contextPending: !view.sessionFile && item.inherited === undefined && view.parent !== null,
203
213
  startup: new AbortController(),
204
- ...(Object.prototype.hasOwnProperty.call(savedView, "sessionLeafId")
205
- ? { sessionLeafId }
206
- : {}),
214
+ ...(Object.hasOwn(savedView, "sessionLeafId") ? { sessionLeafId } : {}),
207
215
  pauseRequested: false,
208
216
  stopRequested: false,
209
217
  });
@@ -257,19 +265,15 @@ export class ThreadManager {
257
265
  : level > maxLevels
258
266
  )
259
267
  throw new Error("Agent depth limit reached");
260
- this.archiveFinished(this.options.maxThreads ?? DEFAULT_MANAGER_SETTINGS.maxThreads);
261
268
  this.assertCapacity();
262
269
  const type = structuredClone(this.options.getType(args.type));
263
270
  if (overrides !== undefined) type.spawnOverrides = { ...overrides };
264
271
  const parentRecord = parent && parent !== "/root" ? this.record(parent) : undefined;
265
272
  this.assertStartableAncestors(path);
273
+ // Last, so a spawn refused by any check above drops nothing.
274
+ this.archiveFinished(this.options.maxThreads ?? DEFAULT_MANAGER_SETTINGS.maxThreads, path);
266
275
  // Reservation is synchronous: subtree cancellation sees children even during lazy parent reopen.
267
- const inherited =
268
- parent === "/root"
269
- ? inheritContext(this.options.rootSnapshot())
270
- : parentRecord?.driver
271
- ? inheritContext(parentRecord.driver.snapshot())
272
- : [];
276
+ const inherited = this.inheritedFor(parent, parentRecord);
273
277
  const now = Date.now();
274
278
  const record: Record = {
275
279
  view: {
@@ -298,7 +302,9 @@ export class ThreadManager {
298
302
  this.records.set(path, record);
299
303
  if (record.contextPending) this.prepareInheritedContext(record);
300
304
  this.start(record, args.task);
301
- return args.wait === false ? this.view(record) : this.wait(caller, path, undefined, signal);
305
+ return args.wait === false
306
+ ? this.view(record)
307
+ : await this.wait(caller, path, undefined, signal);
302
308
  }
303
309
 
304
310
  async steer(caller: string, path: string, message: string): Promise<ThreadView> {
@@ -321,7 +327,9 @@ export class ThreadManager {
321
327
  if (record.stopRequested && active(record.view))
322
328
  throw new Error("Thread is stopping; wait for stopped state before resuming");
323
329
  if (record.view.state === "running") {
324
- await record.driver!.steer(message);
330
+ const live = record.driver;
331
+ if (live === undefined) throw new Error("Running thread has no driver");
332
+ await live.steer(message);
325
333
  this.touch(record);
326
334
  return this.view(record);
327
335
  }
@@ -351,12 +359,13 @@ export class ThreadManager {
351
359
  const finish = (error?: Error) => {
352
360
  if (timer) clearTimeout(timer);
353
361
  signal?.removeEventListener("abort", cancel);
354
- error ? reject(error) : resolve();
362
+ if (error) reject(error);
363
+ else resolve();
355
364
  };
356
365
  const cancel = () => finish(new Error("Waiting cancelled; thread remains running"));
357
366
  signal?.addEventListener("abort", cancel, { once: true });
358
367
  if (timeoutMs !== undefined) timer = setTimeout(() => finish(), timeoutMs);
359
- record.run!.then(
368
+ (record.run ?? Promise.resolve()).then(
360
369
  () => finish(),
361
370
  (error) => finish(new Error(errorText(error))),
362
371
  );
@@ -404,7 +413,7 @@ export class ThreadManager {
404
413
  await item.run;
405
414
  item.view.state = "stopped";
406
415
  item.view.status = "Stopped; session retained";
407
- delete item.view.output;
416
+ item.view.output = undefined;
408
417
  this.touch(item);
409
418
  }),
410
419
  );
@@ -429,14 +438,15 @@ export class ThreadManager {
429
438
  // Idle/restored history may be lazily loaded, but attachment never prompts/resumes a turn.
430
439
  if (!active(record.view)) await this.ensureDriver(record, true);
431
440
  this.assertLive();
432
- const listeners = (record.transcriptListeners ??= new Set());
441
+ const listeners = record.transcriptListeners ?? new Set<TranscriptListener>();
442
+ record.transcriptListeners = listeners;
433
443
  const registered: TranscriptListener = (snapshot) => listener(snapshot);
434
444
  listeners.add(registered);
435
445
  if (record.driver) this.attachTranscript(record, record.driver);
436
446
  else {
437
447
  // A starting child gets a placeholder immediately; existing startup supplies the live source.
438
- void this.ensureDriver(record, true).catch((error) => {
439
- if (this.disposed || !listeners.has(registered)) return;
448
+ this.ensureDriver(record, true).catch((error) => {
449
+ if (this.isShutDown() || !listeners.has(registered)) return;
440
450
  record.observationError = errorText(error);
441
451
  this.publishTranscript(record);
442
452
  });
@@ -445,7 +455,7 @@ export class ThreadManager {
445
455
  snapshot: this.transcriptSnapshot(record),
446
456
  unsubscribe: () => {
447
457
  listeners.delete(registered);
448
- if (!listeners.size) {
458
+ if (listeners.size === 0) {
449
459
  record.detachTranscript?.();
450
460
  record.detachTranscript = undefined;
451
461
  record.transcriptSource = undefined;
@@ -473,7 +483,7 @@ export class ThreadManager {
473
483
  await Promise.all(
474
484
  records.map(async (record) => {
475
485
  await record.driver?.abort();
476
- await record.initializing?.catch(() => {});
486
+ await record.initializing?.catch(swallow);
477
487
  await record.run;
478
488
  record.driver?.dispose();
479
489
  }),
@@ -488,8 +498,8 @@ export class ThreadManager {
488
498
  record.view.state = "starting";
489
499
  record.view.status = "Starting";
490
500
  record.view.startedAt = Date.now();
491
- delete record.view.output;
492
- delete record.view.error;
501
+ record.view.output = undefined;
502
+ record.view.error = undefined;
493
503
  const epoch = this.epoch;
494
504
  let markStarted!: () => void;
495
505
  record.started = new Promise((resolve) => {
@@ -501,7 +511,7 @@ export class ThreadManager {
501
511
  if (contextReady) await abortable(contextReady, record.startup.signal);
502
512
  record.startup.signal.throwIfAborted();
503
513
  const driver = await this.ensureDriver(record);
504
- if (record.stopRequested || this.disposed) {
514
+ if (record.stopRequested || this.isShutDown()) {
505
515
  record.view.state = "stopped";
506
516
  return;
507
517
  }
@@ -511,11 +521,7 @@ export class ThreadManager {
511
521
  this.touch(record);
512
522
  markStarted();
513
523
  await task;
514
- record.view.state = record.stopRequested
515
- ? "stopped"
516
- : record.pauseRequested
517
- ? "paused"
518
- : "completed";
524
+ record.view.state = settledState(record);
519
525
  if (record.view.state === "completed") {
520
526
  record.view.output = driver.output();
521
527
  record.view.status = "Completed; session retained";
@@ -531,7 +537,7 @@ export class ThreadManager {
531
537
  record.liveOutputTokens = 0;
532
538
  markStarted(); // Failed/cancelled initialization must also release callers waiting to steer.
533
539
  this.touch(record);
534
- if (!this.disposed && epoch === this.epoch)
540
+ if (!this.isShutDown() && epoch === this.epoch)
535
541
  this.options.onEvent?.({
536
542
  kind: "settled",
537
543
  thread: this.view(record),
@@ -586,7 +592,7 @@ export class ThreadManager {
586
592
  return this.options.createDriver(options);
587
593
  })
588
594
  .then(async (driver) => {
589
- if (this.disposed || signal.aborted) {
595
+ if (this.isShutDown() || signal.aborted) {
590
596
  await driver.abort();
591
597
  driver.dispose();
592
598
  throw new Error("Driver startup cancelled");
@@ -615,7 +621,8 @@ export class ThreadManager {
615
621
  private attachTranscript(record: Record, driver: AgentDriver): void {
616
622
  if (record.detachTranscript || !record.transcriptListeners?.size) return;
617
623
  const observation = driver.observeTranscript?.((snapshot) => {
618
- if (this.disposed || record.driver !== driver || !record.transcriptListeners?.size) return;
624
+ if (this.isShutDown() || record.driver !== driver || !record.transcriptListeners?.size)
625
+ return;
619
626
  record.transcriptSource = snapshot;
620
627
  this.publishTranscript(record);
621
628
  });
@@ -641,7 +648,7 @@ export class ThreadManager {
641
648
  });
642
649
  }
643
650
  private publishTranscript(record: Record): void {
644
- if (this.disposed || !record.transcriptListeners?.size) return;
651
+ if (this.isShutDown() || !record.transcriptListeners?.size) return;
645
652
  record.observationRevision = (record.observationRevision ?? 0) + 1;
646
653
  const snapshot = this.transcriptSnapshot(record);
647
654
  for (const listener of record.transcriptListeners) {
@@ -655,7 +662,7 @@ export class ThreadManager {
655
662
  private touch(record: Record): void {
656
663
  record.view.updatedAt = Date.now();
657
664
  this.publishTranscript(record);
658
- if (!this.disposed) this.options.onEvent?.({ kind: "change", thread: this.view(record) });
665
+ if (!this.isShutDown()) this.options.onEvent?.({ kind: "change", thread: this.view(record) });
659
666
  }
660
667
  /** Fold driver-cumulative usage into persisted totals. Partials only refresh the live view. */
661
668
  private applyUsage(
@@ -669,7 +676,8 @@ export class ThreadManager {
669
676
  if (input === record.liveInputTokens && output === record.liveOutputTokens) return;
670
677
  record.liveInputTokens = input;
671
678
  record.liveOutputTokens = output;
672
- if (!this.disposed) this.options.onEvent?.({ kind: "metrics", thread: this.view(record) });
679
+ if (!this.isShutDown())
680
+ this.options.onEvent?.({ kind: "metrics", thread: this.view(record) });
673
681
  return;
674
682
  }
675
683
  record.view.inputTokens =
@@ -685,7 +693,7 @@ export class ThreadManager {
685
693
  const startedAt = record.view.startedAt;
686
694
  if (startedAt === undefined) return;
687
695
  record.view.elapsedMs = (record.view.elapsedMs ?? 0) + Math.max(0, Date.now() - startedAt);
688
- delete record.view.startedAt;
696
+ record.view.startedAt = undefined;
689
697
  }
690
698
  private prepareInheritedContext(record: Record, readOnly = false): Promise<void> | undefined {
691
699
  if (!record.contextPending) return record.contextReady;
@@ -709,7 +717,7 @@ export class ThreadManager {
709
717
  throw error;
710
718
  });
711
719
  record.contextReady = ready;
712
- void ready.catch(() => {});
720
+ ready.catch(swallow);
713
721
  return ready;
714
722
  }
715
723
  private sessionLeafId(record: Record): string | null | undefined {
@@ -730,7 +738,7 @@ export class ThreadManager {
730
738
  sessionFile: record.driver ? record.driver.sessionFile : record.view.sessionFile,
731
739
  });
732
740
  // devsys: show per-spawn pins so agent_status reports what the thread was told to run on.
733
- if (record.definition?.spawnOverrides) view.pinned = { ...record.definition.spawnOverrides };
741
+ if (record.definition.spawnOverrides) view.pinned = { ...record.definition.spawnOverrides };
734
742
  if (record.liveInputTokens) view.inputTokens = (view.inputTokens ?? 0) + record.liveInputTokens;
735
743
  if (record.liveOutputTokens)
736
744
  view.outputTokens = (view.outputTokens ?? 0) + record.liveOutputTokens;
@@ -741,8 +749,16 @@ export class ThreadManager {
741
749
  if (!record) throw new Error(`Unknown thread ${path}`);
742
750
  return record;
743
751
  }
752
+ private inheritedFor(parent: string | null, parentRecord: Record | undefined): AgentMessage[] {
753
+ if (parent === "/root") return inheritContext(this.options.rootSnapshot());
754
+ return parentRecord?.driver ? inheritContext(parentRecord.driver.snapshot()) : [];
755
+ }
756
+ /** A method, not a field read: the flag flips across awaits, which static narrowing cannot see. */
757
+ private isShutDown(): boolean {
758
+ return this.disposed;
759
+ }
744
760
  private assertLive(): void {
745
- if (this.disposed) throw new Error("Thread manager has shut down");
761
+ if (this.isShutDown()) throw new Error("Thread manager has shut down");
746
762
  }
747
763
  private assertCallerMaySteer(caller: string): void {
748
764
  if (caller !== "/root" && this.record(caller).stopRequested)
@@ -765,16 +781,27 @@ export class ThreadManager {
765
781
  * on their own history. Make room for one more thread by dropping the oldest finished
766
782
  * thread that has no retained descendant; live and paused threads are never dropped.
767
783
  * Throws only when every retained thread is still live, paused or a parent of one.
784
+ * `spawning` is the path about to be created: its ancestors are kept (a finished parent
785
+ * with no other child is still the new thread's parent).
768
786
  */
769
- private archiveFinished(limit: number): void {
787
+ private archiveFinished(limit: number, spawning: string): void {
770
788
  while (this.records.size >= limit) {
771
789
  const paths = [...this.records.keys()];
772
790
  const victim = paths.find((path) => {
773
791
  const state = this.records.get(path)?.view.state;
774
792
  const finished = state === "completed" || state === "failed" || state === "stopped";
775
- return finished && !paths.some((other) => isDescendant(other, path));
793
+ return (
794
+ finished &&
795
+ !isDescendant(spawning, path) &&
796
+ !paths.some((other) => isDescendant(other, path))
797
+ );
776
798
  });
777
799
  if (victim === undefined) throw new Error("Total thread limit reached");
800
+ const record = this.records.get(victim);
801
+ // Release what the record holds: shutdown() only walks retained records.
802
+ record?.detachTranscript?.();
803
+ record?.transcriptListeners?.clear();
804
+ record?.driver?.dispose();
778
805
  this.records.delete(victim);
779
806
  }
780
807
  }
@@ -20,9 +20,9 @@ export interface AgentType {
20
20
  /** devsys: per-spawn pin set by the coordinator via agent_spawn; beats preferences and inheritance. */
21
21
  spawnOverrides?: { model?: string; thinkingLevel?: ThinkingLevel };
22
22
  /** Pi semantic token for the type pill background and task path foreground. */
23
- color?: string;
23
+ color?: string | undefined;
24
24
  /** Optional literal Nerd Font glyph; displayed only when the labs setting is enabled. */
25
- icon?: string;
25
+ icon?: string | undefined;
26
26
  tools?: { allow?: string[]; block?: string[] };
27
27
  systemPrompt: string;
28
28
  filePath?: string;
@@ -50,20 +50,20 @@ export interface ThreadView {
50
50
  parent: string | null;
51
51
  owner: string;
52
52
  type: string;
53
- color?: string;
54
- icon?: string;
53
+ color?: string | undefined;
54
+ icon?: string | undefined;
55
55
  state: ThreadState;
56
56
  task: string;
57
57
  status: string;
58
- output?: string;
59
- error?: string;
58
+ output?: string | undefined;
59
+ error?: string | undefined;
60
60
  createdAt: number;
61
61
  updatedAt: number;
62
- sessionFile?: string;
62
+ sessionFile?: string | undefined;
63
63
  /** Frozen active time. Renderer adds `Date.now() - startedAt` while a run is active. */
64
64
  elapsedMs?: number;
65
65
  /** Current run start. Live only; omitted from saved views so reloads do not count offline time. */
66
- startedAt?: number;
66
+ startedAt?: number | undefined;
67
67
  /** Cumulative input tokens, including cache read/write. Live views include the in-progress message. */
68
68
  inputTokens?: number;
69
69
  /** Cumulative output tokens. Live views include the in-progress message. */
@@ -76,7 +76,7 @@ type DriverEvent =
76
76
  | {
77
77
  kind: "checkpoint";
78
78
  text: string;
79
- sessionFile?: string;
79
+ sessionFile?: string | undefined;
80
80
  sessionLeafId?: string | null;
81
81
  }
82
82
  | {
@@ -106,7 +106,7 @@ export interface TranscriptSnapshot {
106
106
  tools: readonly TranscriptToolState[];
107
107
  inheritedCount: number;
108
108
  thread?: ThreadView;
109
- error?: string;
109
+ error?: string | undefined;
110
110
  }
111
111
  export interface TranscriptObservation {
112
112
  snapshot: TranscriptSnapshot;
@@ -123,7 +123,7 @@ export interface AgentDriver {
123
123
  abort(): Promise<void>;
124
124
  dispose(): void;
125
125
  sendUpdate(content: string): Promise<void> | void;
126
- sessionFile?: string;
126
+ sessionFile?: string | undefined;
127
127
  sessionLeafId?: string | null;
128
128
  }
129
129
  export interface DriverOptions {
@@ -134,7 +134,7 @@ export interface DriverOptions {
134
134
  onEvent(event: DriverEvent): void;
135
135
  shouldPause(): boolean;
136
136
  parentPath: string | null;
137
- sessionFile?: string;
137
+ sessionFile?: string | undefined;
138
138
  /** Retained transcript of a nested lexical parent. Absent for /root and independent roots. */
139
139
  parentSessionFile?: string;
140
140
  sessionLeafId?: string | null;
@@ -145,15 +145,15 @@ export interface SavedThreadView {
145
145
  parent: string | null;
146
146
  owner: string;
147
147
  type: string;
148
- color?: string;
149
- icon?: string;
148
+ color?: string | undefined;
149
+ icon?: string | undefined;
150
150
  state: ThreadState;
151
151
  task: string;
152
152
  status: string;
153
- output?: string;
154
- error?: string;
153
+ output?: string | undefined;
154
+ error?: string | undefined;
155
155
  createdAt: number;
156
- sessionFile?: string;
156
+ sessionFile?: string | undefined;
157
157
  sessionLeafId?: string | null;
158
158
  /** Frozen cumulative active time. `startedAt` is intentionally not persisted. */
159
159
  elapsedMs?: number;