@shanepadgett/tau-agent 0.33.0 → 0.34.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 (190) hide show
  1. package/docs/context.md +29 -20
  2. package/docs/extending-tau-agent.md +63 -24
  3. package/extensions/appshot/index.ts +112 -111
  4. package/extensions/aside/README.md +7 -0
  5. package/extensions/aside/index.ts +168 -0
  6. package/extensions/aside/panel.ts +83 -0
  7. package/extensions/branch/index.ts +38 -21
  8. package/extensions/cache-diagnostics/index.ts +70 -58
  9. package/extensions/checkpoint/README.md +9 -0
  10. package/extensions/checkpoint/checkpoint-budget.ts +79 -0
  11. package/extensions/checkpoint/checkpoint.ts +277 -0
  12. package/extensions/checkpoint/index.ts +102 -0
  13. package/extensions/checkpoint/messages.ts +169 -0
  14. package/extensions/checkpoint/prompt.ts +24 -0
  15. package/extensions/checkpoint/settings.ts +27 -0
  16. package/extensions/commit/commit-effort.ts +3 -3
  17. package/extensions/context/README.md +12 -8
  18. package/extensions/context/definitions.ts +178 -55
  19. package/extensions/context/index.ts +192 -160
  20. package/extensions/context/panel.ts +57 -37
  21. package/extensions/context/sync.ts +139 -89
  22. package/extensions/context/validation.ts +37 -25
  23. package/extensions/effort/README.md +1 -1
  24. package/extensions/effort/index.ts +3 -3
  25. package/extensions/effort/state.ts +3 -18
  26. package/extensions/explore/guidance.ts +6 -47
  27. package/extensions/explore/index.ts +38 -53
  28. package/extensions/explore/{ast/read → read}/hook.ts +12 -7
  29. package/extensions/explore/{ast/tools → tools}/ast-search.ts +15 -19
  30. package/extensions/explore/{ast/tools → tools}/context.ts +16 -23
  31. package/extensions/explore/{ast/tools → tools}/deps.ts +11 -15
  32. package/extensions/explore/{ast/tools → tools}/discover.ts +15 -17
  33. package/extensions/explore/{ast/tools → tools}/impact.ts +20 -23
  34. package/extensions/explore/{ast/tools → tools}/outline.ts +21 -24
  35. package/extensions/explore/{ast/tools → tools}/relationships.ts +23 -32
  36. package/extensions/explore/{ast/tools → tools}/render.ts +1 -1
  37. package/extensions/explore/{ast/tools → tools}/reverse-deps.ts +11 -15
  38. package/extensions/explore/{ast/tools → tools}/show.ts +16 -19
  39. package/extensions/explore/tools/target-params.ts +9 -0
  40. package/extensions/footer/index.ts +75 -56
  41. package/extensions/handoff/index.ts +8 -30
  42. package/extensions/image-gen/index.ts +46 -40
  43. package/extensions/patch/executor.ts +190 -135
  44. package/extensions/patch/matcher.ts +82 -48
  45. package/extensions/patch/parser.ts +149 -105
  46. package/extensions/patch/render.ts +32 -39
  47. package/extensions/patch/summary.ts +30 -26
  48. package/extensions/ready/README.md +7 -0
  49. package/extensions/ready/index.ts +58 -0
  50. package/extensions/ready/model.ts +128 -0
  51. package/extensions/ready/packs.ts +272 -0
  52. package/extensions/ready/render.ts +445 -0
  53. package/extensions/ready/scan.ts +760 -0
  54. package/extensions/reference/panel.ts +28 -16
  55. package/extensions/review/index.ts +63 -51
  56. package/extensions/review/panel.ts +5 -34
  57. package/extensions/runtime-context/context.ts +30 -26
  58. package/extensions/script-runner/README.md +2 -2
  59. package/extensions/script-runner/index.ts +130 -93
  60. package/extensions/silent-command-runner/index.ts +38 -23
  61. package/extensions/subagent/agents/context-sync.md +185 -33
  62. package/extensions/subagent/agents/scout.md +2 -2
  63. package/extensions/subagent/agents.ts +137 -84
  64. package/extensions/subagent/cmux-dashboard.ts +39 -30
  65. package/extensions/subagent/index.ts +79 -59
  66. package/extensions/subagent/resume.ts +1 -1
  67. package/extensions/subagent/run.ts +304 -193
  68. package/extensions/subagent/runtime.ts +781 -343
  69. package/extensions/tau/index.ts +47 -38
  70. package/extensions/tau-help/help.md +14 -6
  71. package/extensions/tool-loader/README.md +4 -2
  72. package/extensions/tool-loader/index.ts +77 -52
  73. package/extensions/web/index.ts +6 -3
  74. package/extensions/web/webfetch.ts +114 -86
  75. package/package.json +3 -3
  76. package/schemas/tau.schema.json +20 -33
  77. package/shared/bounded-text-result.ts +1 -0
  78. package/shared/checkpoint-visibility.ts +9 -0
  79. package/shared/events.ts +28 -10
  80. package/shared/isolated-session.ts +71 -29
  81. package/shared/model-effort.ts +9 -19
  82. package/shared/model-fallback/index.ts +66 -41
  83. package/shared/settings/load.ts +45 -38
  84. package/shared/temporary-output-store.ts +16 -10
  85. package/{extensions/explore → src}/ast/adapter.ts +11 -2
  86. package/{extensions/explore → src}/ast/engine.ts +54 -38
  87. package/{extensions/explore → src}/ast/format/ast-search.ts +15 -6
  88. package/{extensions/explore → src}/ast/format/context.ts +3 -6
  89. package/src/ast/format/deps.ts +94 -0
  90. package/{extensions/explore → src}/ast/format/discover.ts +2 -2
  91. package/{extensions/explore → src}/ast/format/impact.ts +9 -11
  92. package/{extensions/explore → src}/ast/format/outline.ts +2 -2
  93. package/{extensions/explore → src}/ast/format/relationships.ts +11 -7
  94. package/{extensions/explore → src}/ast/format/show.ts +1 -1
  95. package/src/ast/format/targeted-result.ts +20 -0
  96. package/src/ast/grammars/c_sharp.wasm +0 -0
  97. package/{extensions/explore → src}/ast/grammars/manifest.json +9 -12
  98. package/{extensions/explore → src}/ast/grammars/manifest.ts +0 -4
  99. package/{extensions/explore → src}/ast/graph/file-graph.ts +210 -98
  100. package/{extensions/explore → src}/ast/graph/relationships.ts +285 -72
  101. package/{extensions/explore → src}/ast/ir.ts +6 -0
  102. package/src/ast/languages/csharp-file-deps.ts +90 -0
  103. package/{extensions/explore → src}/ast/languages/csharp.ts +9 -40
  104. package/src/ast/languages/dotted-index.ts +131 -0
  105. package/{extensions/explore → src}/ast/languages/file-dep-util.ts +22 -13
  106. package/src/ast/languages/fixtures/sample.cs +53 -0
  107. package/src/ast/languages/fixtures/sample.cs.expected.json +156 -0
  108. package/src/ast/languages/fixtures/sample.go +37 -0
  109. package/src/ast/languages/fixtures/sample.go.expected.json +96 -0
  110. package/src/ast/languages/fixtures/sample.java +40 -0
  111. package/src/ast/languages/fixtures/sample.java.expected.json +126 -0
  112. package/src/ast/languages/fixtures/sample.kt +31 -0
  113. package/src/ast/languages/fixtures/sample.kt.expected.json +134 -0
  114. package/src/ast/languages/fixtures/sample.md +23 -0
  115. package/src/ast/languages/fixtures/sample.md.expected.json +44 -0
  116. package/src/ast/languages/fixtures/sample.rs +54 -0
  117. package/src/ast/languages/fixtures/sample.rs.expected.json +178 -0
  118. package/src/ast/languages/fixtures/sample.swift +45 -0
  119. package/src/ast/languages/fixtures/sample.swift.expected.json +147 -0
  120. package/src/ast/languages/fixtures/sample.ts +45 -0
  121. package/src/ast/languages/fixtures/sample.ts.expected.json +141 -0
  122. package/src/ast/languages/fixtures/sample.tsx +36 -0
  123. package/src/ast/languages/fixtures/sample.tsx.expected.json +59 -0
  124. package/{extensions/explore → src}/ast/languages/go-file-deps.ts +6 -0
  125. package/{extensions/explore → src}/ast/languages/go.ts +21 -3
  126. package/{extensions/explore → src}/ast/languages/java.ts +5 -38
  127. package/src/ast/languages/jvm-file-deps.ts +135 -0
  128. package/{extensions/explore → src}/ast/languages/kotlin.ts +5 -39
  129. package/{extensions/explore → src}/ast/languages/rust-file-deps.ts +3 -3
  130. package/{extensions/explore → src}/ast/languages/rust.ts +21 -13
  131. package/{extensions/explore → src}/ast/languages/swift-file-deps.ts +22 -15
  132. package/{extensions/explore → src}/ast/languages/swift.ts +9 -39
  133. package/{extensions/explore → src}/ast/languages/tree.ts +47 -0
  134. package/{extensions/explore → src}/ast/languages/typescript-file-deps.ts +108 -0
  135. package/{extensions/explore → src}/ast/languages/typescript-package-surface.ts +4 -3
  136. package/{extensions/explore → src}/ast/languages/typescript.ts +3 -5
  137. package/{extensions/explore → src}/ast/markdown.ts +72 -47
  138. package/{extensions/explore → src}/ast/queries/ast-search.ts +164 -146
  139. package/{extensions/explore → src}/ast/queries/composite-target.ts +1 -1
  140. package/{extensions/explore → src}/ast/queries/context.ts +116 -80
  141. package/{extensions/explore → src}/ast/queries/discover.ts +154 -105
  142. package/src/ast/queries/impact.ts +291 -0
  143. package/{extensions/explore → src}/ast/queries/outline.ts +4 -2
  144. package/{extensions/explore → src}/ast/queries/show.ts +46 -42
  145. package/{extensions/explore → src}/ast/query.ts +1 -1
  146. package/{extensions/explore → src}/ast/registry.ts +1 -2
  147. package/{extensions/explore → src}/ast/scan.ts +1 -1
  148. package/src/ast/session.ts +50 -0
  149. package/{extensions/explore → src}/ast/slice.ts +8 -0
  150. package/{extensions/explore → src/ast}/traverse.ts +32 -20
  151. package/src/file-injection/index.ts +547 -0
  152. package/src/image-generation/client.ts +50 -31
  153. package/src/image-generation/index.ts +177 -134
  154. package/src/index.ts +2 -0
  155. package/src/tool-loading/index.ts +58 -0
  156. package/extensions/context/evidence.ts +0 -517
  157. package/extensions/context/projection.ts +0 -141
  158. package/extensions/context/state.ts +0 -30
  159. package/extensions/context/write-scope.ts +0 -109
  160. package/extensions/explore/ast/format/deps.ts +0 -86
  161. package/extensions/explore/ast/grammars/odin.wasm +0 -0
  162. package/extensions/explore/ast/languages/csharp-file-deps.ts +0 -189
  163. package/extensions/explore/ast/languages/fixtures/sample.cs +0 -17
  164. package/extensions/explore/ast/languages/fixtures/sample.java +0 -21
  165. package/extensions/explore/ast/languages/fixtures/sample.kt +0 -15
  166. package/extensions/explore/ast/languages/fixtures/sample.odin +0 -28
  167. package/extensions/explore/ast/languages/fixtures/sample.rs +0 -31
  168. package/extensions/explore/ast/languages/fixtures/sample.swift +0 -25
  169. package/extensions/explore/ast/languages/jvm-file-deps.ts +0 -217
  170. package/extensions/explore/ast/languages/odin-file-deps.ts +0 -175
  171. package/extensions/explore/ast/languages/odin.ts +0 -407
  172. package/extensions/explore/ast/queries/impact.ts +0 -250
  173. package/extensions/explore/outline-injection.ts +0 -151
  174. package/extensions/explore/read/autoread.ts +0 -118
  175. package/extensions/working-memory/README.md +0 -17
  176. package/extensions/working-memory/checkpoint.ts +0 -242
  177. package/extensions/working-memory/index.ts +0 -341
  178. package/extensions/working-memory/memory.ts +0 -235
  179. package/extensions/working-memory/render.ts +0 -185
  180. package/extensions/working-memory/settings.ts +0 -38
  181. package/extensions/working-memory/state.ts +0 -158
  182. package/shared/autoread.ts +0 -234
  183. package/shared/context-messages.ts +0 -19
  184. package/shared/outline-injection.ts +0 -56
  185. /package/{extensions/explore → src}/ast/format/composite.ts +0 -0
  186. /package/{extensions/explore → src}/ast/grammars/kotlin.wasm +0 -0
  187. /package/{extensions/explore → src}/ast/grammars/swift.wasm +0 -0
  188. /package/{extensions/explore → src}/ast/identity.ts +0 -0
  189. /package/{extensions/explore → src}/ast/package-surface.ts +0 -0
  190. /package/{extensions/explore/ast/read/policy.ts → src/ast/read-policy.ts} +0 -0
@@ -2,7 +2,7 @@ import type { Usage } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { createIsolatedSessionResource, type IsolatedSessionResource } from "../../shared/isolated-session.ts";
4
4
  import type { AgentDefinition } from "./agents.ts";
5
- import { buildColdResumePrompt, retainSubagentTurn } from "./resume.ts";
5
+ import { buildColdResumePrompt, retainSubagentTurn, type RetainedTurnOutcome } from "./resume.ts";
6
6
  import {
7
7
  cloneInvocationSnapshot,
8
8
  createSubagentThread,
@@ -46,6 +46,43 @@ interface TrackedThread extends SubagentThread {
46
46
  disposePromise?: Promise<void>;
47
47
  }
48
48
 
49
+ type ResolveFreshDefinition = () => Promise<
50
+ { ok: true; definition: AgentDefinition } | { ok: false; error: string; phase: SubagentPhase }
51
+ >;
52
+
53
+ interface ExecuteBag {
54
+ agent: string;
55
+ displayName: string;
56
+ definition: AgentDefinition | undefined;
57
+ thread: TrackedThread | undefined;
58
+ threadId: string | undefined;
59
+ releaseThread: (() => void) | undefined;
60
+ releaseGlobal: (() => void) | undefined;
61
+ reservedThread: TrackedThread | undefined;
62
+ provisionalThread: TrackedThread | undefined;
63
+ provisionalResource: IsolatedSessionResource | undefined;
64
+ reservationToken: symbol | undefined;
65
+ admitAdvanced: boolean;
66
+ phase: SubagentPhase;
67
+ lastPublishedStatus: SubagentLifecycle | undefined;
68
+ combined: AbortSignal;
69
+ task: string;
70
+ continuing: boolean;
71
+ threadKey: string | undefined;
72
+ files: readonly string[] | undefined;
73
+ ctx: ExtensionContext;
74
+ parentModel: string;
75
+ parentThinking: NonNullable<ExtensionContext["thinkingLevel"]>;
76
+ onUpdate: ((details: SubagentDetails) => void | Promise<void>) | undefined;
77
+ resolveFreshDefinition: ResolveFreshDefinition;
78
+ generation: number;
79
+ invocationId: string;
80
+ controller: AbortController;
81
+ startedAt: number;
82
+ active: ActiveInvocation;
83
+ admitTicket: number;
84
+ }
85
+
49
86
  function emptyUsage(): SubagentDetails["usage"] {
50
87
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
51
88
  }
@@ -155,21 +192,6 @@ export class SubagentRuntime {
155
192
  return this.listThreads(cwd).map((thread) => thread.id);
156
193
  }
157
194
 
158
- /** Exposed for tests. */
159
- get retainedCount(): number {
160
- return this.threads.size;
161
- }
162
-
163
- /** Exposed for tests. */
164
- get capacityInUse(): number {
165
- return this.threads.size + this.startupReservations.size;
166
- }
167
-
168
- /** Exposed for tests. */
169
- get currentGeneration(): number {
170
- return this.generation;
171
- }
172
-
173
195
  async reset(): Promise<void> {
174
196
  await this.drainLifecycle({ permanent: false });
175
197
  }
@@ -290,9 +312,7 @@ export class SubagentRuntime {
290
312
  definition?: AgentDefinition;
291
313
  signal?: AbortSignal;
292
314
  onUpdate?: (details: SubagentDetails) => void | Promise<void>;
293
- resolveFreshDefinition: () => Promise<
294
- { ok: true; definition: AgentDefinition } | { ok: false; error: string; phase: SubagentPhase }
295
- >;
315
+ resolveFreshDefinition: ResolveFreshDefinition;
296
316
  },
297
317
  state: {
298
318
  generation: number;
@@ -303,359 +323,777 @@ export class SubagentRuntime {
303
323
  admitTicket: number;
304
324
  },
305
325
  ): Promise<SubagentToolResult> {
306
- const { task, continuing, ctx, parentModel, parentThinking, signal, onUpdate, resolveFreshDefinition } = options;
307
- const { generation, invocationId, controller, startedAt, active, admitTicket } = state;
308
- let agent = options.agent;
309
- let displayName = options.agent;
310
- let definition = options.definition;
311
- let thread: TrackedThread | undefined;
312
- let threadId = continuing ? options.threadKey : undefined;
313
- const combined = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]);
326
+ const bag = this.createExecuteBag(options, state);
327
+ const fanOut = (details: SubagentDetails, force = false) => this.fanOutInvocation(bag, details, force);
328
+ try {
329
+ return await this.driveExecute(bag, fanOut);
330
+ } catch (error) {
331
+ const handled = await this.handleExecuteError({
332
+ error,
333
+ agent: bag.agent,
334
+ displayName: bag.displayName,
335
+ task: bag.task,
336
+ phase: bag.phase,
337
+ parentModel: bag.parentModel,
338
+ parentThinking: bag.parentThinking,
339
+ thread: bag.thread,
340
+ threadId: bag.threadId,
341
+ invocationId: bag.invocationId,
342
+ combined: bag.combined,
343
+ continuing: bag.continuing,
344
+ provisionalResource: bag.provisionalResource,
345
+ provisionalThread: bag.provisionalThread,
346
+ fanOut,
347
+ });
348
+ bag.provisionalResource = undefined;
349
+ bag.provisionalThread = undefined;
350
+ return handled;
351
+ } finally {
352
+ await this.cleanupExecuteBag(bag);
353
+ }
354
+ }
314
355
 
315
- let releaseThread: (() => void) | undefined;
316
- let releaseGlobal: (() => void) | undefined;
317
- let reservedThread: TrackedThread | undefined;
318
- let provisionalThread: TrackedThread | undefined;
319
- let provisionalResource: IsolatedSessionResource | undefined;
320
- let reservationToken: symbol | undefined;
321
- let admitAdvanced = false;
322
- let phase: SubagentPhase = "queue";
323
- let lastPublishedStatus: SubagentLifecycle | undefined = "waiting";
324
-
325
- const fanOut = (details: SubagentDetails, force = false) => {
326
- const current = this.invocations.get(invocationId);
327
- if (!current || current.generation !== this.generation) return;
328
- if (!force && details.status === lastPublishedStatus && details.status !== "running") return;
329
- lastPublishedStatus = details.status;
330
- const next: SubagentInvocationSnapshot = {
331
- ...details,
332
- invocationId,
333
- startedAt,
334
- files: current.snapshot.files,
335
- threadId: details.threadId ?? threadId,
336
- agent: details.agent || agent,
337
- displayName: details.displayName || displayName,
338
- };
339
- current.snapshot = next;
340
- this.publish(next);
341
- if (!onUpdate) return;
342
- // Detached: presentation latency must not block admission or child turns.
343
- void Promise.resolve()
344
- .then(() => onUpdate(cloneInvocationSnapshot(next)))
345
- .catch(() => undefined);
356
+ private createExecuteBag(
357
+ options: {
358
+ agent: string;
359
+ task: string;
360
+ files?: readonly string[];
361
+ continuing: boolean;
362
+ threadKey?: string;
363
+ ctx: ExtensionContext;
364
+ parentModel: string;
365
+ parentThinking: NonNullable<ExtensionContext["thinkingLevel"]>;
366
+ definition?: AgentDefinition;
367
+ signal?: AbortSignal;
368
+ onUpdate?: (details: SubagentDetails) => void | Promise<void>;
369
+ resolveFreshDefinition: ResolveFreshDefinition;
370
+ },
371
+ state: {
372
+ generation: number;
373
+ invocationId: string;
374
+ controller: AbortController;
375
+ startedAt: number;
376
+ active: ActiveInvocation;
377
+ admitTicket: number;
378
+ },
379
+ ): ExecuteBag {
380
+ return {
381
+ agent: options.agent,
382
+ displayName: options.agent,
383
+ definition: options.definition,
384
+ thread: undefined,
385
+ threadId: options.continuing ? options.threadKey : undefined,
386
+ releaseThread: undefined,
387
+ releaseGlobal: undefined,
388
+ reservedThread: undefined,
389
+ provisionalThread: undefined,
390
+ provisionalResource: undefined,
391
+ reservationToken: undefined,
392
+ admitAdvanced: false,
393
+ phase: "queue",
394
+ lastPublishedStatus: "waiting",
395
+ combined: AbortSignal.any([state.controller.signal, ...(options.signal ? [options.signal] : [])]),
396
+ task: options.task,
397
+ continuing: options.continuing,
398
+ threadKey: options.threadKey,
399
+ files: options.files,
400
+ ctx: options.ctx,
401
+ parentModel: options.parentModel,
402
+ parentThinking: options.parentThinking,
403
+ onUpdate: options.onUpdate,
404
+ resolveFreshDefinition: options.resolveFreshDefinition,
405
+ generation: state.generation,
406
+ invocationId: state.invocationId,
407
+ controller: state.controller,
408
+ startedAt: state.startedAt,
409
+ active: state.active,
410
+ admitTicket: state.admitTicket,
346
411
  };
412
+ }
347
413
 
348
- const finish = (details: SubagentDetails, text?: string): SubagentToolResult => {
349
- fanOut(details, true);
350
- return { content: [{ type: "text", text: text ?? details.error ?? details.response ?? "" }], details };
414
+ private fanOutInvocation(bag: ExecuteBag, details: SubagentDetails, force = false): void {
415
+ const current = this.invocations.get(bag.invocationId);
416
+ if (!current || current.generation !== this.generation) return;
417
+ if (!force && details.status === bag.lastPublishedStatus && details.status !== "running") return;
418
+ bag.lastPublishedStatus = details.status;
419
+ const next: SubagentInvocationSnapshot = {
420
+ ...details,
421
+ invocationId: bag.invocationId,
422
+ startedAt: bag.startedAt,
423
+ files: current.snapshot.files,
424
+ threadId: details.threadId ?? bag.threadId,
425
+ agent: details.agent || bag.agent,
426
+ displayName: details.displayName || bag.displayName,
351
427
  };
428
+ current.snapshot = next;
429
+ this.publish(next);
430
+ if (!bag.onUpdate) return;
431
+ // Detached: presentation latency must not block admission or child turns.
432
+ void Promise.resolve()
433
+ .then(() => bag.onUpdate?.(cloneInvocationSnapshot(next)))
434
+ .catch(() => undefined);
435
+ }
436
+
437
+ private finishInvocation(bag: ExecuteBag, details: SubagentDetails, text?: string): SubagentToolResult {
438
+ this.fanOutInvocation(bag, details, true);
439
+ return { content: [{ type: "text", text: text ?? details.error ?? details.response ?? "" }], details };
440
+ }
352
441
 
353
- const fail = (
354
- error: string,
355
- failPhase: SubagentPhase,
356
- overrides: Partial<SubagentDetails> = {},
357
- ): SubagentToolResult =>
358
- finish(
359
- baseDetails({
360
- agent: overrides.agent ?? agent,
361
- displayName: overrides.displayName ?? thread?.displayName ?? displayName,
362
- task,
363
- status: overrides.status === "aborted" ? "aborted" : "failed",
364
- phase: failPhase,
365
- model: overrides.model ?? thread?.model ?? parentModel,
366
- thinkingLevel: overrides.thinkingLevel ?? thread?.thinkingLevel ?? parentThinking,
367
- threadId: overrides.threadId ?? thread?.id ?? threadId,
368
- invocationId,
369
- error,
370
- }),
442
+ private failInvocation(
443
+ bag: ExecuteBag,
444
+ error: string,
445
+ failPhase: SubagentPhase,
446
+ overrides: Partial<SubagentDetails> = {},
447
+ ): SubagentToolResult {
448
+ return this.finishInvocation(
449
+ bag,
450
+ baseDetails({
451
+ agent: overrides.agent ?? bag.agent,
452
+ displayName: overrides.displayName ?? bag.thread?.displayName ?? bag.displayName,
453
+ task: bag.task,
454
+ status: overrides.status === "aborted" ? "aborted" : "failed",
455
+ phase: failPhase,
456
+ model: overrides.model ?? bag.thread?.model ?? bag.parentModel,
457
+ thinkingLevel: overrides.thinkingLevel ?? bag.thread?.thinkingLevel ?? bag.parentThinking,
458
+ threadId: overrides.threadId ?? bag.thread?.id ?? bag.threadId,
459
+ invocationId: bag.invocationId,
371
460
  error,
372
- );
461
+ }),
462
+ error,
463
+ );
464
+ }
373
465
 
374
- const abortNow = (failPhase: SubagentPhase = phase, markAdmit = false): SubagentToolResult => {
375
- if (markAdmit && !admitAdvanced) {
376
- this.advanceAdmitTicket(admitTicket);
377
- admitAdvanced = true;
466
+ private abortInvocation(
467
+ bag: ExecuteBag,
468
+ failPhase: SubagentPhase = bag.phase,
469
+ markAdmit = false,
470
+ ): SubagentToolResult {
471
+ if (markAdmit && !bag.admitAdvanced) {
472
+ this.advanceAdmitTicket(bag.admitTicket);
473
+ bag.admitAdvanced = true;
474
+ }
475
+ return this.finishInvocation(
476
+ bag,
477
+ this.terminalFromAbort(
478
+ bag.agent,
479
+ bag.displayName,
480
+ bag.task,
481
+ failPhase,
482
+ bag.thread?.model ?? bag.parentModel,
483
+ bag.thread?.thinkingLevel ?? bag.parentThinking,
484
+ bag.threadId,
485
+ bag.invocationId,
486
+ ),
487
+ );
488
+ }
489
+
490
+ private async driveExecute(
491
+ bag: ExecuteBag,
492
+ fanOut: (details: SubagentDetails, force?: boolean) => void,
493
+ ): Promise<SubagentToolResult> {
494
+ if (this.lifecycleFence || this.disposed || bag.generation !== this.generation) {
495
+ return this.abortInvocation(bag);
496
+ }
497
+
498
+ const admitted = await this.admitExecuteTarget(bag, fanOut);
499
+ if (admitted) return admitted;
500
+
501
+ const gated = await this.gateExecute(bag);
502
+ if (gated) return gated;
503
+
504
+ return await this.runExecuteTurn(bag, fanOut);
505
+ }
506
+
507
+ private async admitExecuteTarget(
508
+ bag: ExecuteBag,
509
+ fanOut: (details: SubagentDetails, force?: boolean) => void,
510
+ ): Promise<SubagentToolResult | undefined> {
511
+ if (bag.continuing) {
512
+ const bound = this.bindContinuingThread({
513
+ threadKey: bag.threadKey ?? "",
514
+ cwd: bag.ctx.cwd,
515
+ parentModel: bag.parentModel,
516
+ parentThinking: bag.parentThinking,
517
+ active: bag.active,
518
+ fanOut,
519
+ });
520
+ if (!bound.ok) return this.failInvocation(bag, bound.error, "discovery", bound.overrides);
521
+ bag.reservedThread = bound.thread;
522
+ bag.thread = bound.thread;
523
+ bag.agent = bound.agent;
524
+ bag.displayName = bound.displayName;
525
+ bag.threadId = bound.threadId;
526
+ bag.definition = bound.definition;
527
+ return undefined;
528
+ }
529
+
530
+ bag.phase = "discovery";
531
+ const prepared = await this.prepareFreshDiscovery({
532
+ generation: bag.generation,
533
+ combined: bag.combined,
534
+ active: bag.active,
535
+ resolveFreshDefinition: bag.resolveFreshDefinition,
536
+ fanOut,
537
+ });
538
+ if (prepared.kind === "aborted") return this.abortInvocation(bag);
539
+ if (prepared.kind === "failed") return this.failInvocation(bag, prepared.error, prepared.phase);
540
+ bag.definition = prepared.definition;
541
+ bag.agent = prepared.agent;
542
+ bag.displayName = prepared.displayName;
543
+ bag.threadId = prepared.threadId;
544
+ bag.reservationToken = prepared.reservationToken;
545
+ return undefined;
546
+ }
547
+
548
+ private async gateExecute(bag: ExecuteBag): Promise<SubagentToolResult | undefined> {
549
+ const gates = await this.acquireExecutionGates({
550
+ thread: bag.thread,
551
+ admitTicket: bag.admitTicket,
552
+ combined: bag.combined,
553
+ generation: bag.generation,
554
+ phase: bag.phase,
555
+ });
556
+ if (gates.kind === "aborted") {
557
+ bag.admitAdvanced = gates.admitAdvanced;
558
+ return this.abortInvocation(bag, gates.phase, gates.markAdmit);
559
+ }
560
+ if (gates.kind === "failed") {
561
+ bag.admitAdvanced = gates.admitAdvanced;
562
+ return this.failInvocation(bag, gates.error, "queue", gates.overrides);
563
+ }
564
+ bag.releaseThread = gates.releaseThread;
565
+ bag.releaseGlobal = gates.releaseGlobal;
566
+ bag.admitAdvanced = true;
567
+ return undefined;
568
+ }
569
+
570
+ private async runExecuteTurn(
571
+ bag: ExecuteBag,
572
+ fanOut: (details: SubagentDetails, force?: boolean) => void,
573
+ ): Promise<SubagentToolResult> {
574
+ if (!bag.thread) {
575
+ bag.phase = "startup";
576
+ if (!bag.threadId || !bag.definition) throw new Error("Subagent startup state is incomplete");
577
+ const started = await this.startFreshThread({
578
+ threadId: bag.threadId,
579
+ displayName: bag.displayName,
580
+ definition: bag.definition,
581
+ task: bag.task,
582
+ ctx: bag.ctx,
583
+ parentThinking: bag.parentThinking,
584
+ combined: bag.combined,
585
+ generation: bag.generation,
586
+ agent: bag.agent,
587
+ active: bag.active,
588
+ fanOut,
589
+ });
590
+ if (started.kind === "aborted") {
591
+ bag.thread = started.thread;
592
+ return this.abortInvocation(bag, "startup");
378
593
  }
379
- return finish(
380
- this.terminalFromAbort(
381
- agent,
382
- displayName,
383
- task,
384
- failPhase,
385
- thread?.model ?? parentModel,
386
- thread?.thinkingLevel ?? parentThinking,
387
- threadId,
388
- invocationId,
389
- ),
390
- );
594
+ bag.provisionalThread = started.thread;
595
+ bag.thread = started.thread;
596
+ bag.reservedThread = bag.thread;
597
+ }
598
+
599
+ const thread = bag.thread;
600
+ const cold = await this.coldResumeIfNeeded({
601
+ continuing: bag.continuing,
602
+ thread,
603
+ generation: bag.generation,
604
+ combined: bag.combined,
605
+ task: bag.task,
606
+ files: bag.files,
607
+ agent: bag.agent,
608
+ active: bag.active,
609
+ fanOut,
610
+ });
611
+ if (cold.kind === "aborted") {
612
+ bag.provisionalResource = cold.provisionalResource;
613
+ return this.abortInvocation(bag, "startup");
614
+ }
615
+ bag.phase = cold.phase ?? bag.phase;
616
+ bag.provisionalResource = cold.provisionalResource;
617
+ const resumePrompt = cold.resumePrompt;
618
+
619
+ const result = await runSubagentTurn({
620
+ thread,
621
+ task: bag.task,
622
+ files: bag.files,
623
+ invocationId: bag.invocationId,
624
+ initial: thread.turns === 0 || resumePrompt !== undefined,
625
+ ...(resumePrompt === undefined ? {} : { resumePrompt }),
626
+ signal: bag.combined,
627
+ onUpdate: (details) => {
628
+ fanOut({ ...details, invocationId: bag.invocationId, threadId: bag.thread?.id ?? bag.threadId });
629
+ },
630
+ });
631
+
632
+ if (!this.isLive(bag.generation, bag.combined)) {
633
+ const aborted = await this.abortAfterTurn({
634
+ result,
635
+ invocationId: bag.invocationId,
636
+ fanOut,
637
+ reservationToken: bag.reservationToken,
638
+ provisionalThread: bag.provisionalThread,
639
+ thread,
640
+ });
641
+ bag.reservationToken = undefined;
642
+ bag.provisionalThread = undefined;
643
+ return aborted;
644
+ }
645
+
646
+ bag.lastPublishedStatus = result.details.status;
647
+ // Drop reservation before publish so capacity never reads above 16.
648
+ this.releaseReservation(bag.reservationToken);
649
+ bag.reservationToken = undefined;
650
+ const finished = await this.retainAndFinishTurn({
651
+ result,
652
+ thread,
653
+ task: bag.task,
654
+ files: bag.files,
655
+ generation: bag.generation,
656
+ continuing: bag.continuing,
657
+ provisionalThread: bag.provisionalThread,
658
+ invocationId: bag.invocationId,
659
+ });
660
+ bag.provisionalThread = undefined;
661
+ return finished;
662
+ }
663
+
664
+ private async cleanupExecuteBag(bag: ExecuteBag): Promise<void> {
665
+ if (!bag.admitAdvanced) this.advanceAdmitTicket(bag.admitTicket);
666
+ this.releaseReservation(bag.reservationToken);
667
+ if (bag.provisionalThread && this.threads.get(bag.provisionalThread.id) !== bag.provisionalThread) {
668
+ await this.disposeThread(bag.provisionalThread).catch(() => undefined);
669
+ }
670
+ if (bag.provisionalResource) await bag.provisionalResource.dispose().catch(() => undefined);
671
+ if (bag.reservedThread) bag.reservedThread.pendingTurns = Math.max(0, bag.reservedThread.pendingTurns - 1);
672
+ bag.releaseGlobal?.();
673
+ bag.releaseThread?.();
674
+ this.controllers.delete(bag.controller);
675
+ this.invocations.delete(bag.invocationId);
676
+ }
677
+
678
+ private bindContinuingThread(options: {
679
+ threadKey: string;
680
+ cwd: string;
681
+ parentModel: string;
682
+ parentThinking: string;
683
+ active: ActiveInvocation;
684
+ fanOut: (details: SubagentDetails, force?: boolean) => void;
685
+ }):
686
+ | {
687
+ ok: true;
688
+ thread: TrackedThread;
689
+ agent: string;
690
+ displayName: string;
691
+ threadId: string;
692
+ definition: AgentDefinition;
693
+ }
694
+ | { ok: false; error: string; overrides: Partial<SubagentDetails> } {
695
+ const { threadKey, cwd, parentModel, parentThinking, active, fanOut } = options;
696
+ const existing = this.threads.get(threadKey);
697
+ if (!existing || existing.cwd !== cwd || existing.disposed) {
698
+ const names = this.threadIds(cwd).join(", ") || "none";
699
+ return {
700
+ ok: false,
701
+ error: `Subagent thread ${threadKey} is unavailable. Active threads: ${names}`,
702
+ overrides: {
703
+ agent: threadKey,
704
+ threadId: threadKey,
705
+ model: parentModel,
706
+ thinkingLevel: parentThinking,
707
+ },
708
+ };
709
+ }
710
+ existing.pendingTurns += 1;
711
+ const agent = existing.definition.name;
712
+ const displayName = existing.displayName;
713
+ const threadId = existing.id;
714
+ fanOut(
715
+ {
716
+ ...active.snapshot,
717
+ agent,
718
+ displayName,
719
+ threadId,
720
+ model: existing.model,
721
+ thinkingLevel: existing.thinkingLevel,
722
+ },
723
+ true,
724
+ );
725
+ return {
726
+ ok: true,
727
+ thread: existing,
728
+ agent,
729
+ displayName,
730
+ threadId,
731
+ definition: existing.definition,
391
732
  };
733
+ }
392
734
 
393
- try {
394
- if (this.lifecycleFence || this.disposed || generation !== this.generation) return abortNow();
395
-
396
- if (continuing) {
397
- const key = options.threadKey ?? "";
398
- const existing = this.threads.get(key);
399
- if (!existing || existing.cwd !== ctx.cwd || existing.disposed) {
400
- const names = this.threadIds(ctx.cwd).join(", ") || "none";
401
- return fail(`Subagent thread ${key} is unavailable. Active threads: ${names}`, "discovery", {
402
- agent: key,
403
- threadId: key,
404
- model: parentModel,
405
- thinkingLevel: parentThinking,
406
- });
407
- }
408
- existing.pendingTurns += 1;
409
- reservedThread = existing;
410
- thread = existing;
411
- agent = existing.definition.name;
412
- displayName = existing.displayName;
413
- threadId = existing.id;
414
- definition = existing.definition;
415
- fanOut(
416
- {
417
- ...active.snapshot,
418
- agent,
419
- displayName,
420
- threadId,
421
- model: existing.model,
422
- thinkingLevel: existing.thinkingLevel,
423
- },
424
- true,
425
- );
426
- } else {
427
- phase = "discovery";
428
- const resolved = await resolveFreshDefinition();
429
- if (!this.isLive(generation, combined)) return abortNow();
430
- if (!resolved.ok) return fail(resolved.error, resolved.phase);
431
- definition = resolved.definition;
432
- agent = definition.name;
433
- displayName = this.assignDisplayName(definition);
434
- threadId = `thread-${this.nextThreadId++}`;
435
- fanOut({ ...active.snapshot, agent, displayName, threadId }, true);
436
-
437
- const reserved = this.reserveFreshCapacity();
438
- if (!reserved.ok) return fail(reserved.error, "queue");
439
- reservationToken = reserved.token;
440
- if (reserved.evicted) await this.disposeThread(reserved.evicted);
441
- }
735
+ private async prepareFreshDiscovery(options: {
736
+ generation: number;
737
+ combined: AbortSignal;
738
+ active: ActiveInvocation;
739
+ resolveFreshDefinition: () => Promise<
740
+ { ok: true; definition: AgentDefinition } | { ok: false; error: string; phase: SubagentPhase }
741
+ >;
742
+ fanOut: (details: SubagentDetails, force?: boolean) => void;
743
+ }): Promise<
744
+ | {
745
+ kind: "ok";
746
+ definition: AgentDefinition;
747
+ agent: string;
748
+ displayName: string;
749
+ threadId: string;
750
+ reservationToken: symbol;
751
+ }
752
+ | { kind: "aborted" }
753
+ | { kind: "failed"; error: string; phase: SubagentPhase }
754
+ > {
755
+ const { generation, combined, active, resolveFreshDefinition, fanOut } = options;
756
+ const resolved = await resolveFreshDefinition();
757
+ if (!this.isLive(generation, combined)) return { kind: "aborted" };
758
+ if (!resolved.ok) return { kind: "failed", error: resolved.error, phase: resolved.phase };
759
+ const definition = resolved.definition;
760
+ const agent = definition.name;
761
+ const displayName = this.assignDisplayName(definition);
762
+ const threadId = `thread-${this.nextThreadId++}`;
763
+ fanOut({ ...active.snapshot, agent, displayName, threadId }, true);
764
+ const reserved = this.reserveFreshCapacity();
765
+ if (!reserved.ok) return { kind: "failed", error: reserved.error, phase: "queue" };
766
+ if (reserved.evicted) await this.disposeThread(reserved.evicted);
767
+ return {
768
+ kind: "ok",
769
+ definition,
770
+ agent,
771
+ displayName,
772
+ threadId,
773
+ reservationToken: reserved.token,
774
+ };
775
+ }
442
776
 
443
- // FIFO barrier assigned at execute() entry — earlier tickets go first regardless of discovery speed.
444
- const admitted = await this.waitAdmitTicket(admitTicket, combined, generation);
445
- if (!admitted) return abortNow(phase, true);
777
+ private async acquireExecutionGates(options: {
778
+ thread: TrackedThread | undefined;
779
+ admitTicket: number;
780
+ combined: AbortSignal;
781
+ generation: number;
782
+ phase: SubagentPhase;
783
+ }): Promise<
784
+ | {
785
+ kind: "ok";
786
+ releaseThread: (() => void) | undefined;
787
+ releaseGlobal: () => void;
788
+ }
789
+ | { kind: "aborted"; phase: SubagentPhase; markAdmit: boolean; admitAdvanced: boolean }
790
+ | {
791
+ kind: "failed";
792
+ error: string;
793
+ admitAdvanced: boolean;
794
+ overrides: Partial<SubagentDetails>;
795
+ }
796
+ > {
797
+ const { thread, admitTicket, combined, generation, phase } = options;
798
+ // FIFO barrier assigned at execute() entry — earlier tickets go first regardless of discovery speed.
799
+ const admitted = await this.waitAdmitTicket(admitTicket, combined, generation);
800
+ if (!admitted) return { kind: "aborted", phase, markAdmit: true, admitAdvanced: false };
446
801
 
447
- if (thread) {
448
- releaseThread = await thread.turnGate.acquire(combined);
449
- if (thread.disposed || this.threads.get(thread.id) !== thread) {
450
- this.advanceAdmitTicket(admitTicket);
451
- admitAdvanced = true;
452
- return fail(`Subagent thread ${thread.id} is unavailable after queueing`, "queue", {
802
+ let releaseThread: (() => void) | undefined;
803
+ if (thread) {
804
+ releaseThread = await thread.turnGate.acquire(combined);
805
+ if (thread.disposed || this.threads.get(thread.id) !== thread) {
806
+ this.advanceAdmitTicket(admitTicket);
807
+ releaseThread();
808
+ return {
809
+ kind: "failed",
810
+ error: `Subagent thread ${thread.id} is unavailable after queueing`,
811
+ admitAdvanced: true,
812
+ overrides: {
453
813
  status: combined.aborted ? "aborted" : "failed",
454
814
  model: thread.model,
455
815
  thinkingLevel: thread.thinkingLevel,
456
816
  threadId: thread.id,
457
- });
458
- }
459
- }
460
- if (!this.isLive(generation, combined)) return abortNow(phase, true);
461
-
462
- try {
463
- releaseGlobal = await this.globalGate.acquire(combined);
464
- } catch {
465
- return abortNow(phase, true);
466
- }
467
- // Next ticket may compete for remaining global slots.
468
- this.advanceAdmitTicket(admitTicket);
469
- admitAdvanced = true;
470
- if (!this.isLive(generation, combined)) return abortNow();
471
-
472
- if (!thread) {
473
- phase = "startup";
474
- if (!threadId || !definition) throw new Error("Subagent startup state is incomplete");
475
- const selectedDefinition = definition;
476
- fanOut({ ...active.snapshot, status: "starting", phase: "startup", agent, threadId }, true);
477
- const created = asTracked(
478
- await createSubagentThread({
479
- id: threadId,
480
- displayName,
481
- definition: selectedDefinition,
482
- extensionPaths: extensionPathsForTools(this.pi, selectedDefinition.tools),
483
- initialTask: task,
484
- ctx,
485
- thinkingLevel: parentThinking,
486
- signal: combined,
487
- onWarning: (warning) => {
488
- const message = `Subagent definition ${selectedDefinition.path}: ${warning}`;
489
- if (this.runtimeWarnings.has(message)) return;
490
- this.runtimeWarnings.add(message);
491
- ctx.ui.notify(message, "warning");
492
- },
493
- }),
494
- );
495
- if (!this.isLive(generation, combined)) {
496
- await this.disposeThread(created);
497
- thread = created;
498
- return abortNow("startup");
499
- }
500
- provisionalThread = created;
501
- thread = created;
502
- thread.pendingTurns += 1;
503
- reservedThread = thread;
817
+ },
818
+ };
504
819
  }
820
+ }
821
+ if (!this.isLive(generation, combined)) {
822
+ releaseThread?.();
823
+ return { kind: "aborted", phase, markAdmit: true, admitAdvanced: false };
824
+ }
505
825
 
506
- let resumePrompt: string | undefined;
507
- if (
508
- continuing &&
509
- thread &&
510
- (thread.lastAssistantMessageAt === undefined ||
511
- this.now() - thread.lastAssistantMessageAt >= SUBAGENT_HOT_WINDOW_MS)
512
- ) {
513
- phase = "startup";
514
- fanOut({ ...active.snapshot, status: "starting", phase: "startup", agent, threadId: thread.id }, true);
515
- const oldResource = thread.resource;
516
- provisionalResource = await createIsolatedSessionResource(thread.sessionInputs, combined);
517
- if (!this.isLive(generation, combined) || thread.disposed || this.threads.get(thread.id) !== thread) {
518
- await provisionalResource.dispose();
519
- provisionalResource = undefined;
520
- if (this.threads.get(thread.id) === thread) this.threads.delete(thread.id);
521
- await this.disposeThread(thread);
522
- return abortNow("startup");
523
- }
524
- thread.resource = provisionalResource;
525
- provisionalResource = undefined;
526
- await oldResource.dispose();
527
- if (!this.isLive(generation, combined)) {
528
- if (this.threads.get(thread.id) === thread) this.threads.delete(thread.id);
529
- await this.disposeThread(thread);
530
- return abortNow("startup");
531
- }
532
- resumePrompt = buildColdResumePrompt({
533
- definition: thread.definition,
534
- state: thread.resumeState,
535
- followUp: task,
536
- hasAutoreadFiles: (options.files?.length ?? 0) > 0,
537
- });
538
- }
826
+ let releaseGlobal: () => void;
827
+ try {
828
+ releaseGlobal = await this.globalGate.acquire(combined);
829
+ } catch {
830
+ releaseThread?.();
831
+ return { kind: "aborted", phase, markAdmit: true, admitAdvanced: false };
832
+ }
833
+ // Next ticket may compete for remaining global slots.
834
+ this.advanceAdmitTicket(admitTicket);
835
+ if (!this.isLive(generation, combined)) {
836
+ // Caller keeps releaseThread/releaseGlobal unset; free them here before abort return.
837
+ releaseGlobal();
838
+ releaseThread?.();
839
+ return { kind: "aborted", phase, markAdmit: false, admitAdvanced: true };
840
+ }
841
+ return { kind: "ok", releaseThread, releaseGlobal };
842
+ }
539
843
 
540
- const result = await runSubagentTurn({
541
- thread,
542
- task,
543
- files: options.files,
544
- invocationId,
545
- initial: thread.turns === 0 || resumePrompt !== undefined,
546
- ...(resumePrompt === undefined ? {} : { resumePrompt }),
844
+ private async startFreshThread(options: {
845
+ threadId: string;
846
+ displayName: string;
847
+ definition: AgentDefinition;
848
+ task: string;
849
+ ctx: ExtensionContext;
850
+ parentThinking: NonNullable<ExtensionContext["thinkingLevel"]>;
851
+ combined: AbortSignal;
852
+ generation: number;
853
+ agent: string;
854
+ active: ActiveInvocation;
855
+ fanOut: (details: SubagentDetails, force?: boolean) => void;
856
+ }): Promise<{ kind: "ok"; thread: TrackedThread } | { kind: "aborted"; thread: TrackedThread }> {
857
+ const {
858
+ threadId,
859
+ displayName,
860
+ definition,
861
+ task,
862
+ ctx,
863
+ parentThinking,
864
+ combined,
865
+ generation,
866
+ agent,
867
+ active,
868
+ fanOut,
869
+ } = options;
870
+ fanOut({ ...active.snapshot, status: "starting", phase: "startup", agent, threadId }, true);
871
+ const created = asTracked(
872
+ await createSubagentThread({
873
+ id: threadId,
874
+ displayName,
875
+ definition,
876
+ extensionPaths: extensionPathsForTools(this.pi, definition.tools),
877
+ initialTask: task,
878
+ ctx,
879
+ thinkingLevel: parentThinking,
547
880
  signal: combined,
548
- onUpdate: (details) => {
549
- fanOut({ ...details, invocationId, threadId: thread?.id ?? threadId });
881
+ onWarning: (warning) => {
882
+ const message = `Subagent definition ${definition.path}: ${warning}`;
883
+ if (this.runtimeWarnings.has(message)) return;
884
+ this.runtimeWarnings.add(message);
885
+ ctx.ui.notify(message, "warning");
550
886
  },
551
- });
887
+ }),
888
+ );
889
+ if (!this.isLive(generation, combined)) {
890
+ await this.disposeThread(created);
891
+ return { kind: "aborted", thread: created };
892
+ }
893
+ created.pendingTurns += 1;
894
+ return { kind: "ok", thread: created };
895
+ }
552
896
 
553
- if (!this.isLive(generation, combined)) {
554
- const details = {
555
- ...result.details,
556
- status: "aborted" as const,
557
- invocationId,
558
- error: result.details.error ?? "Subagent session reset",
559
- };
560
- fanOut(details, true);
561
- this.releaseReservation(reservationToken);
562
- reservationToken = undefined;
563
- if (provisionalThread) {
564
- await this.disposeThread(provisionalThread);
565
- provisionalThread = undefined;
566
- } else if (thread && this.threads.get(thread.id) === thread) {
567
- this.threads.delete(thread.id);
568
- await this.disposeThread(thread);
569
- }
570
- return {
571
- content: [{ type: "text", text: details.error ?? "aborted" }],
572
- details,
573
- ...(result.usage === undefined ? {} : { usage: result.usage }),
574
- };
575
- }
897
+ private async coldResumeIfNeeded(options: {
898
+ continuing: boolean;
899
+ thread: TrackedThread;
900
+ generation: number;
901
+ combined: AbortSignal;
902
+ task: string;
903
+ files: readonly string[] | undefined;
904
+ agent: string;
905
+ active: ActiveInvocation;
906
+ fanOut: (details: SubagentDetails, force?: boolean) => void;
907
+ }): Promise<
908
+ | { kind: "ok"; resumePrompt?: string; phase?: SubagentPhase; provisionalResource?: IsolatedSessionResource }
909
+ | { kind: "aborted"; provisionalResource?: IsolatedSessionResource }
910
+ > {
911
+ const { continuing, thread, generation, combined, task, files, agent, active, fanOut } = options;
912
+ const needsCold =
913
+ continuing &&
914
+ (thread.lastAssistantMessageAt === undefined ||
915
+ this.now() - thread.lastAssistantMessageAt >= SUBAGENT_HOT_WINDOW_MS);
916
+ if (!needsCold) return { kind: "ok" };
576
917
 
577
- lastPublishedStatus = result.details.status;
578
- if (result.retainable) {
579
- thread.resumeState = retainSubagentTurn(thread.resumeState, {
580
- task,
581
- outcome: result.terminalOutcome,
582
- terminalText: result.content,
583
- files: options.files ?? [],
584
- });
585
- thread.lastAssistantMessageAt = result.assistantMessageEndAt.at(-1);
586
- }
918
+ fanOut({ ...active.snapshot, status: "starting", phase: "startup", agent, threadId: thread.id }, true);
919
+ const oldResource = thread.resource;
920
+ const provisionalResource = await createIsolatedSessionResource(thread.sessionInputs, combined);
921
+ if (!this.isLive(generation, combined) || thread.disposed || this.threads.get(thread.id) !== thread) {
922
+ await provisionalResource.dispose();
923
+ if (this.threads.get(thread.id) === thread) this.threads.delete(thread.id);
924
+ await this.disposeThread(thread);
925
+ return { kind: "aborted" };
926
+ }
927
+ thread.resource = provisionalResource;
928
+ await oldResource.dispose();
929
+ if (!this.isLive(generation, combined)) {
930
+ if (this.threads.get(thread.id) === thread) this.threads.delete(thread.id);
931
+ await this.disposeThread(thread);
932
+ return { kind: "aborted" };
933
+ }
934
+ return {
935
+ kind: "ok",
936
+ phase: "startup",
937
+ resumePrompt: buildColdResumePrompt({
938
+ definition: thread.definition,
939
+ state: thread.resumeState,
940
+ followUp: task,
941
+ hasAutoreadFiles: (files?.length ?? 0) > 0,
942
+ }),
943
+ };
944
+ }
587
945
 
588
- // Drop reservation before publish so capacity never reads above 16.
589
- this.releaseReservation(reservationToken);
590
- reservationToken = undefined;
591
- const retained = await this.finalizeThreadRetention({
592
- generation,
593
- continuing,
594
- thread,
595
- provisional: provisionalThread === thread,
596
- result,
597
- });
598
- provisionalThread = undefined;
946
+ private async abortAfterTurn(options: {
947
+ result: {
948
+ details: SubagentDetails;
949
+ usage?: Usage;
950
+ };
951
+ invocationId: string;
952
+ fanOut: (details: SubagentDetails, force?: boolean) => void;
953
+ reservationToken: symbol | undefined;
954
+ provisionalThread: TrackedThread | undefined;
955
+ thread: TrackedThread;
956
+ }): Promise<SubagentToolResult> {
957
+ const { result, invocationId, fanOut, reservationToken, provisionalThread, thread } = options;
958
+ const details = {
959
+ ...result.details,
960
+ status: "aborted" as const,
961
+ invocationId,
962
+ error: result.details.error ?? "Subagent session reset",
963
+ };
964
+ fanOut(details, true);
965
+ this.releaseReservation(reservationToken);
966
+ if (provisionalThread) await this.disposeThread(provisionalThread);
967
+ else if (this.threads.get(thread.id) === thread) {
968
+ this.threads.delete(thread.id);
969
+ await this.disposeThread(thread);
970
+ }
971
+ return {
972
+ content: [{ type: "text", text: details.error ?? "aborted" }],
973
+ details,
974
+ ...(result.usage === undefined ? {} : { usage: result.usage }),
975
+ };
976
+ }
599
977
 
600
- if (!retained) {
601
- return {
602
- content: [{ type: "text", text: result.content }],
603
- details: { ...result.details, displayName: thread.displayName, invocationId, threadId: thread.id },
604
- ...(result.usage === undefined ? {} : { usage: result.usage }),
605
- };
606
- }
978
+ private toolResultFromTurn(
979
+ result: {
980
+ content: string;
981
+ details: SubagentDetails;
982
+ usage?: Usage;
983
+ },
984
+ thread: TrackedThread,
985
+ invocationId: string,
986
+ retained: boolean,
987
+ ): SubagentToolResult {
988
+ const details = {
989
+ ...result.details,
990
+ displayName: thread.displayName,
991
+ invocationId,
992
+ threadId: thread.id,
993
+ };
994
+ const text = retained
995
+ ? `Thread: ${thread.id}\nReuse with subagent({ thread: "${thread.id}", task: "..." })\n\n${result.content}`
996
+ : result.content;
997
+ return {
998
+ content: [{ type: "text", text }],
999
+ details,
1000
+ ...(result.usage === undefined ? {} : { usage: result.usage }),
1001
+ };
1002
+ }
607
1003
 
608
- return {
609
- content: [
610
- {
611
- type: "text",
612
- text: `Thread: ${thread.id}\nReuse with subagent({ thread: "${thread.id}", task: "..." })\n\n${result.content}`,
613
- },
614
- ],
615
- details: { ...result.details, displayName: thread.displayName, invocationId, threadId: thread.id },
616
- ...(result.usage === undefined ? {} : { usage: result.usage }),
617
- };
618
- } catch (error) {
619
- const message = error instanceof Error ? error.message : `Agent ${agent} ${phase} failed`;
620
- const status: SubagentLifecycle = combined.aborted ? "aborted" : "failed";
621
- const details = baseDetails({
622
- agent,
1004
+ private async retainAndFinishTurn(options: {
1005
+ result: {
1006
+ content: string;
1007
+ details: SubagentDetails;
1008
+ retainable: boolean;
1009
+ terminalOutcome: RetainedTurnOutcome;
1010
+ assistantMessageEndAt: readonly number[];
1011
+ usage?: Usage;
1012
+ };
1013
+ thread: TrackedThread;
1014
+ task: string;
1015
+ files: readonly string[] | undefined;
1016
+ generation: number;
1017
+ continuing: boolean;
1018
+ provisionalThread: TrackedThread | undefined;
1019
+ invocationId: string;
1020
+ }): Promise<SubagentToolResult> {
1021
+ const { result, thread, task, files, generation, continuing, provisionalThread, invocationId } = options;
1022
+ if (result.retainable) {
1023
+ thread.resumeState = retainSubagentTurn(thread.resumeState, {
623
1024
  task,
624
- status,
625
- phase,
626
- model: thread?.model ?? parentModel,
627
- thinkingLevel: thread?.thinkingLevel ?? parentThinking,
628
- displayName: thread?.displayName ?? displayName,
629
- threadId: thread?.id ?? threadId,
630
- invocationId,
631
- error: message,
1025
+ outcome: result.terminalOutcome,
1026
+ terminalText: result.content,
1027
+ files: files ?? [],
632
1028
  });
633
- fanOut(details, true);
634
- if (provisionalResource) {
635
- await provisionalResource.dispose().catch(() => undefined);
636
- provisionalResource = undefined;
637
- }
638
- if (provisionalThread) {
639
- await this.disposeThread(provisionalThread);
640
- provisionalThread = undefined;
641
- } else if (thread && continuing && this.threads.get(thread.id) === thread) {
642
- this.threads.delete(thread.id);
643
- await this.disposeThread(thread);
644
- }
645
- return { content: [{ type: "text", text: message }], details };
646
- } finally {
647
- if (!admitAdvanced) this.advanceAdmitTicket(admitTicket);
648
- this.releaseReservation(reservationToken);
649
- if (provisionalThread && this.threads.get(provisionalThread.id) !== provisionalThread) {
650
- await this.disposeThread(provisionalThread).catch(() => undefined);
651
- }
652
- if (provisionalResource) await provisionalResource.dispose().catch(() => undefined);
653
- if (reservedThread) reservedThread.pendingTurns = Math.max(0, reservedThread.pendingTurns - 1);
654
- releaseGlobal?.();
655
- releaseThread?.();
656
- this.controllers.delete(controller);
657
- this.invocations.delete(invocationId);
1029
+ thread.lastAssistantMessageAt = result.assistantMessageEndAt.at(-1);
1030
+ }
1031
+ const retained = await this.finalizeThreadRetention({
1032
+ generation,
1033
+ continuing,
1034
+ thread,
1035
+ provisional: provisionalThread === thread,
1036
+ result,
1037
+ });
1038
+ return this.toolResultFromTurn(result, thread, invocationId, retained);
1039
+ }
1040
+
1041
+ private async handleExecuteError(options: {
1042
+ error: unknown;
1043
+ agent: string;
1044
+ displayName: string;
1045
+ task: string;
1046
+ phase: SubagentPhase;
1047
+ parentModel: string;
1048
+ parentThinking: string;
1049
+ thread: TrackedThread | undefined;
1050
+ threadId: string | undefined;
1051
+ invocationId: string;
1052
+ combined: AbortSignal;
1053
+ continuing: boolean;
1054
+ provisionalResource: IsolatedSessionResource | undefined;
1055
+ provisionalThread: TrackedThread | undefined;
1056
+ fanOut: (details: SubagentDetails, force?: boolean) => void;
1057
+ }): Promise<SubagentToolResult> {
1058
+ const {
1059
+ error,
1060
+ agent,
1061
+ displayName,
1062
+ task,
1063
+ phase,
1064
+ parentModel,
1065
+ parentThinking,
1066
+ thread,
1067
+ threadId,
1068
+ invocationId,
1069
+ combined,
1070
+ continuing,
1071
+ provisionalResource,
1072
+ provisionalThread,
1073
+ fanOut,
1074
+ } = options;
1075
+ const message = error instanceof Error ? error.message : `Agent ${agent} ${phase} failed`;
1076
+ const status: SubagentLifecycle = combined.aborted ? "aborted" : "failed";
1077
+ const details = baseDetails({
1078
+ agent,
1079
+ task,
1080
+ status,
1081
+ phase,
1082
+ model: thread?.model ?? parentModel,
1083
+ thinkingLevel: thread?.thinkingLevel ?? parentThinking,
1084
+ displayName: thread?.displayName ?? displayName,
1085
+ threadId: thread?.id ?? threadId,
1086
+ invocationId,
1087
+ error: message,
1088
+ });
1089
+ fanOut(details, true);
1090
+ if (provisionalResource) await provisionalResource.dispose().catch(() => undefined);
1091
+ if (provisionalThread) await this.disposeThread(provisionalThread);
1092
+ else if (thread && continuing && this.threads.get(thread.id) === thread) {
1093
+ this.threads.delete(thread.id);
1094
+ await this.disposeThread(thread);
658
1095
  }
1096
+ return { content: [{ type: "text", text: message }], details };
659
1097
  }
660
1098
 
661
1099
  private async waitAdmitTicket(ticket: number, signal: AbortSignal, generation: number): Promise<boolean> {