@oh-my-pi/pi-coding-agent 8.11.14 → 8.12.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [8.12.2] - 2026-01-28
6
+
7
+ ### Changed
8
+ - Replaced ripgrep-based file listing with fs.glob for project scans and find/read tooling
9
+
5
10
  ## [8.11.14] - 2026-01-28
6
11
 
7
12
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "8.11.14",
3
+ "version": "8.12.2",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -83,11 +83,11 @@
83
83
  "test": "bun test"
84
84
  },
85
85
  "dependencies": {
86
- "@oh-my-pi/omp-stats": "8.11.14",
87
- "@oh-my-pi/pi-agent-core": "8.11.14",
88
- "@oh-my-pi/pi-ai": "8.11.14",
89
- "@oh-my-pi/pi-tui": "8.11.14",
90
- "@oh-my-pi/pi-utils": "8.11.14",
86
+ "@oh-my-pi/omp-stats": "8.12.2",
87
+ "@oh-my-pi/pi-agent-core": "8.12.2",
88
+ "@oh-my-pi/pi-ai": "8.12.2",
89
+ "@oh-my-pi/pi-tui": "8.12.2",
90
+ "@oh-my-pi/pi-utils": "8.12.2",
91
91
  "@openai/agents": "^0.4.4",
92
92
  "@sinclair/typebox": "^0.34.48",
93
93
  "ajv": "^8.17.1",
package/src/cursor.ts CHANGED
@@ -194,12 +194,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
194
194
 
195
195
  async shell(args: Parameters<NonNullable<ICursorExecHandlers["shell"]>>[0]) {
196
196
  const toolCallId = decodeToolCallId(args.toolCallId);
197
- const timeoutSeconds =
198
- args.timeout && args.timeout > 0
199
- ? args.timeout > 1000
200
- ? Math.ceil(args.timeout / 1000)
201
- : args.timeout
202
- : undefined;
197
+ const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined;
203
198
  const toolResultMessage = await executeTool(this.options, "bash", toolCallId, {
204
199
  command: args.command,
205
200
  workdir: args.workingDirectory || undefined,
@@ -47,6 +47,8 @@ export interface ExtensionUIDialogOptions {
47
47
  timeout?: number;
48
48
  /** Initial cursor position for select dialogs (0-indexed) */
49
49
  initialIndex?: number;
50
+ /** Render an outlined list for select dialogs */
51
+ outline?: boolean;
50
52
  }
51
53
 
52
54
  /**
@@ -6,6 +6,7 @@ import type { TUI } from "@oh-my-pi/pi-tui";
6
6
  export class CountdownTimer {
7
7
  private intervalId: ReturnType<typeof setInterval> | undefined;
8
8
  private remainingSeconds: number;
9
+ private readonly initialMs: number;
9
10
 
10
11
  constructor(
11
12
  timeoutMs: number,
@@ -13,6 +14,7 @@ export class CountdownTimer {
13
14
  private onTick: (seconds: number) => void,
14
15
  private onExpire: () => void,
15
16
  ) {
17
+ this.initialMs = timeoutMs;
16
18
  this.remainingSeconds = Math.ceil(timeoutMs / 1000);
17
19
  this.onTick(this.remainingSeconds);
18
20
 
@@ -28,6 +30,13 @@ export class CountdownTimer {
28
30
  }, 1000);
29
31
  }
30
32
 
33
+ /** Reset the countdown to its initial value */
34
+ reset(): void {
35
+ this.remainingSeconds = Math.ceil(this.initialMs / 1000);
36
+ this.onTick(this.remainingSeconds);
37
+ this.tui?.requestRender();
38
+ }
39
+
31
40
  dispose(): void {
32
41
  if (this.intervalId) {
33
42
  clearInterval(this.intervalId);
@@ -2,7 +2,7 @@
2
2
  * Generic selector component for hooks.
3
3
  * Displays a list of string options with keyboard navigation.
4
4
  */
5
- import { Container, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
5
+ import { Container, matchesKey, Spacer, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
6
6
  import { theme } from "../../modes/theme/theme";
7
7
  import { CountdownTimer } from "./countdown-timer";
8
8
  import { DynamicBorder } from "./dynamic-border";
@@ -11,12 +11,36 @@ export interface HookSelectorOptions {
11
11
  tui?: TUI;
12
12
  timeout?: number;
13
13
  initialIndex?: number;
14
+ outline?: boolean;
15
+ maxVisible?: number;
16
+ }
17
+
18
+ class OutlinedList extends Container {
19
+ private lines: string[] = [];
20
+
21
+ setLines(lines: string[]): void {
22
+ this.lines = lines;
23
+ this.invalidate();
24
+ }
25
+
26
+ render(width: number): string[] {
27
+ const borderColor = (text: string) => theme.fg("border", text);
28
+ const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
29
+ const innerWidth = Math.max(1, width - 2);
30
+ const content = this.lines.map(line => {
31
+ const pad = Math.max(0, innerWidth - visibleWidth(line));
32
+ return `${borderColor(theme.boxSharp.vertical)}${line}${" ".repeat(pad)}${borderColor(theme.boxSharp.vertical)}`;
33
+ });
34
+ return [horizontal, ...content, horizontal];
35
+ }
14
36
  }
15
37
 
16
38
  export class HookSelectorComponent extends Container {
17
39
  private options: string[];
18
40
  private selectedIndex: number;
19
- private listContainer: Container;
41
+ private maxVisible: number;
42
+ private listContainer: Container | undefined;
43
+ private outlinedList: OutlinedList | undefined;
20
44
  private onSelectCallback: (option: string) => void;
21
45
  private onCancelCallback: () => void;
22
46
  private titleText: Text;
@@ -34,6 +58,7 @@ export class HookSelectorComponent extends Container {
34
58
 
35
59
  this.options = options;
36
60
  this.selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1);
61
+ this.maxVisible = Math.max(3, opts?.maxVisible ?? 12);
37
62
  this.onSelectCallback = onSelect;
38
63
  this.onCancelCallback = onCancel;
39
64
  this.baseTitle = title;
@@ -62,8 +87,13 @@ export class HookSelectorComponent extends Container {
62
87
  );
63
88
  }
64
89
 
65
- this.listContainer = new Container();
66
- this.addChild(this.listContainer);
90
+ if (opts?.outline) {
91
+ this.outlinedList = new OutlinedList();
92
+ this.addChild(this.outlinedList);
93
+ } else {
94
+ this.listContainer = new Container();
95
+ this.addChild(this.listContainer);
96
+ }
67
97
  this.addChild(new Spacer(1));
68
98
  this.addChild(new Text(theme.fg("dim", "up/down navigate enter select esc cancel"), 1, 0));
69
99
  this.addChild(new Spacer(1));
@@ -73,17 +103,38 @@ export class HookSelectorComponent extends Container {
73
103
  }
74
104
 
75
105
  private updateList(): void {
76
- this.listContainer.clear();
77
- for (let i = 0; i < this.options.length; i++) {
106
+ const lines: string[] = [];
107
+ const startIndex = Math.max(
108
+ 0,
109
+ Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.options.length - this.maxVisible),
110
+ );
111
+ const endIndex = Math.min(startIndex + this.maxVisible, this.options.length);
112
+
113
+ for (let i = startIndex; i < endIndex; i++) {
78
114
  const isSelected = i === this.selectedIndex;
79
115
  const text = isSelected
80
116
  ? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.options[i])
81
117
  : ` ${theme.fg("text", this.options[i])}`;
82
- this.listContainer.addChild(new Text(text, 1, 0));
118
+ lines.push(text);
119
+ }
120
+
121
+ if (startIndex > 0 || endIndex < this.options.length) {
122
+ lines.push(theme.fg("dim", ` (${this.selectedIndex + 1}/${this.options.length})`));
123
+ }
124
+ if (this.outlinedList) {
125
+ this.outlinedList.setLines(lines);
126
+ return;
127
+ }
128
+ this.listContainer?.clear();
129
+ for (const line of lines) {
130
+ this.listContainer?.addChild(new Text(line, 1, 0));
83
131
  }
84
132
  }
85
133
 
86
134
  handleInput(keyData: string): void {
135
+ // Reset countdown on any interaction
136
+ this.countdown?.reset();
137
+
87
138
  if (matchesKey(keyData, "up") || keyData === "k") {
88
139
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
89
140
  this.updateList();
@@ -1,4 +1,4 @@
1
- import type { Component, TUI } from "@oh-my-pi/pi-tui";
1
+ import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
2
2
  import { Spacer, Text } from "@oh-my-pi/pi-tui";
3
3
  import { logger } from "@oh-my-pi/pi-utils";
4
4
  import { KeybindingsManager } from "../../config/keybindings";
@@ -18,6 +18,17 @@ import type { InteractiveModeContext } from "../../modes/types";
18
18
  import { setTerminalTitle } from "../../utils/title-generator";
19
19
 
20
20
  export class ExtensionUiController {
21
+ private hookSelectorOverlay: OverlayHandle | undefined;
22
+ private hookInputOverlay: OverlayHandle | undefined;
23
+
24
+ private readonly dialogOverlayOptions = {
25
+ anchor: "bottom-center",
26
+ width: "80%",
27
+ minWidth: 40,
28
+ maxHeight: "70%",
29
+ margin: 1,
30
+ } as const;
31
+
21
32
  constructor(private ctx: InteractiveModeContext) {}
22
33
 
23
34
  /**
@@ -491,6 +502,9 @@ export class ExtensionUiController {
491
502
  dialogOptions?: ExtensionUIDialogOptions,
492
503
  ): Promise<string | undefined> {
493
504
  const { promise, resolve } = Promise.withResolvers<string | undefined>();
505
+ this.hookSelectorOverlay?.hide();
506
+ this.hookSelectorOverlay = undefined;
507
+ const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
494
508
  this.ctx.hookSelector = new HookSelectorComponent(
495
509
  title,
496
510
  options,
@@ -502,13 +516,15 @@ export class ExtensionUiController {
502
516
  this.hideHookSelector();
503
517
  resolve(undefined);
504
518
  },
505
- { initialIndex: dialogOptions?.initialIndex, timeout: dialogOptions?.timeout, tui: this.ctx.ui },
519
+ {
520
+ initialIndex: dialogOptions?.initialIndex,
521
+ timeout: dialogOptions?.timeout,
522
+ tui: this.ctx.ui,
523
+ outline: dialogOptions?.outline,
524
+ maxVisible,
525
+ },
506
526
  );
507
-
508
- this.ctx.editorContainer.clear();
509
- this.ctx.editorContainer.addChild(this.ctx.hookSelector);
510
- this.ctx.ui.setFocus(this.ctx.hookSelector);
511
- this.ctx.ui.requestRender();
527
+ this.hookSelectorOverlay = this.ctx.ui.showOverlay(this.ctx.hookSelector, this.dialogOverlayOptions);
512
528
  return promise;
513
529
  }
514
530
 
@@ -516,8 +532,9 @@ export class ExtensionUiController {
516
532
  * Hide the hook selector.
517
533
  */
518
534
  hideHookSelector(): void {
519
- this.ctx.editorContainer.clear();
520
- this.ctx.editorContainer.addChild(this.ctx.editor);
535
+ this.ctx.hookSelector?.dispose();
536
+ this.hookSelectorOverlay?.hide();
537
+ this.hookSelectorOverlay = undefined;
521
538
  this.ctx.hookSelector = undefined;
522
539
  this.ctx.ui.setFocus(this.ctx.editor);
523
540
  this.ctx.ui.requestRender();
@@ -536,6 +553,8 @@ export class ExtensionUiController {
536
553
  */
537
554
  showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
538
555
  const { promise, resolve } = Promise.withResolvers<string | undefined>();
556
+ this.hookInputOverlay?.hide();
557
+ this.hookInputOverlay = undefined;
539
558
  this.ctx.hookInput = new HookInputComponent(
540
559
  title,
541
560
  placeholder,
@@ -548,11 +567,7 @@ export class ExtensionUiController {
548
567
  resolve(undefined);
549
568
  },
550
569
  );
551
-
552
- this.ctx.editorContainer.clear();
553
- this.ctx.editorContainer.addChild(this.ctx.hookInput);
554
- this.ctx.ui.setFocus(this.ctx.hookInput);
555
- this.ctx.ui.requestRender();
570
+ this.hookInputOverlay = this.ctx.ui.showOverlay(this.ctx.hookInput, this.dialogOverlayOptions);
556
571
  return promise;
557
572
  }
558
573
 
@@ -560,8 +575,9 @@ export class ExtensionUiController {
560
575
  * Hide the hook input.
561
576
  */
562
577
  hideHookInput(): void {
563
- this.ctx.editorContainer.clear();
564
- this.ctx.editorContainer.addChild(this.ctx.editor);
578
+ this.ctx.hookInput?.dispose();
579
+ this.hookInputOverlay?.hide();
580
+ this.hookInputOverlay = undefined;
565
581
  this.ctx.hookInput = undefined;
566
582
  this.ctx.ui.setFocus(this.ctx.editor);
567
583
  this.ctx.ui.requestRender();
@@ -5,43 +5,55 @@
5
5
  {{#if appendPrompt}}
6
6
  {{appendPrompt}}
7
7
  {{/if}}
8
- {{#if contextFiles.length}}
9
- # Project Context
8
+ {{#ifAny projectTree contextFiles.length git.isRepo}}
9
+ <project>
10
+ {{#if projectTree}}
11
+ ## Files
12
+ <tree>
13
+ {{projectTree}}
14
+ </tree>
15
+ {{/if}}
10
16
 
11
- <project_context_files>
17
+ {{#if contextFiles.length}}
18
+ ## Context
19
+ <instructions>
12
20
  {{#list contextFiles join="\n"}}
13
21
  <file path="{{path}}">
14
22
  {{content}}
15
23
  </file>
16
24
  {{/list}}
17
- </project_context_files>
25
+ </instructions>
18
26
  {{/if}}
27
+
19
28
  {{#if git.isRepo}}
20
- # Git Status
29
+ ## Version Control
30
+ This is a snapshot. It does not update during the conversation.
21
31
 
22
- This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
23
32
  Current branch: {{git.currentBranch}}
24
33
  Main branch: {{git.mainBranch}}
25
34
 
26
- Status:
27
35
  {{git.status}}
28
36
 
29
- Recent commits:
37
+ ### History
30
38
  {{git.commits}}
31
39
  {{/if}}
40
+ </project>
41
+ {{/ifAny}}
42
+
32
43
  {{#if skills.length}}
33
- The following skills provide specialized instructions for specific tasks.
34
- Use the read tool to load a skill's file when the task matches its description.
44
+ Skills are specialized knowledge.
45
+ They exist because someone learned the hard way.
35
46
 
36
- <available_skills>
47
+ Scan descriptions against your task domain.
48
+ If a skill covers what you're producing, read `skill://<name>` before proceeding.
49
+
50
+ <skills>
37
51
  {{#list skills join="\n"}}
38
- <skill>
39
- <name>{{escapeXml name}}</name>
40
- <description>{{escapeXml description}}</description>
41
- <location>skill://{{escapeXml name}}</location>
52
+ <skill name="{{name}}">
53
+ {{description}}
42
54
  </skill>
43
55
  {{/list}}
44
- </available_skills>
56
+ </skills>
45
57
  {{/if}}
46
58
  {{#if preloadedSkills.length}}
47
59
  The following skills are preloaded in full. Apply their instructions directly.
@@ -49,30 +61,24 @@ The following skills are preloaded in full. Apply their instructions directly.
49
61
  <preloaded_skills>
50
62
  {{#list preloadedSkills join="\n"}}
51
63
  <skill name="{{name}}">
52
- <location>skill://{{escapeXml name}}</location>
53
- <content>
54
64
  {{content}}
55
- </content>
56
65
  </skill>
57
66
  {{/list}}
58
67
  </preloaded_skills>
59
68
  {{/if}}
60
69
  {{#if rules.length}}
61
- The following rules define project-specific guidelines and constraints:
70
+ Rules are local constraints.
71
+ They exist because someone made a mistake here before.
72
+
73
+ Read `rule://<name>` when working in their domain.
62
74
 
63
75
  <rules>
64
76
  {{#list rules join="\n"}}
65
- <rule>
66
- <name>{{escapeXml name}}</name>
67
- <description>{{escapeXml description}}</description>
77
+ <rule name="{{name}}">
78
+ {{description}}
68
79
  {{#if globs.length}}
69
- <globs>
70
- {{#list globs join="\n"}}
71
- <glob>{{escapeXml this}}</glob>
72
- {{/list}}
73
- </globs>
80
+ {{#list globs join="\n"}}<glob>{{this}}</glob>{{/list}}
74
81
  {{/if}}
75
- <location>rule://{{escapeXml name}}</location>
76
82
  </rule>
77
83
  {{/list}}
78
84
  </rules>
@@ -221,22 +221,27 @@ It lies. The code that runs is not the code that works.
221
221
  - Resolve blockers before yielding.
222
222
  </procedure>
223
223
 
224
- <context>
224
+ <project>
225
+ {{#if projectTree}}
226
+ ## Files
227
+ <tree>
228
+ {{projectTree}}
229
+ </tree>
230
+ {{/if}}
231
+
225
232
  {{#if contextFiles.length}}
226
- <project_context_files>
233
+ ## Context
234
+ <instructions>
227
235
  {{#list contextFiles join="\n"}}
228
236
  <file path="{{path}}">
229
237
  {{content}}
230
238
  </file>
231
239
  {{/list}}
232
- </project_context_files>
240
+ </instructions>
233
241
  {{/if}}
234
- </context>
235
242
 
236
243
  {{#if git.isRepo}}
237
- <vcs>
238
- # Git Status
239
-
244
+ ## Version Control
240
245
  This is a snapshot. It does not update during the conversation.
241
246
 
242
247
  Current branch: {{git.currentBranch}}
@@ -244,23 +249,22 @@ Main branch: {{git.mainBranch}}
244
249
 
245
250
  {{git.status}}
246
251
 
247
- ## History
248
-
252
+ ### History
249
253
  {{git.commits}}
250
- </vcs>
251
254
  {{/if}}
255
+ </project>
256
+
252
257
  {{#if skills.length}}
253
258
  <skills>
254
259
  Skills are specialized knowledge.
255
260
  They exist because someone learned the hard way.
256
261
 
257
262
  Scan descriptions against your task domain.
258
- If a skill covers what you're producing, read it before proceeding.
263
+ If a skill covers what you're producing, read `skill://<name>` before proceeding.
259
264
 
260
265
  {{#list skills join="\n"}}
261
266
  <skill name="{{name}}">
262
267
  {{description}}
263
- <path>skill://{{name}}</path>
264
268
  </skill>
265
269
  {{/list}}
266
270
  </skills>
@@ -271,7 +275,6 @@ The following skills are preloaded in full. Apply their instructions directly.
271
275
 
272
276
  {{#list preloadedSkills join="\n"}}
273
277
  <skill name="{{name}}">
274
- <location>skill://{{escapeXml name}}</location>
275
278
  {{content}}
276
279
  </skill>
277
280
  {{/list}}
@@ -282,12 +285,12 @@ The following skills are preloaded in full. Apply their instructions directly.
282
285
  Rules are local constraints.
283
286
  They exist because someone made a mistake here before.
284
287
 
285
- Load when working in their domain:
288
+ Read `rule://<name>` when working in their domain.
289
+
286
290
  {{#list rules join="\n"}}
287
291
  <rule name="{{name}}">
288
292
  {{description}}
289
293
  {{#list globs join="\n"}}<glob>{{this}}</glob>{{/list}}
290
- <path>rule://{{name}}</path>
291
294
  </rule>
292
295
  {{/list}}
293
296
  </rules>
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * System prompt construction and project context loading
3
3
  */
4
+ import * as fs from "node:fs/promises";
4
5
  import * as os from "node:os";
5
6
  import * as path from "node:path";
7
+ import { globPaths } from "@oh-my-pi/pi-utils";
6
8
  import { $ } from "bun";
7
9
  import chalk from "chalk";
8
10
  import { contextFileCapability } from "./capability/context-file";
@@ -129,6 +131,24 @@ function stripQuotes(value: string): string {
129
131
 
130
132
  const AGENTS_MD_PATTERN = "**/AGENTS.md";
131
133
  const AGENTS_MD_LIMIT = 200;
134
+ const PROJECT_TREE_LIMIT = 2000;
135
+ const PROJECT_TREE_PER_DIR_LIMIT = 10;
136
+ const PROJECT_TREE_PER_DIR_DEPTH = 2;
137
+ const PROJECT_TREE_IGNORED = new Set([
138
+ ".git",
139
+ ".hg",
140
+ ".svn",
141
+ ".next",
142
+ ".turbo",
143
+ ".cache",
144
+ ".venv",
145
+ ".idea",
146
+ ".vscode",
147
+ "build",
148
+ "dist",
149
+ "node_modules",
150
+ "target",
151
+ ]);
132
152
 
133
153
  interface AgentsMdSearch {
134
154
  scopePath: string;
@@ -166,6 +186,197 @@ function buildAgentsMdSearch(cwd: string): AgentsMdSearch {
166
186
  };
167
187
  }
168
188
 
189
+ type ProjectTreeEntry = {
190
+ name: string;
191
+ isDirectory: boolean;
192
+ path: string;
193
+ };
194
+
195
+ type ProjectTreeScan = {
196
+ children: Map<string, ProjectTreeEntry[]>;
197
+ truncated: boolean;
198
+ truncatedDirs: Set<string>;
199
+ };
200
+
201
+ const GLOB_TIMEOUT_MS = 5000;
202
+
203
+ /**
204
+ * Scan project tree using fs.promises.glob with exclusion filters.
205
+ * Returns null if glob fails.
206
+ */
207
+ async function scanProjectTreeWithGlob(root: string): Promise<ProjectTreeScan | null> {
208
+ let entries: string[];
209
+ try {
210
+ entries = await globPaths("**/*", {
211
+ cwd: root,
212
+ gitignore: true,
213
+ timeoutMs: GLOB_TIMEOUT_MS,
214
+ });
215
+ } catch {
216
+ return null;
217
+ }
218
+
219
+ // Build directory contents map from file list
220
+ // Map<dirPath, Map<entryPath, isDirectory>>
221
+ const dirContents = new Map<string, Map<string, boolean>>();
222
+ dirContents.set(root, new Map());
223
+
224
+ for (const entry of entries) {
225
+ const filePath = entry.trim();
226
+ if (!filePath) continue;
227
+ const absolutePath = path.join(root, filePath);
228
+ // Check static ignores on path components
229
+ const relative = path.relative(root, absolutePath);
230
+ const parts = relative.split(path.sep);
231
+ if (parts.some(p => PROJECT_TREE_IGNORED.has(p))) continue;
232
+
233
+ // Add file to its parent directory
234
+ const parent = path.dirname(absolutePath);
235
+ if (!dirContents.has(parent)) dirContents.set(parent, new Map());
236
+ dirContents.get(parent)!.set(absolutePath, false);
237
+
238
+ // Add all intermediate directories
239
+ let dir = parent;
240
+ while (dir.length >= root.length && dir !== path.dirname(dir)) {
241
+ const parentDir = path.dirname(dir);
242
+ if (!dirContents.has(parentDir)) dirContents.set(parentDir, new Map());
243
+ dirContents.get(parentDir)!.set(dir, true);
244
+ dir = parentDir;
245
+ }
246
+ }
247
+
248
+ // BFS to build the tree with limits
249
+ const children = new Map<string, ProjectTreeEntry[]>();
250
+ let entryCount = 0;
251
+ let truncated = false;
252
+ const truncatedDirs = new Set<string>();
253
+
254
+ const queue: Array<{ dirPath: string; depth: number }> = [{ dirPath: root, depth: 0 }];
255
+ let cursor = 0;
256
+
257
+ while (cursor < queue.length && !truncated) {
258
+ const { dirPath, depth } = queue[cursor];
259
+ cursor += 1;
260
+
261
+ const contents = dirContents.get(dirPath);
262
+ if (!contents || contents.size === 0) continue;
263
+
264
+ // Get stats for sorting
265
+ const entries = Array.from(contents.entries());
266
+ const withStats = await Promise.all(
267
+ entries.map(async ([entryPath, isDirectory]) => {
268
+ try {
269
+ const stats = await fs.stat(entryPath);
270
+ return { entryPath, isDirectory, mtimeMs: stats.mtimeMs };
271
+ } catch {
272
+ return { entryPath, isDirectory, mtimeMs: 0 };
273
+ }
274
+ }),
275
+ );
276
+
277
+ withStats.sort((a, b) => {
278
+ if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
279
+ return path.basename(a.entryPath).localeCompare(path.basename(b.entryPath));
280
+ });
281
+
282
+ const perDirLimit = depth >= PROJECT_TREE_PER_DIR_DEPTH ? PROJECT_TREE_PER_DIR_LIMIT : null;
283
+ const limited = perDirLimit === null ? withStats : withStats.slice(0, perDirLimit);
284
+ const hasMoreEntries = perDirLimit !== null && withStats.length > perDirLimit;
285
+
286
+ const mapped: ProjectTreeEntry[] = [];
287
+ for (const { entryPath, isDirectory } of limited) {
288
+ if (entryCount >= PROJECT_TREE_LIMIT) {
289
+ truncated = true;
290
+ break;
291
+ }
292
+
293
+ mapped.push({
294
+ name: path.basename(entryPath),
295
+ isDirectory,
296
+ path: entryPath,
297
+ });
298
+ entryCount += 1;
299
+
300
+ if (isDirectory) {
301
+ queue.push({ dirPath: entryPath, depth: depth + 1 });
302
+ }
303
+ }
304
+
305
+ if (!truncated && hasMoreEntries) {
306
+ truncatedDirs.add(dirPath);
307
+ }
308
+ children.set(dirPath, mapped);
309
+ }
310
+
311
+ return { children, truncated, truncatedDirs };
312
+ }
313
+
314
+ async function scanProjectTree(root: string): Promise<ProjectTreeScan> {
315
+ const globResult = await scanProjectTreeWithGlob(root);
316
+ if (globResult) return globResult;
317
+ return { children: new Map(), truncated: false, truncatedDirs: new Set() };
318
+ }
319
+
320
+ function renderProjectTree(scan: ProjectTreeScan, root: string): string {
321
+ const lines: string[] = [];
322
+
323
+ const collapseDir = (dirPath: string): { path: string; entries: ProjectTreeEntry[] } | null => {
324
+ let currentPath = dirPath;
325
+ while (true) {
326
+ const entries = scan.children.get(currentPath);
327
+ if (!entries || entries.length === 0) return null;
328
+ const files = entries.filter(entry => !entry.isDirectory);
329
+ const dirs = entries.filter(entry => entry.isDirectory);
330
+ if (files.length === 0 && dirs.length === 1 && !scan.truncatedDirs.has(currentPath)) {
331
+ currentPath = dirs[0].path;
332
+ continue;
333
+ }
334
+ return { path: currentPath, entries };
335
+ }
336
+ };
337
+
338
+ const renderDir = (dirPath: string, indent: string, isRoot: boolean): void => {
339
+ const collapsed = collapseDir(dirPath);
340
+ if (!collapsed) return;
341
+ const { path: collapsedPath, entries } = collapsed;
342
+
343
+ // For non-root directories, print the header and indent contents
344
+ const contentIndent = isRoot ? indent : `${indent} `;
345
+ if (!isRoot) {
346
+ const relative = path.relative(root, collapsedPath) || ".";
347
+ lines.push(`${indent}@ ${relative}`);
348
+ }
349
+
350
+ const files = entries.filter(entry => !entry.isDirectory);
351
+ const dirs = entries.filter(entry => entry.isDirectory);
352
+
353
+ for (const entry of files) {
354
+ lines.push(`${contentIndent}- ${entry.name}`);
355
+ }
356
+
357
+ if (scan.truncatedDirs.has(collapsedPath)) {
358
+ lines.push(`${contentIndent}- …`);
359
+ }
360
+
361
+ for (const entry of dirs) {
362
+ renderDir(entry.path, contentIndent, false);
363
+ }
364
+ };
365
+
366
+ renderDir(root, "", true);
367
+
368
+ if (scan.truncated) {
369
+ lines.push("…");
370
+ }
371
+
372
+ return lines.join("\n");
373
+ }
374
+
375
+ async function buildProjectTreeSnapshot(root: string): Promise<string> {
376
+ const scan = await scanProjectTree(root);
377
+ return renderProjectTree(scan, root);
378
+ }
379
+
169
380
  function getOsName(): string {
170
381
  switch (process.platform) {
171
382
  case "win32":
@@ -707,6 +918,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
707
918
  // Resolve context files: use provided or discover
708
919
  const contextFiles = providedContextFiles ?? (await loadProjectContextFiles({ cwd: resolvedCwd }));
709
920
  const agentsMdSearch = buildAgentsMdSearch(resolvedCwd);
921
+ const projectTree = await buildProjectTreeSnapshot(resolvedCwd);
710
922
 
711
923
  // Build tool descriptions array
712
924
  // Priority: toolNames (explicit list) > tools (Map) > defaults
@@ -744,6 +956,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
744
956
  customPrompt: resolvedCustomPrompt,
745
957
  appendPrompt: resolvedAppendPrompt ?? "",
746
958
  contextFiles,
959
+ projectTree,
747
960
  agentsMdSearch,
748
961
  git,
749
962
  skills: filteredSkills,
@@ -759,6 +972,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
759
972
  environment: await getEnvironmentInfo(),
760
973
  systemPromptCustomization: systemPromptCustomization ?? "",
761
974
  contextFiles,
975
+ projectTree,
762
976
  agentsMdSearch,
763
977
  git,
764
978
  skills: filteredSkills,
package/src/tools/ask.ts CHANGED
@@ -77,6 +77,7 @@ export interface AskToolDetails {
77
77
 
78
78
  const OTHER_OPTION = "Other (type your own)";
79
79
  const RECOMMENDED_SUFFIX = " (Recommended)";
80
+ const ASK_TIMEOUT_MS = 30000;
80
81
 
81
82
  function getDoneOptionLabel(): string {
82
83
  return `${theme.status.success} Done selecting`;
@@ -113,7 +114,7 @@ interface UIContext {
113
114
  select(
114
115
  prompt: string,
115
116
  options: string[],
116
- options_?: { initialIndex?: number; timeout?: number },
117
+ options_?: { initialIndex?: number; timeout?: number; outline?: boolean },
117
118
  ): Promise<string | undefined>;
118
119
  input(prompt: string): Promise<string | undefined>;
119
120
  }
@@ -131,7 +132,7 @@ async function askSingleQuestion(
131
132
 
132
133
  if (multi) {
133
134
  const selected = new Set<string>();
134
- let cursorIndex = 0;
135
+ let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), optionLabels.length - 1);
135
136
 
136
137
  while (true) {
137
138
  const opts: string[] = [];
@@ -148,13 +149,22 @@ async function askSingleQuestion(
148
149
  opts.push(OTHER_OPTION);
149
150
 
150
151
  const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
151
- const choice = await ui.select(`${prefix}${question}`, opts, { initialIndex: cursorIndex });
152
+ const selectionStart = Date.now();
153
+ const choice = await ui.select(`${prefix}${question}`, opts, {
154
+ initialIndex: cursorIndex,
155
+ timeout: ASK_TIMEOUT_MS,
156
+ outline: true,
157
+ });
158
+ const elapsed = Date.now() - selectionStart;
159
+ const timedOut = elapsed >= ASK_TIMEOUT_MS;
152
160
 
153
161
  if (choice === undefined || choice === doneLabel) break;
154
162
 
155
163
  if (choice === OTHER_OPTION) {
156
- const input = await ui.input("Enter your response:");
157
- if (input) customInput = input;
164
+ if (!timedOut) {
165
+ const input = await ui.input("Enter your response:");
166
+ if (input) customInput = input;
167
+ }
158
168
  break;
159
169
  }
160
170
 
@@ -179,13 +189,18 @@ async function askSingleQuestion(
179
189
  selected.add(opt);
180
190
  }
181
191
  }
192
+
193
+ if (timedOut) {
194
+ break;
195
+ }
182
196
  }
183
197
  selectedOptions = Array.from(selected);
184
198
  } else {
185
199
  const displayLabels = addRecommendedSuffix(optionLabels, recommended);
186
200
  const choice = await ui.select(question, [...displayLabels, OTHER_OPTION], {
187
- timeout: 30000,
201
+ timeout: ASK_TIMEOUT_MS,
188
202
  initialIndex: recommended,
203
+ outline: true,
189
204
  });
190
205
  if (choice === OTHER_OPTION) {
191
206
  const input = await ui.input("Enter your response:");
@@ -911,11 +911,8 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
911
911
  ): Promise<AgentToolResult<FetchToolDetails>> {
912
912
  const { url, timeout: rawTimeout = 20, raw = false } = params;
913
913
 
914
- // Auto-convert milliseconds to seconds if value > 1000 (16+ min is unreasonable)
915
- const timeoutSec = rawTimeout > 1000 ? rawTimeout / 1000 : rawTimeout;
916
-
917
914
  // Clamp to valid range (seconds)
918
- const effectiveTimeout = Math.min(Math.max(timeoutSec, 1), 45);
915
+ const effectiveTimeout = Math.min(Math.max(rawTimeout, 1), 45);
919
916
 
920
917
  if (signal?.aborted) {
921
918
  throw new ToolAbortError();
package/src/tools/find.ts CHANGED
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
- import { isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { globPaths, isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import type { Static } from "@sinclair/typebox";
8
8
  import { Type } from "@sinclair/typebox";
9
9
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -11,14 +11,12 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import findDescription from "../prompts/tools/find.md" with { type: "text" };
13
13
  import { renderFileList, renderStatusLine, renderTreeList } from "../tui";
14
- import { ensureTool } from "../utils/tools-manager";
15
14
  import type { ToolSession } from ".";
16
- import { runRg } from "./grep";
17
15
  import { applyListLimit } from "./list-limit";
18
16
  import type { OutputMeta } from "./output-meta";
19
17
  import { resolveToCwd } from "./path-utils";
20
18
  import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
21
- import { ToolError, throwIfAborted } from "./tool-errors";
19
+ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
22
20
  import { toolResult } from "./tool-result";
23
21
  import { type TruncationResult, truncateHead } from "./truncate";
24
22
 
@@ -30,7 +28,7 @@ const findSchema = Type.Object({
30
28
  });
31
29
 
32
30
  const DEFAULT_LIMIT = 1000;
33
- const RG_TIMEOUT_MS = 5000;
31
+ const GLOB_TIMEOUT_MS = 5000;
34
32
 
35
33
  export interface FindToolDetails {
36
34
  truncation?: TruncationResult;
@@ -80,7 +78,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
80
78
  params: Static<typeof findSchema>,
81
79
  signal?: AbortSignal,
82
80
  _onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
83
- context?: AgentToolContext,
81
+ _context?: AgentToolContext,
84
82
  ): Promise<AgentToolResult<FindToolDetails>> {
85
83
  const { pattern, path: searchDir, limit, hidden } = params;
86
84
 
@@ -107,7 +105,6 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
107
105
  }
108
106
  const includeHidden = hidden ?? true;
109
107
  const globPattern = normalizedPattern.replace(/\\/g, "/");
110
- const globMatcher = new Bun.Glob(globPattern);
111
108
 
112
109
  // If custom operations provided with glob, use that instead of fd
113
110
  if (this.customOps?.glob) {
@@ -171,44 +168,30 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
171
168
  throw new ToolError(`Path is not a directory: ${searchPath}`);
172
169
  }
173
170
 
174
- // Default: use rg
175
- const rgPath = await ensureTool("rg", {
176
- silent: true,
177
- notify: message => context?.ui?.notify(message, "info"),
178
- });
179
- if (!rgPath) {
180
- throw new ToolError("rg is not available and could not be downloaded");
171
+ let lines: string[];
172
+ try {
173
+ lines = await globPaths(globPattern, {
174
+ cwd: searchPath,
175
+ gitignore: true,
176
+ dot: includeHidden,
177
+ signal,
178
+ timeoutMs: GLOB_TIMEOUT_MS,
179
+ });
180
+ } catch (error) {
181
+ if (error instanceof Error && error.name === "AbortError") {
182
+ throw new ToolAbortError();
183
+ }
184
+ if (error instanceof Error && error.name === "TimeoutError") {
185
+ const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
186
+ throw new ToolError(`glob timed out after ${timeoutSeconds}s`);
187
+ }
188
+ throw error;
181
189
  }
182
190
 
183
- const args = [
184
- "--files",
185
- ...(includeHidden ? ["--hidden"] : []),
186
- "--no-require-git",
187
- "--color=never",
188
- "--glob",
189
- "!**/.git/**",
190
- "--glob",
191
- "!**/node_modules/**",
192
- searchPath,
193
- ];
194
-
195
- // Run rg with timeout
196
- const mainTimeoutSignal = AbortSignal.timeout(RG_TIMEOUT_MS);
197
- const mainCombinedSignal = signal ? AbortSignal.any([signal, mainTimeoutSignal]) : mainTimeoutSignal;
198
- const { stdout, stderr, exitCode } = await runRg(rgPath, args, mainCombinedSignal);
199
- const output = stdout.trim();
200
-
201
- // rg exit codes: 0 = found files, 1 = no matches, other = error
202
- // Treat exit code 1 with no output as "no files found"
203
- if (!output) {
204
- if (exitCode !== 0 && exitCode !== 1) {
205
- throw new ToolError(stderr.trim() || `rg failed (exit ${exitCode})`);
206
- }
191
+ if (lines.length === 0) {
207
192
  const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
208
193
  return toolResult(details).text("No files found matching pattern").done();
209
194
  }
210
-
211
- const lines = output.split("\n");
212
195
  const relativized: string[] = [];
213
196
  const mtimes: number[] = [];
214
197
 
@@ -220,16 +203,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
220
203
  }
221
204
 
222
205
  const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
223
- let relativePath = line;
224
- if (line.startsWith(searchPath)) {
225
- relativePath = line.slice(searchPath.length + 1); // +1 for the /
226
- } else {
227
- relativePath = path.relative(searchPath, line);
228
- }
229
- const matchPath = relativePath.replace(/\\/g, "/");
230
- if (!globMatcher.match(matchPath)) {
231
- continue;
232
- }
206
+ let relativePath = line.replace(/\\/g, "/");
233
207
 
234
208
  let mtimeMs = 0;
235
209
  let isDirectory = false;
@@ -691,10 +691,8 @@ export const geminiImageTool: CustomTool<typeof geminiImageSchema, GeminiImageTo
691
691
  }
692
692
 
693
693
  const { timeout: rawTimeout = DEFAULT_TIMEOUT_SECONDS } = params;
694
- // Auto-convert milliseconds to seconds if value > 1000 (16+ min is unreasonable)
695
- let timeoutSeconds = rawTimeout > 1000 ? rawTimeout / 1000 : rawTimeout;
696
694
  // Clamp to reasonable range: 1s - 600s (10 min)
697
- timeoutSeconds = Math.max(1, Math.min(600, timeoutSeconds));
695
+ const timeoutSeconds = Math.max(1, Math.min(600, rawTimeout));
698
696
  const requestSignal = createRequestSignal(signal, timeoutSeconds);
699
697
 
700
698
  if (provider === "antigravity") {
package/src/tools/grep.ts CHANGED
@@ -73,13 +73,22 @@ export interface RgResult {
73
73
  *
74
74
  * @throws ToolAbortError if signal is aborted
75
75
  */
76
- export async function runRg(rgPath: string, args: string[], signal?: AbortSignal): Promise<RgResult> {
77
- const child = ptree.cspawn([rgPath, ...args], { signal });
76
+ export async function runRg(
77
+ rgPath: string,
78
+ args: string[],
79
+ options?: { signal?: AbortSignal; timeoutMs?: number },
80
+ ): Promise<RgResult> {
81
+ const child = ptree.cspawn([rgPath, ...args], { signal: options?.signal, timeout: options?.timeoutMs });
82
+ const timeoutSeconds = options?.timeoutMs ? Math.max(1, Math.round(options.timeoutMs / 1000)) : undefined;
83
+ const timeoutMessage = timeoutSeconds ? `rg timed out after ${timeoutSeconds}s` : "rg timed out";
78
84
 
79
85
  let stdout: string;
80
86
  try {
81
87
  stdout = await child.nothrow().text();
82
88
  } catch (err) {
89
+ if (err instanceof ptree.TimeoutError) {
90
+ throw new ToolError(timeoutMessage);
91
+ }
83
92
  if (err instanceof ptree.Exception && err.aborted) {
84
93
  throw new ToolAbortError();
85
94
  }
@@ -91,6 +100,9 @@ export async function runRg(rgPath: string, args: string[], signal?: AbortSignal
91
100
  await child.exited;
92
101
  } catch (err) {
93
102
  exitError = err;
103
+ if (err instanceof ptree.TimeoutError) {
104
+ throw new ToolError(timeoutMessage);
105
+ }
94
106
  if (err instanceof ptree.Exception && err.aborted) {
95
107
  throw new ToolAbortError();
96
108
  }
package/src/tools/read.ts CHANGED
@@ -4,7 +4,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
4
4
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
- import { ptree } from "@oh-my-pi/pi-utils";
7
+ import { globPaths, ptree } from "@oh-my-pi/pi-utils";
8
8
  import { Type } from "@sinclair/typebox";
9
9
  import { CONFIG_DIR_NAME } from "../config";
10
10
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -16,7 +16,6 @@ import { renderCodeCell, renderOutputBlock, renderStatusLine } from "../tui";
16
16
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
17
17
  import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
18
18
  import { ensureTool } from "../utils/tools-manager";
19
- import { runRg } from "./grep";
20
19
  import { applyListLimit } from "./list-limit";
21
20
  import { LsTool } from "./ls";
22
21
  import type { OutputMeta } from "./output-meta";
@@ -49,6 +48,7 @@ const MAX_FUZZY_RESULTS = 5;
49
48
  const MAX_FUZZY_CANDIDATES = 20000;
50
49
  const MIN_BASE_SIMILARITY = 0.5;
51
50
  const MIN_FULL_SIMILARITY = 0.6;
51
+ const GLOB_TIMEOUT_MS = 5000;
52
52
 
53
53
  function normalizePathForMatch(value: string): string {
54
54
  return value
@@ -162,95 +162,32 @@ function similarityScore(a: string, b: string): number {
162
162
  async function listCandidateFiles(
163
163
  searchRoot: string,
164
164
  signal?: AbortSignal,
165
- notify?: (message: string) => void,
165
+ _notify?: (message: string) => void,
166
166
  ): Promise<{ files: string[]; truncated: boolean; error?: string }> {
167
- let rgPath: string | undefined;
167
+ let files: string[];
168
168
  try {
169
- rgPath = await ensureTool("rg", { silent: true, notify });
170
- } catch {
171
- return { files: [], truncated: false, error: "rg not available" };
172
- }
173
-
174
- if (!rgPath) {
175
- return { files: [], truncated: false, error: "rg not available" };
176
- }
177
-
178
- const args: string[] = [
179
- "--files",
180
- "--color=never",
181
- "--hidden",
182
- "--glob",
183
- "!**/.git/**",
184
- "--glob",
185
- "!**/node_modules/**",
186
- ];
187
-
188
- const gitignoreFiles = new Set<string>();
189
- const rootGitignore = path.join(searchRoot, ".gitignore");
190
- if (await Bun.file(rootGitignore).exists()) {
191
- gitignoreFiles.add(rootGitignore);
192
- }
193
-
194
- try {
195
- const gitignoreArgs = [
196
- "--files",
197
- "--color=never",
198
- "--hidden",
199
- "--no-ignore",
200
- "--glob",
201
- "!**/.git/**",
202
- "--glob",
203
- "!**/node_modules/**",
204
- "--glob",
205
- ".gitignore",
206
- searchRoot,
207
- ];
208
- const { stdout } = await runRg(rgPath, gitignoreArgs, signal);
209
- const output = stdout.trim();
210
- if (output) {
211
- const nestedGitignores = output
212
- .split("\n")
213
- .map(line => line.replace(/\r$/, "").trim())
214
- .filter(line => line.length > 0);
215
- for (const file of nestedGitignores) {
216
- const normalized = file.replace(/\\/g, "/");
217
- if (normalized.includes("/node_modules/") || normalized.includes("/.git/")) {
218
- continue;
219
- }
220
- gitignoreFiles.add(file);
221
- }
222
- }
169
+ files = await globPaths("**/*", {
170
+ cwd: searchRoot,
171
+ gitignore: true,
172
+ dot: true,
173
+ signal,
174
+ timeoutMs: GLOB_TIMEOUT_MS,
175
+ });
223
176
  } catch (error) {
224
- if (error instanceof ToolAbortError) {
225
- throw error;
177
+ if (error instanceof Error && error.name === "AbortError") {
178
+ throw new ToolAbortError();
226
179
  }
227
- // Ignore gitignore scan errors.
228
- }
229
-
230
- for (const gitignorePath of gitignoreFiles) {
231
- args.push("--ignore-file", gitignorePath);
232
- }
233
-
234
- args.push(searchRoot);
235
-
236
- const { stdout, stderr, exitCode } = await runRg(rgPath, args, signal);
237
- const output = stdout.trim();
238
-
239
- if (!output) {
240
- // rg exit codes: 0 = ok, 1 = no matches, other = error
241
- if (exitCode !== 0 && exitCode !== 1) {
242
- return { files: [], truncated: false, error: stderr.trim() || `rg failed (exit ${exitCode})` };
180
+ if (error instanceof Error && error.name === "TimeoutError") {
181
+ const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
182
+ return { files: [], truncated: false, error: `glob timed out after ${timeoutSeconds}s` };
243
183
  }
244
- return { files: [], truncated: false };
184
+ const message = error instanceof Error ? error.message : String(error);
185
+ return { files: [], truncated: false, error: message };
245
186
  }
246
187
 
247
- const files = output
248
- .split("\n")
249
- .map(line => line.replace(/\r$/, "").trim())
250
- .filter(line => line.length > 0);
251
-
252
- const truncated = files.length > MAX_FUZZY_CANDIDATES;
253
- const limited = truncated ? files.slice(0, MAX_FUZZY_CANDIDATES) : files;
188
+ const normalizedFiles = files.map(line => line.replace(/\r$/, "").trim()).filter(line => line.length > 0);
189
+ const truncated = normalizedFiles.length > MAX_FUZZY_CANDIDATES;
190
+ const limited = truncated ? normalizedFiles.slice(0, MAX_FUZZY_CANDIDATES) : normalizedFiles;
254
191
 
255
192
  return { files: limited, truncated };
256
193
  }
@@ -304,11 +241,7 @@ async function findReadPathSuggestions(
304
241
  const cleaned = file.replace(/\r$/, "").trim();
305
242
  if (!cleaned) continue;
306
243
 
307
- const relativePath = path.isAbsolute(cleaned)
308
- ? cleaned.startsWith(searchRoot)
309
- ? cleaned.slice(searchRoot.length + 1)
310
- : path.relative(searchRoot, cleaned)
311
- : cleaned;
244
+ const relativePath = cleaned;
312
245
 
313
246
  if (!relativePath || relativePath.startsWith("..")) {
314
247
  continue;
package/src/tools/ssh.ts CHANGED
@@ -162,10 +162,8 @@ export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
162
162
  const hostInfo = await ensureHostInfo(hostConfig);
163
163
  const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
164
164
 
165
- // Auto-convert milliseconds to seconds if value > 1000 (16+ min is unreasonable)
166
- let timeoutSec = rawTimeout > 1000 ? rawTimeout / 1000 : rawTimeout;
167
165
  // Clamp to reasonable range: 1s - 3600s (1 hour)
168
- timeoutSec = Math.max(1, Math.min(3600, timeoutSec));
166
+ const timeoutSec = Math.max(1, Math.min(3600, rawTimeout));
169
167
  const timeoutMs = timeoutSec * 1000;
170
168
 
171
169
  const tailBuffer = createTailBuffer(DEFAULT_MAX_BYTES);