@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.3

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 (154) hide show
  1. package/CHANGELOG.md +113 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/config/keybindings.d.ts +2 -2
  6. package/dist/types/config/model-provider-priority.d.ts +1 -0
  7. package/dist/types/config/model-resolver.d.ts +4 -1
  8. package/dist/types/config/settings.d.ts +7 -2
  9. package/dist/types/debug/report-bundle.d.ts +3 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  11. package/dist/types/edit/index.d.ts +0 -1
  12. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  14. package/dist/types/lsp/client.d.ts +10 -0
  15. package/dist/types/lsp/index.d.ts +0 -5
  16. package/dist/types/main.d.ts +14 -9
  17. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  19. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  20. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  21. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  22. package/dist/types/modes/components/session-selector.d.ts +16 -7
  23. package/dist/types/modes/components/status-line.d.ts +2 -0
  24. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  25. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -0
  27. package/dist/types/modes/magic-keywords.d.ts +1 -1
  28. package/dist/types/modes/markdown-prose.d.ts +1 -1
  29. package/dist/types/modes/types.d.ts +7 -0
  30. package/dist/types/modes/workflow.d.ts +3 -3
  31. package/dist/types/session/auth-storage.d.ts +1 -1
  32. package/dist/types/session/messages.d.ts +11 -8
  33. package/dist/types/session/session-manager.d.ts +5 -2
  34. package/dist/types/session/yield-queue.d.ts +10 -1
  35. package/dist/types/task/executor.d.ts +10 -0
  36. package/dist/types/tools/eval-render.d.ts +0 -1
  37. package/dist/types/tools/eval.d.ts +8 -0
  38. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  39. package/dist/types/tools/github-cache.d.ts +12 -0
  40. package/dist/types/tools/index.d.ts +31 -0
  41. package/dist/types/tools/path-utils.d.ts +13 -1
  42. package/dist/types/tools/read.d.ts +2 -1
  43. package/dist/types/tools/render-utils.d.ts +3 -1
  44. package/dist/types/tools/renderers.d.ts +0 -15
  45. package/dist/types/tools/search.d.ts +2 -2
  46. package/dist/types/tools/write.d.ts +0 -2
  47. package/dist/types/tools/yield.d.ts +8 -0
  48. package/dist/types/tui/code-cell.d.ts +0 -2
  49. package/dist/types/tui/hyperlink.d.ts +5 -7
  50. package/dist/types/tui/output-block.d.ts +0 -18
  51. package/package.json +9 -9
  52. package/src/cli/args.ts +3 -1
  53. package/src/cli/dry-balance-cli.ts +2 -4
  54. package/src/cli/gallery-cli.ts +4 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  56. package/src/cli/gallery-fixtures/fs.ts +68 -1
  57. package/src/cli/gallery-fixtures/types.ts +8 -1
  58. package/src/cli/startup-cwd.ts +68 -0
  59. package/src/commands/launch.ts +3 -0
  60. package/src/commit/agentic/agent.ts +1 -0
  61. package/src/commit/model-selection.ts +3 -2
  62. package/src/config/model-provider-priority.ts +55 -0
  63. package/src/config/model-registry.ts +4 -22
  64. package/src/config/model-resolver.ts +39 -7
  65. package/src/config/settings.ts +86 -41
  66. package/src/debug/index.ts +8 -0
  67. package/src/debug/raw-sse-buffer.ts +7 -4
  68. package/src/debug/report-bundle.ts +9 -0
  69. package/src/edit/file-snapshot-store.ts +33 -1
  70. package/src/edit/hashline/diff.ts +86 -0
  71. package/src/edit/hashline/execute.ts +14 -1
  72. package/src/edit/hashline/filesystem.ts +2 -1
  73. package/src/edit/index.ts +31 -17
  74. package/src/edit/renderer.ts +116 -31
  75. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  76. package/src/eval/js/context-manager.ts +32 -15
  77. package/src/eval/js/shared/prelude.txt +26 -10
  78. package/src/eval/llm-bridge.ts +14 -3
  79. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  80. package/src/eval/py/executor.ts +23 -11
  81. package/src/eval/py/prelude.py +1 -1
  82. package/src/extensibility/extensions/types.ts +10 -1
  83. package/src/internal-urls/docs-index.generated.ts +7 -7
  84. package/src/lsp/client.ts +23 -11
  85. package/src/lsp/config.ts +11 -1
  86. package/src/lsp/index.ts +189 -61
  87. package/src/main.ts +144 -78
  88. package/src/mcp/tool-bridge.ts +2 -0
  89. package/src/memories/index.ts +2 -2
  90. package/src/modes/components/assistant-message.ts +3 -15
  91. package/src/modes/components/custom-editor.ts +143 -111
  92. package/src/modes/components/late-diagnostics-message.ts +60 -0
  93. package/src/modes/components/model-selector.ts +59 -13
  94. package/src/modes/components/oauth-selector.ts +33 -7
  95. package/src/modes/components/plan-review-overlay.ts +26 -5
  96. package/src/modes/components/read-tool-group.ts +415 -35
  97. package/src/modes/components/session-selector.ts +89 -35
  98. package/src/modes/components/status-line.ts +19 -4
  99. package/src/modes/components/tips.txt +1 -1
  100. package/src/modes/components/tool-execution.ts +7 -49
  101. package/src/modes/components/transcript-container.ts +108 -32
  102. package/src/modes/components/user-message.ts +1 -1
  103. package/src/modes/controllers/event-controller.ts +32 -1
  104. package/src/modes/controllers/input-controller.ts +56 -9
  105. package/src/modes/interactive-mode.ts +107 -20
  106. package/src/modes/magic-keywords.ts +1 -1
  107. package/src/modes/markdown-prose.ts +1 -1
  108. package/src/modes/theme/shimmer.ts +20 -9
  109. package/src/modes/types.ts +7 -0
  110. package/src/modes/utils/ui-helpers.ts +26 -5
  111. package/src/modes/workflow.ts +10 -10
  112. package/src/prompts/system/manual-continue.md +7 -0
  113. package/src/prompts/system/plan-mode-active.md +56 -72
  114. package/src/prompts/system/workflow-notice.md +1 -1
  115. package/src/prompts/tools/bash.md +9 -0
  116. package/src/prompts/tools/browser.md +1 -1
  117. package/src/prompts/tools/eval.md +5 -2
  118. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  119. package/src/prompts/tools/read.md +2 -2
  120. package/src/sdk.ts +85 -10
  121. package/src/session/agent-session.ts +42 -15
  122. package/src/session/auth-storage.ts +2 -0
  123. package/src/session/messages.ts +21 -14
  124. package/src/session/session-manager.ts +98 -25
  125. package/src/session/yield-queue.ts +20 -2
  126. package/src/task/executor.ts +72 -36
  127. package/src/task/render.ts +3 -4
  128. package/src/tiny/title-client.ts +6 -1
  129. package/src/tools/bash.ts +7 -7
  130. package/src/tools/browser/tab-supervisor.ts +13 -1
  131. package/src/tools/browser/tab-worker.ts +33 -4
  132. package/src/tools/eval-render.ts +4 -23
  133. package/src/tools/eval.ts +13 -2
  134. package/src/tools/find.ts +148 -99
  135. package/src/tools/gh-cache-invalidation.ts +200 -0
  136. package/src/tools/github-cache.ts +25 -0
  137. package/src/tools/index.ts +32 -0
  138. package/src/tools/inspect-image.ts +2 -2
  139. package/src/tools/path-utils.ts +47 -24
  140. package/src/tools/plan-mode-guard.ts +52 -7
  141. package/src/tools/read.ts +41 -20
  142. package/src/tools/render-utils.ts +3 -1
  143. package/src/tools/renderers.ts +0 -15
  144. package/src/tools/search.ts +38 -3
  145. package/src/tools/ssh.ts +0 -1
  146. package/src/tools/todo.ts +1 -0
  147. package/src/tools/write.ts +5 -14
  148. package/src/tools/yield.ts +10 -1
  149. package/src/tui/code-cell.ts +1 -6
  150. package/src/tui/hyperlink.ts +13 -23
  151. package/src/tui/output-block.ts +2 -97
  152. package/src/utils/commit-message-generator.ts +2 -2
  153. package/src/utils/enhanced-paste.ts +30 -2
  154. package/src/web/search/providers/codex.ts +37 -8
@@ -16,9 +16,8 @@ import type { EvalCellResult, EvalLanguage, EvalStatusEvent, EvalToolDetails } f
16
16
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
17
17
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
18
18
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
19
- import { shimmerEnabled } from "../modes/theme/shimmer";
20
19
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
21
- import { borderShimmerTick, markFramedBlockComponent, renderCodeCell } from "../tui";
20
+ import { markFramedBlockComponent, renderCodeCell } from "../tui";
22
21
  import {
23
22
  JSON_TREE_MAX_DEPTH_COLLAPSED,
24
23
  JSON_TREE_MAX_DEPTH_EXPANDED,
@@ -491,8 +490,7 @@ export const evalToolRenderer = {
491
490
 
492
491
  return markFramedBlockComponent({
493
492
  render: (width: number): string[] => {
494
- const animate = options.isPartial && shimmerEnabled();
495
- const key = `${animate ? borderShimmerTick() : 0}|${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
493
+ const key = `${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
496
494
  if (cached && cached.key === key && cached.width === width) {
497
495
  return cached.result;
498
496
  }
@@ -510,13 +508,9 @@ export const evalToolRenderer = {
510
508
  status: "pending",
511
509
  width,
512
510
  // Always render the full source: the code is fixed input, not the
513
- // streaming part, so it is never compacted. While still pending
514
- // (args streaming) the block is not yet committed to native
515
- // scrollback — its head is only committed once a result exists and
516
- // the code has finalized (see `isStreamingPreviewAppendOnly`).
511
+ // streaming part, so it is never compacted.
517
512
  codeMaxLines: Number.POSITIVE_INFINITY,
518
513
  expanded: options.expanded,
519
- animate,
520
514
  },
521
515
  uiTheme,
522
516
  );
@@ -579,8 +573,7 @@ export const evalToolRenderer = {
579
573
  render: (width: number): string[] => {
580
574
  const expanded = options.renderContext?.expanded ?? options.expanded;
581
575
  const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
582
- const animate = options.isPartial && shimmerEnabled();
583
- const key = `${expanded}|${previewLines}|${options.spinnerFrame}|${animate ? borderShimmerTick() : 0}`;
576
+ const key = `${expanded}|${previewLines}|${options.spinnerFrame}`;
584
577
  if (cached && cached.key === key && cached.width === width) {
585
578
  return cached.result;
586
579
  }
@@ -622,7 +615,6 @@ export const evalToolRenderer = {
622
615
  codeMaxLines: Number.POSITIVE_INFINITY,
623
616
  expanded,
624
617
  width,
625
- animate,
626
618
  },
627
619
  uiTheme,
628
620
  );
@@ -752,17 +744,6 @@ export const evalToolRenderer = {
752
744
  };
753
745
  },
754
746
 
755
- // Append-only once a result exists (args complete → code finalized). The code
756
- // is rendered in full as a fixed top-anchored prefix, and the streamed stdout
757
- // below it only appends rows at the bottom, so the scrolled-off head commits
758
- // to native scrollback instead of being yanked — collapsed or expanded, since
759
- // the collapsed output cap keeps its sliding tail in the bottom live region.
760
- // Returns false while still pending: the code is mid-stream (args incomplete)
761
- // and its header still reads "pending", so committing it would strand a stale
762
- // pending preview in history.
763
- isStreamingPreviewAppendOnly(_args: EvalRenderArgs, _options: RenderResultOptions, result?: unknown): boolean {
764
- return result != null;
765
- },
766
747
  mergeCallAndResult: true,
767
748
  inline: true,
768
749
  };
package/src/tools/eval.ts CHANGED
@@ -88,12 +88,21 @@ function formatDisplayOutputsForText(outputs: EvalDisplayOutput[]): string {
88
88
  export interface EvalToolDescriptionOptions {
89
89
  py?: boolean;
90
90
  js?: boolean;
91
+ /**
92
+ * Whether `agent()` is allowed in this session. Driven by the parent's
93
+ * spawn policy (`getSessionSpawns`). Defaults to `true` for backward
94
+ * compatibility — when the session forbids spawning, the prelude doc
95
+ * omits the `agent()` entry so the model does not promise itself a
96
+ * helper that will only ever throw "spawns disabled".
97
+ */
98
+ spawns?: boolean;
91
99
  }
92
100
 
93
101
  export function getEvalToolDescription(options: EvalToolDescriptionOptions = {}): string {
94
102
  const py = options.py ?? true;
95
103
  const js = options.js ?? true;
96
- return prompt.render(evalDescription, { py, js });
104
+ const spawns = options.spawns ?? true;
105
+ return prompt.render(evalDescription, { py, js, spawns });
97
106
  }
98
107
 
99
108
  export interface EvalToolOptions {
@@ -169,7 +178,9 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
169
178
  get description(): string {
170
179
  if (!this.session) return getEvalToolDescription();
171
180
  const backends = resolveEvalBackends(this.session);
172
- return getEvalToolDescription({ py: backends.python, js: backends.js });
181
+ const sessionSpawns = this.session.getSessionSpawns?.() ?? "*";
182
+ const spawnsAllowed = sessionSpawns !== "" && sessionSpawns !== null;
183
+ return getEvalToolDescription({ py: backends.python, js: backends.js, spawns: spawnsAllowed });
173
184
  }
174
185
  readonly parameters = evalSchema;
175
186
  readonly concurrency = "exclusive";
package/src/tools/find.ts CHANGED
@@ -117,6 +117,12 @@ export interface FindToolOptions {
117
117
  operations?: FindOperations;
118
118
  }
119
119
 
120
+ interface FindTarget {
121
+ searchPath: string;
122
+ globPattern: string;
123
+ hasGlob: boolean;
124
+ }
125
+
120
126
  export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
121
127
  readonly name = "find";
122
128
  readonly approval = "read" as const;
@@ -193,15 +199,31 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
193
199
  }
194
200
 
195
201
  const multiPattern = await resolveExplicitFindPatterns(effectivePatterns, this.session.cwd);
196
- const parsedPattern = multiPattern ? null : parseFindPattern(effectivePatterns[0] ?? ".");
197
- const hasGlob = multiPattern ? true : (parsedPattern?.hasGlob ?? false);
198
- const globPattern = multiPattern?.globPattern ?? parsedPattern?.globPattern ?? "**/*";
199
- const searchPath = resolveToCwd(multiPattern?.basePath ?? parsedPattern?.basePath ?? ".", this.session.cwd);
200
- const scopePath = multiPattern?.scopePath ?? formatScopePath(searchPath);
201
-
202
- if (searchPath === "/") {
203
- throw new ToolError("Searching from root directory '/' is not allowed");
202
+ const isSingle = !multiPattern;
203
+ const targets: FindTarget[] = multiPattern
204
+ ? multiPattern.targets.map(target => ({
205
+ searchPath: resolveToCwd(target.basePath, this.session.cwd),
206
+ globPattern: target.globPattern,
207
+ hasGlob: target.hasGlob,
208
+ }))
209
+ : [
210
+ (() => {
211
+ const parsed = parseFindPattern(effectivePatterns[0] ?? ".");
212
+ return {
213
+ searchPath: resolveToCwd(parsed.basePath, this.session.cwd),
214
+ globPattern: parsed.globPattern,
215
+ hasGlob: parsed.hasGlob,
216
+ };
217
+ })(),
218
+ ];
219
+ const scopePath = multiPattern?.scopePath ?? formatScopePath(targets[0].searchPath);
220
+
221
+ for (const target of targets) {
222
+ if (target.searchPath === "/") {
223
+ throw new ToolError("Searching from root directory '/' is not allowed");
224
+ }
204
225
  }
226
+
205
227
  const requestedLimit = limit ?? DEFAULT_LIMIT;
206
228
  if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) {
207
229
  throw new ToolError("Limit must be a positive number");
@@ -213,9 +235,9 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
213
235
  const timeoutMs = Math.min(MAX_GLOB_TIMEOUT_MS, Math.max(MIN_GLOB_TIMEOUT_MS, requestedTimeoutMs));
214
236
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
215
237
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
216
- const formatMatchPath = (matchPath: string, fileType?: natives.FileType): string => {
238
+ const formatMatchPath = (matchPath: string, base: string, fileType?: natives.FileType): string => {
217
239
  const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
218
- const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(searchPath, matchPath);
240
+ const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(base, matchPath);
219
241
  return formatPathRelativeToCwd(absolutePath, this.session.cwd, {
220
242
  trailingSlash: fileType === natives.FileType.Dir || hadTrailingSlash,
221
243
  });
@@ -276,45 +298,41 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
276
298
  return resultBuilder.done();
277
299
  };
278
300
 
301
+ // Walk each user path as its own root and run the globs concurrently.
302
+ // Collapsing multiple paths to a shared base would force the walker to
303
+ // traverse and stat every unrelated sibling under that ancestor; per-path
304
+ // roots keep each scan bounded to exactly what the user asked for.
279
305
  if (this.#customOps?.glob) {
280
- if (!(await this.#customOps.exists(searchPath))) {
281
- throw new ToolError(`Path not found: ${scopePath}`);
282
- }
283
-
284
- if (!hasGlob && this.#customOps.stat) {
285
- const stat = await this.#customOps.stat(searchPath);
286
- if (stat.isFile()) {
287
- return buildResult([scopePath]);
306
+ const customOps = this.#customOps;
307
+ const perTarget = await Promise.all(
308
+ targets.map(async target => {
309
+ if (!(await customOps.exists(target.searchPath))) {
310
+ if (isSingle) throw new ToolError(`Path not found: ${scopePath}`);
311
+ return [] as string[];
312
+ }
313
+ if (!target.hasGlob && customOps.stat) {
314
+ const stat = await customOps.stat(target.searchPath);
315
+ if (stat.isFile()) return [formatScopePath(target.searchPath)];
316
+ }
317
+ const results = await customOps.glob(target.globPattern, target.searchPath, {
318
+ ignore: ["**/node_modules/**", "**/.git/**"],
319
+ limit: effectiveLimit,
320
+ });
321
+ return results.map(matchPath => formatMatchPath(matchPath, target.searchPath));
322
+ }),
323
+ );
324
+ const seen = new Set<string>();
325
+ const merged: string[] = [];
326
+ for (const group of perTarget) {
327
+ for (const entry of group) {
328
+ if (seen.has(entry)) continue;
329
+ seen.add(entry);
330
+ merged.push(entry);
288
331
  }
289
332
  }
290
-
291
- const results = await this.#customOps.glob(globPattern, searchPath, {
292
- ignore: ["**/node_modules/**", "**/.git/**"],
293
- limit: effectiveLimit,
294
- });
295
- const relativized = results.map(p => formatMatchPath(p));
296
-
297
- return buildResult(relativized);
298
- }
299
-
300
- let searchStat: fs.Stats;
301
- try {
302
- searchStat = await fs.promises.stat(searchPath);
303
- } catch (err) {
304
- if (isEnoent(err)) {
305
- throw new ToolError(`Path not found: ${scopePath}`);
306
- }
307
- throw err;
308
- }
309
-
310
- if (!hasGlob && searchStat.isFile()) {
311
- return buildResult([scopePath]);
312
- }
313
- if (!searchStat.isDirectory()) {
314
- throw new ToolError(`Path is not a directory: ${searchPath}`);
333
+ return buildResult(merged);
315
334
  }
316
335
 
317
- let matches: natives.GlobMatch[];
318
336
  const onUpdateMatches: string[] = [];
319
337
  const onUpdateMtimes: number[] = [];
320
338
  const updateIntervalMs = 200;
@@ -335,80 +353,111 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
335
353
  details,
336
354
  });
337
355
  };
338
- const onMatch = (err: Error | null, match: natives.GlobMatch | null) => {
339
- if (err || combinedSignal.aborted || !match?.path) return;
340
- const relativePath = formatMatchPath(match.path, match.fileType);
341
- onUpdateMatches.push(relativePath);
342
- onUpdateMtimes.push(match.mtime ?? 0);
343
- emitUpdate();
344
- };
345
-
346
- const doGlob = async (useGitignore: boolean) =>
347
- untilAborted(combinedSignal, () =>
348
- natives.glob(
349
- {
350
- pattern: globPattern,
351
- path: searchPath,
352
- hidden: includeHidden,
353
- maxResults: effectiveLimit,
354
- sortByMtime: true,
355
- gitignore: useGitignore,
356
- signal: combinedSignal,
357
- },
358
- onMatch,
359
- ),
360
- );
356
+ const streamed = new Set<string>();
357
+ const makeOnMatch =
358
+ (base: string) =>
359
+ (err: Error | null, match: natives.GlobMatch | null): void => {
360
+ if (err || combinedSignal.aborted || !match?.path) return;
361
+ const relativePath = formatMatchPath(match.path, base, match.fileType);
362
+ if (streamed.has(relativePath)) return;
363
+ streamed.add(relativePath);
364
+ onUpdateMatches.push(relativePath);
365
+ onUpdateMtimes.push(match.mtime ?? 0);
366
+ emitUpdate();
367
+ };
361
368
 
362
369
  let timedOut = false;
363
- try {
364
- const result = await doGlob(useGitignore);
365
- // Native glob returns a bounded mtime-ranked set; keep the JS sort for
366
- // deterministic ordering across cached and uncached native paths.
367
- result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
368
- matches = result.matches;
369
- } catch (error) {
370
- if (error instanceof Error && error.name === "AbortError") {
371
- if (timeoutSignal.aborted && !signal?.aborted) {
372
- timedOut = true;
373
- matches = [];
374
- } else {
370
+ const runTarget = async (target: FindTarget): Promise<Array<{ path: string; mtime: number }>> => {
371
+ throwIfAborted(signal);
372
+ let stat: fs.Stats;
373
+ try {
374
+ stat = await fs.promises.stat(target.searchPath);
375
+ } catch (err) {
376
+ if (isEnoent(err)) {
377
+ if (isSingle) throw new ToolError(`Path not found: ${scopePath}`);
378
+ return [];
379
+ }
380
+ throw err;
381
+ }
382
+ if (!target.hasGlob && stat.isFile()) {
383
+ return [{ path: formatScopePath(target.searchPath), mtime: stat.mtimeMs }];
384
+ }
385
+ if (!stat.isDirectory()) {
386
+ if (isSingle) throw new ToolError(`Path is not a directory: ${target.searchPath}`);
387
+ return [];
388
+ }
389
+ try {
390
+ const result = await untilAborted(combinedSignal, () =>
391
+ natives.glob(
392
+ {
393
+ pattern: target.globPattern,
394
+ path: target.searchPath,
395
+ hidden: includeHidden,
396
+ maxResults: effectiveLimit,
397
+ sortByMtime: true,
398
+ gitignore: useGitignore,
399
+ // parseFindPattern explicitly prepends "**/" when the user's
400
+ // pattern begins with a glob (so `*.ts` becomes `**/*.ts`).
401
+ // Anything that arrives here without "**/" was scoped to a
402
+ // single directory by the user (e.g. `dir/*`); disable the
403
+ // native auto-recursion so `dir/*` does not silently match
404
+ // `dir/sub/nested.ts`.
405
+ recursive: false,
406
+ signal: combinedSignal,
407
+ },
408
+ makeOnMatch(target.searchPath),
409
+ ),
410
+ );
411
+ throwIfAborted(signal);
412
+ const out: Array<{ path: string; mtime: number }> = [];
413
+ for (const match of result.matches) {
414
+ if (!match.path) continue;
415
+ out.push({
416
+ path: formatMatchPath(match.path, target.searchPath, match.fileType),
417
+ mtime: match.mtime ?? 0,
418
+ });
419
+ }
420
+ return out;
421
+ } catch (error) {
422
+ if (error instanceof Error && error.name === "AbortError") {
423
+ if (timeoutSignal.aborted && !signal?.aborted) {
424
+ timedOut = true;
425
+ return [];
426
+ }
375
427
  throw new ToolAbortError();
376
428
  }
377
- } else {
378
429
  throw error;
379
430
  }
380
- }
431
+ };
432
+
433
+ const perTarget = await Promise.all(targets.map(runTarget));
381
434
 
382
435
  if (timedOut) {
383
436
  // Drain the partial matches accumulated during streaming and return them
384
437
  // instead of throwing — empty results after a multi-second wait force the
385
438
  // caller to retry blind, which is the worst possible outcome.
386
- const seen = new Set<string>();
387
- const partial: Array<{ p: string; m: number }> = [];
388
- for (let i = 0; i < onUpdateMatches.length; i++) {
389
- const entry = onUpdateMatches[i];
390
- if (seen.has(entry)) continue;
391
- seen.add(entry);
392
- partial.push({ p: entry, m: onUpdateMtimes[i] ?? 0 });
393
- }
439
+ const partial = onUpdateMatches.map((entry, index) => ({ p: entry, m: onUpdateMtimes[index] ?? 0 }));
394
440
  partial.sort((a, b) => b.m - a.m);
395
- const sortedPaths = partial.map(e => e.p);
441
+ const sortedPaths = partial.map(entry => entry.p);
396
442
  const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
397
443
  const notice = `find timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — increase timeout or narrow pattern`;
398
444
  return buildResult(sortedPaths, { notice, forceTruncated: true });
399
445
  }
400
446
 
401
- const relativized: string[] = [];
402
- for (const match of matches) {
403
- throwIfAborted(signal);
404
- if (!match.path) {
405
- continue;
447
+ // Merge per-target results: native glob already ranks each target's own
448
+ // matches by mtime and caps them at the limit, so a global mtime re-sort
449
+ // plus dedup yields the correct top-N across all roots.
450
+ const seen = new Set<string>();
451
+ const merged: Array<{ path: string; mtime: number }> = [];
452
+ for (const group of perTarget) {
453
+ for (const entry of group) {
454
+ if (seen.has(entry.path)) continue;
455
+ seen.add(entry.path);
456
+ merged.push(entry);
406
457
  }
407
-
408
- relativized.push(formatMatchPath(match.path, match.fileType));
409
458
  }
410
-
411
- return buildResult(relativized);
459
+ merged.sort((a, b) => b.mtime - a.mtime);
460
+ return buildResult(merged.map(entry => entry.path));
412
461
  });
413
462
  }
414
463
  }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Detect cache-mutating `gh` subcommands inside a bash invocation and drop
3
+ * the matching `github-cache` rows so a subsequent `issue://<n>` or
4
+ * `pr://<n>` read sees the post-mutation state instead of the stale
5
+ * pre-mutation snapshot.
6
+ *
7
+ * Triggered before the bash command runs: on success the cache is now
8
+ * empty and the next read fetches fresh; on failure the worst case is one
9
+ * extra `gh` round-trip on the following read. That cost is bounded and
10
+ * eliminates the much-worse "issue shows OPEN for up to softTtlSec after
11
+ * `gh issue close`" failure mode reported by users.
12
+ *
13
+ * Detector scope: ops that change visible issue/PR state — `close`,
14
+ * `reopen`, `merge`, `delete`, `ready`, `lock`, `unlock`, `pin`, `unpin`,
15
+ * `transfer`, plus the comment/review/edit ops that change the rendered
16
+ * body. We deliberately over-invalidate (e.g. all matching rows for the
17
+ * number, all auth_keys) because the upside of staleness elimination
18
+ * dwarfs the cost of one cache miss.
19
+ */
20
+ import { invalidateAllForNumber } from "./github-cache";
21
+
22
+ const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i;
23
+ const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i;
24
+
25
+ /** Subcommands that mutate the rendered issue/PR view in any meaningful way. */
26
+ const MUTATING_ISSUE_SUBCMDS: Record<string, true> = {
27
+ close: true,
28
+ reopen: true,
29
+ delete: true,
30
+ edit: true,
31
+ comment: true,
32
+ lock: true,
33
+ unlock: true,
34
+ pin: true,
35
+ unpin: true,
36
+ transfer: true,
37
+ develop: true,
38
+ };
39
+
40
+ const MUTATING_PR_SUBCMDS: Record<string, true> = {
41
+ close: true,
42
+ reopen: true,
43
+ merge: true,
44
+ ready: true,
45
+ edit: true,
46
+ comment: true,
47
+ review: true,
48
+ lock: true,
49
+ unlock: true,
50
+ };
51
+ /**
52
+ * Walk a single shell command's token stream looking for a top-level
53
+ * `gh (issue|pr) <subcmd> <id-or-url>` invocation and return the
54
+ * invalidation key when one is found. Returns `null` for non-matching
55
+ * commands so the caller can iterate cheaply.
56
+ */
57
+ function detectGhMutation(tokens: readonly string[]): { number: number; repo?: string } | null {
58
+ const ghIdx = tokens.indexOf("gh");
59
+ if (ghIdx === -1) return null;
60
+ const subject = tokens[ghIdx + 1];
61
+ if (subject !== "issue" && subject !== "pr") return null;
62
+ const subcmd = tokens[ghIdx + 2];
63
+ if (!subcmd) return null;
64
+ const expected = subject === "issue" ? MUTATING_ISSUE_SUBCMDS : MUTATING_PR_SUBCMDS;
65
+ if (!expected[subcmd]) return null;
66
+
67
+ let repo: string | undefined;
68
+ // First pass: scan for --repo so it wins regardless of position relative
69
+ // to the issue/PR identifier (gh accepts the flag both before and after
70
+ // the positional argument).
71
+ for (let i = ghIdx + 3; i < tokens.length; i++) {
72
+ const token = tokens[i];
73
+ if (token === "-R" || token === "--repo") {
74
+ const next = tokens[i + 1];
75
+ if (next) repo = next;
76
+ i++;
77
+ continue;
78
+ }
79
+ if (token.startsWith("--repo=")) {
80
+ repo = token.slice("--repo=".length);
81
+ }
82
+ }
83
+ for (let i = ghIdx + 3; i < tokens.length; i++) {
84
+ const token = tokens[i];
85
+ if (token === "-R" || token === "--repo") {
86
+ i++;
87
+ continue;
88
+ }
89
+ if (token.startsWith("-")) continue;
90
+ const direct = /^\d+$/.test(token) ? Number(token) : undefined;
91
+ if (direct !== undefined && Number.isSafeInteger(direct) && direct > 0) {
92
+ return repo !== undefined ? { number: direct, repo } : { number: direct };
93
+ }
94
+ const urlMatch = (subject === "pr" ? PR_URL_PATTERN : ISSUE_URL_PATTERN).exec(token);
95
+ if (urlMatch) {
96
+ const num = Number(urlMatch[2]);
97
+ if (Number.isSafeInteger(num) && num > 0) {
98
+ // URL carries its own repo and wins over a stray --repo flag.
99
+ return { number: num, repo: urlMatch[1] };
100
+ }
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Conservative tokenizer that splits a bash command into individual word
108
+ * tokens. Handles single/double-quoted strings, backslash escapes, and
109
+ * standard operators (`;`, `&&`, `||`, `|`, `&`, newlines) as token
110
+ * boundaries that emit a sentinel `";"` so the caller treats the segments
111
+ * as independent command sequences. We do not attempt full POSIX shell
112
+ * parsing — heredocs, command substitution, and arithmetic expansion are
113
+ * out of scope; the detector simply falls through when it cannot find a
114
+ * clean `gh issue|pr <subcmd>` triple.
115
+ */
116
+ function tokenize(command: string): string[][] {
117
+ const segments: string[][] = [];
118
+ let current: string[] = [];
119
+ let buffer = "";
120
+ let inSingle = false;
121
+ let inDouble = false;
122
+ const pushBuffer = () => {
123
+ if (buffer.length > 0) {
124
+ current.push(buffer);
125
+ buffer = "";
126
+ }
127
+ };
128
+ const pushSegment = () => {
129
+ pushBuffer();
130
+ if (current.length > 0) segments.push(current);
131
+ current = [];
132
+ };
133
+ for (let i = 0; i < command.length; i++) {
134
+ const ch = command[i];
135
+ if (inSingle) {
136
+ if (ch === "'") {
137
+ inSingle = false;
138
+ continue;
139
+ }
140
+ buffer += ch;
141
+ continue;
142
+ }
143
+ if (inDouble) {
144
+ if (ch === "\\" && i + 1 < command.length) {
145
+ const next = command[i + 1];
146
+ if (next === '"' || next === "\\" || next === "$" || next === "`") {
147
+ buffer += next;
148
+ i++;
149
+ continue;
150
+ }
151
+ }
152
+ if (ch === '"') {
153
+ inDouble = false;
154
+ continue;
155
+ }
156
+ buffer += ch;
157
+ continue;
158
+ }
159
+ if (ch === "'") {
160
+ inSingle = true;
161
+ continue;
162
+ }
163
+ if (ch === '"') {
164
+ inDouble = true;
165
+ continue;
166
+ }
167
+ if (ch === "\\" && i + 1 < command.length) {
168
+ buffer += command[i + 1];
169
+ i++;
170
+ continue;
171
+ }
172
+ if (ch === " " || ch === "\t") {
173
+ pushBuffer();
174
+ continue;
175
+ }
176
+ if (ch === "\n" || ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")") {
177
+ pushSegment();
178
+ // `&&`, `||` already collapsed by the segment break above.
179
+ continue;
180
+ }
181
+ buffer += ch;
182
+ }
183
+ pushSegment();
184
+ return segments;
185
+ }
186
+
187
+ /**
188
+ * Drop `github-cache` rows for any `gh issue|pr <mutating-subcmd>` call
189
+ * embedded in `command`. Safe to invoke unconditionally; no-op when the
190
+ * command does not touch GitHub state.
191
+ */
192
+ export function invalidateGithubCacheForBashCommand(command: string): void {
193
+ if (!command?.includes("gh")) return;
194
+ const segments = tokenize(command);
195
+ for (const segment of segments) {
196
+ const hit = detectGhMutation(segment);
197
+ if (!hit) continue;
198
+ invalidateAllForNumber(hit.number, hit.repo);
199
+ }
200
+ }
@@ -316,6 +316,31 @@ export function invalidate(
316
316
  }
317
317
  }
318
318
 
319
+ /**
320
+ * Drop every cached row for a given issue/PR number, regardless of repo,
321
+ * auth key, include_comments flag, or row kind ({@link CacheKind}). Best-effort:
322
+ * swallows DB failures the same way {@link invalidate} does.
323
+ *
324
+ * Used by the bash-side detector that reacts to `gh issue close` / `gh pr merge`
325
+ * style mutations. Repo + auth-key narrowing is intentionally skipped because
326
+ * the bash command often does not name the repo (defaults to cwd's `gh`
327
+ * config) and resolving the *current* repo from `cwd` for every bash call would
328
+ * be far more expensive than a write-amplified DELETE.
329
+ */
330
+ export function invalidateAllForNumber(number: number, repo?: string): void {
331
+ const db = openDb();
332
+ if (!db) return;
333
+ try {
334
+ if (repo === undefined) {
335
+ db.prepare("DELETE FROM github_view_cache WHERE number = ?").run(number);
336
+ } else {
337
+ db.prepare("DELETE FROM github_view_cache WHERE number = ? AND repo = ?").run(number, normalizeRepo(repo));
338
+ }
339
+ } catch (err) {
340
+ logger.debug("github cache: invalidateAllForNumber failed", { err: String(err) });
341
+ }
342
+ }
343
+
319
344
  /** Drop every cached row. Test helper. */
320
345
  export function clearAll(): void {
321
346
  const db = openDb();