@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/types/cli/startup-cwd.d.ts +2 -0
  3. package/dist/types/commands/launch.d.ts +3 -0
  4. package/dist/types/config/keybindings.d.ts +2 -2
  5. package/dist/types/config/model-provider-priority.d.ts +1 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -1
  7. package/dist/types/config/settings.d.ts +7 -2
  8. package/dist/types/debug/report-bundle.d.ts +3 -0
  9. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  10. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  12. package/dist/types/lsp/client.d.ts +10 -0
  13. package/dist/types/main.d.ts +3 -9
  14. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  16. package/dist/types/modes/components/status-line.d.ts +2 -0
  17. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  18. package/dist/types/modes/interactive-mode.d.ts +1 -0
  19. package/dist/types/modes/magic-keywords.d.ts +1 -1
  20. package/dist/types/modes/markdown-prose.d.ts +1 -1
  21. package/dist/types/modes/types.d.ts +3 -0
  22. package/dist/types/modes/workflow.d.ts +3 -3
  23. package/dist/types/session/auth-storage.d.ts +1 -1
  24. package/dist/types/session/session-manager.d.ts +5 -2
  25. package/dist/types/task/executor.d.ts +10 -0
  26. package/dist/types/tools/eval.d.ts +8 -0
  27. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  28. package/dist/types/tools/github-cache.d.ts +12 -0
  29. package/dist/types/tools/path-utils.d.ts +8 -0
  30. package/dist/types/tools/search.d.ts +2 -2
  31. package/dist/types/tools/yield.d.ts +8 -0
  32. package/package.json +9 -9
  33. package/src/cli/args.ts +3 -1
  34. package/src/cli/dry-balance-cli.ts +2 -4
  35. package/src/cli/startup-cwd.ts +68 -0
  36. package/src/commands/launch.ts +3 -0
  37. package/src/commit/model-selection.ts +3 -2
  38. package/src/config/model-provider-priority.ts +55 -0
  39. package/src/config/model-registry.ts +4 -22
  40. package/src/config/model-resolver.ts +39 -7
  41. package/src/config/settings.ts +86 -41
  42. package/src/debug/index.ts +8 -0
  43. package/src/debug/raw-sse-buffer.ts +7 -4
  44. package/src/debug/report-bundle.ts +9 -0
  45. package/src/edit/file-snapshot-store.ts +33 -1
  46. package/src/edit/hashline/filesystem.ts +2 -1
  47. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  48. package/src/eval/js/context-manager.ts +32 -15
  49. package/src/eval/llm-bridge.ts +14 -3
  50. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  51. package/src/eval/py/executor.ts +23 -11
  52. package/src/eval/py/prelude.py +1 -1
  53. package/src/extensibility/extensions/types.ts +10 -1
  54. package/src/internal-urls/docs-index.generated.ts +3 -3
  55. package/src/lsp/client.ts +23 -11
  56. package/src/lsp/config.ts +11 -1
  57. package/src/lsp/index.ts +61 -9
  58. package/src/main.ts +91 -65
  59. package/src/mcp/tool-bridge.ts +2 -0
  60. package/src/memories/index.ts +2 -2
  61. package/src/modes/components/custom-editor.ts +143 -111
  62. package/src/modes/components/model-selector.ts +59 -13
  63. package/src/modes/components/oauth-selector.ts +33 -7
  64. package/src/modes/components/status-line.ts +19 -4
  65. package/src/modes/components/tips.txt +1 -1
  66. package/src/modes/components/user-message.ts +1 -1
  67. package/src/modes/controllers/event-controller.ts +26 -0
  68. package/src/modes/controllers/input-controller.ts +46 -7
  69. package/src/modes/interactive-mode.ts +107 -20
  70. package/src/modes/magic-keywords.ts +1 -1
  71. package/src/modes/markdown-prose.ts +1 -1
  72. package/src/modes/theme/shimmer.ts +20 -9
  73. package/src/modes/types.ts +3 -0
  74. package/src/modes/workflow.ts +10 -10
  75. package/src/prompts/system/workflow-notice.md +1 -1
  76. package/src/prompts/tools/bash.md +9 -0
  77. package/src/prompts/tools/browser.md +1 -1
  78. package/src/prompts/tools/eval.md +2 -1
  79. package/src/prompts/tools/read.md +2 -2
  80. package/src/sdk.ts +26 -9
  81. package/src/session/agent-session.ts +37 -12
  82. package/src/session/auth-storage.ts +2 -0
  83. package/src/session/session-manager.ts +96 -23
  84. package/src/task/executor.ts +71 -36
  85. package/src/task/render.ts +3 -4
  86. package/src/tools/bash.ts +7 -0
  87. package/src/tools/browser/tab-supervisor.ts +13 -1
  88. package/src/tools/browser/tab-worker.ts +33 -4
  89. package/src/tools/eval.ts +13 -2
  90. package/src/tools/find.ts +7 -0
  91. package/src/tools/gh-cache-invalidation.ts +200 -0
  92. package/src/tools/github-cache.ts +25 -0
  93. package/src/tools/inspect-image.ts +2 -2
  94. package/src/tools/path-utils.ts +28 -2
  95. package/src/tools/plan-mode-guard.ts +52 -7
  96. package/src/tools/read.ts +25 -12
  97. package/src/tools/search.ts +38 -3
  98. package/src/tools/write.ts +2 -2
  99. package/src/tools/yield.ts +10 -1
  100. package/src/utils/commit-message-generator.ts +2 -2
  101. package/src/utils/enhanced-paste.ts +30 -2
  102. package/src/web/search/providers/codex.ts +37 -8
@@ -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();
@@ -5,7 +5,7 @@ import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import * as z from "zod/v4";
6
6
  import { extractTextContent } from "../commit/utils";
7
7
 
8
- import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
8
+ import { expandRoleAlias, getModelMatchPreferences, resolveModelFromString } from "../config/model-resolver";
9
9
  import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
10
10
  import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
11
11
  import {
@@ -72,7 +72,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
72
72
  throw new ToolError("No models available for inspect_image.");
73
73
  }
74
74
 
75
- const matchPreferences = { usageOrder: this.session.settings.getStorage()?.getModelUsageOrder() };
75
+ const matchPreferences = getModelMatchPreferences(this.session.settings);
76
76
  const resolvePattern = (pattern: string | undefined): Model<Api> | undefined => {
77
77
  if (!pattern) return undefined;
78
78
  const expanded = expandRoleAlias(pattern, this.session.settings);
@@ -601,6 +601,23 @@ export function parseSearchPath(filePath: string): ParsedSearchPath {
601
601
  };
602
602
  }
603
603
 
604
+ /**
605
+ * Async sibling of {@link parseSearchPath} that prefers literal interpretation
606
+ * when a path containing glob metacharacters resolves to an existing entry on
607
+ * disk. Disambiguates Next.js/SvelteKit routes like `apps/[id]/page.tsx` —
608
+ * without this, `[id]` is parsed as a glob character class and silently
609
+ * matches nothing.
610
+ */
611
+ export async function parseSearchPathPreferringLiteral(filePath: string, cwd: string): Promise<ParsedSearchPath> {
612
+ if (!hasGlobPathChars(filePath) || isInternalUrlPath(filePath)) return parseSearchPath(filePath);
613
+ try {
614
+ await fs.promises.stat(resolveToCwd(filePath, cwd));
615
+ return { basePath: filePath };
616
+ } catch {
617
+ return parseSearchPath(filePath);
618
+ }
619
+ }
620
+
604
621
  // Parse a find pattern into a base directory path and a glob pattern.
605
622
  // Examples:
606
623
  // src/app/**/\*.tsx -> { basePath: "src/app", globPattern: "**/*.tsx", hasGlob: true }
@@ -707,7 +724,7 @@ async function resolveSearchPathItems(
707
724
 
708
725
  const parsedItems = await Promise.all(
709
726
  pathItems.map(async item => {
710
- const parsedPath = parseSearchPath(item);
727
+ const parsedPath = await parseSearchPathPreferringLiteral(item, cwd);
711
728
  const absoluteBasePath = resolveToCwd(parsedPath.basePath, cwd);
712
729
  const stat = await fs.promises.stat(absoluteBasePath);
713
730
  return { raw: item, parsedPath, absoluteBasePath, stat };
@@ -946,6 +963,15 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
946
963
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
947
964
  throw new ToolError("`paths` must contain non-empty paths or globs");
948
965
  }
966
+ // External (http/https/ftp/file) URLs are not searchable; route the caller
967
+ // to `read` instead of letting the path-resolver surface a confusing
968
+ // "Path not found" for a slash-stripped URL.
969
+ const externalUrl = rawPaths.find(rawPath => /^(?:https?|ftp|file|ws|wss):\/\//i.test(rawPath));
970
+ if (externalUrl) {
971
+ throw new ToolError(
972
+ `Cannot ${internalUrlAction} external URL: ${externalUrl}. Use \`read\` to fetch web content, then search the returned text.`,
973
+ );
974
+ }
949
975
  const internalRouter = InternalUrlRouter.instance();
950
976
  const resolvedPathInputs: string[] = [];
951
977
  const immutableSourcePaths = new Set<string>();
@@ -989,7 +1015,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
989
1015
  let multiTargets: ResolvedSearchTarget[] | undefined;
990
1016
  let exactFilePaths: string[] | undefined;
991
1017
  if (effectivePaths.length === 1) {
992
- const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
1018
+ const parsedPath = await parseSearchPathPreferringLiteral(effectivePaths[0] ?? ".", cwd);
993
1019
  searchPath = resolveToCwd(parsedPath.basePath, cwd);
994
1020
  globFilter = parsedPath.glob;
995
1021
  scopePath = formatPathRelativeToCwd(searchPath, cwd);
@@ -1,4 +1,6 @@
1
- import { resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { resolveLocalRoot, resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
2
4
  import type { ToolSession } from ".";
3
5
  import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
4
6
  import { ToolError } from "./tool-errors";
@@ -6,11 +8,54 @@ import { ToolError } from "./tool-errors";
6
8
  const VAULT_SCHEME_PREFIX = "vault:";
7
9
  const LOCAL_SCHEME_PREFIX = "local:";
8
10
 
9
- /** True when `targetPath` addresses the session-local artifact sandbox
10
- * (`local://…`). Those files are not part of the working tree, so plan mode
11
- * treats them as freely writable scratch/plan space. */
12
- function targetsLocalSandbox(targetPath: string): boolean {
13
- return normalizeLocalScheme(targetPath).startsWith(LOCAL_SCHEME_PREFIX);
11
+ /** Resolve the absolute path of the session's `local://` artifact sandbox.
12
+ * Returns `null` when the session has no artifact wiring (e.g. tests). */
13
+ function localSandboxRoot(session: ToolSession): string | null {
14
+ try {
15
+ return path.resolve(
16
+ resolveLocalRoot({
17
+ getArtifactsDir: session.getArtifactsDir,
18
+ getSessionId: session.getSessionId,
19
+ }),
20
+ );
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /** True when `absolutePath` resolves inside `root` (== root or under it). */
27
+ function isWithinRoot(absolutePath: string, root: string): boolean {
28
+ if (absolutePath === root) return true;
29
+ const sep = `${root}${path.sep}`;
30
+ return absolutePath.startsWith(sep);
31
+ }
32
+
33
+ /** True when `targetPath` addresses the session-local artifact sandbox.
34
+ * Accepts both `local://…` URLs and absolute paths pointing inside the
35
+ * resolved sandbox root — the latter is what `read local://…` echoes back
36
+ * in the `[path#tag]` header. Those files are not part of the working tree,
37
+ * so plan mode treats them as freely writable scratch/plan space. */
38
+ function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean {
39
+ const normalized = normalizeLocalScheme(targetPath);
40
+ if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) return true;
41
+ if (!path.isAbsolute(normalized)) return false;
42
+ const root = localSandboxRoot(session);
43
+ if (!root) return false;
44
+ // Compare both raw and realpath-normalized forms so that
45
+ // `/tmp/…` vs `/private/tmp/…` (macOS) and other symlink-collapsed
46
+ // roots both resolve to the same sandbox identity.
47
+ const resolved = path.resolve(normalized);
48
+ if (isWithinRoot(resolved, root)) return true;
49
+ try {
50
+ const realRoot = fs.realpathSync.native(root);
51
+ if (isWithinRoot(resolved, realRoot)) return true;
52
+ // `resolved` itself may live in `/tmp/...` while `realRoot` is `/private/tmp/...`;
53
+ // realpath the parent dir of `resolved` so we catch that direction too.
54
+ const realParent = fs.realpathSync.native(path.dirname(resolved));
55
+ return isWithinRoot(path.join(realParent, path.basename(resolved)), realRoot);
56
+ } catch {
57
+ return false;
58
+ }
14
59
  }
15
60
 
16
61
  /**
@@ -55,7 +100,7 @@ export function enforcePlanModeWrite(
55
100
  throw new ToolError("Plan mode: deleting files is not allowed.");
56
101
  }
57
102
 
58
- if (targetsLocalSandbox(targetPath)) return;
103
+ if (targetsLocalSandbox(session, targetPath)) return;
59
104
 
60
105
  throw new ToolError(
61
106
  "Plan mode: the working tree is read-only. Write your plan to a local://<slug>-plan.md file instead.",
package/src/tools/read.ts CHANGED
@@ -9,7 +9,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
10
  import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
11
11
  import * as z from "zod/v4";
12
- import { getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
12
+ import { canonicalSnapshotKey, getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
13
13
  import { normalizeToLF } from "../edit/normalize";
14
14
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
15
15
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -131,7 +131,7 @@ function recordFullHashlineContext(
131
131
  ): HashlineHeaderContext | undefined {
132
132
  if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
133
133
  const normalized = normalizeToLF(fullText);
134
- const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
134
+ const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
135
135
  return {
136
136
  header: formatHashlineHeader(displayPath, tag),
137
137
  tag,
@@ -1750,15 +1750,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1750
1750
  // Convert document via markit.
1751
1751
  const result = await convertFileWithMarkit(absolutePath, signal);
1752
1752
  if (result.ok) {
1753
- // Apply truncation to converted content
1754
- const truncation = truncateHead(result.content);
1755
- const outputText = truncation.content;
1756
-
1757
- details = { truncation };
1758
- sourcePath = absolutePath;
1759
- truncationInfo = { result: truncation, options: { direction: "head", startLine: 1 } };
1760
-
1761
- content = [{ type: "text", text: outputText }];
1753
+ // Route the converted markdown through the in-memory text builder
1754
+ // so line-range selectors (`file.pdf:50-100`, `:5-16,40-80`) and
1755
+ // raw mode apply against the converted output. Without this,
1756
+ // `file.pdf:50-100` silently returned the head of the document
1757
+ // because only `truncateHead` was being applied.
1758
+ if (isMultiRange(parsed) && parsed.kind === "lines") {
1759
+ return this.#buildInMemoryMultiRangeResult(result.content, parsed.ranges, {
1760
+ details: { resolvedPath: absolutePath },
1761
+ sourcePath: absolutePath,
1762
+ entityLabel: "document",
1763
+ });
1764
+ }
1765
+ const { offset, limit } = selToOffsetLimit(parsed);
1766
+ return this.#buildInMemoryTextResult(result.content, offset, limit, {
1767
+ details: { resolvedPath: absolutePath },
1768
+ sourcePath: absolutePath,
1769
+ entityLabel: "document",
1770
+ raw: isRawSelector(parsed),
1771
+ });
1762
1772
  } else if (result.error) {
1763
1773
  content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
1764
1774
  } else {
@@ -1944,7 +1954,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1944
1954
  // full file and any anchor validates while the file is unchanged.
1945
1955
  const isWholeFile = offset === undefined && limit === undefined && !wasTruncated;
1946
1956
  const tag = isWholeFile
1947
- ? getFileSnapshotStore(this.session).record(absolutePath, normalizeToLF(collectedLines.join("\n")))
1957
+ ? getFileSnapshotStore(this.session).record(
1958
+ canonicalSnapshotKey(absolutePath),
1959
+ normalizeToLF(collectedLines.join("\n")),
1960
+ )
1948
1961
  : await recordFileSnapshot(this.session, absolutePath);
1949
1962
  if (tag) {
1950
1963
  hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
@@ -83,6 +83,7 @@ const searchSchema = z
83
83
  gitignore: z.boolean().optional().describe("respect gitignore"),
84
84
  skip: z
85
85
  .number()
86
+ .nullable()
86
87
  .optional()
87
88
  .describe("files to skip before collecting results — use to paginate when the prior call hit the file limit"),
88
89
  })
@@ -107,6 +108,10 @@ export const SINGLE_FILE_MATCHES = 200;
107
108
  * (DEFAULT_FILE_LIMIT files × MULTI_FILE_PER_FILE_MATCHES matches) plus
108
109
  * pagination headroom so the caller can see total file count. */
109
110
  const INTERNAL_TOTAL_CAP = 2000;
111
+ /** Mirrors `MAX_FILE_BYTES` in `crates/pi-natives/src/grep.rs`. Native grep
112
+ * silently returns no matches for files larger than this; surface a warning
113
+ * when the caller explicitly targeted such a file so they know to chunk it. */
114
+ const NATIVE_GREP_MAX_FILE_BYTES = 4 * 1024 * 1024;
110
115
 
111
116
  /**
112
117
  * Parsed `paths` entry — a path (possibly archive-shaped) plus an optional
@@ -666,7 +671,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
666
671
  throw new ToolError("Pattern must not be empty");
667
672
  }
668
673
 
669
- const normalizedSkip = skip === undefined ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN;
674
+ const normalizedSkip =
675
+ skip === undefined || skip === null ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN;
670
676
  if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
671
677
  throw new ToolError("Skip must be a non-negative number");
672
678
  }
@@ -728,7 +734,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
728
734
  // reason instead of a downstream "path not found" from the scope resolver.
729
735
  throw new ToolError(
730
736
  `Cannot search archive member(s): ${archiveUnreadable.join(", ")}. ` +
731
- `Read the file directly with \`read <archive>:<member>\` and grep the returned content, ` +
737
+ `Read the member with \`read <archive>:<member>\` and inspect the returned text, ` +
732
738
  `or pass a UTF-8 text member.`,
733
739
  );
734
740
  }
@@ -991,6 +997,34 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
991
997
  : "";
992
998
  const { record: recordFile, list: fileList } = createFileRecorder();
993
999
  const fileMatchCounts = new Map<string, number>();
1000
+ // Detect explicit file targets that exceed the native grep size cap.
1001
+ // Native silently returns no matches above the cap; without this note the
1002
+ // caller sees "no matches" for a literal pattern that visibly exists.
1003
+ const oversizedNote = await (async (): Promise<string | undefined> => {
1004
+ const explicitFileTargets: string[] = [];
1005
+ if (exactFilePaths) {
1006
+ explicitFileTargets.push(...exactFilePaths);
1007
+ } else if (searchablePaths.length > 0 && !isDirectory && !multiTargets) {
1008
+ explicitFileTargets.push(searchPath);
1009
+ }
1010
+ if (explicitFileTargets.length === 0) return undefined;
1011
+ const oversized: string[] = [];
1012
+ await Promise.all(
1013
+ explicitFileTargets.map(async target => {
1014
+ try {
1015
+ const st = await stat(target);
1016
+ if (st.isFile() && st.size > NATIVE_GREP_MAX_FILE_BYTES) {
1017
+ oversized.push(path.relative(this.session.cwd, target) || target);
1018
+ }
1019
+ } catch {
1020
+ // Stat failures here are surfaced by other code paths.
1021
+ }
1022
+ }),
1023
+ );
1024
+ if (oversized.length === 0) return undefined;
1025
+ const limitMb = Math.floor(NATIVE_GREP_MAX_FILE_BYTES / (1024 * 1024));
1026
+ return `Skipped oversized files (>${limitMb}MB grep limit; split the file or narrow with \`read\`): ${oversized.join(", ")}`;
1027
+ })();
994
1028
  const archiveNote =
995
1029
  archiveUnreadable.length > 0
996
1030
  ? `Skipped archive entries (search supports text members only): ${archiveUnreadable.join(", ")}`
@@ -1002,7 +1036,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
1002
1036
  const missingPathsNote =
1003
1037
  missingPathsForNote.length > 0 ? `Skipped missing paths: ${missingPathsForNote.join(", ")}` : undefined;
1004
1038
  const warningNote =
1005
- [missingPathsNote, archiveNote].filter((s): s is string => Boolean(s)).join("\n") || undefined;
1039
+ [missingPathsNote, archiveNote, oversizedNote].filter((s): s is string => Boolean(s)).join("\n") ||
1040
+ undefined;
1006
1041
  if (selectedMatches.length === 0) {
1007
1042
  const details: SearchToolDetails = {
1008
1043
  scopePath,
@@ -8,7 +8,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
9
9
  import * as z from "zod/v4";
10
10
 
11
- import { getFileSnapshotStore } from "../edit/file-snapshot-store";
11
+ import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store";
12
12
  import { normalizeToLF } from "../edit/normalize";
13
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
14
  import { InternalUrlRouter } from "../internal-urls";
@@ -132,7 +132,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
132
132
  function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, content: string): string | undefined {
133
133
  if (!resolveFileDisplayMode(session).hashLines) return undefined;
134
134
  const normalized = normalizeToLF(content);
135
- const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
135
+ const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
136
136
  return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag);
137
137
  }
138
138
 
@@ -20,6 +20,14 @@ export interface YieldDetails {
20
20
  data: unknown;
21
21
  status: "success" | "aborted";
22
22
  error?: string;
23
+ /**
24
+ * Set when the yield tool exhausted its in-tool schema-retry budget
25
+ * (MAX_SCHEMA_RETRIES) and accepted the data anyway. Surfaced so the
26
+ * executor's post-mortem finalizer can honor the override instead of
27
+ * re-rejecting the same payload with `schema_violation` — keeping the
28
+ * subagent's acceptance and the parent's view of the result in lockstep.
29
+ */
30
+ schemaOverridden?: boolean;
23
31
  }
24
32
 
25
33
  function formatSchema(schema: unknown): string {
@@ -237,7 +245,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
237
245
  : "Result submitted.";
238
246
  return {
239
247
  content: [{ type: "text", text: responseText }],
240
- details: { data, status, error: errorMessage },
248
+ details: { data, status, error: errorMessage, schemaOverridden: schemaValidationOverridden || undefined },
241
249
  };
242
250
  }
243
251
  }
@@ -254,6 +262,7 @@ subprocessToolRegistry.register<YieldDetails>("yield", {
254
262
  data: record.data,
255
263
  status,
256
264
  error: typeof record.error === "string" ? record.error : undefined,
265
+ schemaOverridden: record.schemaOverridden === true ? true : undefined,
257
266
  };
258
267
  },
259
268
  shouldTerminate: event => !event.isError,
@@ -8,7 +8,7 @@ import { completeSimple } from "@oh-my-pi/pi-ai";
8
8
  import { logger, prompt } from "@oh-my-pi/pi-utils";
9
9
 
10
10
  import type { ModelRegistry } from "../config/model-registry";
11
- import { resolveModelRoleValue } from "../config/model-resolver";
11
+ import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
12
12
  import type { Settings } from "../config/settings";
13
13
  import MODEL_PRIO from "../priority.json" with { type: "json" };
14
14
  import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
@@ -51,7 +51,7 @@ function getSmolModelCandidates(
51
51
  candidates.push({ model, thinkingLevel });
52
52
  };
53
53
 
54
- const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
54
+ const matchPreferences = getModelMatchPreferences(settings);
55
55
  const configuredSmol = resolveModelRoleValue(settings.getModelRole("smol"), availableModels, {
56
56
  settings,
57
57
  matchPreferences,
@@ -7,6 +7,8 @@ const PASTE_EVENT_NAME_BASE64 = Buffer.from("Paste event", "utf8").toString("bas
7
7
 
8
8
  const IMAGE_MIME_PRIORITY = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
9
9
  const TEXT_MIME_TYPE = "text/plain";
10
+ /** Kitty's "give me the list of available MIME types" sentinel — see `TARGETS_MIME` in `kitty/clipboard.py`. */
11
+ const MIME_LISTING_TARGET = ".";
10
12
 
11
13
  type PasteReadKind = "image" | "text";
12
14
 
@@ -18,6 +20,7 @@ export interface Osc5522Packet {
18
20
  interface PasteListingState {
19
21
  phase: "listing";
20
22
  mimes: string[];
23
+ kittyDotPayload?: true;
21
24
  pw?: string;
22
25
  loc?: string;
23
26
  }
@@ -144,6 +147,25 @@ export class EnhancedPasteController {
144
147
  if (!mimeType) return;
145
148
 
146
149
  if (state.phase === "listing") {
150
+ // Kitty (as of writing) implements the "list available MIME types"
151
+ // response shape by sending a single DATA packet with `mime="."` and
152
+ // the available types packed into the payload as a whitespace-
153
+ // separated list (see `fulfill_read_request` in
154
+ // kovidgoyal/kitty:kitty/clipboard.py). The 5522-mode ancillary
155
+ // spec instead encodes each type as its own DATA packet with an
156
+ // empty payload. Support both — fall through to the per-packet
157
+ // form when the dot sentinel has no payload, or when the packet
158
+ // already names a concrete MIME type.
159
+ if (mimeType === MIME_LISTING_TARGET) {
160
+ if (!packet.payload) return;
161
+ const listing = decodeBase64Utf8(packet.payload);
162
+ if (!listing) return;
163
+ state.kittyDotPayload = true;
164
+ for (const candidate of listing.split(/\s+/)) {
165
+ if (candidate && candidate !== MIME_LISTING_TARGET) state.mimes.push(candidate);
166
+ }
167
+ return;
168
+ }
147
169
  state.mimes.push(mimeType);
148
170
  return;
149
171
  }
@@ -192,11 +214,17 @@ export class EnhancedPasteController {
192
214
  chunks: [],
193
215
  };
194
216
 
195
- const metadata = [`type=read`, `mime=${Buffer.from(selected.mimeType, "utf8").toString("base64")}`];
217
+ const encodedMime = Buffer.from(selected.mimeType, "utf8").toString("base64");
218
+ const metadata = ["type=read"];
196
219
  if (state.loc) metadata.push(`loc=${state.loc}`);
197
220
  if (state.pw) {
198
221
  metadata.push(`pw=${state.pw}`, `name=${PASTE_EVENT_NAME_BASE64}`);
199
222
  }
200
- this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")}${OSC_TERMINATOR_ST}`);
223
+ if (state.kittyDotPayload) {
224
+ this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")};${encodedMime}${OSC_TERMINATOR_BEL}`);
225
+ return;
226
+ }
227
+ metadata.push(`mime=${encodedMime}`);
228
+ this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")}${OSC_TERMINATOR_BEL}`);
201
229
  }
202
230
  }