@oh-my-pi/pi-coding-agent 16.3.4 → 16.3.6

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 (88) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/cli.js +3522 -3485
  3. package/dist/types/edit/file-snapshot-store.d.ts +4 -2
  4. package/dist/types/edit/renderer.d.ts +0 -1
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  6. package/dist/types/extensibility/shared-events.d.ts +8 -1
  7. package/dist/types/hindsight/content.d.ts +4 -0
  8. package/dist/types/internal-urls/artifact-protocol.d.ts +8 -0
  9. package/dist/types/internal-urls/types.d.ts +10 -0
  10. package/dist/types/lsp/config.d.ts +4 -0
  11. package/dist/types/lsp/edits.d.ts +2 -0
  12. package/dist/types/mcp/oauth-discovery.d.ts +30 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +15 -10
  14. package/dist/types/modes/components/bash-execution.d.ts +6 -0
  15. package/dist/types/modes/components/eval-execution.d.ts +6 -0
  16. package/dist/types/modes/components/login-dialog.d.ts +10 -2
  17. package/dist/types/modes/components/tool-execution.d.ts +3 -13
  18. package/dist/types/modes/components/transcript-container.d.ts +26 -29
  19. package/dist/types/modes/controllers/mcp-command-controller.d.ts +20 -3
  20. package/dist/types/modes/rpc/rpc-client.d.ts +7 -3
  21. package/dist/types/modes/rpc/rpc-types.d.ts +6 -0
  22. package/dist/types/modes/utils/transcript-render-helpers.d.ts +15 -6
  23. package/dist/types/session/agent-session.d.ts +2 -0
  24. package/dist/types/subprocess/worker-runtime.d.ts +11 -0
  25. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  26. package/dist/types/tools/bash.d.ts +0 -2
  27. package/dist/types/tools/browser/tab-supervisor.d.ts +10 -0
  28. package/dist/types/tools/eval-render.d.ts +0 -2
  29. package/dist/types/tools/renderers.d.ts +0 -20
  30. package/dist/types/tools/ssh.d.ts +0 -2
  31. package/dist/types/tools/write.d.ts +1 -1
  32. package/dist/types/utils/git.d.ts +16 -0
  33. package/package.json +12 -12
  34. package/src/cli/auth-broker-cli.ts +13 -2
  35. package/src/cli/tiny-models-cli.ts +12 -5
  36. package/src/edit/file-snapshot-store.ts +5 -2
  37. package/src/edit/renderer.ts +0 -5
  38. package/src/extensibility/custom-tools/types.ts +2 -0
  39. package/src/extensibility/shared-events.ts +9 -1
  40. package/src/hindsight/content.ts +31 -0
  41. package/src/internal-urls/artifact-protocol.ts +97 -53
  42. package/src/internal-urls/types.ts +10 -0
  43. package/src/lsp/config.ts +15 -0
  44. package/src/lsp/edits.ts +28 -7
  45. package/src/lsp/index.ts +46 -4
  46. package/src/mcp/oauth-discovery.ts +88 -18
  47. package/src/mnemopi/state.ts +26 -2
  48. package/src/modes/components/assistant-message.ts +134 -82
  49. package/src/modes/components/bash-execution.ts +9 -0
  50. package/src/modes/components/chat-transcript-builder.ts +8 -4
  51. package/src/modes/components/eval-execution.ts +9 -0
  52. package/src/modes/components/login-dialog.ts +16 -2
  53. package/src/modes/components/mcp-add-wizard.ts +9 -1
  54. package/src/modes/components/tool-execution.ts +4 -50
  55. package/src/modes/components/transcript-container.ts +82 -458
  56. package/src/modes/components/tree-selector.ts +9 -3
  57. package/src/modes/controllers/command-controller.ts +0 -3
  58. package/src/modes/controllers/event-controller.ts +74 -14
  59. package/src/modes/controllers/extension-ui-controller.ts +0 -1
  60. package/src/modes/controllers/input-controller.ts +4 -10
  61. package/src/modes/controllers/mcp-command-controller.ts +106 -29
  62. package/src/modes/controllers/selector-controller.ts +9 -1
  63. package/src/modes/interactive-mode.ts +12 -8
  64. package/src/modes/rpc/rpc-client.ts +8 -4
  65. package/src/modes/rpc/rpc-mode.ts +1 -0
  66. package/src/modes/rpc/rpc-types.ts +13 -1
  67. package/src/modes/setup-wizard/scenes/sign-in.ts +18 -0
  68. package/src/modes/utils/transcript-render-helpers.ts +40 -13
  69. package/src/modes/utils/ui-helpers.ts +12 -7
  70. package/src/prompts/tools/read.md +1 -1
  71. package/src/sdk.ts +1 -0
  72. package/src/session/agent-session.ts +148 -1
  73. package/src/session/session-context.ts +7 -0
  74. package/src/subprocess/worker-runtime.ts +219 -2
  75. package/src/task/worktree.ts +28 -6
  76. package/src/tiny/worker.ts +14 -4
  77. package/src/tools/bash-skill-urls.ts +3 -3
  78. package/src/tools/bash.ts +0 -4
  79. package/src/tools/browser/tab-supervisor.ts +47 -3
  80. package/src/tools/eval-render.ts +0 -20
  81. package/src/tools/grep.ts +19 -2
  82. package/src/tools/path-utils.ts +4 -0
  83. package/src/tools/read.ts +261 -21
  84. package/src/tools/renderers.ts +0 -20
  85. package/src/tools/ssh.ts +0 -16
  86. package/src/tools/write.ts +13 -6
  87. package/src/utils/git.ts +20 -0
  88. package/src/utils/open.ts +51 -6
@@ -324,6 +324,22 @@ export declare const patch: {
324
324
  };
325
325
  export declare const cherryPick: ((cwd: string, revision: string, signal?: AbortSignal) => Promise<void>) & {
326
326
  abort(cwd: string, signal?: AbortSignal): Promise<void>;
327
+ /**
328
+ * Skip the current commit of an in-progress cherry-pick sequence and
329
+ * continue with the rest of the range. Use after {@link isEmptyError}
330
+ * reports the current attempt collapsed to a no-op — the alternative,
331
+ * `--abort`, throws away every remaining commit in the range.
332
+ */
333
+ skip(cwd: string, signal?: AbortSignal): Promise<void>;
334
+ /**
335
+ * True when a cherry-pick failure was caused by the current commit
336
+ * being empty against HEAD — either redundant with an already-applied
337
+ * change, or auto-resolved to HEAD by a 3-way merge. Callers should
338
+ * `--skip` in this case to advance the sequencer rather than aborting
339
+ * the whole range: an empty commit is not a merge conflict, and any
340
+ * later commits in the range still deserve to land.
341
+ */
342
+ isEmptyError(err: unknown): boolean;
327
343
  };
328
344
  export declare const stash: {
329
345
  /** Stash working tree + index changes. Returns true when git created a new stash entry. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.3.4",
4
+ "version": "16.3.6",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -56,17 +56,17 @@
56
56
  "@agentclientprotocol/sdk": "0.25.0",
57
57
  "@babel/parser": "^7.29.7",
58
58
  "@mozilla/readability": "^0.6.0",
59
- "@oh-my-pi/hashline": "16.3.4",
60
- "@oh-my-pi/omp-stats": "16.3.4",
61
- "@oh-my-pi/pi-agent-core": "16.3.4",
62
- "@oh-my-pi/pi-ai": "16.3.4",
63
- "@oh-my-pi/pi-catalog": "16.3.4",
64
- "@oh-my-pi/pi-mnemopi": "16.3.4",
65
- "@oh-my-pi/pi-natives": "16.3.4",
66
- "@oh-my-pi/pi-tui": "16.3.4",
67
- "@oh-my-pi/pi-utils": "16.3.4",
68
- "@oh-my-pi/pi-wire": "16.3.4",
69
- "@oh-my-pi/snapcompact": "16.3.4",
59
+ "@oh-my-pi/hashline": "16.3.6",
60
+ "@oh-my-pi/omp-stats": "16.3.6",
61
+ "@oh-my-pi/pi-agent-core": "16.3.6",
62
+ "@oh-my-pi/pi-ai": "16.3.6",
63
+ "@oh-my-pi/pi-catalog": "16.3.6",
64
+ "@oh-my-pi/pi-mnemopi": "16.3.6",
65
+ "@oh-my-pi/pi-natives": "16.3.6",
66
+ "@oh-my-pi/pi-tui": "16.3.6",
67
+ "@oh-my-pi/pi-utils": "16.3.6",
68
+ "@oh-my-pi/pi-wire": "16.3.6",
69
+ "@oh-my-pi/snapcompact": "16.3.6",
70
70
  "@opentelemetry/api": "^1.9.1",
71
71
  "@opentelemetry/context-async-hooks": "^2.7.1",
72
72
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -222,8 +222,19 @@ async function runLocalLogin(provider: OAuthProvider): Promise<void> {
222
222
  // for non-paste-code providers, so this is defense-in-depth on the same gate.
223
223
  const usesManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(provider);
224
224
  await storage.login(provider, {
225
- onAuth({ url, instructions }) {
226
- process.stdout.write(`\nOpen this URL in your browser:\n${url}\n`);
225
+ onAuth({ url, launchUrl, instructions }) {
226
+ process.stdout.write("\nOpen this URL in your browser:\n");
227
+ // Full URL first so the CLI works from any machine, including SSH
228
+ // sessions where a `launchUrl` (loopback `/launch` on the OMP
229
+ // host) would resolve against the caller's browser and fail.
230
+ // Headless capture is unaffected: it reads the first URL line.
231
+ process.stdout.write(`${url}\n`);
232
+ if (launchUrl && launchUrl !== url) {
233
+ // Local shortcut for the machine running OMP. Terminals or
234
+ // screen-scrapers narrower than the full URL still get an
235
+ // unbroken copy target here.
236
+ process.stdout.write(`Local shortcut (this machine only): ${launchUrl}\n`);
237
+ }
227
238
  if (instructions) process.stdout.write(`${instructions}\n`);
228
239
  process.stdout.write("\n");
229
240
  },
@@ -35,12 +35,19 @@ function writeLine(text = ""): void {
35
35
  process.stdout.write(`${text}\n`);
36
36
  }
37
37
 
38
+ const ACTIONABLE_DOWNLOAD_ERROR_LINE = /PI_TINY_|CUDA|cuDNN|cudnn|libcudnn|tiny-title-runtime|onnxruntime-node/i;
39
+
38
40
  function downloadErrorSummary(error: string | undefined): string | undefined {
39
- return error
40
- ?.split(/\r?\n/)
41
- .map(line => line.trim())
42
- .find(line => line.length > 0)
43
- ?.replace(/^Error:\s*/, "");
41
+ const lines =
42
+ error
43
+ ?.split(/\r?\n/)
44
+ .map(line => line.trim().replace(/^Error:\s*/, ""))
45
+ .filter(line => line.length > 0) ?? [];
46
+ const first = lines[0];
47
+ if (!first) return undefined;
48
+ const details = lines.slice(1).filter(line => ACTIONABLE_DOWNLOAD_ERROR_LINE.test(line));
49
+ if (details.length === 0) return first;
50
+ return [first, ...details].join("\n");
44
51
  }
45
52
 
46
53
  export function resolveModels(model: string | undefined): TinyLocalModelKey[] {
@@ -74,17 +74,20 @@ export function canonicalSnapshotKey(absolutePath: string): string {
74
74
  * Producers that only displayed a slice of the file (range reads, search hits)
75
75
  * use this to mint a whole-file tag: the displayed lines stay partial, but the
76
76
  * tag fingerprints the entire file so a follow-up edit anchored at any line
77
- * validates whenever the live file is byte-identical to what was read.
77
+ * validates whenever the live file is byte-identical to what was read. Raw
78
+ * reads pass `seenLines` even though they do not emit a header, letting a prior
79
+ * or later same-content hashline tag inherit the raw range's provenance.
78
80
  */
79
81
  export async function recordFileSnapshot(
80
82
  session: FileSnapshotStoreOwner,
81
83
  absolutePath: string,
84
+ seenLines?: Iterable<number>,
82
85
  ): Promise<string | undefined> {
83
86
  try {
84
87
  const file = Bun.file(absolutePath);
85
88
  if (file.size > SNAPSHOT_MAX_BYTES) return undefined;
86
89
  const normalized = normalizeToLF(await file.text());
87
- return getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
90
+ return getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized, seenLines);
88
91
  } catch {
89
92
  return undefined;
90
93
  }
@@ -696,11 +696,6 @@ function wrapEditRendererLine(line: string, width: number): string[] {
696
696
 
697
697
  export const editToolRenderer = {
698
698
  mergeCallAndResult: true,
699
- // Pending preview is a TAIL window of the streamed diff ("… N more lines
700
- // above" + last rows); the result render re-anchors the block top-first, so
701
- // committing the preview's settled head would strand a stale call-box
702
- // fragment in native scrollback.
703
- provisionalPendingPreview: true,
704
699
 
705
700
  renderCall(
706
701
  args: EditRenderArgs,
@@ -26,6 +26,7 @@ import type * as PiCodingAgent from "../../index";
26
26
  import type { Theme } from "../../modes/theme/theme";
27
27
  import type { ReadonlySessionManager } from "../../session/session-manager";
28
28
  import type { TodoItem } from "../../tools/todo";
29
+ import type { RecoveredRetryError } from "../shared-events";
29
30
  import type * as TypeBox from "../typebox";
30
31
 
31
32
  /** Alias for clarity */
@@ -133,6 +134,7 @@ export type CustomToolSessionEvent =
133
134
  success: boolean;
134
135
  attempt: number;
135
136
  finalError?: string;
137
+ recoveredErrors?: RecoveredRetryError[];
136
138
  }
137
139
  | {
138
140
  reason: "ttsr_triggered";
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
16
16
  import type { CompactionPreparation, CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
17
- import type { ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
17
+ import type { AssistantRetryRecovery, ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
18
18
  import type { Rule } from "../capability/rule";
19
19
  import type { Goal, GoalModeState } from "../goals/state";
20
20
  import type { BranchSummaryEntry, CompactionEntry, SessionEntry } from "../session/session-entries";
@@ -241,12 +241,20 @@ export interface AutoRetryStartEvent {
241
241
  errorId?: number;
242
242
  }
243
243
 
244
+ export interface RecoveredRetryError {
245
+ entryId: string;
246
+ persistenceKey?: string;
247
+ note: string;
248
+ retryRecovery: AssistantRetryRecovery;
249
+ }
250
+
244
251
  /** Fired when auto-retry ends */
245
252
  export interface AutoRetryEndEvent {
246
253
  type: "auto_retry_end";
247
254
  success: boolean;
248
255
  attempt: number;
249
256
  finalError?: string;
257
+ recoveredErrors?: RecoveredRetryError[];
250
258
  }
251
259
 
252
260
  // ============================================================================
@@ -25,6 +25,7 @@ const LEGACY_HINDSIGHT_MEMORIES_REGEX = /<hindsight_memories>[\s\S]*?<\/hindsigh
25
25
  const LEGACY_RELEVANT_MEMORIES_REGEX = /<relevant_memories>[\s\S]*?<\/relevant_memories>/g;
26
26
  const MENTAL_MODELS_REGEX = /<mental_models>[\s\S]*?<\/mental_models>/g;
27
27
 
28
+ const RETENTION_PROTOCOL_MARKER_REGEX = /^\[(?:role:\s*[-_a-zA-Z0-9]+|[-_a-zA-Z0-9]+:end)\]$/;
28
29
  /**
29
30
  * Strip `<memories>`, `<mental_models>`, and legacy memory blocks.
30
31
  *
@@ -205,6 +206,32 @@ function formatRetentionMessages(messages: HindsightMessage[]): RetentionTranscr
205
206
  return { transcript, messageCount: parts.length };
206
207
  }
207
208
 
209
+ function formatEmbeddableRetentionMessages(messages: HindsightMessage[]): RetentionTranscript {
210
+ const parts: string[] = [];
211
+ for (const msg of messages) {
212
+ const content = stripRetentionProtocolMarkers(stripMemoryTags(msg.content)).trim();
213
+ if (!hasSubstantiveContent(content)) continue;
214
+ parts.push(content);
215
+ }
216
+
217
+ if (parts.length === 0) return { transcript: null, messageCount: 0 };
218
+
219
+ const transcript = parts.join("\n\n");
220
+ if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
221
+
222
+ return { transcript, messageCount: parts.length };
223
+ }
224
+
225
+ /** Remove retention framing lines from a stored coding-agent episode transcript. */
226
+ export function stripRetentionProtocolMarkers(content: string): string {
227
+ return content
228
+ .split(/\r?\n/)
229
+ .filter(line => !RETENTION_PROTOCOL_MARKER_REGEX.test(line.trim()))
230
+ .join("\n")
231
+ .replace(/\n{3,}/g, "\n\n")
232
+ .trim();
233
+ }
234
+
208
235
  export function prepareRetentionTranscript(
209
236
  messages: HindsightMessage[],
210
237
  retainFullWindow = false,
@@ -229,6 +256,10 @@ export function prepareRetentionTranscript(
229
256
  return formatRetentionMessages(targetMessages);
230
257
  }
231
258
 
259
+ /** Format all retention messages without protocol markers for embedding, FTS, and recall display. */
260
+ export function prepareEmbeddableRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
261
+ return formatEmbeddableRetentionMessages(messages);
262
+ }
232
263
  /** Format only user-authored messages for memory fact/entity extraction. */
233
264
  export function prepareUserRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
234
265
  return formatRetentionMessages(messages.filter(message => message.role === "user"));
@@ -15,75 +15,119 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
15
15
  import { artifactsDirsFromRegistry } from "./registry-helpers";
16
16
  import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
17
17
 
18
- export class ArtifactProtocolHandler implements ProtocolHandler {
19
- readonly scheme = "artifact";
20
- readonly immutable = true;
18
+ const MAX_INLINE_ARTIFACT_BYTES = 8 * 1024 * 1024;
21
19
 
22
- async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
23
- const id = url.rawHost || url.hostname;
24
- if (!id) {
25
- throw new Error("artifact:// URL requires a numeric ID: artifact://0");
20
+ /** Filesystem location for a session artifact, resolved without materializing its content. */
21
+ export interface ResolvedArtifactFile {
22
+ id: string;
23
+ path: string;
24
+ size: number;
25
+ }
26
+
27
+ function parseArtifactId(url: InternalUrl): string {
28
+ const id = url.rawHost || url.hostname;
29
+ if (!id) {
30
+ throw new Error("artifact:// URL requires a numeric ID: artifact://0");
31
+ }
32
+ if (!/^\d+$/.test(id)) {
33
+ throw new Error(`artifact:// ID must be numeric, got: ${id}`);
34
+ }
35
+ return id;
36
+ }
37
+
38
+ /** Resolve an `artifact://` URL to its backing file without reading artifact bytes. */
39
+ export async function resolveArtifactFile(url: InternalUrl, context?: ResolveContext): Promise<ResolvedArtifactFile> {
40
+ const id = parseArtifactId(url);
41
+
42
+ // Artifact ids are per-session counters; in multi-session hosts the same
43
+ // id exists in several dirs. Pin resolution to the calling session's
44
+ // artifacts dir first so `artifact://3` means *this* session's #3.
45
+ const dirs = artifactsDirsFromRegistry();
46
+ const pinnedDir = context?.localProtocolOptions?.getArtifactsDir?.() ?? null;
47
+ if (pinnedDir) {
48
+ const pinnedIndex = dirs.indexOf(pinnedDir);
49
+ if (pinnedIndex >= 0) dirs.splice(pinnedIndex, 1);
50
+ dirs.unshift(pinnedDir);
51
+ }
52
+
53
+ if (dirs.length === 0) {
54
+ throw new Error("No session - artifacts unavailable");
55
+ }
56
+
57
+ let foundPath: string | undefined;
58
+ let anyDirExists = false;
59
+ const availableIds = new Set<string>();
60
+
61
+ for (const dir of dirs) {
62
+ let files: string[];
63
+ try {
64
+ files = await fs.readdir(dir);
65
+ anyDirExists = true;
66
+ } catch (err) {
67
+ if (isEnoent(err)) continue;
68
+ throw err;
26
69
  }
27
- if (!/^\d+$/.test(id)) {
28
- throw new Error(`artifact:// ID must be numeric, got: ${id}`);
70
+ const match = files.find(f => f.startsWith(`${id}.`));
71
+ if (match) {
72
+ foundPath = path.join(dir, match);
73
+ break;
29
74
  }
30
-
31
- // Artifact ids are per-session counters; in multi-session hosts the same
32
- // id exists in several dirs. Pin resolution to the calling session's
33
- // artifacts dir first so `artifact://3` means *this* session's #3.
34
- const dirs = artifactsDirsFromRegistry();
35
- const pinnedDir = context?.localProtocolOptions?.getArtifactsDir?.() ?? null;
36
- if (pinnedDir) {
37
- const pinnedIndex = dirs.indexOf(pinnedDir);
38
- if (pinnedIndex >= 0) dirs.splice(pinnedIndex, 1);
39
- dirs.unshift(pinnedDir);
75
+ for (const f of files) {
76
+ const m = f.match(/^(\d+)\./);
77
+ if (m) availableIds.add(m[1]);
40
78
  }
79
+ }
41
80
 
42
- if (dirs.length === 0) {
43
- throw new Error("No session - artifacts unavailable");
44
- }
81
+ if (!anyDirExists) {
82
+ throw new Error("No artifacts directory found");
83
+ }
45
84
 
46
- let foundPath: string | undefined;
47
- let anyDirExists = false;
48
- const availableIds = new Set<string>();
85
+ if (!foundPath) {
86
+ const sorted = [...availableIds].sort((a, b) => Number(a) - Number(b));
87
+ const availableStr = sorted.length > 0 ? sorted.join(", ") : "none";
88
+ throw new Error(`Artifact ${id} not found. Available: ${availableStr}`);
89
+ }
49
90
 
50
- for (const dir of dirs) {
51
- let files: string[];
52
- try {
53
- files = await fs.readdir(dir);
54
- anyDirExists = true;
55
- } catch (err) {
56
- if (isEnoent(err)) continue;
57
- throw err;
58
- }
59
- const match = files.find(f => f.startsWith(`${id}.`));
60
- if (match) {
61
- foundPath = path.join(dir, match);
62
- break;
63
- }
64
- for (const f of files) {
65
- const m = f.match(/^(\d+)\./);
66
- if (m) availableIds.add(m[1]);
67
- }
68
- }
91
+ const stat = await Bun.file(foundPath).stat();
92
+ if (stat.isDirectory()) {
93
+ throw new Error(`Artifact ${id} resolved to a directory, not a file`);
94
+ }
95
+ return { id, path: foundPath, size: stat.size };
96
+ }
97
+
98
+ export class ArtifactProtocolHandler implements ProtocolHandler {
99
+ readonly scheme = "artifact";
100
+ readonly immutable = true;
101
+
102
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
103
+ const artifact = await resolveArtifactFile(url, context);
69
104
 
70
- if (!anyDirExists) {
71
- throw new Error("No artifacts directory found");
105
+ // Path-only callers (search/grep, bash URL expansion) never touch the
106
+ // artifact bytes. Return the resource shape so those flows keep working
107
+ // on artifacts of any size — only content materialization is gated.
108
+ if (context?.pathOnly) {
109
+ return {
110
+ url: url.href,
111
+ content: "",
112
+ contentType: "text/plain",
113
+ size: artifact.size,
114
+ sourcePath: artifact.path,
115
+ };
72
116
  }
73
117
 
74
- if (!foundPath) {
75
- const sorted = [...availableIds].sort((a, b) => Number(a) - Number(b));
76
- const availableStr = sorted.length > 0 ? sorted.join(", ") : "none";
77
- throw new Error(`Artifact ${id} not found. Available: ${availableStr}`);
118
+ if (artifact.size > MAX_INLINE_ARTIFACT_BYTES) {
119
+ throw new Error(
120
+ `Artifact ${artifact.id} is ${artifact.size} bytes; full internal resolution is blocked. Use read selectors such as artifact://${artifact.id}:1-3000 or artifact://${artifact.id}:raw:1-3000, and use the artifact file path for search/copy workflows: ${artifact.path}`,
121
+ );
78
122
  }
79
123
 
80
- const content = await Bun.file(foundPath).text();
124
+ const content = await Bun.file(artifact.path).text();
81
125
  return {
82
126
  url: url.href,
83
127
  content,
84
128
  contentType: "text/plain",
85
- size: Buffer.byteLength(content, "utf-8"),
86
- sourcePath: foundPath,
129
+ size: artifact.size,
130
+ sourcePath: artifact.path,
87
131
  };
88
132
  }
89
133
 
@@ -107,6 +107,16 @@ export interface ResolveContext {
107
107
  * reject directory resources, so they never need the listing.
108
108
  */
109
109
  skipDirectoryListing?: boolean;
110
+ /**
111
+ * When set, handlers that would otherwise materialize expensive content
112
+ * (e.g. reading a multi-MiB artifact into memory just to expose its
113
+ * `sourcePath`) may return the resource shape without content. Callers
114
+ * that only need `sourcePath` — search/grep, bash URL expansion — pass
115
+ * this so a large `artifact://` still resolves to its backing file
116
+ * without OOM risk. Handlers that cannot separate path from content
117
+ * ignore the flag.
118
+ */
119
+ pathOnly?: boolean;
110
120
  }
111
121
 
112
122
  /**
package/src/lsp/config.ts CHANGED
@@ -201,6 +201,21 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean {
201
201
  return false;
202
202
  }
203
203
 
204
+ /**
205
+ * Check whether any ancestor directory of a file is an LSP project root.
206
+ */
207
+ export function hasRootMarkerAncestor(filePath: string, markers: string[]): boolean {
208
+ if (markers.length === 0) return false;
209
+
210
+ let dir = path.dirname(path.resolve(filePath));
211
+ while (true) {
212
+ if (hasRootMarkers(dir, markers)) return true;
213
+ const parent = path.dirname(dir);
214
+ if (parent === dir) return false;
215
+ dir = parent;
216
+ }
217
+ }
218
+
204
219
  // =============================================================================
205
220
  // Local Binary Resolution
206
221
  // =============================================================================
package/src/lsp/edits.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
- import path from "node:path";
2
+ import * as path from "node:path";
3
3
  import { formatPathRelativeToCwd } from "../tools/path-utils";
4
4
  import { ToolError } from "../tools/tool-errors";
5
5
  import type {
@@ -48,6 +48,17 @@ export function applyTextEditsToString(content: string, edits: TextEdit[]): stri
48
48
  function comparePosition(a: Position, b: Position): number {
49
49
  return a.line === b.line ? a.character - b.character : a.line - b.line;
50
50
  }
51
+ function positionsEqual(a: Position, b: Position): boolean {
52
+ return a.line === b.line && a.character === b.character;
53
+ }
54
+
55
+ function rangesEqual(a: Range, b: Range): boolean {
56
+ return positionsEqual(a.start, b.start) && positionsEqual(a.end, b.end);
57
+ }
58
+
59
+ function isEmptyRange(range: Range): boolean {
60
+ return positionsEqual(range.start, range.end);
61
+ }
51
62
 
52
63
  function formatRange(range: Range): string {
53
64
  return `${range.start.line + 1}:${range.start.character + 1}-${range.end.line + 1}:${range.end.character + 1}`;
@@ -63,6 +74,8 @@ export function rangesOverlap(a: Range, b: Range): boolean {
63
74
  * Equal start positions tiebreak by original array index descending so that,
64
75
  * applied bottom-up, inserts at the same position land in array order
65
76
  * (LSP spec: the order of edits in the array defines the order in the result).
77
+ * Byte-identical non-empty range edits are idempotent, so duplicate server
78
+ * output is collapsed before overlap validation.
66
79
  */
67
80
  export function sortAndValidateTextEdits(edits: TextEdit[]): TextEdit[] {
68
81
  const sorted = edits
@@ -77,21 +90,29 @@ export function sortAndValidateTextEdits(edits: TextEdit[]): TextEdit[] {
77
90
  return b.index - a.index;
78
91
  })
79
92
  .map(entry => entry.edit);
93
+ const unique: TextEdit[] = [];
94
+ for (const edit of sorted) {
95
+ const prev = unique[unique.length - 1];
96
+ if (prev && !isEmptyRange(edit.range) && rangesEqual(prev.range, edit.range) && prev.newText === edit.newText) {
97
+ continue;
98
+ }
99
+ unique.push(edit);
100
+ }
80
101
 
81
102
  // Detect overlapping ranges: in reverse-sorted order, each edit's start
82
103
  // must be >= the next edit's end. If not, the edits would clobber each other
83
- // once applied bottom-up (typically a multi-server rename with stale positions).
84
- for (let i = 0; i < sorted.length - 1; i++) {
85
- const later = sorted[i].range;
86
- const earlier = sorted[i + 1].range;
104
+ // once applied bottom-up.
105
+ for (let i = 0; i < unique.length - 1; i++) {
106
+ const later = unique[i].range;
107
+ const earlier = unique[i + 1].range;
87
108
  if (comparePosition(earlier.end, later.start) > 0) {
88
109
  throw new ToolError(
89
- `overlapping LSP edits: ${formatRange(earlier)} conflicts with ${formatRange(later)}; multi-server rename produced inconsistent edits`,
110
+ `overlapping LSP edits: ${formatRange(earlier)} conflicts with ${formatRange(later)}; LSP produced inconsistent edits`,
90
111
  );
91
112
  }
92
113
  }
93
114
 
94
- return sorted;
115
+ return unique;
95
116
  }
96
117
 
97
118
  /**
package/src/lsp/index.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  waitForProjectLoaded,
32
32
  } from "./client";
33
33
  import { getLinterClient } from "./clients";
34
- import { getServersForFile, type LspConfig, loadConfig } from "./config";
34
+ import { getServersForFile, hasRootMarkerAncestor, type LspConfig, loadConfig } from "./config";
35
35
  import {
36
36
  applyTextEdits,
37
37
  applyTextEditsToString,
@@ -357,6 +357,41 @@ function limitDiagnosticMessages(messages: string[]): string[] {
357
357
  return messages.slice(0, DIAGNOSTIC_MESSAGE_LIMIT);
358
358
  }
359
359
 
360
+ const ORPHAN_TYPESCRIPT_PROJECT_DIAGNOSTIC_CODES: Record<number, true> = {
361
+ 1375: true,
362
+ 1378: true,
363
+ 2307: true,
364
+ 2580: true,
365
+ 2591: true,
366
+ 2792: true,
367
+ 2867: true,
368
+ };
369
+
370
+ function diagnosticCodeNumber(diagnostic: Diagnostic): number | null {
371
+ if (typeof diagnostic.code === "number") return diagnostic.code;
372
+ if (typeof diagnostic.code === "string" && /^\d+$/.test(diagnostic.code)) return Number(diagnostic.code);
373
+ return null;
374
+ }
375
+ function isTypeScriptProjectDiagnostic(serverName: string, diagnostic: Diagnostic): boolean {
376
+ if (diagnostic.source !== "typescript" && !serverName.toLowerCase().includes("typescript")) {
377
+ return false;
378
+ }
379
+ const code = diagnosticCodeNumber(diagnostic);
380
+ return code !== null && ORPHAN_TYPESCRIPT_PROJECT_DIAGNOSTIC_CODES[code] === true;
381
+ }
382
+
383
+ function filterOrphanProjectDiagnostics(
384
+ absolutePath: string,
385
+ serverName: string,
386
+ serverConfig: ServerConfig,
387
+ diagnostics: Diagnostic[],
388
+ ): Diagnostic[] {
389
+ if (!serverConfig.rootMarkers.length || hasRootMarkerAncestor(absolutePath, serverConfig.rootMarkers)) {
390
+ return diagnostics;
391
+ }
392
+ return diagnostics.filter(diagnostic => !isTypeScriptProjectDiagnostic(serverName, diagnostic));
393
+ }
394
+
360
395
  const LOCATION_CONTEXT_LINES = 1;
361
396
  const REFERENCE_CONTEXT_LIMIT = 50;
362
397
 
@@ -709,7 +744,7 @@ async function getDiagnosticsForFile(
709
744
  if (serverConfig.createClient) {
710
745
  const linterClient = getLinterClient(serverName, serverConfig, cwd);
711
746
  const diagnostics = await linterClient.lint(absolutePath);
712
- return { serverName, diagnostics };
747
+ return { serverName, serverConfig, diagnostics };
713
748
  }
714
749
 
715
750
  // Default: use LSP
@@ -728,14 +763,21 @@ async function getDiagnosticsForFile(
728
763
  minVersion,
729
764
  expectedDocumentVersion,
730
765
  });
731
- return { serverName, diagnostics };
766
+ return { serverName, serverConfig, diagnostics };
732
767
  }),
733
768
  );
734
769
 
735
770
  for (const result of results) {
736
771
  if (result.status === "fulfilled") {
737
772
  serverNames.push(result.value.serverName);
738
- allDiagnostics.push(...result.value.diagnostics);
773
+ allDiagnostics.push(
774
+ ...filterOrphanProjectDiagnostics(
775
+ absolutePath,
776
+ result.value.serverName,
777
+ result.value.serverConfig,
778
+ result.value.diagnostics,
779
+ ),
780
+ );
739
781
  }
740
782
  }
741
783