@shanepadgett/tau-agent 0.22.1 → 0.24.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.
package/docs/subagents.md CHANGED
@@ -26,7 +26,7 @@ Paths may be relative to the parent's current working directory or absolute. Tau
26
26
  | **User (global)** | `~/.pi/agent/tau/agents/*.md` | You want the agent in every project |
27
27
  | **Project** | nearest trusted `.pi/tau/agents/*.md` | Repo-specific helpers |
28
28
 
29
- Precedence: **project overrides user**, which overrides Tau's built-ins (`review`, `web-research`). Duplicate names in one scope are invalid.
29
+ Precedence: **project overrides user**, which overrides Tau's built-ins (`review`, `scout`, `web-research`). Duplicate names in one scope are invalid.
30
30
 
31
31
  ## Definition format
32
32
 
@@ -83,6 +83,7 @@ Tau assigns one display name to each fresh child and keeps it for follow-up turn
83
83
  ## Built-ins
84
84
 
85
85
  - `review` — adversarial, read-only review for correctness, runtime risks, duplication, and over- or under-engineering
86
+ - `scout` — find local files, symbols, data flow, constraints, and unknowns without changing anything
86
87
  - `web-research` — `websearch`, `codesearch`, `webfetch`
87
88
  - `context-sync` — maps meaningful uncommitted work into `.pi/contexts`. Offered to the coding agent when `extensions.context.sync.enabled` and `sync.automation` are true. Manual `/context-sync` remains when sync is enabled with `automation` false. Validation can auto-run it when `validation.enabled` and `sync.enabled`
88
89
 
@@ -2,8 +2,19 @@
2
2
 
3
3
  Explore is Tau's first-party filesystem exploration extension.
4
4
 
5
- It exists so agents can inspect paths, discover files, search text, and read file contents with compact model payloads and readable tool rows. Autoread establishes complete-file knowledge while that source remains in active context. Later reads can return an unchanged marker or a smaller diff instead of resending the file.
5
+ It exists so agents can inspect paths, discover files, search text, inspect declarations in supported source languages, and read exact source with compact model payloads and readable tool rows. Autoread establishes complete-file knowledge while that source remains in active context. Later reads can return an unchanged marker or a smaller diff instead of resending the file.
6
6
 
7
7
  When the source baseline is no longer available, such as after compaction or a cold subagent resume, `read` safely returns the current full source.
8
8
 
9
- Agents invoke it with `ls`, `find`, `grep`, and `read`. Users run `/read-stats` to see estimated token and cost savings for the session.
9
+ Agents invoke it with `ls`, `find`, `grep`, `outline`, `symbol`, and `read`. `outline` returns public declaration signatures and parenthesized numeric locators for TypeScript, TSX, Odin, Go, Rust, C#, Java, Kotlin, and Swift files. It also accepts a package directory, which inspects supported files directly inside it. Exact-name filters narrow the result, while `includePrivate` exposes internal declarations. `symbol` accepts those numbers to retrieve several exact declarations in one call, can add bounded surrounding lines, and rejects the whole batch when any locator is stale. Users run `/read-stats` to see estimated token and cost savings for the session.
10
+
11
+ Installed packages support `outline` and `symbol` on Apple Silicon Macs. They include the worker, so users do not need Rust or Cargo. On other platforms, the rest of Explore remains available and AST tools report the platform limit when invoked.
12
+
13
+ Use the cheapest useful step:
14
+
15
+ 1. Outline a package directory to discover its public API.
16
+ 2. Add exact names when likely declarations are known.
17
+ 3. Set `includePrivate` when implementation work needs internals.
18
+ 4. Send several locators to `symbol` for complete declarations.
19
+ 5. Add context lines when the edit needs nearby source.
20
+ 6. Use ranged or whole-file `read` for cross-cutting logic, exact formatting, or parser gaps.
@@ -0,0 +1,483 @@
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ defineTool,
5
+ formatSize,
6
+ keyHint,
7
+ truncateHead,
8
+ type Theme,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
11
+ import { stat } from "node:fs/promises";
12
+ import { basename, extname, resolve } from "node:path";
13
+ import { Type } from "typebox";
14
+ import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.js";
15
+ import { formatPathForDisplay, resolveExplorePath, stripLeadingAt } from "./path-display.ts";
16
+ import type {
17
+ AstClient,
18
+ AstLanguage,
19
+ OutlineEntry,
20
+ OutlineFileResult,
21
+ OutlineTarget,
22
+ OutlineTargetResult,
23
+ SymbolBatchResult,
24
+ } from "./ast-worker.ts";
25
+
26
+ const outlineParams = Type.Object(
27
+ {
28
+ path: Type.String({
29
+ description: "TypeScript, TSX, Odin, Go, Rust, C#, Java, Kotlin, or Swift source file or directory",
30
+ }),
31
+ includePrivate: Type.Optional(Type.Boolean({ description: "Include private declarations and members" })),
32
+ names: Type.Optional(
33
+ Type.Array(Type.String(), {
34
+ minItems: 1,
35
+ description: "Exact top-level or member declaration names",
36
+ }),
37
+ ),
38
+ },
39
+ { additionalProperties: false },
40
+ );
41
+ const symbolParams = Type.Object(
42
+ {
43
+ locators: Type.Array(Type.Integer({ minimum: 1 }), {
44
+ minItems: 1,
45
+ description: "Numeric locators shown in parentheses by outline",
46
+ }),
47
+ contextLines: Type.Optional(
48
+ Type.Integer({ minimum: 0, description: "Lines of source context before and after each declaration" }),
49
+ ),
50
+ },
51
+ { additionalProperties: false },
52
+ );
53
+
54
+ interface AstToolDetails {
55
+ kind: "outline" | "symbol";
56
+ result: OutlineTargetResult | SymbolBatchResult;
57
+ declarationCount: number;
58
+ sourceBytes: number;
59
+ returnedBytes: number;
60
+ avoidedBytes: number;
61
+ truncated: boolean;
62
+ }
63
+
64
+ interface LocatorRecord {
65
+ id: number;
66
+ token: string;
67
+ path: string;
68
+ name: string;
69
+ stale: boolean;
70
+ }
71
+
72
+ type OutlineArgs = { path: string; includePrivate?: boolean; names?: string[] };
73
+
74
+ export function createAstTools(client: AstClient, rowState: ToolRowStateStore) {
75
+ const locators = new Map<number, LocatorRecord>();
76
+ let nextLocator = 1;
77
+
78
+ function compact(
79
+ text: string,
80
+ sourceBytes: number,
81
+ declarationCount: number,
82
+ kind: AstToolDetails["kind"],
83
+ result: OutlineTargetResult | SymbolBatchResult,
84
+ ): { text: string; details: AstToolDetails } {
85
+ const truncation = truncateHead(text, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
86
+ const returned = truncation.truncated
87
+ ? `${truncation.content}\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`
88
+ : truncation.content;
89
+ const returnedBytes = Buffer.byteLength(returned);
90
+ return {
91
+ text: returned,
92
+ details: {
93
+ kind,
94
+ result,
95
+ declarationCount,
96
+ sourceBytes,
97
+ returnedBytes,
98
+ avoidedBytes: Math.max(0, sourceBytes - returnedBytes),
99
+ truncated: truncation.truncated,
100
+ },
101
+ };
102
+ }
103
+
104
+ function locator(entry: OutlineEntry, path: string): number {
105
+ const id = nextLocator++;
106
+ const record = { id, token: entry.locator, path: resolve(path), name: entry.name, stale: false };
107
+ locators.set(id, record);
108
+ return id;
109
+ }
110
+
111
+ function renderEntry(entry: OutlineEntry, path: string, language: AstLanguage, indent: string): string {
112
+ const lines =
113
+ entry.range.start.line === entry.range.end.line
114
+ ? `${entry.range.start.line + 1}`
115
+ : `${entry.range.start.line + 1}-${entry.range.end.line + 1}`;
116
+ let signature = entry.signature.replace(/\s+/g, " ").trim();
117
+ if (language === "odin") {
118
+ signature = signature
119
+ .replace(/\s*([(),:])\s*/g, "$1")
120
+ .replace(/\s*->\s*/g, "->")
121
+ .replace(/\s*:=\s*/g, ":=")
122
+ .replace(/\s*\{\s*$/, "");
123
+ }
124
+ const label = signature && signature.includes(entry.name) ? signature : `${entry.name} ${signature}`.trim();
125
+ return `${indent}${lines}(${locator(entry, path)}): ${label}`;
126
+ }
127
+
128
+ function renderOutlineFile(file: OutlineFileResult, cwd: string): string[] {
129
+ const lines = [
130
+ `${formatPathForDisplay(file.path, cwd)} (${file.language}, ${file.lineCount} lines, ${formatSize(file.byteLength)})`,
131
+ ];
132
+ if (file.diagnostics.errorNodes > 0 || file.diagnostics.missingNodes > 0) {
133
+ lines.push(
134
+ `warning: parser recovered with ${file.diagnostics.errorNodes} ERROR and ${file.diagnostics.missingNodes} MISSING nodes`,
135
+ );
136
+ }
137
+ const declarations = file.items.filter((item) => !item.isImport);
138
+ const groups = new Map<string, typeof declarations>();
139
+ for (const item of declarations) {
140
+ const visibility = item.isExported ? "public" : "private";
141
+ const key = `${visibility} ${item.symbolType}`;
142
+ const group = groups.get(key);
143
+ if (group) group.push(item);
144
+ else groups.set(key, [item]);
145
+ }
146
+ for (const [label, items] of groups) {
147
+ lines.push("", label);
148
+ for (const item of items) {
149
+ lines.push(renderEntry(item, file.path, file.language, ""));
150
+ for (const member of item.members) {
151
+ lines.push(renderEntry(member, file.path, file.language, " "));
152
+ }
153
+ }
154
+ }
155
+ return lines;
156
+ }
157
+
158
+ const outline = defineTool<typeof outlineParams, AstToolDetails>({
159
+ name: "outline",
160
+ label: "outline",
161
+ description:
162
+ "Inspect public declarations in one supported source file or non-recursive package directory without returning implementation bodies. Parenthesized numbers are locators for symbol.",
163
+ promptSnippet: "Inspect public declarations and get symbol locators without reading implementation bodies",
164
+ promptGuidelines: [
165
+ "Use a public package outline first to discover reusable APIs; add exact names when likely symbols are known.",
166
+ "Set includePrivate when internal implementation discovery is needed.",
167
+ "Treat each parenthesized number after a line range as that declaration's symbol locator.",
168
+ "Use symbol with several locators when complete declaration source is needed.",
169
+ ],
170
+ parameters: outlineParams,
171
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
172
+ const path = resolveExplorePath(ctx.cwd, params.path);
173
+ const metadata = await stat(path);
174
+ const target: OutlineTarget = metadata.isDirectory()
175
+ ? { kind: "directory", path }
176
+ : { kind: "file", path, language: languageForPath(path) };
177
+ const names = params.names ?? [];
178
+ const result = await client.outline(target, params.includePrivate ?? false, names, signal);
179
+ const lines = result.files.flatMap((file, index) => [
180
+ ...(index === 0 ? [] : [""]),
181
+ ...renderOutlineFile(file, ctx.cwd),
182
+ ]);
183
+ const declarationCount = result.files.reduce(
184
+ (count, file) =>
185
+ count +
186
+ file.items.reduce((fileCount, item) => fileCount + (item.isImport ? 0 : 1 + item.members.length), 0),
187
+ 0,
188
+ );
189
+ if (declarationCount === 0) lines.push(names.length > 0 ? "No matching declarations" : "No declarations");
190
+ const output = compact(lines.join("\n"), result.totalByteLength, declarationCount, "outline", result);
191
+ return { content: [{ type: "text", text: output.text }], details: output.details };
192
+ },
193
+ renderCall(args, theme, context) {
194
+ rowState.watch(context.toolCallId, context.invalidate);
195
+ const component =
196
+ (context.lastComponent as AstCallComponent | undefined) ??
197
+ new AstCallComponent(
198
+ rowState,
199
+ context.toolCallId,
200
+ "outline",
201
+ [stripLeadingAt(args.path)],
202
+ outlineOptionVariants(args),
203
+ theme,
204
+ );
205
+ component.set([stripLeadingAt(args.path)], outlineOptionVariants(args), theme);
206
+ return component;
207
+ },
208
+ renderResult(result, options, theme, context) {
209
+ rowState.watch(context.toolCallId, context.invalidate);
210
+ return renderAstResult(result, options.expanded, theme, context);
211
+ },
212
+ });
213
+
214
+ const symbol = defineTool<typeof symbolParams, AstToolDetails>({
215
+ name: "symbol",
216
+ label: "symbol",
217
+ description:
218
+ "Return exact declaration source for one or more numeric outline locators, with optional surrounding lines; stale locators fail atomically.",
219
+ promptSnippet: "Retrieve exact declaration source for several outline locators",
220
+ parameters: symbolParams,
221
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
222
+ const records = params.locators.map((id) => {
223
+ const record = locators.get(id);
224
+ if (!record) throw new Error(`Unknown symbol locator: ${id}. Run outline again.`);
225
+ if (record.stale) throw new Error(`Symbol locator ${id} is stale. Run outline again.`);
226
+ return record;
227
+ });
228
+ const result = await client.symbol(
229
+ [...new Set(records.map((record) => record.token))],
230
+ params.contextLines ?? 0,
231
+ signal,
232
+ );
233
+ const requestedByToken = new Map(records.map((record) => [record.token, record]));
234
+ const lines: string[] = [];
235
+ for (const [blockIndex, block] of result.blocks.entries()) {
236
+ if (blockIndex > 0) lines.push("");
237
+ const represented = block.declarationIndexes.flatMap((index) => {
238
+ const declaration = result.declarations[index];
239
+ const record = declaration ? requestedByToken.get(declaration.locator) : undefined;
240
+ return record ? [record] : [];
241
+ });
242
+ const range = block.returnedRange;
243
+ const lineRange =
244
+ range.start.line === range.end.line
245
+ ? `${range.start.line + 1}`
246
+ : `${range.start.line + 1}-${range.end.line + 1}`;
247
+ lines.push(
248
+ formatPathForDisplay(block.path, ctx.cwd),
249
+ `${lineRange}(${represented.map((record) => record.id).join(",")}): ${represented.map((record) => record.name).join(", ")}`,
250
+ block.source,
251
+ );
252
+ }
253
+ const sourceBytes = result.blocks.reduce((count, block) => count + Buffer.byteLength(block.source), 0);
254
+ const output = compact(lines.join("\n"), sourceBytes, result.declarations.length, "symbol", result);
255
+ return { content: [{ type: "text", text: output.text }], details: output.details };
256
+ },
257
+ renderCall(args, theme, context) {
258
+ rowState.watch(context.toolCallId, context.invalidate);
259
+ const targets = symbolTargetVariants(args.locators, locators);
260
+ const component =
261
+ (context.lastComponent as AstCallComponent | undefined) ??
262
+ new AstCallComponent(
263
+ rowState,
264
+ context.toolCallId,
265
+ "symbol",
266
+ targets,
267
+ [args.contextLines === undefined ? "" : `[context=${args.contextLines}]`],
268
+ theme,
269
+ );
270
+ component.set(targets, [args.contextLines === undefined ? "" : `[context=${args.contextLines}]`], theme);
271
+ return component;
272
+ },
273
+ renderResult(result, options, theme, context) {
274
+ rowState.watch(context.toolCallId, context.invalidate);
275
+ return renderAstResult(result, options.expanded, theme, context);
276
+ },
277
+ });
278
+
279
+ return {
280
+ outline,
281
+ symbol,
282
+ clear() {
283
+ locators.clear();
284
+ nextLocator = 1;
285
+ },
286
+ invalidate(paths: readonly string[]) {
287
+ const changed = new Set(paths.map((path) => resolve(path)));
288
+ for (const record of locators.values()) {
289
+ if (changed.has(record.path)) record.stale = true;
290
+ }
291
+ },
292
+ };
293
+ }
294
+
295
+ function renderAstResult(
296
+ result: { content: Array<{ type: string; text?: string }>; details?: AstToolDetails },
297
+ expanded: boolean,
298
+ theme: Theme,
299
+ context: { lastComponent?: Component; isError: boolean },
300
+ ): Text {
301
+ const details = result.details;
302
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
303
+ if (!expanded && !context.isError) {
304
+ const declarationCount = details?.declarationCount ?? 0;
305
+ const noun = declarationCount === 1 ? "declaration" : "declarations";
306
+ const byteSummary = details
307
+ ? `, ${formatSize(details.returnedBytes)} returned, ${formatSize(details.avoidedBytes)} avoided`
308
+ : "";
309
+ text.setText(
310
+ theme.fg("muted", `${declarationCount} ${noun}${byteSummary} (`) +
311
+ keyHint("app.tools.expand", "to expand") +
312
+ theme.fg("muted", ")"),
313
+ );
314
+ return text;
315
+ }
316
+
317
+ const output = result.content
318
+ .filter((item): item is { type: string; text: string } => item.type === "text" && typeof item.text === "string")
319
+ .map((item) => item.text)
320
+ .join("\n");
321
+ text.setText(
322
+ output
323
+ ? output
324
+ .split("\n")
325
+ .map((line) => theme.fg("toolOutput", line))
326
+ .join("\n")
327
+ : "",
328
+ );
329
+ return text;
330
+ }
331
+
332
+ class AstCallComponent implements Component {
333
+ private readonly rowState: ToolRowStateStore;
334
+ private readonly rowId: string;
335
+ private readonly tool: string;
336
+ private targetVariants: string[];
337
+ private optionVariants: string[];
338
+ private theme: Theme;
339
+
340
+ constructor(
341
+ rowState: ToolRowStateStore,
342
+ rowId: string,
343
+ tool: string,
344
+ targetVariants: string[],
345
+ optionVariants: string[],
346
+ theme: Theme,
347
+ ) {
348
+ this.rowState = rowState;
349
+ this.rowId = rowId;
350
+ this.tool = tool;
351
+ this.targetVariants = targetVariants;
352
+ this.optionVariants = optionVariants;
353
+ this.theme = theme;
354
+ }
355
+
356
+ set(targetVariants: string[], optionVariants: string[], theme: Theme): void {
357
+ this.targetVariants = targetVariants;
358
+ this.optionVariants = optionVariants;
359
+ this.theme = theme;
360
+ }
361
+
362
+ render(width: number): string[] {
363
+ if (width <= 0) return [];
364
+ const prefixWidth = visibleWidth(`${this.tool} → `);
365
+ const shortestTarget = this.targetVariants.at(-1) ?? "";
366
+ const minimumTargetWidth = Math.min(12, visibleWidth(shortestTarget));
367
+ const options =
368
+ this.optionVariants.find(
369
+ (candidate) => prefixWidth + minimumTargetWidth + (candidate ? visibleWidth(candidate) + 1 : 0) <= width,
370
+ ) ??
371
+ this.optionVariants.at(-1) ??
372
+ "";
373
+ const optionsWidth = options ? visibleWidth(options) + 1 : 0;
374
+ const targetWidth = Math.max(1, width - prefixWidth - optionsWidth);
375
+ const target = this.targetVariants.find((candidate) => visibleWidth(candidate) <= targetWidth) ?? shortestTarget;
376
+ const displayTarget = truncateLeft(target, targetWidth);
377
+ const line =
378
+ formatToolRowTitle(this.rowState, this.rowId, this.tool, this.theme) +
379
+ this.theme.fg("toolOutput", " → ") +
380
+ this.theme.fg("accent", displayTarget) +
381
+ (options ? ` ${this.theme.fg("muted", options)}` : "");
382
+ return [truncateToWidth(line, width, "")];
383
+ }
384
+
385
+ invalidate(): void {}
386
+ }
387
+
388
+ function truncateLeft(text: string, width: number): string {
389
+ if (width <= 0) return "";
390
+ if (visibleWidth(text) <= width) return text;
391
+ if (width === 1) return "…";
392
+
393
+ let suffix = "";
394
+ for (const character of Array.from(text).reverse()) {
395
+ if (visibleWidth(`…${character}${suffix}`) > width) break;
396
+ suffix = character + suffix;
397
+ }
398
+ return `…${suffix}`;
399
+ }
400
+
401
+ function outlineOptionVariants(args: OutlineArgs): string[] {
402
+ const fixed = args.includePrivate ? ["private"] : [];
403
+ const names = args.names ?? [];
404
+ if (names.length === 0) return [fixed.length > 0 ? `[${fixed.join(" ")}]` : ""];
405
+
406
+ const variants = [];
407
+ for (let shown = names.length; shown >= 1; shown -= 1) {
408
+ const omitted = names.length - shown;
409
+ const namesText = omitted > 0 ? `${names.slice(0, shown).join(",")},+${omitted}` : names.join(",");
410
+ variants.push(`[${[...fixed, `names=${namesText}`].join(" ")}]`);
411
+ }
412
+ variants.push(`[${[...fixed, `names=${names.length}`].join(" ")}]`);
413
+ return variants;
414
+ }
415
+
416
+ function symbolTargetVariants(ids: readonly number[], locators: ReadonlyMap<number, LocatorRecord>): string[] {
417
+ const records: LocatorRecord[] = [];
418
+ for (const id of new Set(ids)) {
419
+ const record = locators.get(id);
420
+ if (!record) return [ids.join(",")];
421
+ records.push(record);
422
+ }
423
+ const first = records[0];
424
+ if (!first) return ["symbols"];
425
+
426
+ const fileCount = new Set(records.map((record) => record.path)).size;
427
+ const variants: string[] = [];
428
+ if (fileCount === 1) {
429
+ const file = basename(first.path);
430
+ for (let shown = records.length; shown >= 1; shown -= 1) {
431
+ const omitted = records.length - shown;
432
+ const suffix = omitted > 0 ? `,+${omitted}` : "";
433
+ variants.push(
434
+ `${file}: ${records
435
+ .slice(0, shown)
436
+ .map((record) => record.name)
437
+ .join(",")}${suffix}`,
438
+ );
439
+ }
440
+ variants.push(`${file}: ${records.length} symbols`);
441
+ return variants;
442
+ }
443
+
444
+ for (let shown = records.length; shown >= 1; shown -= 1) {
445
+ const omitted = records.length - shown;
446
+ const suffix = omitted > 0 ? `,+${omitted}` : "";
447
+ variants.push(
448
+ `${records
449
+ .slice(0, shown)
450
+ .map((record) => `${record.name}@${basename(record.path)}`)
451
+ .join(",")}${suffix}`,
452
+ );
453
+ }
454
+ variants.push(`${records.length} symbols in ${fileCount} files`);
455
+ return variants;
456
+ }
457
+
458
+ function languageForPath(path: string): AstLanguage {
459
+ switch (extname(path).toLowerCase()) {
460
+ case ".ts":
461
+ return "typeScript";
462
+ case ".tsx":
463
+ return "tsx";
464
+ case ".odin":
465
+ return "odin";
466
+ case ".go":
467
+ return "go";
468
+ case ".rs":
469
+ return "rust";
470
+ case ".cs":
471
+ return "cSharp";
472
+ case ".java":
473
+ return "java";
474
+ case ".kt":
475
+ case ".ktm":
476
+ case ".kts":
477
+ return "kotlin";
478
+ case ".swift":
479
+ return "swift";
480
+ default:
481
+ throw new Error(`Unsupported outline file type: ${extname(path) || "no extension"}`);
482
+ }
483
+ }
@@ -0,0 +1,317 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export type AstLanguage = "typeScript" | "tsx" | "odin" | "go" | "rust" | "cSharp" | "java" | "kotlin" | "swift";
7
+
8
+ export type OutlineTarget = { kind: "file"; path: string; language: AstLanguage } | { kind: "directory"; path: string };
9
+
10
+ export interface SourcePosition {
11
+ line: number;
12
+ column: number;
13
+ }
14
+
15
+ export interface SourceRange {
16
+ startByte: number;
17
+ endByte: number;
18
+ start: SourcePosition;
19
+ end: SourcePosition;
20
+ }
21
+
22
+ export interface OutlineEntry {
23
+ role: "item" | "member";
24
+ symbolType: string;
25
+ name: string;
26
+ range: SourceRange;
27
+ signature: string;
28
+ astKind: string;
29
+ locator: string;
30
+ }
31
+
32
+ export interface OutlineItem extends OutlineEntry {
33
+ isImport: boolean;
34
+ isExported: boolean;
35
+ members: Array<OutlineEntry & { isPublic: boolean }>;
36
+ }
37
+
38
+ export interface OutlineFileResult {
39
+ path: string;
40
+ language: AstLanguage;
41
+ sourceFingerprint: string;
42
+ byteLength: number;
43
+ lineCount: number;
44
+ diagnostics: { errorNodes: number; missingNodes: number };
45
+ items: OutlineItem[];
46
+ }
47
+
48
+ export interface OutlineTargetResult {
49
+ path: string;
50
+ files: OutlineFileResult[];
51
+ totalByteLength: number;
52
+ totalLineCount: number;
53
+ }
54
+
55
+ export interface SymbolDeclaration {
56
+ locator: string;
57
+ path: string;
58
+ language: AstLanguage;
59
+ sourceFingerprint: string;
60
+ declarationRange: SourceRange;
61
+ }
62
+
63
+ export interface SymbolBlock {
64
+ path: string;
65
+ returnedRange: SourceRange;
66
+ declarationIndexes: number[];
67
+ source: string;
68
+ }
69
+
70
+ export interface SymbolBatchResult {
71
+ declarations: SymbolDeclaration[];
72
+ blocks: SymbolBlock[];
73
+ }
74
+
75
+ export interface AstClient {
76
+ outline(
77
+ target: OutlineTarget,
78
+ includePrivate: boolean,
79
+ names: string[],
80
+ signal: AbortSignal | undefined,
81
+ ): Promise<OutlineTargetResult>;
82
+ symbol(locators: string[], contextLines: number, signal: AbortSignal | undefined): Promise<SymbolBatchResult>;
83
+ shutdown(): Promise<void>;
84
+ }
85
+
86
+ type WorkerRequestPayload =
87
+ | { operation: "handshake" }
88
+ | { operation: "outline"; target: OutlineTarget; includePrivate: boolean; names: string[] }
89
+ | { operation: "symbol"; locators: string[]; contextLines: number };
90
+
91
+ interface WorkerResponse {
92
+ requestId: number;
93
+ protocolVersion: number;
94
+ success: boolean;
95
+ result?: Record<string, unknown>;
96
+ error?: { code?: string; message?: string };
97
+ }
98
+
99
+ interface PendingRequest {
100
+ resolve(value: Record<string, unknown>): void;
101
+ reject(error: Error): void;
102
+ removeAbortListener(): void;
103
+ }
104
+
105
+ const PROTOCOL_VERSION = 2;
106
+ const MAX_FRAME_BYTES = 8 * 1024 * 1024;
107
+ const STDERR_BYTES = 16 * 1024;
108
+
109
+ export type AstWorkerResolution = { command: string } | { error: Error };
110
+
111
+ export function resolveAstWorkerCommand(
112
+ packageRoot: string,
113
+ platform: NodeJS.Platform,
114
+ arch: string,
115
+ ): AstWorkerResolution {
116
+ const packagedCommand = join(packageRoot, "native-bin", "darwin-arm64", "tau-ast");
117
+ if (platform === "darwin" && arch === "arm64" && existsSync(packagedCommand)) return { command: packagedCommand };
118
+
119
+ const sourceRoot = join(packageRoot, "native", "tau-ast");
120
+ if (existsSync(join(sourceRoot, "Cargo.toml")))
121
+ return { command: join(sourceRoot, "target", "release", `tau-ast${platform === "win32" ? ".exe" : ""}`) };
122
+
123
+ if (platform === "darwin" && arch === "arm64")
124
+ return {
125
+ error: new Error(
126
+ "tau-ast is missing from this @shanepadgett/tau-agent installation. Reinstall the package before using outline or symbol.",
127
+ ),
128
+ };
129
+ return {
130
+ error: new Error(
131
+ `Packaged AST tools currently require an Apple Silicon Mac (darwin-arm64); this host is ${platform}-${arch}.`,
132
+ ),
133
+ };
134
+ }
135
+
136
+ export class AstWorkerClient implements AstClient {
137
+ private readonly command: string | undefined;
138
+ private readonly args: readonly string[];
139
+ private child: ChildProcessWithoutNullStreams | undefined;
140
+ private startPromise: Promise<void> | undefined;
141
+ private pending = new Map<number, PendingRequest>();
142
+ private nextRequestId = 1;
143
+ private incoming = Buffer.alloc(0);
144
+ private stderr = "";
145
+
146
+ constructor(command: string | undefined = undefined, args: readonly string[] = []) {
147
+ this.command = command;
148
+ this.args = args;
149
+ }
150
+
151
+ async outline(
152
+ target: OutlineTarget,
153
+ includePrivate: boolean,
154
+ names: string[],
155
+ signal: AbortSignal | undefined,
156
+ ): Promise<OutlineTargetResult> {
157
+ const result = await this.request({ operation: "outline", target, includePrivate, names }, signal);
158
+ if (result.kind !== "outline") throw new Error("tau-ast returned the wrong result for outline");
159
+ return result as unknown as OutlineTargetResult;
160
+ }
161
+
162
+ async symbol(locators: string[], contextLines: number, signal: AbortSignal | undefined): Promise<SymbolBatchResult> {
163
+ const result = await this.request({ operation: "symbol", locators, contextLines }, signal);
164
+ if (result.kind !== "symbol") throw new Error("tau-ast returned the wrong result for symbol");
165
+ return result as unknown as SymbolBatchResult;
166
+ }
167
+
168
+ async shutdown(): Promise<void> {
169
+ const child = this.child;
170
+ if (!child) return;
171
+ this.child = undefined;
172
+ this.startPromise = undefined;
173
+ this.rejectPending(new Error("tau-ast worker shut down"));
174
+ child.stdin.end();
175
+ if (child.exitCode !== null) return;
176
+ await Promise.race([
177
+ new Promise<void>((resolve) => child.once("exit", () => resolve())),
178
+ new Promise<void>((resolve) => setTimeout(resolve, 250)),
179
+ ]);
180
+ if (child.exitCode === null) child.kill();
181
+ }
182
+
183
+ private async request(
184
+ request: WorkerRequestPayload,
185
+ signal: AbortSignal | undefined,
186
+ ): Promise<Record<string, unknown>> {
187
+ await this.ensureStarted();
188
+ return this.send(request, signal);
189
+ }
190
+
191
+ private async ensureStarted(): Promise<void> {
192
+ if (this.startPromise) {
193
+ await this.startPromise;
194
+ return;
195
+ }
196
+ if (this.child) return;
197
+ this.startPromise ??= this.start().finally(() => {
198
+ this.startPromise = undefined;
199
+ });
200
+ await this.startPromise;
201
+ }
202
+
203
+ 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);
207
+ if ("error" in resolution) throw resolution.error;
208
+ const child = spawn(resolution.command, this.args, { stdio: ["pipe", "pipe", "pipe"] });
209
+ this.child = child;
210
+ this.incoming = Buffer.alloc(0);
211
+ this.stderr = "";
212
+ child.stdout.on("data", (chunk: Buffer) => this.receive(child, chunk));
213
+ child.stderr.on("data", (chunk: Buffer) => {
214
+ this.stderr = Buffer.from(`${this.stderr}${chunk.toString("utf8")}`)
215
+ .subarray(-STDERR_BYTES)
216
+ .toString("utf8");
217
+ });
218
+ child.on("error", (error) => this.fail(child, new Error(`Failed to start tau-ast: ${error.message}`)));
219
+ child.on("exit", (code, signal) => {
220
+ if (this.child !== child) return;
221
+ const suffix = this.stderr.trim() ? `: ${this.stderr.trim()}` : "";
222
+ this.fail(child, new Error(`tau-ast exited (${signal ?? code ?? "unknown"})${suffix}`), false);
223
+ });
224
+ try {
225
+ const result = await this.send({ operation: "handshake" }, undefined);
226
+ if (result.kind !== "handshake") throw new Error("tau-ast handshake returned the wrong result");
227
+ } catch (error) {
228
+ const failure = error instanceof Error ? error : new Error(String(error));
229
+ this.fail(child, failure);
230
+ throw failure;
231
+ }
232
+ }
233
+
234
+ private send(request: WorkerRequestPayload, signal: AbortSignal | undefined): Promise<Record<string, unknown>> {
235
+ const child = this.child;
236
+ if (!child) return Promise.reject(new Error("tau-ast worker is not running"));
237
+ if (signal?.aborted) return Promise.reject(new Error("tau-ast request cancelled"));
238
+ const requestId = this.nextRequestId++;
239
+ const payload = Buffer.from(JSON.stringify({ ...request, requestId, protocolVersion: PROTOCOL_VERSION }));
240
+ const frame = Buffer.allocUnsafe(payload.length + 4);
241
+ frame.writeUInt32BE(payload.length, 0);
242
+ payload.copy(frame, 4);
243
+
244
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
245
+ const onAbort = (): void => this.fail(child, new Error("tau-ast request cancelled"));
246
+ signal?.addEventListener("abort", onAbort, { once: true });
247
+ this.pending.set(requestId, {
248
+ resolve,
249
+ reject,
250
+ removeAbortListener: () => signal?.removeEventListener("abort", onAbort),
251
+ });
252
+ child.stdin.write(frame, (error) => {
253
+ if (error) this.fail(child, new Error(`Failed to write tau-ast request: ${error.message}`));
254
+ });
255
+ });
256
+ }
257
+
258
+ private receive(child: ChildProcessWithoutNullStreams, chunk: Buffer): void {
259
+ if (this.child !== child) return;
260
+ 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);
288
+ return;
289
+ }
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);
299
+ }
300
+ }
301
+
302
+ private fail(child: ChildProcessWithoutNullStreams, error: Error, kill = true): void {
303
+ if (this.child !== child) return;
304
+ this.child = undefined;
305
+ this.incoming = Buffer.alloc(0);
306
+ this.rejectPending(error);
307
+ if (kill && child.exitCode === null) child.kill();
308
+ }
309
+
310
+ private rejectPending(error: Error): void {
311
+ for (const request of this.pending.values()) {
312
+ request.removeAbortListener();
313
+ request.reject(error);
314
+ }
315
+ this.pending.clear();
316
+ }
317
+ }
@@ -1,5 +1,9 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { resolve } from "node:path";
3
+ import { onTauEvent } from "../../shared/events.js";
2
4
  import { createToolRowStateStore } from "../../shared/tool-row-state.js";
5
+ import { createAstTools } from "./ast-tools.ts";
6
+ import { AstWorkerClient } from "./ast-worker.ts";
3
7
  import { registerAutoread } from "./autoread.ts";
4
8
  import { createFindTool } from "./find.ts";
5
9
  import { createGrepTool } from "./grep.ts";
@@ -13,7 +17,11 @@ export default function exploreExtension(pi: ExtensionAPI): void {
13
17
  const rowState = createToolRowStateStore(pi, "explore.tool-row-state");
14
18
  const readCache = createReadCacheStore();
15
19
  const readSnapshots = createReadSnapshotStore();
20
+ const astClient = new AstWorkerClient();
21
+ const ast = createAstTools(astClient, rowState);
16
22
  registerAutoread(pi, rowState);
23
+ pi.registerTool(ast.outline);
24
+ pi.registerTool(ast.symbol);
17
25
  pi.registerTool(createLsTool(rowState));
18
26
  pi.registerTool(createFindTool(rowState));
19
27
  pi.registerTool(createGrepTool(rowState));
@@ -31,7 +39,19 @@ export default function exploreExtension(pi: ExtensionAPI): void {
31
39
  pi.on("session_start", () => {
32
40
  rowState.clear();
33
41
  readSnapshots.clear();
42
+ ast.clear();
43
+ });
44
+ onTauEvent(pi, "explore.ast", "tau:file-mutation.applied", (event) => {
45
+ const paths = event.changes.flatMap((change) => [
46
+ resolve(event.cwd, change.path),
47
+ ...(change.move ? [resolve(event.cwd, change.move.from), resolve(event.cwd, change.move.to)] : []),
48
+ ]);
49
+ ast.invalidate(paths);
34
50
  });
35
51
  pi.on("session_compact", () => readSnapshots.clear());
36
52
  pi.on("session_tree", () => readSnapshots.clear());
53
+ pi.on("session_shutdown", async () => {
54
+ ast.clear();
55
+ await astClient.shutdown();
56
+ });
37
57
  }
@@ -10,6 +10,9 @@ const LOCKFILE_PATH = "package-lock.json";
10
10
  const WORKFLOW_NAME = "publish.yml";
11
11
  const WORKFLOW_WAIT_MS = 10 * 60_000;
12
12
  const POLL_INTERVAL_MS = 5_000;
13
+ const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
14
+ const CARGO_BUILD_TIMEOUT_MS = 15 * 60_000;
15
+ const TAU_AST_SCRIPT = "packages/agent/scripts/package-tau-ast.ts";
13
16
 
14
17
  type Bump = "patch" | "minor" | "major";
15
18
 
@@ -65,8 +68,10 @@ async function publish(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<
65
68
  if (!repo) throw new Error("No git repository found.");
66
69
  if (repo.fileCount > 0)
67
70
  throw new Error("Working tree is not clean. Commit or stash every change before publishing.");
68
- await run(pi, ctx, "gh", ["auth", "status"], repo.root);
69
- await run(pi, ctx, "npm", ["run", "check:package-sources"], repo.root);
71
+ if (process.platform !== "darwin" || process.arch !== "arm64")
72
+ throw new Error(`/publish requires darwin-arm64; this host is ${process.platform}-${process.arch}.`);
73
+ await run(pi, ctx, "gh", ["auth", "status"], repo.root, DEFAULT_COMMAND_TIMEOUT_MS);
74
+ await run(pi, ctx, "npm", ["run", "check:package-sources"], repo.root, DEFAULT_COMMAND_TIMEOUT_MS);
70
75
 
71
76
  const manifests = await loadManifests(repo.root);
72
77
  const currentVersion = sharedVersion(manifests);
@@ -124,10 +129,86 @@ async function completePublish(
124
129
  progress?.update("write package versions");
125
130
  await writeReleaseVersion(root, manifests, version);
126
131
  }
127
- progress?.update("npm pack --dry-run @shanepadgett/tau-tui");
128
- await run(pi, ctx, "npm", ["pack", "--dry-run", "--workspace", "@shanepadgett/tau-tui"], root);
129
- progress?.update("npm pack --dry-run @shanepadgett/tau-agent");
130
- await run(pi, ctx, "npm", ["pack", "--dry-run", "--workspace", "@shanepadgett/tau-agent"], root);
132
+ try {
133
+ progress?.update("build tau-ast darwin-arm64");
134
+ await run(
135
+ pi,
136
+ ctx,
137
+ "cargo",
138
+ [
139
+ "build",
140
+ "--release",
141
+ "--locked",
142
+ "--target",
143
+ "aarch64-apple-darwin",
144
+ "--manifest-path",
145
+ "packages/agent/native/tau-ast/Cargo.toml",
146
+ ],
147
+ root,
148
+ CARGO_BUILD_TIMEOUT_MS,
149
+ );
150
+ progress?.update("stage tau-ast");
151
+ await run(
152
+ pi,
153
+ ctx,
154
+ process.execPath,
155
+ ["--experimental-strip-types", TAU_AST_SCRIPT, "stage"],
156
+ root,
157
+ DEFAULT_COMMAND_TIMEOUT_MS,
158
+ );
159
+ progress?.update("smoke test tau-ast");
160
+ await run(
161
+ pi,
162
+ ctx,
163
+ process.execPath,
164
+ ["--experimental-strip-types", TAU_AST_SCRIPT, "smoke"],
165
+ root,
166
+ DEFAULT_COMMAND_TIMEOUT_MS,
167
+ );
168
+ progress?.update("npm pack --dry-run @shanepadgett/tau-tui");
169
+ await run(
170
+ pi,
171
+ ctx,
172
+ "npm",
173
+ ["pack", "--dry-run", "--workspace", "@shanepadgett/tau-tui"],
174
+ root,
175
+ DEFAULT_COMMAND_TIMEOUT_MS,
176
+ );
177
+ progress?.update("npm pack --dry-run @shanepadgett/tau-agent");
178
+ await run(
179
+ pi,
180
+ ctx,
181
+ "npm",
182
+ ["pack", "--dry-run", "--workspace", "@shanepadgett/tau-agent"],
183
+ root,
184
+ DEFAULT_COMMAND_TIMEOUT_MS,
185
+ );
186
+ progress?.update("verify packed tau-ast");
187
+ await run(
188
+ pi,
189
+ ctx,
190
+ process.execPath,
191
+ ["--experimental-strip-types", TAU_AST_SCRIPT, "verify-pack"],
192
+ root,
193
+ DEFAULT_COMMAND_TIMEOUT_MS,
194
+ );
195
+ } finally {
196
+ await run(
197
+ pi,
198
+ ctx,
199
+ process.execPath,
200
+ ["--experimental-strip-types", TAU_AST_SCRIPT, "clean"],
201
+ root,
202
+ DEFAULT_COMMAND_TIMEOUT_MS,
203
+ );
204
+ }
205
+
206
+ const trackedNativeFiles = await git.run(["ls-files", "--", "packages/agent/native-bin"], { cwd: root });
207
+ const stagedNativeFiles = await git.run(["diff", "--cached", "--name-only", "--", "packages/agent/native-bin"], {
208
+ cwd: root,
209
+ });
210
+ if (trackedNativeFiles || stagedNativeFiles)
211
+ throw new Error("Release blocked: packages/agent/native-bin/ contains tracked or staged files.");
131
212
 
132
213
  if (tag) {
133
214
  progress?.update("git add release files");
@@ -225,8 +306,9 @@ async function run(
225
306
  command: string,
226
307
  args: string[],
227
308
  cwd: string,
309
+ timeout: number,
228
310
  ): Promise<string> {
229
- const result = await pi.exec(command, args, { cwd, signal: ctx.signal, timeout: 120_000 });
311
+ const result = await pi.exec(command, args, { cwd, signal: ctx.signal, timeout });
230
312
  if (result.code === 0) return result.stdout.trim();
231
313
  throw new Error([result.stderr, result.stdout].filter(Boolean).join("\n").trim() || `${command} failed`);
232
314
  }
@@ -258,6 +340,7 @@ async function monitorWorkflow(
258
340
  "databaseId,status,conclusion,url",
259
341
  ],
260
342
  root,
343
+ DEFAULT_COMMAND_TIMEOUT_MS,
261
344
  );
262
345
  const workflowRun = (JSON.parse(output) as WorkflowRun[])[0];
263
346
  if (workflowRun) {
@@ -4,8 +4,12 @@ import { ToolPanel } from "@shanepadgett/tau-tui";
4
4
 
5
5
  const RELEASE_STEPS = [
6
6
  "write package versions",
7
+ "build tau-ast darwin-arm64",
8
+ "stage tau-ast",
9
+ "smoke test tau-ast",
7
10
  "npm pack --dry-run @shanepadgett/tau-tui",
8
11
  "npm pack --dry-run @shanepadgett/tau-agent",
12
+ "verify packed tau-ast",
9
13
  "git add release files",
10
14
  "git commit",
11
15
  "git tag",
@@ -9,6 +9,7 @@ Each fresh child also gets a display name from its agent definition. The name st
9
9
  Tau includes these built-in agents:
10
10
 
11
11
  - `review` performs adversarial, read-only code review for correctness, runtime risks, duplication, and over- or under-engineering.
12
+ - `scout` finds local files, symbols, data flow, constraints, and unknowns without changing anything.
12
13
  - `web-research` researches web and code sources with `websearch`, `codesearch`, and `webfetch`.
13
14
  - `context-sync` maps meaningful uncommitted work into `.pi/contexts`. Agent-driven use is `extensions.context.sync.automation` (requires `sync.enabled`). `/context-sync` is the manual/nudge path when sync is enabled. Validation can auto-run it when validation and sync are enabled.
14
15
 
@@ -71,54 +71,82 @@ function cap(text: string | undefined, limit: number): string {
71
71
  return `${normalized.slice(0, limit - 1)}…`;
72
72
  }
73
73
 
74
+ function tableCell(text: string, limit: number): string {
75
+ const compact = cap(text.replace(/\s+/g, " ").trim(), limit);
76
+ return compact.replaceAll("|", "&#124;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
77
+ }
78
+
79
+ function quote(text: string): string {
80
+ return cap(text, 1200)
81
+ .replaceAll("<", "&lt;")
82
+ .replaceAll(">", "&gt;")
83
+ .split("\n")
84
+ .map((line) => `> ${line}`)
85
+ .join("\n");
86
+ }
87
+
88
+ function elapsed(durationMs: number): string {
89
+ if (durationMs < 1000) return "<1s";
90
+ if (durationMs < 60_000) return `${(durationMs / 1000).toFixed(1)}s`;
91
+ const minutes = Math.floor(durationMs / 60_000);
92
+ const seconds = Math.floor((durationMs % 60_000) / 1000);
93
+ return `${minutes}m ${seconds}s`;
94
+ }
95
+
96
+ function dashboardState(status: SubagentInvocationSnapshot["status"]): string {
97
+ switch (status) {
98
+ case "waiting":
99
+ return "queued";
100
+ case "completed":
101
+ return "done";
102
+ case "aborted":
103
+ return "stopped";
104
+ default:
105
+ return status;
106
+ }
107
+ }
108
+
74
109
  export function formatDashboardMarkdown(snapshots: readonly SubagentInvocationSnapshot[]): string {
75
110
  const ordered = [...snapshots].sort(
76
111
  (a, b) => a.startedAt - b.startedAt || a.invocationId.localeCompare(b.invocationId),
77
112
  );
78
- const activeCount = ordered.filter(
79
- (snapshot) => snapshot.status !== "completed" && snapshot.status !== "failed" && snapshot.status !== "aborted",
113
+ const activeCount = ordered.filter((snapshot) =>
114
+ ["waiting", "starting", "running"].includes(snapshot.status),
80
115
  ).length;
81
- const lines = [
82
- "# Subagent dashboard",
83
- "",
84
- `Active: ${activeCount} · Completed: ${ordered.length - activeCount}`,
85
- "",
86
- ];
116
+ const doneCount = ordered.filter((snapshot) => snapshot.status === "completed").length;
117
+ const failedCount = ordered.filter((snapshot) => snapshot.status === "failed").length;
118
+ const stoppedCount = ordered.filter((snapshot) => snapshot.status === "aborted").length;
119
+ const counts = [`${activeCount} active`, `${doneCount} done`];
120
+ if (failedCount > 0) counts.push(`${failedCount} failed`);
121
+ if (stoppedCount > 0) counts.push(`${stoppedCount} stopped`);
122
+ const lines = ["# Subagents", "", counts.join(" · "), ""];
87
123
  if (ordered.length === 0) {
88
- lines.push("_No active invocations._", "");
124
+ lines.push("_No subagents._", "");
89
125
  return lines.join("\n");
90
126
  }
127
+ lines.push("| Agent | State | Last tool | Calls | Time |", "| --- | --- | --- | ---: | ---: |");
91
128
  for (const details of ordered) {
92
- const identity = `${details.displayName} (${details.agent})`;
129
+ const latest = details.actions.at(-1);
130
+ const currentTool = details.currentActivity?.match(/^\S+/)?.[0];
131
+ const lastTool = currentTool ?? latest?.tool ?? "";
93
132
  lines.push(
94
- `## ${identity}`,
95
- "",
96
- `\`${details.status}${details.phase ? ` · ${details.phase}` : ""}\` · inv ${details.invocationId} · tools ${details.toolCalls} · ${(details.durationMs / 1000).toFixed(1)}s`,
97
- `model ${details.model} · thinking ${details.thinkingLevel}`,
133
+ `| ${tableCell(`${details.displayName} (${details.agent})`, 48)} | ${dashboardState(details.status)} | ${tableCell(lastTool, 32) || "—"} | ${details.toolCalls} | ${elapsed(details.durationMs)} |`,
134
+ );
135
+ }
136
+ lines.push("", "## Inputs", "");
137
+ for (const details of ordered) {
138
+ lines.push(
139
+ `### ${tableCell(details.displayName, 80)}`,
98
140
  "",
99
- "### Task",
141
+ `${tableCell(details.agent, 80)} · ${tableCell(details.invocationId, 80)}`,
100
142
  "",
101
- cap(details.task, 1200) || "_(empty)_",
143
+ quote(details.task) || "> _(empty)_",
102
144
  );
103
- if (details.currentActivity) {
104
- lines.push("", "### Current", "", cap(details.currentActivity, 400));
105
- }
106
- if (details.actions.length > 0) {
107
- lines.push("", "### Actions", "");
108
- for (const action of details.actions.slice(-12)) {
109
- const mark = action.error ? "!" : "·";
110
- lines.push(`- ${mark} ${cap(action.summary, 200)}`);
111
- }
112
- if (details.omittedActions > 0) {
113
- lines.push(`- _${details.omittedActions} earlier actions omitted_`);
114
- }
115
- }
116
- if (details.response) {
117
- lines.push("", "### Response", "", cap(details.response, 3000));
118
- }
119
- if (details.error) {
120
- lines.push("", "### Error", "", cap(details.error, 800));
145
+ if (details.files.length > 0) {
146
+ lines.push("", "**Files**", "");
147
+ for (const file of details.files) lines.push(`- ${tableCell(file, 300)}`);
121
148
  }
149
+ if (details.error) lines.push("", "**Error**", "", quote(details.error));
122
150
  lines.push("");
123
151
  }
124
152
  return lines.join("\n");
@@ -77,6 +77,7 @@ export interface SubagentDetails {
77
77
  export interface SubagentInvocationSnapshot extends SubagentDetails {
78
78
  invocationId: string;
79
79
  startedAt: number;
80
+ files: readonly string[];
80
81
  }
81
82
 
82
83
  export interface SubagentThread {
@@ -243,6 +243,7 @@ export class SubagentRuntime {
243
243
  }),
244
244
  invocationId,
245
245
  startedAt,
246
+ files: [...(options.files ?? [])],
246
247
  };
247
248
  const active: ActiveInvocation = {
248
249
  snapshot: initialSnapshot,
@@ -330,6 +331,7 @@ export class SubagentRuntime {
330
331
  ...details,
331
332
  invocationId,
332
333
  startedAt,
334
+ files: current.snapshot.files,
333
335
  threadId: details.threadId ?? threadId,
334
336
  agent: details.agent || agent,
335
337
  displayName: details.displayName || displayName,
@@ -40,7 +40,7 @@ Gives the agent `context_prune` for creating a hard context checkpoint after bro
40
40
 
41
41
  ## explore
42
42
 
43
- Replaces Pi’s filesystem inspection tools with compact Tau versions: `ls`, `find`, `grep`, and `read`. They produce smaller model payloads and readable tool rows. Repeated `read` calls return unchanged markers or useful diffs when branch history proves the agent already saw the base content. A failed patch unlocks one normal reread of the affected path. `/read-stats` shows estimated token and cost savings for the current chat and whole session.
43
+ Replaces Pi’s filesystem inspection tools with compact Tau versions: `ls`, `find`, `grep`, and `read`. It also adds `outline` for public declarations in TypeScript, TSX, Odin, Go, Rust, C#, Java, Kotlin, and Swift files or package directories. Exact-name filters and `includePrivate` narrow or expand that surface. Parenthesized numbers identify declarations for `symbol`, which retrieves several complete declarations with optional surrounding lines and rejects stale batches. Installed packages support `outline` and `symbol` on Apple Silicon Macs without requiring Rust or Cargo; other platforms retain the rest of Explore and report the limit when an AST tool is invoked. These tools produce smaller model payloads and readable tool rows. Repeated `read` calls return unchanged markers or useful diffs when branch history proves the agent already saw the base content. A failed patch unlocks one normal reread of the affected path. `/read-stats` shows estimated token and cost savings for the current chat and whole session.
44
44
 
45
45
  ## footer
46
46
 
@@ -100,7 +100,7 @@ Adds `Alt+S` to stash the current prompt draft and `/pop` to browse stashed draf
100
100
 
101
101
  ## subagent
102
102
 
103
- Gives Tau a subagent delegation tool for isolated, focused work. The built-in `review` agent performs adversarial, read-only code reviews; `web-research` handles external research. Known files can be autoread as line-numbered snapshots into a fresh or retained child turn. Tau can continue a retained child thread when follow-up work depends on its prior reads and reasoning. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in agents show the pattern. Each subagent can register its own model, tools, and pool of display names. Reused pool names get numeric suffixes. In interactive cmux sessions, Tau opens one temporary Markdown dashboard for live subagent progress; it does not change how children run and closes shortly after the active cohort finishes.
103
+ Gives Tau a subagent delegation tool for isolated, focused work. The built-in `review` agent performs adversarial, read-only code reviews; `scout` finds local files, symbols, data flow, constraints, and unknowns without changing anything; `web-research` handles external research. Known files can be autoread as line-numbered snapshots into a fresh or retained child turn. Tau can continue a retained child thread when follow-up work depends on its prior reads and reasoning. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in agents show the pattern. Each subagent can register its own model, tools, and pool of display names. Reused pool names get numeric suffixes. In interactive cmux sessions, Tau opens one temporary Markdown dashboard for live subagent progress; it does not change how children run and closes shortly after the active cohort finishes.
104
104
 
105
105
  ## tau-help
106
106
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "Tau is a custom agentic harness built with pi extensions",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -31,10 +31,11 @@
31
31
  "themes/**/*",
32
32
  "docs/**/*",
33
33
  "schemas/**/*",
34
+ "native-bin/**/*",
34
35
  "README.md"
35
36
  ],
36
37
  "dependencies": {
37
- "@shanepadgett/tau-tui": "0.22.1",
38
+ "@shanepadgett/tau-tui": "0.24.0",
38
39
  "@toon-format/toon": "2.3.0",
39
40
  "image-size": "2.0.2",
40
41
  "smol-toml": "1.7.0"