@shanepadgett/tau-agent 0.26.0 → 0.28.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 (121) 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/sync.ts +1 -9
  5. package/extensions/context-pruning/README.md +2 -2
  6. package/extensions/context-pruning/index.ts +58 -57
  7. package/extensions/context-pruning/prune.ts +2 -2
  8. package/extensions/context-pruning/render.ts +51 -39
  9. package/extensions/context-pruning/settings.ts +4 -5
  10. package/extensions/explore/README.md +13 -14
  11. package/extensions/explore/ast/adapter.ts +97 -0
  12. package/extensions/explore/ast/engine.ts +470 -0
  13. package/extensions/explore/ast/format/ast-search.ts +97 -0
  14. package/extensions/explore/ast/format/composite.ts +26 -0
  15. package/extensions/explore/ast/format/context.ts +66 -0
  16. package/extensions/explore/ast/format/deps.ts +86 -0
  17. package/extensions/explore/ast/format/discover.ts +85 -0
  18. package/extensions/explore/ast/format/impact.ts +105 -0
  19. package/extensions/explore/ast/format/outline.ts +66 -0
  20. package/extensions/explore/ast/format/relationships.ts +81 -0
  21. package/extensions/explore/ast/format/show.ts +38 -0
  22. package/extensions/explore/ast/grammars/kotlin.wasm +0 -0
  23. package/extensions/explore/ast/grammars/manifest.json +41 -0
  24. package/extensions/explore/ast/grammars/manifest.ts +99 -0
  25. package/extensions/explore/ast/grammars/odin.wasm +0 -0
  26. package/extensions/explore/ast/grammars/swift.wasm +0 -0
  27. package/extensions/explore/ast/graph/file-graph.ts +360 -0
  28. package/extensions/explore/ast/graph/relationships.ts +597 -0
  29. package/extensions/explore/ast/identity.ts +119 -0
  30. package/extensions/explore/ast/ir.ts +130 -0
  31. package/extensions/explore/ast/languages/csharp-file-deps.ts +189 -0
  32. package/extensions/explore/ast/languages/csharp.ts +493 -0
  33. package/extensions/explore/ast/languages/file-dep-util.ts +120 -0
  34. package/extensions/explore/ast/languages/fixtures/sample.cs +17 -0
  35. package/extensions/explore/ast/languages/fixtures/sample.java +21 -0
  36. package/extensions/explore/ast/languages/fixtures/sample.kt +15 -0
  37. package/extensions/explore/ast/languages/fixtures/sample.odin +28 -0
  38. package/extensions/explore/ast/languages/fixtures/sample.rs +31 -0
  39. package/extensions/explore/ast/languages/fixtures/sample.swift +25 -0
  40. package/extensions/explore/ast/languages/go-file-deps.ts +139 -0
  41. package/extensions/explore/ast/languages/go.ts +545 -0
  42. package/extensions/explore/ast/languages/java.ts +412 -0
  43. package/extensions/explore/ast/languages/jvm-file-deps.ts +217 -0
  44. package/extensions/explore/ast/languages/kotlin.ts +611 -0
  45. package/extensions/explore/ast/languages/odin-file-deps.ts +175 -0
  46. package/extensions/explore/ast/languages/odin.ts +407 -0
  47. package/extensions/explore/ast/languages/rust-file-deps.ts +159 -0
  48. package/extensions/explore/ast/languages/rust.ts +506 -0
  49. package/extensions/explore/ast/languages/swift-file-deps.ts +59 -0
  50. package/extensions/explore/ast/languages/swift.ts +466 -0
  51. package/extensions/explore/ast/languages/tree.ts +220 -0
  52. package/extensions/explore/ast/languages/typescript-file-deps.ts +259 -0
  53. package/extensions/explore/ast/languages/typescript-package-surface.ts +440 -0
  54. package/extensions/explore/ast/languages/typescript.ts +716 -0
  55. package/extensions/explore/ast/markdown.ts +145 -0
  56. package/extensions/explore/ast/package-surface.ts +42 -0
  57. package/extensions/explore/ast/queries/ast-search.ts +306 -0
  58. package/extensions/explore/ast/queries/composite-target.ts +77 -0
  59. package/extensions/explore/ast/queries/context.ts +406 -0
  60. package/extensions/explore/ast/queries/discover.ts +427 -0
  61. package/extensions/explore/ast/queries/impact.ts +250 -0
  62. package/extensions/explore/ast/queries/outline.ts +203 -0
  63. package/extensions/explore/ast/queries/show.ts +262 -0
  64. package/extensions/explore/ast/query.ts +48 -0
  65. package/extensions/explore/ast/read/hook.ts +125 -0
  66. package/extensions/explore/ast/read/policy.ts +55 -0
  67. package/extensions/explore/ast/registry.ts +120 -0
  68. package/extensions/explore/ast/scan.ts +76 -0
  69. package/extensions/explore/ast/slice.ts +31 -0
  70. package/extensions/explore/ast/tools/ast-search.ts +138 -0
  71. package/extensions/explore/ast/tools/context.ts +126 -0
  72. package/extensions/explore/ast/tools/deps.ts +72 -0
  73. package/extensions/explore/ast/tools/discover.ts +208 -0
  74. package/extensions/explore/ast/tools/impact.ts +135 -0
  75. package/extensions/explore/ast/tools/outline.ts +200 -0
  76. package/extensions/explore/ast/tools/relationships.ts +160 -0
  77. package/extensions/explore/ast/tools/render.ts +121 -0
  78. package/extensions/explore/ast/tools/reverse-deps.ts +72 -0
  79. package/extensions/explore/ast/tools/show.ts +116 -0
  80. package/extensions/explore/guidance.ts +52 -0
  81. package/extensions/explore/index.ts +91 -45
  82. package/extensions/explore/read/autoread.ts +118 -0
  83. package/extensions/explore/settings.ts +64 -0
  84. package/extensions/explore/traverse.ts +260 -86
  85. package/extensions/handoff/index.ts +1 -1
  86. package/extensions/patch/executor.ts +95 -1
  87. package/extensions/patch/index.ts +1 -0
  88. package/extensions/publish/index.ts +18 -85
  89. package/extensions/publish/publish-ui.ts +0 -4
  90. package/extensions/soul/README.md +7 -4
  91. package/extensions/soul/index.ts +11 -5
  92. package/extensions/soul/prompt.ts +30 -14
  93. package/extensions/soul/settings.ts +7 -2
  94. package/extensions/subagent/agents/context-sync.md +6 -10
  95. package/extensions/subagent/agents/review.md +44 -24
  96. package/extensions/subagent/agents/scout.md +53 -34
  97. package/extensions/subagent/run.ts +1 -1
  98. package/extensions/tau-help/help.md +2 -2
  99. package/package.json +6 -5
  100. package/schemas/tau.schema.json +51 -6
  101. package/{extensions/explore → shared}/autoread.ts +21 -7
  102. package/shared/bounded-text-result.ts +304 -0
  103. package/shared/events.ts +1 -0
  104. package/shared/full-file-knowledge.ts +49 -0
  105. package/shared/temporary-output-store.ts +275 -0
  106. package/extensions/explore/ast-tools.ts +0 -828
  107. package/extensions/explore/ast-worker.ts +0 -356
  108. package/extensions/explore/find.ts +0 -147
  109. package/extensions/explore/full-file-knowledge.ts +0 -234
  110. package/extensions/explore/grep.ts +0 -749
  111. package/extensions/explore/limits.ts +0 -22
  112. package/extensions/explore/ls.ts +0 -113
  113. package/extensions/explore/path-display.ts +0 -41
  114. package/extensions/explore/path-tree.ts +0 -150
  115. package/extensions/explore/read-cache.ts +0 -273
  116. package/extensions/explore/read-snapshots.ts +0 -57
  117. package/extensions/explore/read-stats-panel.ts +0 -154
  118. package/extensions/explore/read-stats.ts +0 -120
  119. package/extensions/explore/read.ts +0 -335
  120. package/extensions/explore/result.ts +0 -29
  121. package/native-bin/darwin-arm64/tau-ast +0 -0
@@ -0,0 +1,118 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { prepareAutoreadMessage, registerAutoread, type PreparedAutoreadMessage } from "../../../shared/autoread.ts";
5
+ import { createCompleteFileMeta } from "../../../shared/full-file-knowledge.ts";
6
+ import { loadTauExtensionSettings } from "../../../shared/settings/load.ts";
7
+ import type { ToolRowStateStore } from "../../../shared/tool-row-state.ts";
8
+ import type { ExploreEngine } from "../ast/engine.ts";
9
+ import { formatOutlineFile } from "../ast/format/outline.ts";
10
+ import { formatLargeReadOutline, shouldOutlineFullRead, type ExploreReadSettings } from "../ast/read/policy.ts";
11
+ import { outlinePath } from "../ast/queries/outline.ts";
12
+ import exploreSettings from "../settings.ts";
13
+ import { resolveExplorePath } from "../traverse.ts";
14
+
15
+ const DEFAULT_OUTLINE_OPTIONS = {
16
+ includePrivate: false,
17
+ includeDocs: false,
18
+ names: [] as readonly string[],
19
+ };
20
+
21
+ export function registerExploreAutoread(
22
+ pi: ExtensionAPI,
23
+ rowState: ToolRowStateStore,
24
+ engineFor: (cwd: string) => ExploreEngine,
25
+ ): void {
26
+ let readSettings: ExploreReadSettings = exploreSettings.defaults.read;
27
+
28
+ pi.on("session_start", async (_event, ctx) => {
29
+ const settings = await loadTauExtensionSettings(ctx, exploreSettings);
30
+ readSettings = settings.read;
31
+ });
32
+
33
+ registerAutoread(pi, rowState, async (options) => {
34
+ return prepareExploreAutoreadMessage({
35
+ ...options,
36
+ engine: engineFor(options.cwd),
37
+ readSettings,
38
+ });
39
+ });
40
+ }
41
+
42
+ async function prepareExploreAutoreadMessage(options: {
43
+ rowId: string;
44
+ path: string;
45
+ cwd: string;
46
+ source: string;
47
+ batchId: string;
48
+ signal: AbortSignal | undefined;
49
+ isLifecycleCurrent: () => boolean;
50
+ maximumBytes?: number;
51
+ engine: ExploreEngine;
52
+ readSettings: ExploreReadSettings;
53
+ }): Promise<PreparedAutoreadMessage> {
54
+ const { engine, readSettings, ...shared } = options;
55
+ if (!readSettings.enabled) return prepareAutoreadMessage(shared);
56
+
57
+ const absolutePath = resolveExplorePath(options.cwd, options.path);
58
+ if (engine.registry.adapterForPath(absolutePath) === undefined) {
59
+ return prepareAutoreadMessage(shared);
60
+ }
61
+
62
+ assertPreparationCurrent(options.signal, options.isLifecycleCurrent);
63
+
64
+ let lineCount: number;
65
+ try {
66
+ const source = await engine.sourceForFile(absolutePath);
67
+ lineCount = source.ir.lineCount;
68
+ } catch {
69
+ return prepareAutoreadMessage(shared);
70
+ }
71
+
72
+ if (!shouldOutlineFullRead(lineCount, readSettings.structureThresholdLines)) {
73
+ return prepareAutoreadMessage(shared);
74
+ }
75
+
76
+ const abort = options.signal ?? new AbortController().signal;
77
+ const result = await outlinePath(engine, options.path, DEFAULT_OUTLINE_OPTIONS, abort);
78
+ assertPreparationCurrent(options.signal, options.isLifecycleCurrent);
79
+ if (result.mode !== "file") {
80
+ return prepareAutoreadMessage(shared);
81
+ }
82
+
83
+ const outlineBody =
84
+ result.file.rows.length === 0 ? "No declarations" : formatOutlineFile(result.file, engine.cwd, false);
85
+ const outlineText = formatLargeReadOutline(outlineBody);
86
+ const messageContent = `${options.path}\n${outlineText}`;
87
+ const pathKey = resolve(options.cwd, options.path);
88
+ const readCache = createCompleteFileMeta({
89
+ pathKey,
90
+ presentation: "plain",
91
+ servedHash: createHash("sha256").update(outlineText, "utf8").digest("hex"),
92
+ mode: "baseline",
93
+ sourceText: outlineText,
94
+ returnedText: messageContent,
95
+ totalLines: lineCount,
96
+ summary: `outline (${lineCount} lines)`,
97
+ });
98
+
99
+ return {
100
+ customType: "tau.autoread",
101
+ content: messageContent,
102
+ display: true,
103
+ details: {
104
+ rowId: options.rowId,
105
+ path: options.path,
106
+ cwd: options.cwd,
107
+ source: options.source,
108
+ batchId: options.batchId,
109
+ status: "read",
110
+ readCache,
111
+ },
112
+ };
113
+ }
114
+
115
+ function assertPreparationCurrent(signal: AbortSignal | undefined, isLifecycleCurrent: () => boolean): void {
116
+ signal?.throwIfAborted();
117
+ if (!isLifecycleCurrent()) throw new Error("Autoread preparation crossed a session lifecycle boundary");
118
+ }
@@ -0,0 +1,64 @@
1
+ import { Type } from "typebox";
2
+ import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
3
+
4
+ export default defineTauExtensionSettings({
5
+ key: "explore",
6
+ defaults: {
7
+ context: {
8
+ defaultBudgetTokens: 8000 as number,
9
+ },
10
+ read: {
11
+ enabled: true as boolean,
12
+ structureThresholdLines: 200 as number,
13
+ maxRangeLines: 200 as number,
14
+ },
15
+ },
16
+ schema: Type.Object(
17
+ {
18
+ context: Type.Optional(
19
+ Type.Object(
20
+ {
21
+ defaultBudgetTokens: Type.Optional(
22
+ Type.Integer({
23
+ default: 8000,
24
+ minimum: 1,
25
+ description: "Default token budget for context when the caller omits budget.",
26
+ }),
27
+ ),
28
+ },
29
+ { additionalProperties: false },
30
+ ),
31
+ ),
32
+ read: Type.Optional(
33
+ Type.Object(
34
+ {
35
+ enabled: Type.Optional(
36
+ Type.Boolean({
37
+ default: true,
38
+ description:
39
+ "Master switch for Explore structural read overlay and large-source autoread outline path. When false, Pi read and autoread keep ordinary full bodies.",
40
+ }),
41
+ ),
42
+ structureThresholdLines: Type.Optional(
43
+ Type.Integer({
44
+ default: 200,
45
+ minimum: 1,
46
+ description:
47
+ "Registered source at or under this line count may be full-read. Above it, full read model-visible result is outline only.",
48
+ }),
49
+ ),
50
+ maxRangeLines: Type.Optional(
51
+ Type.Integer({
52
+ default: 200,
53
+ minimum: 1,
54
+ description: "Maximum lines returned by one ranged read on registered source.",
55
+ }),
56
+ ),
57
+ },
58
+ { additionalProperties: false },
59
+ ),
60
+ ),
61
+ },
62
+ { additionalProperties: false },
63
+ ),
64
+ });
@@ -1,39 +1,80 @@
1
1
  import type { Stats } from "node:fs";
2
- import { lstat, readdir, readFile } from "node:fs/promises";
3
- import { dirname, join, matchesGlob, resolve, sep } from "node:path";
4
- import { formatPathForDisplay, isWithinPath, relativeSlash, toSlashPath } from "./path-display.ts";
2
+ import { lstat, opendir, readFile } from "node:fs/promises";
3
+ import { isAbsolute, join, matchesGlob, relative, resolve, sep } from "node:path";
4
+
5
+ /** Default recursive budgets for structural directory ops (path-conventions.md). */
6
+ export const DEFAULT_TRAVERSAL_BUDGETS = {
7
+ maxFiles: 2000,
8
+ maxSourceBytes: 64 * 1024 * 1024,
9
+ maxDepth: 32,
10
+ maxElapsedMs: 20_000,
11
+ } as const;
12
+
13
+ export type TraversalBudgets = {
14
+ maxFiles: number;
15
+ maxSourceBytes: number;
16
+ maxDepth: number;
17
+ maxElapsedMs: number;
18
+ };
5
19
 
6
20
  export type PathKind = "file" | "dir";
7
21
 
8
- export interface TraversalEntry {
22
+ export type TraversalHit = {
9
23
  absolutePath: string;
10
24
  displayPath: string;
11
25
  type: PathKind;
12
26
  stats: Stats;
13
27
  depth: number;
14
- empty?: boolean;
15
- }
28
+ };
29
+
30
+ export type TraversalLimit = "maxFiles" | "maxSourceBytes" | "maxDepth" | "maxElapsedMs" | "cancelled";
16
31
 
17
- export interface CollectPathOptions {
32
+ export type TraversalResult = {
33
+ entries: TraversalHit[];
34
+ /** Set when a budget or cancellation stopped the walk before the tree was complete. */
35
+ limit: TraversalLimit | undefined;
36
+ filesVisited: number;
37
+ sourceBytes: number;
38
+ elapsedMs: number;
39
+ };
40
+
41
+ export type WalkOptions = {
18
42
  cwd: string;
19
43
  root: string;
20
- maxDepth?: number;
21
- includeRoot: boolean;
22
- includeHidden: boolean;
23
- includeIgnored: boolean;
24
- includeNoise: boolean;
25
- }
44
+ /** Include the root path itself when it is a file or directory. Default true. */
45
+ includeRoot?: boolean;
46
+ /** Descend into hidden path segments (name starts with `.`). Default false. */
47
+ includeHidden?: boolean;
48
+ /** Include paths matching gitignore-style rules. Default false. */
49
+ includeIgnored?: boolean;
50
+ /** Include common noise directories (node_modules, dist, …). Default false. */
51
+ includeNoise?: boolean;
52
+ /** Only yield files (skip directory entries in the result list). Default true. */
53
+ filesOnly?: boolean;
54
+ budgets?: Partial<TraversalBudgets>;
55
+ signal?: AbortSignal;
56
+ /**
57
+ * Called after each accepted file. Return false to stop early without a budget limit
58
+ * (caller owns the stop reason). Async allowed for cooperative yielding.
59
+ */
60
+ onFile?: (hit: TraversalHit) => void | boolean | Promise<void | boolean>;
61
+ /**
62
+ * If set, files for which this returns false are skipped without counting toward budgets.
63
+ * Directories are not filtered here.
64
+ */
65
+ matchFile?: (hit: TraversalHit) => boolean;
66
+ };
26
67
 
27
- interface IgnoreRule {
68
+ type IgnoreRule = {
28
69
  baseDir: string;
29
70
  pattern: string;
30
71
  negated: boolean;
31
72
  dirOnly: boolean;
32
73
  hasSlash: boolean;
33
74
  anchored: boolean;
34
- }
75
+ };
35
76
 
36
- const NOISE_PATH_NAMES = new Set([
77
+ const NOISE_NAMES = new Set([
37
78
  ".git",
38
79
  "node_modules",
39
80
  "dist",
@@ -46,20 +87,65 @@ const NOISE_PATH_NAMES = new Set([
46
87
  "out",
47
88
  ]);
48
89
 
49
- function entryKind(stats: Stats): PathKind {
50
- return stats.isDirectory() ? "dir" : "file";
90
+ export function stripLeadingAt(value: string): string {
91
+ return value.startsWith("@") ? value.slice(1) : value;
92
+ }
93
+
94
+ function toSlashPath(value: string): string {
95
+ return value.replaceAll("\\", "/");
96
+ }
97
+
98
+ export function resolveExplorePath(cwd: string, input: string): string {
99
+ const path = stripLeadingAt(input);
100
+ if (path.length === 0) return resolve(cwd);
101
+ return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
102
+ }
103
+
104
+ export function pathResolutionError(error: unknown, input: string): Error {
105
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
106
+ return new Error(`Path not found: ${stripLeadingAt(input)}`);
107
+ }
108
+ return error instanceof Error ? error : new Error(String(error));
109
+ }
110
+
111
+ function isWithinPath(parent: string, child: string): boolean {
112
+ const rel = relative(resolve(parent), resolve(child));
113
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
114
+ }
115
+
116
+ export function formatPathForDisplay(absolutePath: string, cwd: string): string {
117
+ const resolvedCwd = resolve(cwd);
118
+ const resolvedPath = resolve(absolutePath);
119
+ const rel = relative(resolvedCwd, resolvedPath);
120
+ if (rel === "") return ".";
121
+ if (!rel.startsWith("..") && !isAbsolute(rel)) return toSlashPath(rel);
122
+ return toSlashPath(resolvedPath);
123
+ }
124
+
125
+ function relativeSlash(from: string, to: string): string {
126
+ const rel = relative(resolve(from), resolve(to));
127
+ return rel === "" ? "." : toSlashPath(rel);
128
+ }
129
+
130
+ function mergeBudgets(partial: Partial<TraversalBudgets> | undefined): TraversalBudgets {
131
+ return {
132
+ maxFiles: partial?.maxFiles ?? DEFAULT_TRAVERSAL_BUDGETS.maxFiles,
133
+ maxSourceBytes: partial?.maxSourceBytes ?? DEFAULT_TRAVERSAL_BUDGETS.maxSourceBytes,
134
+ maxDepth: partial?.maxDepth ?? DEFAULT_TRAVERSAL_BUDGETS.maxDepth,
135
+ maxElapsedMs: partial?.maxElapsedMs ?? DEFAULT_TRAVERSAL_BUDGETS.maxElapsedMs,
136
+ };
51
137
  }
52
138
 
53
139
  function pathSegments(displayPath: string): string[] {
54
140
  return displayPath.split("/").filter((segment) => segment.length > 0 && segment !== ".");
55
141
  }
56
142
 
57
- function hasHiddenPathSegment(displayPath: string): boolean {
143
+ function hasHiddenSegment(displayPath: string): boolean {
58
144
  return pathSegments(displayPath).some((segment) => segment.startsWith("."));
59
145
  }
60
146
 
61
- function hasNoisePathSegment(displayPath: string): boolean {
62
- return pathSegments(displayPath).some((segment) => NOISE_PATH_NAMES.has(segment));
147
+ function hasNoiseSegment(displayPath: string): boolean {
148
+ return pathSegments(displayPath).some((segment) => NOISE_NAMES.has(segment));
63
149
  }
64
150
 
65
151
  function parseIgnoreLine(baseDir: string, rawLine: string): IgnoreRule | undefined {
@@ -74,7 +160,14 @@ function parseIgnoreLine(baseDir: string, rawLine: string): IgnoreRule | undefin
74
160
  if (anchored) line = line.slice(1);
75
161
  line = toSlashPath(line);
76
162
  if (!line) return undefined;
77
- return { baseDir, pattern: line, negated, dirOnly, hasSlash: line.includes("/"), anchored };
163
+ return {
164
+ baseDir,
165
+ pattern: line,
166
+ negated,
167
+ dirOnly,
168
+ hasSlash: line.includes("/"),
169
+ anchored,
170
+ };
78
171
  }
79
172
 
80
173
  async function readIgnoreRules(baseDir: string): Promise<IgnoreRule[]> {
@@ -89,27 +182,27 @@ async function readIgnoreRules(baseDir: string): Promise<IgnoreRule[]> {
89
182
  }
90
183
  }
91
184
 
92
- async function appendIgnoreRulesForDirectory(
93
- directory: string,
94
- inherited: readonly IgnoreRule[],
95
- ): Promise<IgnoreRule[]> {
96
- const rules = await readIgnoreRules(directory);
97
- return rules.length === 0 ? [...inherited] : [...inherited, ...rules];
185
+ async function appendIgnoreRules(directory: string, inherited: readonly IgnoreRule[]): Promise<IgnoreRule[]> {
186
+ const local = await readIgnoreRules(directory);
187
+ return local.length === 0 ? [...inherited] : [...inherited, ...local];
98
188
  }
99
189
 
100
190
  async function collectIgnoreRulesToDirectory(cwd: string, directory: string): Promise<IgnoreRule[]> {
101
191
  const resolvedCwd = resolve(cwd);
102
192
  const resolvedDirectory = resolve(directory);
103
- if (!isWithinPath(resolvedCwd, resolvedDirectory)) return [];
193
+ // Outside the session cwd (absolute foreign roots): load that directory's own ignore only.
194
+ if (!isWithinPath(resolvedCwd, resolvedDirectory)) {
195
+ return appendIgnoreRules(resolvedDirectory, []);
196
+ }
104
197
 
105
198
  let current = resolvedCwd;
106
- let rules: IgnoreRule[] = await appendIgnoreRulesForDirectory(current, []);
199
+ let rules = await appendIgnoreRules(current, []);
107
200
  const rel = relativeSlash(resolvedCwd, resolvedDirectory);
108
201
  if (rel === ".") return rules;
109
202
 
110
203
  for (const segment of rel.split("/")) {
111
204
  current = join(current, segment.split("/").join(sep));
112
- rules = await appendIgnoreRulesForDirectory(current, rules);
205
+ rules = await appendIgnoreRules(current, rules);
113
206
  }
114
207
  return rules;
115
208
  }
@@ -130,68 +223,153 @@ function isIgnored(absolutePath: string, kind: PathKind, rules: readonly IgnoreR
130
223
  return ignored;
131
224
  }
132
225
 
133
- function shouldSkipPath(
226
+ function shouldSkip(
134
227
  absolutePath: string,
135
228
  kind: PathKind,
136
229
  rules: readonly IgnoreRule[],
137
- options: CollectPathOptions,
230
+ options: {
231
+ walkRoot: string;
232
+ includeHidden: boolean;
233
+ includeIgnored: boolean;
234
+ includeNoise: boolean;
235
+ },
138
236
  ): boolean {
139
- const displayPath = formatPathForDisplay(absolutePath, options.cwd);
140
- if (!options.includeHidden && hasHiddenPathSegment(displayPath)) return true;
141
- if (!options.includeNoise && hasNoisePathSegment(displayPath)) return true;
237
+ // Hidden/noise are relative to the walk root — not the session cwd display path.
238
+ // Otherwise absolute roots under ~/.local (etc.) look entirely "hidden".
239
+ const rel = relativeSlash(options.walkRoot, absolutePath);
240
+ const underRoot = rel === "." || (!rel.startsWith("..") && !isAbsolute(rel));
241
+ const segmentPath = underRoot && rel !== "." ? rel : "";
242
+ if (!options.includeHidden && segmentPath.length > 0 && hasHiddenSegment(segmentPath)) return true;
243
+ if (!options.includeNoise && segmentPath.length > 0 && hasNoiseSegment(segmentPath)) return true;
142
244
  return !options.includeIgnored && isIgnored(absolutePath, kind, rules);
143
245
  }
144
246
 
145
- function sortEntries(entries: TraversalEntry[]): TraversalEntry[] {
146
- return entries.sort((a, b) => {
247
+ function entryKind(stats: Stats): PathKind {
248
+ return stats.isDirectory() ? "dir" : "file";
249
+ }
250
+
251
+ function sortHits(hits: TraversalHit[]): TraversalHit[] {
252
+ return hits.sort((a, b) => {
147
253
  if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
148
254
  return a.displayPath.localeCompare(b.displayPath);
149
255
  });
150
256
  }
151
257
 
152
- async function readDirectoryNames(directory: string, cwd: string): Promise<string[]> {
258
+ async function listNames(directory: string, cwd: string): Promise<string[]> {
153
259
  try {
154
- return await readdir(directory);
260
+ const names: string[] = [];
261
+ for await (const entry of await opendir(directory)) {
262
+ names.push(entry.name);
263
+ }
264
+ return names;
155
265
  } catch {
156
266
  throw new Error(`Cannot read directory: ${formatPathForDisplay(directory, cwd)}`);
157
267
  }
158
268
  }
159
269
 
160
- export async function collectPaths(options: CollectPathOptions): Promise<TraversalEntry[]> {
161
- const entries: TraversalEntry[] = [];
162
- const entryByPath = new Map<string, TraversalEntry>();
163
- const rootStats = await lstat(options.root);
164
- const rootKind = entryKind(rootStats);
165
- const rootDirectory = rootKind === "dir" ? options.root : dirname(options.root);
166
- const rootRules = options.includeIgnored ? [] : await collectIgnoreRulesToDirectory(options.cwd, rootDirectory);
167
-
168
- function pushEntry(absolutePath: string, stats: Stats, depth: number): TraversalEntry {
169
- const entry = {
170
- absolutePath,
171
- displayPath: formatPathForDisplay(absolutePath, options.cwd),
172
- type: entryKind(stats),
173
- stats,
174
- depth,
175
- };
176
- entries.push(entry);
177
- entryByPath.set(absolutePath, entry);
178
- return entry;
270
+ /**
271
+ * Ignore-aware directory walk for structural scans.
272
+ * Budgets (files / source bytes / depth / elapsed) are separate from model output limits.
273
+ * Hitting a budget sets `result.limit` — not silent complete-tree success.
274
+ */
275
+ export async function walkPaths(options: WalkOptions): Promise<TraversalResult> {
276
+ const budgets = mergeBudgets(options.budgets);
277
+ const includeRoot = options.includeRoot !== false;
278
+ const includeHidden = options.includeHidden === true;
279
+ const includeIgnored = options.includeIgnored === true;
280
+ const includeNoise = options.includeNoise === true;
281
+ const filesOnly = options.filesOnly !== false;
282
+ const started = Date.now();
283
+
284
+ const entries: TraversalHit[] = [];
285
+ let filesVisited = 0;
286
+ let sourceBytes = 0;
287
+ let limit: TraversalLimit | undefined;
288
+
289
+ const elapsed = () => Date.now() - started;
290
+
291
+ const budgetHit = (): TraversalLimit | undefined => {
292
+ if (options.signal?.aborted) return "cancelled";
293
+ if (elapsed() >= budgets.maxElapsedMs) return "maxElapsedMs";
294
+ if (filesVisited >= budgets.maxFiles) return "maxFiles";
295
+ if (sourceBytes > budgets.maxSourceBytes) return "maxSourceBytes";
296
+ return undefined;
297
+ };
298
+
299
+ const stopIfNeeded = (): boolean => {
300
+ const hit = budgetHit();
301
+ if (hit === undefined) return false;
302
+ limit = limit ?? hit;
303
+ return true;
304
+ };
305
+
306
+ async function pushFile(hit: TraversalHit): Promise<boolean> {
307
+ if (options.matchFile !== undefined && !options.matchFile(hit)) return true;
308
+ if (stopIfNeeded()) return false;
309
+ if (filesVisited >= budgets.maxFiles) {
310
+ limit = "maxFiles";
311
+ return false;
312
+ }
313
+ const nextBytes = sourceBytes + hit.stats.size;
314
+ // Allow the first file even if it alone exceeds the byte budget; report the limit.
315
+ if (filesVisited > 0 && nextBytes > budgets.maxSourceBytes) {
316
+ limit = "maxSourceBytes";
317
+ return false;
318
+ }
319
+ filesVisited += 1;
320
+ sourceBytes = nextBytes;
321
+ if (sourceBytes > budgets.maxSourceBytes) limit = "maxSourceBytes";
322
+ entries.push(hit);
323
+ const cont = await options.onFile?.(hit);
324
+ if (cont === false) return false;
325
+ return !stopIfNeeded();
179
326
  }
180
327
 
181
- if (options.includeRoot) pushEntry(options.root, rootStats, 0);
182
- if (rootKind !== "dir") return entries;
328
+ const root = resolveExplorePath(options.cwd, options.root);
329
+ const rootStats = await lstat(root);
330
+ const rootKind = entryKind(rootStats);
331
+ const rootDir = rootKind === "dir" ? root : resolve(root, "..");
332
+ const walkRoot = rootKind === "dir" ? root : rootDir;
333
+ const skipOpts = { walkRoot, includeHidden, includeIgnored, includeNoise };
334
+ const rootRules = includeIgnored ? [] : await collectIgnoreRulesToDirectory(options.cwd, rootDir);
183
335
 
184
- async function walkDirectory(directory: string, depth: number, rules: readonly IgnoreRule[]): Promise<number> {
185
- if (options.maxDepth !== undefined && depth >= options.maxDepth) return 0;
336
+ if (includeRoot) {
337
+ const rootHit: TraversalHit = {
338
+ absolutePath: root,
339
+ displayPath: formatPathForDisplay(root, options.cwd),
340
+ type: rootKind,
341
+ stats: rootStats,
342
+ depth: 0,
343
+ };
344
+ if (rootKind === "file") {
345
+ await pushFile(rootHit);
346
+ return { entries, limit, filesVisited, sourceBytes, elapsedMs: elapsed() };
347
+ }
348
+ if (!filesOnly) entries.push(rootHit);
349
+ } else if (rootKind === "file") {
350
+ return { entries, limit, filesVisited, sourceBytes, elapsedMs: elapsed() };
351
+ }
186
352
 
187
- const names = await readDirectoryNames(directory, options.cwd);
353
+ async function walkDir(directory: string, depth: number, rules: readonly IgnoreRule[]): Promise<void> {
354
+ if (stopIfNeeded()) return;
355
+ if (depth >= budgets.maxDepth) {
356
+ limit = limit ?? "maxDepth";
357
+ return;
358
+ }
188
359
 
189
- const children: TraversalEntry[] = [];
360
+ const names = await listNames(directory, options.cwd);
361
+ const children: TraversalHit[] = [];
190
362
  for (const name of names) {
191
- const childPath = `${directory}/${name}`;
192
- const stats = await lstat(childPath);
363
+ if (stopIfNeeded()) return;
364
+ const childPath = join(directory, name);
365
+ let stats: Stats;
366
+ try {
367
+ stats = await lstat(childPath);
368
+ } catch {
369
+ continue;
370
+ }
193
371
  const kind = entryKind(stats);
194
- if (shouldSkipPath(childPath, kind, rules, options)) continue;
372
+ if (shouldSkip(childPath, kind, rules, skipOpts)) continue;
195
373
  children.push({
196
374
  absolutePath: childPath,
197
375
  displayPath: formatPathForDisplay(childPath, options.cwd),
@@ -201,26 +379,22 @@ export async function collectPaths(options: CollectPathOptions): Promise<Travers
201
379
  });
202
380
  }
203
381
 
204
- for (const child of sortEntries(children)) {
205
- entries.push(child);
206
- entryByPath.set(child.absolutePath, child);
207
- if (child.type !== "dir") continue;
208
- const childRules = options.includeIgnored
209
- ? rules
210
- : await appendIgnoreRulesForDirectory(child.absolutePath, rules);
211
- const visibleChildren = await walkDirectory(child.absolutePath, child.depth, childRules);
212
- if (visibleChildren === 0 && (options.maxDepth === undefined || child.depth < options.maxDepth)) {
213
- child.empty = true;
382
+ for (const child of sortHits(children)) {
383
+ if (stopIfNeeded()) return;
384
+ if (child.type === "file") {
385
+ if (!(await pushFile(child))) return;
386
+ continue;
214
387
  }
388
+ if (!filesOnly) entries.push(child);
389
+ if (child.depth >= budgets.maxDepth) {
390
+ limit = limit ?? "maxDepth";
391
+ continue;
392
+ }
393
+ const childRules = includeIgnored ? rules : await appendIgnoreRules(child.absolutePath, rules);
394
+ await walkDir(child.absolutePath, child.depth, childRules);
215
395
  }
216
-
217
- return children.length;
218
396
  }
219
397
 
220
- const visibleChildren = await walkDirectory(options.root, 0, rootRules);
221
- const rootEntry = entryByPath.get(options.root);
222
- if (rootEntry && visibleChildren === 0 && (options.maxDepth === undefined || options.maxDepth > 0)) {
223
- rootEntry.empty = true;
224
- }
225
- return entries;
398
+ if (rootKind === "dir") await walkDir(root, 0, rootRules);
399
+ return { entries, limit, filesVisited, sourceBytes, elapsedMs: elapsed() };
226
400
  }
@@ -6,7 +6,7 @@ import {
6
6
  type ExtensionAPI,
7
7
  serializeConversation,
8
8
  } from "@earendil-works/pi-coding-agent";
9
- import { prepareAutoreadMessage, type AutoreadDetails } from "../explore/autoread.ts";
9
+ import { prepareAutoreadMessage, type AutoreadDetails } from "../../shared/autoread.ts";
10
10
  import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
11
11
  import { errorText } from "../../shared/text.ts";
12
12
  import { buildHandoffRequest, type HandoffDraft, handoffDraftFromToolInput, HANDOFF_TOOL } from "./model.ts";