@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.
- package/CHANGELOG.md +113 -1
- package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
- package/dist/types/cli/startup-cwd.d.ts +2 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-provider-priority.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +4 -1
- package/dist/types/config/settings.d.ts +7 -2
- package/dist/types/debug/report-bundle.d.ts +3 -0
- package/dist/types/edit/file-snapshot-store.d.ts +18 -10
- package/dist/types/edit/index.d.ts +0 -1
- package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +4 -1
- package/dist/types/lsp/client.d.ts +10 -0
- package/dist/types/lsp/index.d.ts +0 -5
- package/dist/types/main.d.ts +14 -9
- package/dist/types/mcp/tool-bridge.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +0 -9
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
- package/dist/types/modes/components/read-tool-group.d.ts +6 -0
- package/dist/types/modes/components/session-selector.d.ts +16 -7
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +0 -18
- package/dist/types/modes/controllers/event-controller.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/magic-keywords.d.ts +1 -1
- package/dist/types/modes/markdown-prose.d.ts +1 -1
- package/dist/types/modes/types.d.ts +7 -0
- package/dist/types/modes/workflow.d.ts +3 -3
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/messages.d.ts +11 -8
- package/dist/types/session/session-manager.d.ts +5 -2
- package/dist/types/session/yield-queue.d.ts +10 -1
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/tools/eval-render.d.ts +0 -1
- package/dist/types/tools/eval.d.ts +8 -0
- package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
- package/dist/types/tools/github-cache.d.ts +12 -0
- package/dist/types/tools/index.d.ts +31 -0
- package/dist/types/tools/path-utils.d.ts +13 -1
- package/dist/types/tools/read.d.ts +2 -1
- package/dist/types/tools/render-utils.d.ts +3 -1
- package/dist/types/tools/renderers.d.ts +0 -15
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/tools/write.d.ts +0 -2
- package/dist/types/tools/yield.d.ts +8 -0
- package/dist/types/tui/code-cell.d.ts +0 -2
- package/dist/types/tui/hyperlink.d.ts +5 -7
- package/dist/types/tui/output-block.d.ts +0 -18
- package/package.json +9 -9
- package/src/cli/args.ts +3 -1
- package/src/cli/dry-balance-cli.ts +2 -4
- package/src/cli/gallery-cli.ts +4 -0
- package/src/cli/gallery-fixtures/codeintel.ts +0 -1
- package/src/cli/gallery-fixtures/fs.ts +68 -1
- package/src/cli/gallery-fixtures/types.ts +8 -1
- package/src/cli/startup-cwd.ts +68 -0
- package/src/commands/launch.ts +3 -0
- package/src/commit/agentic/agent.ts +1 -0
- package/src/commit/model-selection.ts +3 -2
- package/src/config/model-provider-priority.ts +55 -0
- package/src/config/model-registry.ts +4 -22
- package/src/config/model-resolver.ts +39 -7
- package/src/config/settings.ts +86 -41
- package/src/debug/index.ts +8 -0
- package/src/debug/raw-sse-buffer.ts +7 -4
- package/src/debug/report-bundle.ts +9 -0
- package/src/edit/file-snapshot-store.ts +33 -1
- package/src/edit/hashline/diff.ts +86 -0
- package/src/edit/hashline/execute.ts +14 -1
- package/src/edit/hashline/filesystem.ts +2 -1
- package/src/edit/index.ts +31 -17
- package/src/edit/renderer.ts +116 -31
- package/src/eval/__tests__/llm-bridge.test.ts +20 -0
- package/src/eval/js/context-manager.ts +32 -15
- package/src/eval/js/shared/prelude.txt +26 -10
- package/src/eval/llm-bridge.ts +14 -3
- package/src/eval/py/__tests__/prelude.test.ts +19 -0
- package/src/eval/py/executor.ts +23 -11
- package/src/eval/py/prelude.py +1 -1
- package/src/extensibility/extensions/types.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/lsp/client.ts +23 -11
- package/src/lsp/config.ts +11 -1
- package/src/lsp/index.ts +189 -61
- package/src/main.ts +144 -78
- package/src/mcp/tool-bridge.ts +2 -0
- package/src/memories/index.ts +2 -2
- package/src/modes/components/assistant-message.ts +3 -15
- package/src/modes/components/custom-editor.ts +143 -111
- package/src/modes/components/late-diagnostics-message.ts +60 -0
- package/src/modes/components/model-selector.ts +59 -13
- package/src/modes/components/oauth-selector.ts +33 -7
- package/src/modes/components/plan-review-overlay.ts +26 -5
- package/src/modes/components/read-tool-group.ts +415 -35
- package/src/modes/components/session-selector.ts +89 -35
- package/src/modes/components/status-line.ts +19 -4
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/tool-execution.ts +7 -49
- package/src/modes/components/transcript-container.ts +108 -32
- package/src/modes/components/user-message.ts +1 -1
- package/src/modes/controllers/event-controller.ts +32 -1
- package/src/modes/controllers/input-controller.ts +56 -9
- package/src/modes/interactive-mode.ts +107 -20
- package/src/modes/magic-keywords.ts +1 -1
- package/src/modes/markdown-prose.ts +1 -1
- package/src/modes/theme/shimmer.ts +20 -9
- package/src/modes/types.ts +7 -0
- package/src/modes/utils/ui-helpers.ts +26 -5
- package/src/modes/workflow.ts +10 -10
- package/src/prompts/system/manual-continue.md +7 -0
- package/src/prompts/system/plan-mode-active.md +56 -72
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/bash.md +9 -0
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/eval.md +5 -2
- package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +85 -10
- package/src/session/agent-session.ts +42 -15
- package/src/session/auth-storage.ts +2 -0
- package/src/session/messages.ts +21 -14
- package/src/session/session-manager.ts +98 -25
- package/src/session/yield-queue.ts +20 -2
- package/src/task/executor.ts +72 -36
- package/src/task/render.ts +3 -4
- package/src/tiny/title-client.ts +6 -1
- package/src/tools/bash.ts +7 -7
- package/src/tools/browser/tab-supervisor.ts +13 -1
- package/src/tools/browser/tab-worker.ts +33 -4
- package/src/tools/eval-render.ts +4 -23
- package/src/tools/eval.ts +13 -2
- package/src/tools/find.ts +148 -99
- package/src/tools/gh-cache-invalidation.ts +200 -0
- package/src/tools/github-cache.ts +25 -0
- package/src/tools/index.ts +32 -0
- package/src/tools/inspect-image.ts +2 -2
- package/src/tools/path-utils.ts +47 -24
- package/src/tools/plan-mode-guard.ts +52 -7
- package/src/tools/read.ts +41 -20
- package/src/tools/render-utils.ts +3 -1
- package/src/tools/renderers.ts +0 -15
- package/src/tools/search.ts +38 -3
- package/src/tools/ssh.ts +0 -1
- package/src/tools/todo.ts +1 -0
- package/src/tools/write.ts +5 -14
- package/src/tools/yield.ts +10 -1
- package/src/tui/code-cell.ts +1 -6
- package/src/tui/hyperlink.ts +13 -23
- package/src/tui/output-block.ts +2 -97
- package/src/utils/commit-message-generator.ts +2 -2
- package/src/utils/enhanced-paste.ts +30 -2
- package/src/web/search/providers/codex.ts +37 -8
package/src/tools/eval-render.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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(
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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(
|
|
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();
|