@shanepadgett/tau-agent 0.25.1 → 0.27.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 (32) hide show
  1. package/docs/subagents.md +1 -1
  2. package/extensions/appshot/README.md +1 -1
  3. package/extensions/appshot/index.ts +5 -11
  4. package/extensions/context-pruning/README.md +2 -2
  5. package/extensions/context-pruning/index.ts +58 -57
  6. package/extensions/context-pruning/render.ts +51 -39
  7. package/extensions/context-pruning/settings.ts +4 -5
  8. package/extensions/explore/README.md +18 -10
  9. package/extensions/explore/ast-guidance.ts +67 -0
  10. package/extensions/explore/ast-languages.ts +61 -0
  11. package/extensions/explore/ast-tools.ts +1597 -94
  12. package/extensions/explore/ast-worker.ts +768 -60
  13. package/extensions/explore/index.ts +62 -6
  14. package/extensions/explore/orientation-state.ts +280 -0
  15. package/extensions/explore/read-stats-panel.ts +23 -1
  16. package/extensions/explore/read-stats.ts +21 -3
  17. package/extensions/explore/read.ts +58 -21
  18. package/extensions/explore/settings.ts +46 -0
  19. package/extensions/explore/traverse.ts +15 -4
  20. package/extensions/patch/executor.ts +95 -1
  21. package/extensions/patch/index.ts +1 -0
  22. package/extensions/soul/prompt.ts +1 -3
  23. package/extensions/subagent/agents/review.md +76 -25
  24. package/extensions/subagent/agents/scout.md +118 -22
  25. package/extensions/subagent/session-resource.ts +1 -1
  26. package/extensions/tau-help/help.md +3 -1
  27. package/native-bin/darwin-arm64/tau-ast +0 -0
  28. package/package.json +2 -3
  29. package/schemas/tau.schema.json +44 -4
  30. package/shared/bounded-text-result.ts +304 -0
  31. package/shared/events.ts +2 -1
  32. package/shared/temporary-output-store.ts +275 -0
@@ -2,10 +2,32 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { isAstLanguage, type AstLanguage } from "./ast-languages.ts";
5
6
 
6
- export type AstLanguage = "typeScript" | "tsx" | "odin" | "go" | "rust" | "cSharp" | "java" | "kotlin" | "swift";
7
+ export type { AstLanguage } from "./ast-languages.ts";
7
8
 
8
- export type OutlineTarget = { kind: "file"; path: string; language: AstLanguage } | { kind: "directory"; path: string };
9
+ export type OutlineTarget =
10
+ | { kind: "file"; path: string; language: AstLanguage }
11
+ | { kind: "directory"; path: string }
12
+ | {
13
+ kind: "recursiveDirectory";
14
+ path: string;
15
+ budgets: RecursiveOutlineBudgets;
16
+ };
17
+
18
+ export interface RecursiveOutlineBudgets {
19
+ maxFiles: number;
20
+ maxSourceBytes: number;
21
+ maxDepth: number;
22
+ maxElapsedMs: number;
23
+ }
24
+
25
+ const RECURSIVE_OUTLINE_BUDGETS: RecursiveOutlineBudgets = {
26
+ maxFiles: 2000,
27
+ maxSourceBytes: 64 * 1024 * 1024,
28
+ maxDepth: 32,
29
+ maxElapsedMs: 20_000,
30
+ };
9
31
 
10
32
  export interface SourcePosition {
11
33
  line: number;
@@ -23,13 +45,22 @@ export interface OutlineEntry {
23
45
  role: "item" | "member";
24
46
  symbolType: string;
25
47
  name: string;
48
+ qualifiedName: string;
26
49
  range: SourceRange;
50
+ nameRange: SourceRange;
51
+ receiverRange?: SourceRange;
52
+ bodyRange?: SourceRange;
27
53
  signature: string;
28
54
  astKind: string;
29
- locator: string;
55
+ certainty: "certain" | "recovered" | "nearRecovery";
56
+ certaintyReason?: string;
57
+ locator?: string;
30
58
  }
31
59
 
60
+ export type SymbolView = "signature" | "signatureWithDocs" | "declaration" | "declarationWithImports";
61
+
32
62
  export interface OutlineItem extends OutlineEntry {
63
+ rowKind: "package" | "import" | "declaration" | "export" | "sideEffect";
33
64
  isImport: boolean;
34
65
  isExported: boolean;
35
66
  members: Array<OutlineEntry & { isPublic: boolean }>;
@@ -52,12 +83,43 @@ export interface OutlineTargetResult {
52
83
  totalLineCount: number;
53
84
  }
54
85
 
86
+ export interface RecursiveOutlineDiagnostic {
87
+ relativePath: string;
88
+ language: AstLanguage | undefined;
89
+ code: string;
90
+ message: string;
91
+ sourceFingerprint: string | undefined;
92
+ }
93
+
94
+ export interface RecursiveOutlineSummary {
95
+ discoveredFiles: number;
96
+ supportedFiles: number;
97
+ unsupportedFiles: number;
98
+ emittedFiles: number;
99
+ unreadableFiles: number;
100
+ oversizedFiles: number;
101
+ failedFiles: number;
102
+ parserDegradedFiles: number;
103
+ totalByteLength: number;
104
+ totalLineCount: number;
105
+ fileLimitReached: boolean;
106
+ sourceByteLimitReached: boolean;
107
+ depthLimitReached: boolean;
108
+ elapsedLimitReached: boolean;
109
+ }
110
+
111
+ export interface RecursiveOutlineCallbacks {
112
+ onFile(relativePath: string, file: OutlineFileResult): Promise<void>;
113
+ onDiagnostic(diagnostic: RecursiveOutlineDiagnostic): Promise<void>;
114
+ }
115
+
55
116
  export interface SymbolDeclaration {
56
117
  locator: string;
57
118
  path: string;
58
119
  language: AstLanguage;
59
120
  sourceFingerprint: string;
60
121
  declarationRange: SourceRange;
122
+ diagnostics: string[];
61
123
  }
62
124
 
63
125
  export interface SymbolBlock {
@@ -72,39 +134,402 @@ export interface SymbolBatchResult {
72
134
  blocks: SymbolBlock[];
73
135
  }
74
136
 
137
+ export type ApiDeclarationKind =
138
+ | "module"
139
+ | "namespace"
140
+ | "package"
141
+ | "class"
142
+ | "method"
143
+ | "property"
144
+ | "field"
145
+ | "constructor"
146
+ | "enum"
147
+ | "interface"
148
+ | "function"
149
+ | "variable"
150
+ | "constant"
151
+ | "object"
152
+ | "enumMember"
153
+ | "struct"
154
+ | "event"
155
+ | "operator"
156
+ | "typeParameter"
157
+ | "heading";
158
+
159
+ export type ApiQuery =
160
+ | { kind: "exactName"; name: string }
161
+ | { kind: "prefixName"; name: string }
162
+ | { kind: "substringName"; name: string }
163
+ | { kind: "fuzzyName"; name: string; maxCandidates: number; maxWork: number }
164
+ | { kind: "declarationKind"; declarationKind: ApiDeclarationKind }
165
+ | { kind: "documentation"; terms: string[]; maxCandidates: number; maxWork: number };
166
+
167
+ export type ApiSurfaceFilter = "all" | "public" | "private" | "sourceExport" | "packageSurface";
168
+
169
+ export interface ApiCandidate {
170
+ locator: string;
171
+ language: AstLanguage;
172
+ sourceFingerprint: string;
173
+ name: string;
174
+ qualifiedName: string;
175
+ symbolType: ApiDeclarationKind;
176
+ signature: string;
177
+ definingFile: string;
178
+ range: SourceRange;
179
+ visibility: "public" | "protected" | "internal" | "packagePrivate" | "filePrivate" | "private" | "unknown";
180
+ sourceExport: "yes" | "no" | "unknown";
181
+ packageSurface: "yes" | "no" | "unknown";
182
+ internalOnly: "yes" | "no" | "unknown";
183
+ reExportChain: string[];
184
+ callerAccess: {
185
+ modulePath: string;
186
+ importStatement: string;
187
+ accessExpression: string;
188
+ form: "direct" | "qualified";
189
+ } | null;
190
+ provenance: "exact" | "inferred" | "ambiguous" | "unsupported";
191
+ certainty: "certain" | "recovered" | "nearRecovery";
192
+ certaintyReason: string | null;
193
+ uncertainty: string | null;
194
+ }
195
+
196
+ export interface ApiDiscoverySummary {
197
+ filesScanned: number;
198
+ declarationsConsidered: number;
199
+ resultsReturned: number;
200
+ resultLimit: number;
201
+ omittedCandidates: number;
202
+ candidateLimitReached: boolean;
203
+ workLimitReached: boolean;
204
+ resolutionDiagnostics: number;
205
+ totalSourceBytes: number;
206
+ fileLimitReached: boolean;
207
+ sourceByteLimitReached: boolean;
208
+ depthLimitReached: boolean;
209
+ elapsedLimitReached: boolean;
210
+ }
211
+
212
+ export interface ApiDiscoveryResult {
213
+ path: string;
214
+ candidates: ApiCandidate[];
215
+ summary: ApiDiscoverySummary;
216
+ }
217
+
218
+ export interface AstSearchBindingValue {
219
+ range: SourceRange;
220
+ preview: string;
221
+ previewTruncated: boolean;
222
+ }
223
+
224
+ export interface AstSearchBinding {
225
+ name: string;
226
+ values: AstSearchBindingValue[];
227
+ valuesTruncated: boolean;
228
+ }
229
+
230
+ export interface AstSearchScope {
231
+ astKind: string;
232
+ range: SourceRange;
233
+ preview: string;
234
+ previewTruncated: boolean;
235
+ locator: string;
236
+ }
237
+
238
+ export interface AstSearchMatch {
239
+ relativePath: string;
240
+ language: AstLanguage;
241
+ sourceFingerprint: string;
242
+ range: SourceRange;
243
+ preview: string;
244
+ previewTruncated: boolean;
245
+ bindings: AstSearchBinding[];
246
+ bindingsTruncated: boolean;
247
+ certainty: "certain" | "recovered" | "nearRecovery";
248
+ certaintyReason?: string;
249
+ locator: string;
250
+ enclosingScope?: AstSearchScope;
251
+ }
252
+
253
+ export interface AstSearchSummary {
254
+ filesDiscovered: number;
255
+ filesFiltered: number;
256
+ languageFilteredFiles: number;
257
+ literalFilteredFiles: number;
258
+ filesRead: number;
259
+ filesParsed: number;
260
+ filesSearched: number;
261
+ unreadableFiles: number;
262
+ oversizedFiles: number;
263
+ failedFiles: number;
264
+ parserDegradedFiles: number;
265
+ sourceBytes: number;
266
+ matchesFound: number;
267
+ matchesReturned: number;
268
+ resultLimit: number;
269
+ resultLimitReached: boolean;
270
+ literalPrefilterApplied: boolean;
271
+ potentialKindPrefilterApplied: boolean;
272
+ diagnosticsOmitted: number;
273
+ fileLimitReached: boolean;
274
+ sourceByteLimitReached: boolean;
275
+ depthLimitReached: boolean;
276
+ elapsedLimitReached: boolean;
277
+ }
278
+
279
+ export interface AstSearchResult {
280
+ path: string;
281
+ language: AstLanguage;
282
+ pattern: string;
283
+ targetSourceFingerprint: string | null;
284
+ matches: AstSearchMatch[];
285
+ diagnostics: Array<{ relativePath: string; code: string; message: string }>;
286
+ summary: AstSearchSummary;
287
+ }
288
+
289
+ export type RelationshipOperation = "references" | "callers" | "callees" | "implementations" | "tests";
290
+
291
+ export interface EditableScope {
292
+ locator: string;
293
+ language: AstLanguage;
294
+ kind: string;
295
+ qualifiedIdentity: string;
296
+ range: SourceRange;
297
+ bodyRange: SourceRange | null;
298
+ sourceFingerprint: string;
299
+ certainty: "certain" | "recovered" | "nearRecovery";
300
+ certaintyReason: string | null;
301
+ }
302
+
303
+ export interface RelationshipLocation {
304
+ relativePath: string;
305
+ language: AstLanguage;
306
+ sourceFingerprint: string;
307
+ range: SourceRange;
308
+ relationshipKind:
309
+ | "reference"
310
+ | "typeUsage"
311
+ | "caller"
312
+ | "callee"
313
+ | "implementation"
314
+ | "override"
315
+ | "reExport"
316
+ | "test";
317
+ certainty: "exact" | "inferred" | "ambiguous";
318
+ parseCertainty: "certain" | "recovered" | "nearRecovery";
319
+ certaintyReason: string | null;
320
+ classification: "production" | "test" | "generated" | "reExport";
321
+ targetLocator: string;
322
+ targetPath: string;
323
+ targetSourceFingerprint: string;
324
+ candidateLocators: string[];
325
+ candidatePaths: string[];
326
+ candidateSourceFingerprints: string[];
327
+ competingCandidatesOmitted: number;
328
+ actionable: boolean;
329
+ sitePreview: string;
330
+ sitePreviewTruncated: boolean;
331
+ enclosingScope: EditableScope;
332
+ }
333
+
334
+ export interface RelationshipResult {
335
+ path: string;
336
+ operation: RelationshipOperation;
337
+ targetName: string;
338
+ targetLocator: string;
339
+ relationships: RelationshipLocation[];
340
+ summary: {
341
+ filesScanned: number;
342
+ sourceBytes: number;
343
+ parserDegradedFiles: number;
344
+ relationshipsFound: number;
345
+ relationshipsReturned: number;
346
+ resultLimit: number;
347
+ resultLimitReached: boolean;
348
+ ambiguousRelationships: number;
349
+ diagnostics: number;
350
+ fileLimitReached: boolean;
351
+ sourceByteLimitReached: boolean;
352
+ depthLimitReached: boolean;
353
+ elapsedLimitReached: boolean;
354
+ };
355
+ }
356
+
357
+ export type EditOperation =
358
+ | { kind: "replaceDeclaration"; source: string }
359
+ | { kind: "replaceBody"; body: string }
360
+ | { kind: "insertDeclaration"; position: "before" | "after"; source: string }
361
+ | {
362
+ kind: "renameDeclaration";
363
+ newName: string;
364
+ scope: { kind: "file" } | { kind: "repository"; path: string };
365
+ includeInferred: boolean;
366
+ };
367
+
368
+ export interface PlannedEdit {
369
+ range: SourceRange;
370
+ replacement: string;
371
+ }
372
+
373
+ export interface EditFilePlan {
374
+ path: string;
375
+ expectedFingerprint: string;
376
+ source: string;
377
+ edits: PlannedEdit[];
378
+ }
379
+
380
+ export interface EditPlanResult {
381
+ files: EditFilePlan[];
382
+ skippedImpacts: Array<{
383
+ path: string;
384
+ range: SourceRange;
385
+ reason: "ambiguous" | "uncertainParse" | "inferredNotApproved";
386
+ candidateLocators: string[];
387
+ candidatePaths: string[];
388
+ }>;
389
+ freshLocators: Array<{
390
+ locator: string;
391
+ path: string;
392
+ name: string;
393
+ sourceFingerprint: string;
394
+ }>;
395
+ }
396
+
75
397
  export interface AstClient {
398
+ getGeneration(): number;
76
399
  outline(
77
400
  target: OutlineTarget,
78
401
  includePrivate: boolean,
402
+ includeDocs: boolean,
79
403
  names: string[],
80
404
  signal: AbortSignal | undefined,
81
405
  ): Promise<OutlineTargetResult>;
82
- symbol(locators: string[], contextLines: number, signal: AbortSignal | undefined): Promise<SymbolBatchResult>;
406
+ outlineRecursive(
407
+ path: string,
408
+ includePrivate: boolean,
409
+ includeDocs: boolean,
410
+ names: string[],
411
+ callbacks: RecursiveOutlineCallbacks,
412
+ signal: AbortSignal | undefined,
413
+ ): Promise<RecursiveOutlineSummary>;
414
+ symbol(
415
+ locators: string[],
416
+ view: SymbolView,
417
+ contextLines: number,
418
+ signal: AbortSignal | undefined,
419
+ ): Promise<SymbolBatchResult>;
420
+ discoverApi(
421
+ path: string,
422
+ query: ApiQuery,
423
+ surface: ApiSurfaceFilter,
424
+ resultLimit: number,
425
+ signal: AbortSignal | undefined,
426
+ ): Promise<ApiDiscoveryResult>;
427
+ search(
428
+ path: string,
429
+ language: AstLanguage,
430
+ pattern: string,
431
+ resultLimit: number,
432
+ signal: AbortSignal | undefined,
433
+ ): Promise<AstSearchResult>;
434
+ relationships(
435
+ path: string,
436
+ locator: string,
437
+ relationship: RelationshipOperation,
438
+ resultLimit: number,
439
+ signal: AbortSignal | undefined,
440
+ ): Promise<RelationshipResult>;
441
+ planEdit(locator: string, edit: EditOperation, signal: AbortSignal | undefined): Promise<EditPlanResult>;
83
442
  shutdown(): Promise<void>;
84
443
  }
85
444
 
86
445
  type WorkerRequestPayload =
87
446
  | { operation: "handshake" }
88
- | { operation: "outline"; target: OutlineTarget; includePrivate: boolean; names: string[] }
89
- | { operation: "symbol"; locators: string[]; contextLines: number };
447
+ | {
448
+ operation: "outline";
449
+ target: OutlineTarget;
450
+ includePrivate: boolean;
451
+ includeDocs: boolean;
452
+ names: string[];
453
+ }
454
+ | {
455
+ operation: "symbol";
456
+ locators: string[];
457
+ view: SymbolView;
458
+ contextLines: number;
459
+ }
460
+ | {
461
+ operation: "apiDiscover";
462
+ path: string;
463
+ budgets: RecursiveOutlineBudgets;
464
+ query: ApiQuery;
465
+ surface: ApiSurfaceFilter;
466
+ resultLimit: number;
467
+ }
468
+ | {
469
+ operation: "astSearch";
470
+ path: string;
471
+ language: AstLanguage;
472
+ budgets: RecursiveOutlineBudgets;
473
+ pattern: string;
474
+ resultLimit: number;
475
+ }
476
+ | {
477
+ operation: "relationships";
478
+ path: string;
479
+ budgets: RecursiveOutlineBudgets;
480
+ locator: string;
481
+ relationship: RelationshipOperation;
482
+ resultLimit: number;
483
+ }
484
+ | {
485
+ operation: "planEdit";
486
+ locator: string;
487
+ edit: EditOperation;
488
+ budgets: RecursiveOutlineBudgets;
489
+ };
90
490
 
91
491
  interface WorkerResponse {
92
492
  requestId: number;
93
493
  protocolVersion: number;
94
494
  success: boolean;
95
495
  result?: Record<string, unknown>;
96
- error?: { code?: string; message?: string };
496
+ error?: { code?: string; message?: string; sourceFingerprint?: string };
97
497
  }
98
498
 
99
- interface PendingRequest {
499
+ export class AstWorkerError extends Error {
500
+ readonly code: string;
501
+ readonly sourceFingerprint: string | undefined;
502
+
503
+ constructor(code: string, message: string, sourceFingerprint: string | undefined = undefined) {
504
+ super(message);
505
+ this.name = "AstWorkerError";
506
+ this.code = code;
507
+ this.sourceFingerprint = sourceFingerprint;
508
+ }
509
+ }
510
+
511
+ interface PendingUnaryRequest {
512
+ kind: "unary";
100
513
  resolve(value: Record<string, unknown>): void;
101
514
  reject(error: Error): void;
102
515
  removeAbortListener(): void;
103
516
  }
104
517
 
105
- const PROTOCOL_VERSION = 2;
518
+ interface PendingStreamRequest {
519
+ kind: "recursiveOutline";
520
+ started: boolean;
521
+ callbacks: RecursiveOutlineCallbacks;
522
+ resolve(value: RecursiveOutlineSummary): void;
523
+ reject(error: Error): void;
524
+ removeAbortListener(): void;
525
+ }
526
+
527
+ type PendingRequest = PendingUnaryRequest | PendingStreamRequest;
528
+
529
+ const PROTOCOL_VERSION = 13;
106
530
  const MAX_FRAME_BYTES = 8 * 1024 * 1024;
107
531
  const STDERR_BYTES = 16 * 1024;
532
+ const HANDSHAKE_TIMEOUT_MS = 2000;
108
533
 
109
534
  export type AstWorkerResolution = { command: string } | { error: Error };
110
535
 
@@ -118,12 +543,14 @@ export function resolveAstWorkerCommand(
118
543
 
119
544
  const sourceRoot = join(packageRoot, "native", "tau-ast");
120
545
  if (existsSync(join(sourceRoot, "Cargo.toml")))
121
- return { command: join(sourceRoot, "target", "release", `tau-ast${platform === "win32" ? ".exe" : ""}`) };
546
+ return {
547
+ command: join(sourceRoot, "target", "release", `tau-ast${platform === "win32" ? ".exe" : ""}`),
548
+ };
122
549
 
123
550
  if (platform === "darwin" && arch === "arm64")
124
551
  return {
125
552
  error: new Error(
126
- "tau-ast is missing from this @shanepadgett/tau-agent installation. Reinstall the package before using outline or symbol.",
553
+ "tau-ast is missing from this @shanepadgett/tau-agent installation. Reinstall the package before using api_discover, ast_search, outline, or symbol.",
127
554
  ),
128
555
  };
129
556
  return {
@@ -133,6 +560,10 @@ export function resolveAstWorkerCommand(
133
560
  };
134
561
  }
135
562
 
563
+ function resolveDefaultAstWorkerCommand(): AstWorkerResolution {
564
+ return resolveAstWorkerCommand(fileURLToPath(new URL("../../", import.meta.url)), process.platform, process.arch);
565
+ }
566
+
136
567
  export class AstWorkerClient implements AstClient {
137
568
  private readonly command: string | undefined;
138
569
  private readonly args: readonly string[];
@@ -141,35 +572,134 @@ export class AstWorkerClient implements AstClient {
141
572
  private pending = new Map<number, PendingRequest>();
142
573
  private nextRequestId = 1;
143
574
  private incoming = Buffer.alloc(0);
575
+ private readonly drainingChildren = new Set<ChildProcessWithoutNullStreams>();
144
576
  private stderr = "";
577
+ private generation = 0;
578
+ private capabilities: readonly AstLanguage[] | undefined;
145
579
 
146
580
  constructor(command: string | undefined = undefined, args: readonly string[] = []) {
147
581
  this.command = command;
148
582
  this.args = args;
149
583
  }
150
584
 
585
+ getGeneration(): number {
586
+ return this.generation;
587
+ }
588
+
589
+ private resolveCommand(): AstWorkerResolution {
590
+ return this.command ? { command: this.command } : resolveDefaultAstWorkerCommand();
591
+ }
592
+
593
+ async supportedLanguages(): Promise<readonly AstLanguage[]> {
594
+ await this.ensureStarted();
595
+ if (!this.capabilities) throw new Error("tau-ast handshake omitted supported languages");
596
+ return this.capabilities;
597
+ }
598
+
151
599
  async outline(
152
600
  target: OutlineTarget,
153
601
  includePrivate: boolean,
602
+ includeDocs: boolean,
154
603
  names: string[],
155
604
  signal: AbortSignal | undefined,
156
605
  ): Promise<OutlineTargetResult> {
157
- const result = await this.request({ operation: "outline", target, includePrivate, names }, signal);
606
+ const result = await this.request({ operation: "outline", target, includePrivate, includeDocs, names }, signal);
158
607
  if (result.kind !== "outline") throw new Error("tau-ast returned the wrong result for outline");
159
608
  return result as unknown as OutlineTargetResult;
160
609
  }
161
610
 
162
- async symbol(locators: string[], contextLines: number, signal: AbortSignal | undefined): Promise<SymbolBatchResult> {
163
- const result = await this.request({ operation: "symbol", locators, contextLines }, signal);
611
+ async outlineRecursive(
612
+ path: string,
613
+ includePrivate: boolean,
614
+ includeDocs: boolean,
615
+ names: string[],
616
+ callbacks: RecursiveOutlineCallbacks,
617
+ signal: AbortSignal | undefined,
618
+ ): Promise<RecursiveOutlineSummary> {
619
+ await this.ensureStarted();
620
+ const target: OutlineTarget = {
621
+ kind: "recursiveDirectory",
622
+ path,
623
+ budgets: RECURSIVE_OUTLINE_BUDGETS,
624
+ };
625
+ return this.sendRecursive(
626
+ { operation: "outline", target, includePrivate, includeDocs, names },
627
+ callbacks,
628
+ signal,
629
+ );
630
+ }
631
+
632
+ async symbol(
633
+ locators: string[],
634
+ view: SymbolView,
635
+ contextLines: number,
636
+ signal: AbortSignal | undefined,
637
+ ): Promise<SymbolBatchResult> {
638
+ const result = await this.request({ operation: "symbol", locators, view, contextLines }, signal);
164
639
  if (result.kind !== "symbol") throw new Error("tau-ast returned the wrong result for symbol");
165
640
  return result as unknown as SymbolBatchResult;
166
641
  }
167
642
 
643
+ async discoverApi(
644
+ path: string,
645
+ query: ApiQuery,
646
+ surface: ApiSurfaceFilter,
647
+ resultLimit: number,
648
+ signal: AbortSignal | undefined,
649
+ ): Promise<ApiDiscoveryResult> {
650
+ const result = await this.request(
651
+ { operation: "apiDiscover", path, budgets: RECURSIVE_OUTLINE_BUDGETS, query, surface, resultLimit },
652
+ signal,
653
+ );
654
+ if (result.kind !== "apiDiscovery") throw new Error("tau-ast returned the wrong result for API discovery");
655
+ return result as unknown as ApiDiscoveryResult;
656
+ }
657
+
658
+ async search(
659
+ path: string,
660
+ language: AstLanguage,
661
+ pattern: string,
662
+ resultLimit: number,
663
+ signal: AbortSignal | undefined,
664
+ ): Promise<AstSearchResult> {
665
+ const result = await this.request(
666
+ { operation: "astSearch", path, language, budgets: RECURSIVE_OUTLINE_BUDGETS, pattern, resultLimit },
667
+ signal,
668
+ );
669
+ if (result.kind !== "astSearch") throw new Error("tau-ast returned the wrong result for ast_search");
670
+ return result as unknown as AstSearchResult;
671
+ }
672
+
673
+ async relationships(
674
+ path: string,
675
+ locator: string,
676
+ relationship: RelationshipOperation,
677
+ resultLimit: number,
678
+ signal: AbortSignal | undefined,
679
+ ): Promise<RelationshipResult> {
680
+ const result = await this.request(
681
+ { operation: "relationships", path, budgets: RECURSIVE_OUTLINE_BUDGETS, locator, relationship, resultLimit },
682
+ signal,
683
+ );
684
+ if (result.kind !== "relationships") throw new Error("tau-ast returned the wrong result for relationships");
685
+ return result as unknown as RelationshipResult;
686
+ }
687
+
688
+ async planEdit(locator: string, edit: EditOperation, signal: AbortSignal | undefined): Promise<EditPlanResult> {
689
+ const result = await this.request(
690
+ { operation: "planEdit", locator, edit, budgets: RECURSIVE_OUTLINE_BUDGETS },
691
+ signal,
692
+ );
693
+ if (result.kind !== "editPlan") throw new Error("tau-ast returned the wrong result for edit planning");
694
+ return result as unknown as EditPlanResult;
695
+ }
696
+
168
697
  async shutdown(): Promise<void> {
169
698
  const child = this.child;
170
699
  if (!child) return;
171
700
  this.child = undefined;
172
701
  this.startPromise = undefined;
702
+ this.capabilities = undefined;
173
703
  this.rejectPending(new Error("tau-ast worker shut down"));
174
704
  child.stdin.end();
175
705
  if (child.exitCode !== null) return;
@@ -201,11 +731,12 @@ export class AstWorkerClient implements AstClient {
201
731
  }
202
732
 
203
733
  private async start(): Promise<void> {
204
- const resolution = this.command
205
- ? { command: this.command }
206
- : resolveAstWorkerCommand(fileURLToPath(new URL("../../", import.meta.url)), process.platform, process.arch);
734
+ const resolution = this.resolveCommand();
207
735
  if ("error" in resolution) throw resolution.error;
208
- const child = spawn(resolution.command, this.args, { stdio: ["pipe", "pipe", "pipe"] });
736
+ const child = spawn(resolution.command, this.args, {
737
+ stdio: ["pipe", "pipe", "pipe"],
738
+ });
739
+ this.generation += 1;
209
740
  this.child = child;
210
741
  this.incoming = Buffer.alloc(0);
211
742
  this.stderr = "";
@@ -221,17 +752,59 @@ export class AstWorkerClient implements AstClient {
221
752
  const suffix = this.stderr.trim() ? `: ${this.stderr.trim()}` : "";
222
753
  this.fail(child, new Error(`tau-ast exited (${signal ?? code ?? "unknown"})${suffix}`), false);
223
754
  });
755
+ const handshakeTimeout = setTimeout(
756
+ () => this.fail(child, new Error(`tau-ast handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms`)),
757
+ HANDSHAKE_TIMEOUT_MS,
758
+ );
224
759
  try {
225
760
  const result = await this.send({ operation: "handshake" }, undefined);
226
761
  if (result.kind !== "handshake") throw new Error("tau-ast handshake returned the wrong result");
762
+ if (!Array.isArray(result.supportedLanguages)) {
763
+ throw new Error("tau-ast handshake omitted supported languages");
764
+ }
765
+ this.capabilities = result.supportedLanguages.filter(isAstLanguage);
227
766
  } catch (error) {
228
767
  const failure = error instanceof Error ? error : new Error(String(error));
229
768
  this.fail(child, failure);
230
769
  throw failure;
770
+ } finally {
771
+ clearTimeout(handshakeTimeout);
231
772
  }
232
773
  }
233
774
 
234
775
  private send(request: WorkerRequestPayload, signal: AbortSignal | undefined): Promise<Record<string, unknown>> {
776
+ return this.sendPending(request, signal, (resolve, reject, removeAbortListener) => ({
777
+ kind: "unary",
778
+ resolve,
779
+ reject,
780
+ removeAbortListener,
781
+ }));
782
+ }
783
+
784
+ private sendRecursive(
785
+ request: WorkerRequestPayload,
786
+ callbacks: RecursiveOutlineCallbacks,
787
+ signal: AbortSignal | undefined,
788
+ ): Promise<RecursiveOutlineSummary> {
789
+ return this.sendPending(request, signal, (resolve, reject, removeAbortListener) => ({
790
+ kind: "recursiveOutline",
791
+ started: false,
792
+ callbacks,
793
+ resolve,
794
+ reject,
795
+ removeAbortListener,
796
+ }));
797
+ }
798
+
799
+ private sendPending<T>(
800
+ request: WorkerRequestPayload,
801
+ signal: AbortSignal | undefined,
802
+ createPending: (
803
+ resolve: (value: T) => void,
804
+ reject: (error: Error) => void,
805
+ removeAbortListener: () => void,
806
+ ) => PendingRequest,
807
+ ): Promise<T> {
235
808
  const child = this.child;
236
809
  if (!child) return Promise.reject(new Error("tau-ast worker is not running"));
237
810
  if (signal?.aborted) return Promise.reject(new Error("tau-ast request cancelled"));
@@ -241,14 +814,13 @@ export class AstWorkerClient implements AstClient {
241
814
  frame.writeUInt32BE(payload.length, 0);
242
815
  payload.copy(frame, 4);
243
816
 
244
- return new Promise<Record<string, unknown>>((resolve, reject) => {
817
+ return new Promise<T>((resolve, reject) => {
245
818
  const onAbort = (): void => this.fail(child, new Error("tau-ast request cancelled"));
246
819
  signal?.addEventListener("abort", onAbort, { once: true });
247
- this.pending.set(requestId, {
248
- resolve,
249
- reject,
250
- removeAbortListener: () => signal?.removeEventListener("abort", onAbort),
251
- });
820
+ this.pending.set(
821
+ requestId,
822
+ createPending(resolve, reject, () => signal?.removeEventListener("abort", onAbort)),
823
+ );
252
824
  child.stdin.write(frame, (error) => {
253
825
  if (error) this.fail(child, new Error(`Failed to write tau-ast request: ${error.message}`));
254
826
  });
@@ -258,51 +830,121 @@ export class AstWorkerClient implements AstClient {
258
830
  private receive(child: ChildProcessWithoutNullStreams, chunk: Buffer): void {
259
831
  if (this.child !== child) return;
260
832
  this.incoming = Buffer.concat([this.incoming, chunk]);
261
- while (this.incoming.length >= 4) {
262
- const length = this.incoming.readUInt32BE(0);
263
- if (length > MAX_FRAME_BYTES) {
264
- this.fail(child, new Error(`tau-ast response frame exceeds ${MAX_FRAME_BYTES} bytes`));
265
- return;
266
- }
267
- if (this.incoming.length < length + 4) return;
268
- const payload = this.incoming.subarray(4, length + 4);
269
- this.incoming = this.incoming.subarray(length + 4);
270
- let response: WorkerResponse;
271
- try {
272
- response = JSON.parse(payload.toString("utf8")) as WorkerResponse;
273
- } catch (error) {
274
- this.fail(child, new Error(`tau-ast returned malformed JSON: ${String(error)}`));
275
- return;
276
- }
277
- const pending = this.pending.get(response.requestId);
278
- if (!pending) {
279
- this.fail(child, new Error(`tau-ast returned unknown request id ${String(response.requestId)}`));
280
- return;
281
- }
282
- this.pending.delete(response.requestId);
283
- pending.removeAbortListener();
284
- if (response.protocolVersion !== PROTOCOL_VERSION) {
285
- const error = new Error(`tau-ast response used protocol ${response.protocolVersion}`);
286
- pending.reject(error);
287
- this.fail(child, error);
833
+ void this.drain(child);
834
+ }
835
+
836
+ private async drain(child: ChildProcessWithoutNullStreams): Promise<void> {
837
+ if (this.drainingChildren.has(child) || this.child !== child) return;
838
+ this.drainingChildren.add(child);
839
+ try {
840
+ while (this.child === child && this.incoming.length >= 4) {
841
+ const length = this.incoming.readUInt32BE(0);
842
+ if (length > MAX_FRAME_BYTES) {
843
+ this.fail(child, new Error(`tau-ast response frame exceeds ${MAX_FRAME_BYTES} bytes`));
844
+ return;
845
+ }
846
+ if (this.incoming.length < length + 4) return;
847
+ const payload = this.incoming.subarray(4, length + 4);
848
+ this.incoming = this.incoming.subarray(length + 4);
849
+ let response: WorkerResponse;
850
+ try {
851
+ response = JSON.parse(payload.toString("utf8")) as WorkerResponse;
852
+ } catch (error) {
853
+ this.fail(child, new Error(`tau-ast returned malformed JSON: ${String(error)}`));
854
+ return;
855
+ }
856
+ const pending = this.pending.get(response.requestId);
857
+ if (!pending) {
858
+ this.fail(child, new Error(`tau-ast returned unknown request id ${String(response.requestId)}`));
859
+ return;
860
+ }
861
+ if (response.protocolVersion !== PROTOCOL_VERSION) {
862
+ const error = new Error(`tau-ast response used protocol ${response.protocolVersion}`);
863
+ this.fail(child, error);
864
+ return;
865
+ }
866
+ if (!response.success) {
867
+ this.pending.delete(response.requestId);
868
+ pending.removeAbortListener();
869
+ pending.reject(
870
+ new AstWorkerError(
871
+ response.error?.code ?? "worker_error",
872
+ response.error?.message ?? response.error?.code ?? "tau-ast request failed",
873
+ response.error?.sourceFingerprint,
874
+ ),
875
+ );
876
+ continue;
877
+ }
878
+ if (!response.result || typeof response.result !== "object") {
879
+ this.fail(child, new Error("tau-ast response omitted its result"));
880
+ return;
881
+ }
882
+ if (pending.kind === "unary") {
883
+ this.pending.delete(response.requestId);
884
+ pending.removeAbortListener();
885
+ pending.resolve(response.result);
886
+ continue;
887
+ }
888
+ const kind = response.result.kind;
889
+ if (kind === "recursiveStart") {
890
+ if (pending.started || typeof response.result.path !== "string" || !isRecord(response.result.budgets)) {
891
+ this.fail(child, new Error("tau-ast recursive outline returned more than one start frame"));
892
+ return;
893
+ }
894
+ pending.started = true;
895
+ continue;
896
+ }
897
+ if (!pending.started) {
898
+ this.fail(child, new Error("tau-ast recursive outline omitted its start frame"));
899
+ return;
900
+ }
901
+ if (kind === "recursiveFile") {
902
+ const file = parseRecursiveFile(response.result);
903
+ if (!file) {
904
+ this.fail(child, new Error("tau-ast recursive file frame is malformed"));
905
+ return;
906
+ }
907
+ await pending.callbacks.onFile(response.result.relativePath as string, file);
908
+ if (this.child !== child) return;
909
+ continue;
910
+ }
911
+ if (kind === "recursiveDiagnostic") {
912
+ const diagnostic = parseRecursiveDiagnostic(response.result);
913
+ if (!diagnostic) {
914
+ this.fail(child, new Error("tau-ast recursive diagnostic frame is malformed"));
915
+ return;
916
+ }
917
+ await pending.callbacks.onDiagnostic(diagnostic);
918
+ if (this.child !== child) return;
919
+ continue;
920
+ }
921
+ if (kind === "recursiveComplete") {
922
+ const summary = parseRecursiveSummary(response.result);
923
+ if (!summary) {
924
+ this.fail(child, new Error("tau-ast recursive completion frame is malformed"));
925
+ return;
926
+ }
927
+ this.pending.delete(response.requestId);
928
+ pending.removeAbortListener();
929
+ pending.resolve(summary);
930
+ continue;
931
+ }
932
+ this.fail(child, new Error(`tau-ast recursive outline returned unexpected frame ${String(kind)}`));
288
933
  return;
289
934
  }
290
- if (!response.success) {
291
- pending.reject(new Error(response.error?.message ?? response.error?.code ?? "tau-ast request failed"));
292
- continue;
293
- }
294
- if (!response.result || typeof response.result !== "object") {
295
- pending.reject(new Error("tau-ast response omitted its result"));
296
- continue;
297
- }
298
- pending.resolve(response.result);
935
+ } catch (error) {
936
+ this.fail(child, error instanceof Error ? error : new Error(String(error)));
937
+ } finally {
938
+ this.drainingChildren.delete(child);
299
939
  }
300
940
  }
301
941
 
302
942
  private fail(child: ChildProcessWithoutNullStreams, error: Error, kill = true): void {
303
943
  if (this.child !== child) return;
304
944
  this.child = undefined;
945
+ this.generation += 1;
305
946
  this.incoming = Buffer.alloc(0);
947
+ this.capabilities = undefined;
306
948
  this.rejectPending(error);
307
949
  if (kill && child.exitCode === null) child.kill();
308
950
  }
@@ -315,3 +957,69 @@ export class AstWorkerClient implements AstClient {
315
957
  this.pending.clear();
316
958
  }
317
959
  }
960
+
961
+ function parseRecursiveFile(result: Record<string, unknown>): OutlineFileResult | undefined {
962
+ if (typeof result.relativePath !== "string" || !isRecord(result.file)) return undefined;
963
+ const file = result.file;
964
+ if (
965
+ typeof file.path !== "string" ||
966
+ !isAstLanguage(file.language) ||
967
+ typeof file.sourceFingerprint !== "string" ||
968
+ !isCount(file.byteLength) ||
969
+ !isCount(file.lineCount) ||
970
+ !isRecord(file.diagnostics) ||
971
+ !isCount(file.diagnostics.errorNodes) ||
972
+ !isCount(file.diagnostics.missingNodes) ||
973
+ !Array.isArray(file.items)
974
+ ) {
975
+ return undefined;
976
+ }
977
+ return file as unknown as OutlineFileResult;
978
+ }
979
+
980
+ function parseRecursiveDiagnostic(result: Record<string, unknown>): RecursiveOutlineDiagnostic | undefined {
981
+ if (
982
+ typeof result.relativePath !== "string" ||
983
+ typeof result.code !== "string" ||
984
+ typeof result.message !== "string" ||
985
+ (result.language !== undefined && !isAstLanguage(result.language)) ||
986
+ (result.sourceFingerprint !== undefined && typeof result.sourceFingerprint !== "string")
987
+ ) {
988
+ return undefined;
989
+ }
990
+ return {
991
+ relativePath: result.relativePath,
992
+ language: result.language as AstLanguage | undefined,
993
+ code: result.code,
994
+ message: result.message,
995
+ sourceFingerprint: result.sourceFingerprint as string | undefined,
996
+ };
997
+ }
998
+
999
+ function parseRecursiveSummary(result: Record<string, unknown>): RecursiveOutlineSummary | undefined {
1000
+ const counts = [
1001
+ "discoveredFiles",
1002
+ "supportedFiles",
1003
+ "unsupportedFiles",
1004
+ "emittedFiles",
1005
+ "unreadableFiles",
1006
+ "oversizedFiles",
1007
+ "failedFiles",
1008
+ "parserDegradedFiles",
1009
+ "totalByteLength",
1010
+ "totalLineCount",
1011
+ ] as const;
1012
+ const limits = ["fileLimitReached", "sourceByteLimitReached", "depthLimitReached", "elapsedLimitReached"] as const;
1013
+ if (counts.some((name) => !isCount(result[name])) || limits.some((name) => typeof result[name] !== "boolean")) {
1014
+ return undefined;
1015
+ }
1016
+ return result as unknown as RecursiveOutlineSummary;
1017
+ }
1018
+
1019
+ function isRecord(value: unknown): value is Record<string, unknown> {
1020
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1021
+ }
1022
+
1023
+ function isCount(value: unknown): value is number {
1024
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
1025
+ }