@shanepadgett/tau-agent 0.26.0 → 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.
@@ -2,20 +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 =
7
- | "typeScript"
8
- | "tsx"
9
- | "odin"
10
- | "go"
11
- | "rust"
12
- | "cSharp"
13
- | "java"
14
- | "kotlin"
15
- | "swift"
16
- | "markdown";
7
+ export type { AstLanguage } from "./ast-languages.ts";
17
8
 
18
- 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
+ };
19
31
 
20
32
  export interface SourcePosition {
21
33
  line: number;
@@ -45,7 +57,7 @@ export interface OutlineEntry {
45
57
  locator?: string;
46
58
  }
47
59
 
48
- export type SymbolView = "signature" | "declaration" | "declarationWithImports";
60
+ export type SymbolView = "signature" | "signatureWithDocs" | "declaration" | "declarationWithImports";
49
61
 
50
62
  export interface OutlineItem extends OutlineEntry {
51
63
  rowKind: "package" | "import" | "declaration" | "export" | "sideEffect";
@@ -71,12 +83,43 @@ export interface OutlineTargetResult {
71
83
  totalLineCount: number;
72
84
  }
73
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
+
74
116
  export interface SymbolDeclaration {
75
117
  locator: string;
76
118
  path: string;
77
119
  language: AstLanguage;
78
120
  sourceFingerprint: string;
79
121
  declarationRange: SourceRange;
122
+ diagnostics: string[];
80
123
  }
81
124
 
82
125
  export interface SymbolBlock {
@@ -91,6 +134,266 @@ export interface SymbolBatchResult {
91
134
  blocks: SymbolBlock[];
92
135
  }
93
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
+
94
397
  export interface AstClient {
95
398
  getGeneration(): number;
96
399
  outline(
@@ -100,37 +403,133 @@ export interface AstClient {
100
403
  names: string[],
101
404
  signal: AbortSignal | undefined,
102
405
  ): Promise<OutlineTargetResult>;
406
+ outlineRecursive(
407
+ path: string,
408
+ includePrivate: boolean,
409
+ includeDocs: boolean,
410
+ names: string[],
411
+ callbacks: RecursiveOutlineCallbacks,
412
+ signal: AbortSignal | undefined,
413
+ ): Promise<RecursiveOutlineSummary>;
103
414
  symbol(
104
415
  locators: string[],
105
416
  view: SymbolView,
106
417
  contextLines: number,
107
418
  signal: AbortSignal | undefined,
108
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>;
109
442
  shutdown(): Promise<void>;
110
443
  }
111
444
 
112
445
  type WorkerRequestPayload =
113
446
  | { operation: "handshake" }
114
- | { operation: "outline"; target: OutlineTarget; includePrivate: boolean; includeDocs: boolean; names: string[] }
115
- | { operation: "symbol"; locators: string[]; view: SymbolView; 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
+ };
116
490
 
117
491
  interface WorkerResponse {
118
492
  requestId: number;
119
493
  protocolVersion: number;
120
494
  success: boolean;
121
495
  result?: Record<string, unknown>;
122
- error?: { code?: string; message?: string };
496
+ error?: { code?: string; message?: string; sourceFingerprint?: string };
123
497
  }
124
498
 
125
- 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";
126
513
  resolve(value: Record<string, unknown>): void;
127
514
  reject(error: Error): void;
128
515
  removeAbortListener(): void;
129
516
  }
130
517
 
131
- const PROTOCOL_VERSION = 5;
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;
132
530
  const MAX_FRAME_BYTES = 8 * 1024 * 1024;
133
531
  const STDERR_BYTES = 16 * 1024;
532
+ const HANDSHAKE_TIMEOUT_MS = 2000;
134
533
 
135
534
  export type AstWorkerResolution = { command: string } | { error: Error };
136
535
 
@@ -144,12 +543,14 @@ export function resolveAstWorkerCommand(
144
543
 
145
544
  const sourceRoot = join(packageRoot, "native", "tau-ast");
146
545
  if (existsSync(join(sourceRoot, "Cargo.toml")))
147
- 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
+ };
148
549
 
149
550
  if (platform === "darwin" && arch === "arm64")
150
551
  return {
151
552
  error: new Error(
152
- "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.",
153
554
  ),
154
555
  };
155
556
  return {
@@ -159,6 +560,10 @@ export function resolveAstWorkerCommand(
159
560
  };
160
561
  }
161
562
 
563
+ function resolveDefaultAstWorkerCommand(): AstWorkerResolution {
564
+ return resolveAstWorkerCommand(fileURLToPath(new URL("../../", import.meta.url)), process.platform, process.arch);
565
+ }
566
+
162
567
  export class AstWorkerClient implements AstClient {
163
568
  private readonly command: string | undefined;
164
569
  private readonly args: readonly string[];
@@ -167,8 +572,10 @@ export class AstWorkerClient implements AstClient {
167
572
  private pending = new Map<number, PendingRequest>();
168
573
  private nextRequestId = 1;
169
574
  private incoming = Buffer.alloc(0);
575
+ private readonly drainingChildren = new Set<ChildProcessWithoutNullStreams>();
170
576
  private stderr = "";
171
577
  private generation = 0;
578
+ private capabilities: readonly AstLanguage[] | undefined;
172
579
 
173
580
  constructor(command: string | undefined = undefined, args: readonly string[] = []) {
174
581
  this.command = command;
@@ -179,6 +586,16 @@ export class AstWorkerClient implements AstClient {
179
586
  return this.generation;
180
587
  }
181
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
+
182
599
  async outline(
183
600
  target: OutlineTarget,
184
601
  includePrivate: boolean,
@@ -191,6 +608,27 @@ export class AstWorkerClient implements AstClient {
191
608
  return result as unknown as OutlineTargetResult;
192
609
  }
193
610
 
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
+
194
632
  async symbol(
195
633
  locators: string[],
196
634
  view: SymbolView,
@@ -202,11 +640,66 @@ export class AstWorkerClient implements AstClient {
202
640
  return result as unknown as SymbolBatchResult;
203
641
  }
204
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
+
205
697
  async shutdown(): Promise<void> {
206
698
  const child = this.child;
207
699
  if (!child) return;
208
700
  this.child = undefined;
209
701
  this.startPromise = undefined;
702
+ this.capabilities = undefined;
210
703
  this.rejectPending(new Error("tau-ast worker shut down"));
211
704
  child.stdin.end();
212
705
  if (child.exitCode !== null) return;
@@ -238,11 +731,11 @@ export class AstWorkerClient implements AstClient {
238
731
  }
239
732
 
240
733
  private async start(): Promise<void> {
241
- const resolution = this.command
242
- ? { command: this.command }
243
- : resolveAstWorkerCommand(fileURLToPath(new URL("../../", import.meta.url)), process.platform, process.arch);
734
+ const resolution = this.resolveCommand();
244
735
  if ("error" in resolution) throw resolution.error;
245
- 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
+ });
246
739
  this.generation += 1;
247
740
  this.child = child;
248
741
  this.incoming = Buffer.alloc(0);
@@ -259,17 +752,59 @@ export class AstWorkerClient implements AstClient {
259
752
  const suffix = this.stderr.trim() ? `: ${this.stderr.trim()}` : "";
260
753
  this.fail(child, new Error(`tau-ast exited (${signal ?? code ?? "unknown"})${suffix}`), false);
261
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
+ );
262
759
  try {
263
760
  const result = await this.send({ operation: "handshake" }, undefined);
264
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);
265
766
  } catch (error) {
266
767
  const failure = error instanceof Error ? error : new Error(String(error));
267
768
  this.fail(child, failure);
268
769
  throw failure;
770
+ } finally {
771
+ clearTimeout(handshakeTimeout);
269
772
  }
270
773
  }
271
774
 
272
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> {
273
808
  const child = this.child;
274
809
  if (!child) return Promise.reject(new Error("tau-ast worker is not running"));
275
810
  if (signal?.aborted) return Promise.reject(new Error("tau-ast request cancelled"));
@@ -279,14 +814,13 @@ export class AstWorkerClient implements AstClient {
279
814
  frame.writeUInt32BE(payload.length, 0);
280
815
  payload.copy(frame, 4);
281
816
 
282
- return new Promise<Record<string, unknown>>((resolve, reject) => {
817
+ return new Promise<T>((resolve, reject) => {
283
818
  const onAbort = (): void => this.fail(child, new Error("tau-ast request cancelled"));
284
819
  signal?.addEventListener("abort", onAbort, { once: true });
285
- this.pending.set(requestId, {
286
- resolve,
287
- reject,
288
- removeAbortListener: () => signal?.removeEventListener("abort", onAbort),
289
- });
820
+ this.pending.set(
821
+ requestId,
822
+ createPending(resolve, reject, () => signal?.removeEventListener("abort", onAbort)),
823
+ );
290
824
  child.stdin.write(frame, (error) => {
291
825
  if (error) this.fail(child, new Error(`Failed to write tau-ast request: ${error.message}`));
292
826
  });
@@ -296,44 +830,112 @@ export class AstWorkerClient implements AstClient {
296
830
  private receive(child: ChildProcessWithoutNullStreams, chunk: Buffer): void {
297
831
  if (this.child !== child) return;
298
832
  this.incoming = Buffer.concat([this.incoming, chunk]);
299
- while (this.incoming.length >= 4) {
300
- const length = this.incoming.readUInt32BE(0);
301
- if (length > MAX_FRAME_BYTES) {
302
- this.fail(child, new Error(`tau-ast response frame exceeds ${MAX_FRAME_BYTES} bytes`));
303
- return;
304
- }
305
- if (this.incoming.length < length + 4) return;
306
- const payload = this.incoming.subarray(4, length + 4);
307
- this.incoming = this.incoming.subarray(length + 4);
308
- let response: WorkerResponse;
309
- try {
310
- response = JSON.parse(payload.toString("utf8")) as WorkerResponse;
311
- } catch (error) {
312
- this.fail(child, new Error(`tau-ast returned malformed JSON: ${String(error)}`));
313
- return;
314
- }
315
- const pending = this.pending.get(response.requestId);
316
- if (!pending) {
317
- this.fail(child, new Error(`tau-ast returned unknown request id ${String(response.requestId)}`));
318
- return;
319
- }
320
- this.pending.delete(response.requestId);
321
- pending.removeAbortListener();
322
- if (response.protocolVersion !== PROTOCOL_VERSION) {
323
- const error = new Error(`tau-ast response used protocol ${response.protocolVersion}`);
324
- pending.reject(error);
325
- 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)}`));
326
933
  return;
327
934
  }
328
- if (!response.success) {
329
- pending.reject(new Error(response.error?.message ?? response.error?.code ?? "tau-ast request failed"));
330
- continue;
331
- }
332
- if (!response.result || typeof response.result !== "object") {
333
- pending.reject(new Error("tau-ast response omitted its result"));
334
- continue;
335
- }
336
- 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);
337
939
  }
338
940
  }
339
941
 
@@ -342,6 +944,7 @@ export class AstWorkerClient implements AstClient {
342
944
  this.child = undefined;
343
945
  this.generation += 1;
344
946
  this.incoming = Buffer.alloc(0);
947
+ this.capabilities = undefined;
345
948
  this.rejectPending(error);
346
949
  if (kill && child.exitCode === null) child.kill();
347
950
  }
@@ -354,3 +957,69 @@ export class AstWorkerClient implements AstClient {
354
957
  this.pending.clear();
355
958
  }
356
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
+ }