@oh-my-pi/pi-coding-agent 15.1.6 → 15.1.7

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 (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/types/config/settings-schema.d.ts +15 -7
  3. package/dist/types/hashline/hash.d.ts +4 -4
  4. package/dist/types/hashline/recovery.d.ts +5 -0
  5. package/dist/types/lsp/edits.d.ts +8 -1
  6. package/dist/types/session/agent-session.d.ts +16 -0
  7. package/dist/types/session/client-bridge.d.ts +1 -0
  8. package/dist/types/tools/find.d.ts +4 -0
  9. package/dist/types/tools/resolve.d.ts +5 -0
  10. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  11. package/package.json +7 -7
  12. package/src/config/settings-schema.ts +22 -7
  13. package/src/dap/session.ts +58 -5
  14. package/src/edit/modes/patch.ts +46 -0
  15. package/src/eval/js/context-manager.ts +11 -7
  16. package/src/eval/js/shared/rewrite-imports.ts +21 -9
  17. package/src/eval/js/shared/runtime.ts +2 -1
  18. package/src/hashline/hash.ts +11 -8
  19. package/src/hashline/parser.ts +23 -6
  20. package/src/hashline/recovery.ts +44 -3
  21. package/src/lsp/edits.ts +92 -38
  22. package/src/lsp/index.ts +110 -7
  23. package/src/lsp/utils.ts +13 -0
  24. package/src/modes/acp/acp-client-bridge.ts +1 -0
  25. package/src/modes/components/status-line/segments.ts +1 -1
  26. package/src/prompts/tools/bash.md +14 -0
  27. package/src/prompts/tools/debug.md +4 -1
  28. package/src/prompts/tools/find.md +10 -0
  29. package/src/prompts/tools/hashline.md +5 -3
  30. package/src/prompts/tools/resolve.md +1 -1
  31. package/src/prompts/tools/search.md +2 -1
  32. package/src/prompts/tools/task.md +4 -0
  33. package/src/prompts/tools/todo-write.md +2 -0
  34. package/src/session/agent-session.ts +116 -8
  35. package/src/session/client-bridge.ts +1 -0
  36. package/src/slash-commands/builtin-registry.ts +1 -1
  37. package/src/task/index.ts +33 -5
  38. package/src/task/render.ts +4 -1
  39. package/src/tools/browser/tab-supervisor.ts +23 -3
  40. package/src/tools/browser/tab-worker.ts +4 -2
  41. package/src/tools/browser.ts +1 -1
  42. package/src/tools/debug.ts +19 -2
  43. package/src/tools/find.ts +80 -24
  44. package/src/tools/read.ts +3 -6
  45. package/src/tools/resolve.ts +54 -22
  46. package/src/tools/search.ts +31 -0
  47. package/src/tools/todo-write.ts +11 -4
  48. package/src/tools/tool-timeouts.ts +1 -1
  49. package/src/utils/tools-manager.ts +29 -22
  50. package/src/web/search/providers/codex.ts +3 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.7] - 2026-05-19
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `debug` launch/attach failures so `configurationDone` no longer masks the underlying DAP launch error, early stop-outcome watchers cannot emit unhandled rejections, and directory-valued launch programs are rejected before adapter selection. ([#1187](https://github.com/can1357/oh-my-pi/issues/1187))
10
+ - Fixed hashline edit payloads that use a readability space after `~` by warning on separator-padding-shaped payload blocks and tightening the model prompt. ([#1166](https://github.com/can1357/oh-my-pi/issues/1166))
11
+ - Fixed ACP bash permission requests to include execute tool metadata and command content so clients can render command approval prompts consistently. ([#1189](https://github.com/can1357/oh-my-pi/issues/1189))
12
+ - Fixed the status-line fast-mode indicator (`⚡`) rendering for scoped service tiers (`openai-only`, `claude-only`) even when the active model's provider didn't realize them — e.g. `serviceTier: "openai-only"` would still show the indicator next to a Claude model the wire request couldn't apply fast mode to. The indicator now consults a new `AgentSession.isFastModeActive()` predicate that runs the configured tier through `resolveServiceTier(tier, model.provider)` and only lights up when the result is `"priority"` for the current model. `isFastModeEnabled()` keeps its scope-aware semantics so `/fast on|off|toggle` and `/fast status` continue to reflect the user's configured intent.
13
+
14
+ ### Added
15
+
16
+ - Added scoped service tier values to the `serviceTier` setting: `priority (OpenAI only)` and `priority (Claude only)`. They let you opt into premium processing on one provider family without paying premium costs on the other when switching models mid-session. `/fast on` continues to set the unscoped `"priority"` (active everywhere supported); `/fast status` and `isFastModeEnabled()` now report `on` for any scoped value too.
17
+
18
+ ### Changed
19
+
20
+ - Changed `/fast` to be a single provider-agnostic toggle: enabling the command sets `serviceTier: "priority"` for every provider, and the anthropic-messages provider translates `priority` into `speed: "fast"` plus the `fast-mode-2026-02-01` beta. Anthropic fast mode is currently supported on Claude Opus 4.6 and 4.7; the server rejects other models, which triggers the provider's auto-fallback (request retried without the priority signal, `providerSessionState.fastModeDisabled` persisted for the rest of the session). The session listens for the `"priority"` marker in `AssistantMessage.disabledFeatures`, syncs `/fast` off, and emits a warning notice. Re-running `/fast on` clears the per-session disable so the next request actually re-tries priority.
21
+
5
22
  ## [15.1.6] - 2026-05-19
6
23
 
7
24
  ### Fixed
@@ -884,12 +884,12 @@ export declare const SETTINGS_SCHEMA: {
884
884
  };
885
885
  readonly serviceTier: {
886
886
  readonly type: "enum";
887
- readonly values: readonly ["none", "auto", "default", "flex", "scale", "priority"];
887
+ readonly values: readonly ["none", "auto", "default", "flex", "scale", "priority", "openai-only", "claude-only"];
888
888
  readonly default: "none";
889
889
  readonly ui: {
890
890
  readonly tab: "model";
891
891
  readonly label: "Service Tier";
892
- readonly description: "OpenAI processing priority (none = omit parameter)";
892
+ readonly description: 'Processing priority hint (none = omit). OpenAI accepts the tier values directly; Anthropic realizes `priority` as `speed: "fast"` on supported Opus models. Scoped values target one family.';
893
893
  readonly options: readonly [{
894
894
  readonly value: "none";
895
895
  readonly label: "None";
@@ -897,23 +897,31 @@ export declare const SETTINGS_SCHEMA: {
897
897
  }, {
898
898
  readonly value: "auto";
899
899
  readonly label: "Auto";
900
- readonly description: "Use provider default tier selection";
900
+ readonly description: "Use provider default tier selection (OpenAI)";
901
901
  }, {
902
902
  readonly value: "default";
903
903
  readonly label: "Default";
904
- readonly description: "Standard priority processing";
904
+ readonly description: "Standard priority processing (OpenAI)";
905
905
  }, {
906
906
  readonly value: "flex";
907
907
  readonly label: "Flex";
908
- readonly description: "Use flexible capacity tier when available";
908
+ readonly description: "Flexible capacity tier when available (OpenAI)";
909
909
  }, {
910
910
  readonly value: "scale";
911
911
  readonly label: "Scale";
912
- readonly description: "Use Scale Tier credits when available";
912
+ readonly description: "Scale Tier credits when available (OpenAI)";
913
913
  }, {
914
914
  readonly value: "priority";
915
915
  readonly label: "Priority";
916
- readonly description: "Use Priority processing";
916
+ readonly description: "Priority on every supported provider (OpenAI `service_tier`, Anthropic fast mode)";
917
+ }, {
918
+ readonly value: "openai-only";
919
+ readonly label: "Priority (OpenAI only)";
920
+ readonly description: "Priority on OpenAI/OpenAI-Codex requests; ignored elsewhere";
921
+ }, {
922
+ readonly value: "claude-only";
923
+ readonly label: "Priority (Claude only)";
924
+ readonly description: "Anthropic fast mode on direct Claude requests; ignored elsewhere (incl. Bedrock/Vertex)";
917
925
  }];
918
926
  };
919
927
  };
@@ -111,10 +111,10 @@ export declare const HL_BODY_SEP = "|";
111
111
  export declare const HL_BODY_SEP_RE_RAW: string;
112
112
  /**
113
113
  * Compute a 2-character hash of a single line via xxHash32 mod 647 over
114
- * {@link HL_BIGRAMS}. Lines with no letter or digit mix the line number
115
- * into the seed so adjacent identical punctuation-only lines (e.g. brace-only
116
- * lines) get distinct hashes; lines with significant content stay
117
- * line-number-independent so a line is identifiable across small shifts.
114
+ * {@link HL_BIGRAMS}. The hash depends only on the line's content (after
115
+ * stripping CR and trailing whitespace); the `idx` parameter is accepted
116
+ * for call-site symmetry with line numbers but is intentionally unused so
117
+ * that anchors remain stable across line shifts caused by sibling edits.
118
118
  *
119
119
  * The line input should not include a trailing newline.
120
120
  */
@@ -18,5 +18,10 @@ export interface HashlineRecoveryResult {
18
18
  * onto the current on-disk content. Returns `null` when no recovery is
19
19
  * possible — callers should propagate the original mismatch error in that
20
20
  * case.
21
+ *
22
+ * Recovery is gated on a strict precondition: every line the model anchored
23
+ * MUST be present in the cached snapshot AND its content MUST hash to the
24
+ * model-supplied hash. This prevents 3-way merges from silently sliding onto
25
+ * the wrong site when only tangential parts of the file went stale.
21
26
  */
22
27
  export declare function tryRecoverHashlineWithCache(args: HashlineRecoveryArgs): HashlineRecoveryResult | null;
@@ -1,9 +1,16 @@
1
- import type { TextEdit, WorkspaceEdit } from "./types";
1
+ import type { Range, TextEdit, WorkspaceEdit } from "./types";
2
2
  /**
3
3
  * Apply text edits to a string in-memory.
4
4
  * Edits are applied in reverse order (bottom-to-top) to preserve line/character indices.
5
5
  */
6
6
  export declare function applyTextEditsToString(content: string, edits: TextEdit[]): string;
7
+ /** True when two ranges overlap (share any position other than a touching boundary). */
8
+ export declare function rangesOverlap(a: Range, b: Range): boolean;
9
+ /**
10
+ * Flatten a WorkspaceEdit's text edits into a Map<uri, TextEdit[]>.
11
+ * Resource operations (create/rename/delete) are ignored — callers handle them separately.
12
+ */
13
+ export declare function flattenWorkspaceTextEdits(edit: WorkspaceEdit): Map<string, TextEdit[]>;
7
14
  /**
8
15
  * Apply text edits to a file.
9
16
  * Edits are applied in reverse order (bottom-to-top) to preserve line/character indices.
@@ -618,7 +618,23 @@ export declare class AgentSession {
618
618
  * @returns New level, or undefined if model doesn't support thinking
619
619
  */
620
620
  cycleThinkingLevel(): ThinkingLevel | undefined;
621
+ /**
622
+ * True when *any* fast-mode-granting service tier is configured, regardless
623
+ * of whether the active model's provider actually realizes it. Used by the
624
+ * toggle (`/fast on|off`) so re-toggling a scoped tier (`openai-only`,
625
+ * `claude-only`) doesn't silently broaden it to unscoped `priority`.
626
+ *
627
+ * For "is fast mode actually applied to the next request?" use
628
+ * {@link isFastModeActive} instead — that one respects the model's provider.
629
+ */
621
630
  isFastModeEnabled(): boolean;
631
+ /**
632
+ * True when the configured `serviceTier` resolves to `"priority"` for the
633
+ * *currently selected model's provider*. Returns false for scoped tiers
634
+ * that don't match (e.g. `"openai-only"` on an anthropic model) and when
635
+ * no model is selected.
636
+ */
637
+ isFastModeActive(): boolean;
622
638
  setServiceTier(serviceTier: ServiceTier | undefined): void;
623
639
  setFastMode(enabled: boolean): void;
624
640
  toggleFastMode(): boolean;
@@ -25,6 +25,7 @@ export interface ClientBridgePermissionToolCall {
25
25
  kind?: string;
26
26
  status?: "pending" | "in_progress" | "completed" | "failed";
27
27
  rawInput?: unknown;
28
+ content?: unknown[];
28
29
  locations?: {
29
30
  path: string;
30
31
  line?: number;
@@ -9,7 +9,9 @@ import { type OutputMeta } from "./output-meta";
9
9
  declare const findSchema: z.ZodObject<{
10
10
  paths: z.ZodArray<z.ZodString>;
11
11
  hidden: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
12
+ gitignore: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
12
13
  limit: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
14
+ timeout: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
13
15
  }, z.core.$strict>;
14
16
  export type FindToolInput = z.infer<typeof findSchema>;
15
17
  export interface FindToolDetails {
@@ -62,7 +64,9 @@ export declare class FindTool implements AgentTool<typeof findSchema, FindToolDe
62
64
  readonly parameters: z.ZodObject<{
63
65
  paths: z.ZodArray<z.ZodString>;
64
66
  hidden: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
67
+ gitignore: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
65
68
  limit: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
69
+ timeout: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
66
70
  }, z.core.$strict>;
67
71
  readonly strict = true;
68
72
  constructor(session: ToolSession, options?: FindToolOptions);
@@ -49,6 +49,11 @@ export declare function runResolveInvocation(params: ResolveParams, options: {
49
49
  label: string;
50
50
  apply(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown>>;
51
51
  reject?(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown> | undefined>;
52
+ /** Invoked synchronously when `apply()` throws, before the error is rethrown.
53
+ * The queued caller uses this to re-push the resolve directive so the
54
+ * pending preview survives a failed apply (e.g. overlapping ast_edit
55
+ * replacements) and the model can `discard` or fix-and-retry. */
56
+ onApplyError?(error: unknown): void;
52
57
  }): Promise<AgentToolResult<ResolveToolDetails>>;
53
58
  export declare class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolDetails> {
54
59
  private readonly session;
@@ -20,7 +20,7 @@ export declare const TOOL_TIMEOUTS: {
20
20
  readonly browser: {
21
21
  readonly default: 30;
22
22
  readonly min: 1;
23
- readonly max: 30;
23
+ readonly max: 300;
24
24
  };
25
25
  readonly ssh: {
26
26
  readonly default: 60;
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": "15.1.6",
4
+ "version": "15.1.7",
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",
@@ -47,12 +47,12 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/omp-stats": "15.1.6",
51
- "@oh-my-pi/pi-agent-core": "15.1.6",
52
- "@oh-my-pi/pi-ai": "15.1.6",
53
- "@oh-my-pi/pi-natives": "15.1.6",
54
- "@oh-my-pi/pi-tui": "15.1.6",
55
- "@oh-my-pi/pi-utils": "15.1.6",
50
+ "@oh-my-pi/omp-stats": "15.1.7",
51
+ "@oh-my-pi/pi-agent-core": "15.1.7",
52
+ "@oh-my-pi/pi-ai": "15.1.7",
53
+ "@oh-my-pi/pi-natives": "15.1.7",
54
+ "@oh-my-pi/pi-tui": "15.1.7",
55
+ "@oh-my-pi/pi-utils": "15.1.7",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -755,19 +755,34 @@ export const SETTINGS_SCHEMA = {
755
755
 
756
756
  serviceTier: {
757
757
  type: "enum",
758
- values: ["none", "auto", "default", "flex", "scale", "priority"] as const,
758
+ values: ["none", "auto", "default", "flex", "scale", "priority", "openai-only", "claude-only"] as const,
759
759
  default: "none",
760
760
  ui: {
761
761
  tab: "model",
762
762
  label: "Service Tier",
763
- description: "OpenAI processing priority (none = omit parameter)",
763
+ description:
764
+ 'Processing priority hint (none = omit). OpenAI accepts the tier values directly; Anthropic realizes `priority` as `speed: "fast"` on supported Opus models. Scoped values target one family.',
764
765
  options: [
765
766
  { value: "none", label: "None", description: "Omit service_tier parameter" },
766
- { value: "auto", label: "Auto", description: "Use provider default tier selection" },
767
- { value: "default", label: "Default", description: "Standard priority processing" },
768
- { value: "flex", label: "Flex", description: "Use flexible capacity tier when available" },
769
- { value: "scale", label: "Scale", description: "Use Scale Tier credits when available" },
770
- { value: "priority", label: "Priority", description: "Use Priority processing" },
767
+ { value: "auto", label: "Auto", description: "Use provider default tier selection (OpenAI)" },
768
+ { value: "default", label: "Default", description: "Standard priority processing (OpenAI)" },
769
+ { value: "flex", label: "Flex", description: "Flexible capacity tier when available (OpenAI)" },
770
+ { value: "scale", label: "Scale", description: "Scale Tier credits when available (OpenAI)" },
771
+ {
772
+ value: "priority",
773
+ label: "Priority",
774
+ description: "Priority on every supported provider (OpenAI `service_tier`, Anthropic fast mode)",
775
+ },
776
+ {
777
+ value: "openai-only",
778
+ label: "Priority (OpenAI only)",
779
+ description: "Priority on OpenAI/OpenAI-Codex requests; ignored elsewhere",
780
+ },
781
+ {
782
+ value: "claude-only",
783
+ label: "Priority (Claude only)",
784
+ description: "Anthropic fast mode on direct Claude requests; ignored elsewhere (incl. Bedrock/Vertex)",
785
+ },
771
786
  ],
772
787
  },
773
788
  },
@@ -105,6 +105,41 @@ function toErrorMessage(value: unknown): string {
105
105
  return String(value);
106
106
  }
107
107
 
108
+ interface DapStartRequestFailure {
109
+ rejected: boolean;
110
+ error?: unknown;
111
+ }
112
+
113
+ function trackDapStartRequest<T>(promise: Promise<T>, failure: DapStartRequestFailure): Promise<T> {
114
+ return promise.catch(error => {
115
+ failure.rejected = true;
116
+ failure.error = error;
117
+ throw error;
118
+ });
119
+ }
120
+
121
+ function combineDapStartErrors(command: "launch" | "attach", startError: unknown, configurationError: unknown): Error {
122
+ const startMessage = toErrorMessage(startError);
123
+ const configurationMessage = toErrorMessage(configurationError);
124
+ if (startMessage === configurationMessage) {
125
+ return startError instanceof Error ? startError : new Error(startMessage);
126
+ }
127
+ return new Error(
128
+ `DAP ${command} failed: ${startMessage}\nDAP configurationDone also failed: ${configurationMessage}`,
129
+ );
130
+ }
131
+
132
+ async function throwPreferredDapStartError(
133
+ command: "launch" | "attach",
134
+ startFailure: DapStartRequestFailure,
135
+ configurationError: unknown,
136
+ ): Promise<never> {
137
+ await Promise.resolve();
138
+ if (startFailure.rejected) {
139
+ throw combineDapStartErrors(command, startFailure.error, configurationError);
140
+ }
141
+ throw configurationError;
142
+ }
108
143
  function normalizePath(filePath: string): string {
109
144
  return path.resolve(filePath);
110
145
  }
@@ -209,12 +244,20 @@ export class DapSessionManager {
209
244
  // DAP spec: many adapters do not respond to launch until after
210
245
  // configurationDone. Fire launch, complete the config handshake,
211
246
  // then await the launch response.
212
- const launchPromise = client.sendRequest("launch", launchArguments, signal, timeoutMs);
247
+ const launchFailure: DapStartRequestFailure = { rejected: false };
248
+ const launchPromise = trackDapStartRequest(
249
+ client.sendRequest("launch", launchArguments, signal, timeoutMs),
250
+ launchFailure,
251
+ );
213
252
  // Mark handled so a fast error response doesn't become an unhandled
214
253
  // rejection while we await the config handshake. The actual error
215
254
  // still propagates when we await launchPromise below.
216
255
  launchPromise.catch(() => {});
217
- await this.#completeConfigurationHandshake(session, signal, timeoutMs);
256
+ try {
257
+ await this.#completeConfigurationHandshake(session, signal, timeoutMs);
258
+ } catch (error) {
259
+ await throwPreferredDapStartError("launch", launchFailure, error);
260
+ }
218
261
  await launchPromise;
219
262
  // Try to capture initial stopped state (e.g. stopOnEntry).
220
263
  // Timeout is acceptable — the program may simply be running.
@@ -262,9 +305,17 @@ export class DapSessionManager {
262
305
  signal,
263
306
  Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS),
264
307
  );
265
- const attachPromise = client.sendRequest("attach", attachArguments, signal, timeoutMs);
308
+ const attachFailure: DapStartRequestFailure = { rejected: false };
309
+ const attachPromise = trackDapStartRequest(
310
+ client.sendRequest("attach", attachArguments, signal, timeoutMs),
311
+ attachFailure,
312
+ );
266
313
  attachPromise.catch(() => {});
267
- await this.#completeConfigurationHandshake(session, signal, timeoutMs);
314
+ try {
315
+ await this.#completeConfigurationHandshake(session, signal, timeoutMs);
316
+ } catch (error) {
317
+ await throwPreferredDapStartError("attach", attachFailure, error);
318
+ }
268
319
  await attachPromise;
269
320
  try {
270
321
  await untilAborted(signal, initialStopPromise);
@@ -1085,7 +1136,9 @@ export class DapSessionManager {
1085
1136
  for (const p of promises) {
1086
1137
  p.catch(() => {});
1087
1138
  }
1088
- return Promise.race(promises);
1139
+ const outcome = Promise.race(promises);
1140
+ outcome.catch(() => {});
1141
+ return outcome;
1089
1142
  }
1090
1143
 
1091
1144
  /**
@@ -26,6 +26,7 @@ import {
26
26
  import { outputMeta } from "../../tools/output-meta";
27
27
  import { resolveToCwd } from "../../tools/path-utils";
28
28
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
29
+ import { ToolError } from "../../tools/tool-errors";
29
30
  import {
30
31
  ApplyPatchError,
31
32
  type DiffHunk,
@@ -1714,6 +1715,24 @@ export async function executePatchSingle(
1714
1715
 
1715
1716
  await assertEditableFile(resolvedPath, path);
1716
1717
 
1718
+ // Capture pre-edit content so we can verify the write actually hit disk.
1719
+ // `LspFileSystem.writeFile` delegates to a writethrough callback that, in
1720
+ // some host integrations, has been observed to report success without
1721
+ // persisting bytes — leaving the tool to claim "Updated <path>" while the
1722
+ // file on disk is byte-identical to before. After the write we re-read
1723
+ // the file and assert the bytes match the expected newContent; relying
1724
+ // on stat (mtime/size) is unreliable because filesystems with coarse
1725
+ // timestamp resolution can record an unchanged mtime even when the
1726
+ // content was rewritten, and same-length rewrites leave size unchanged.
1727
+ let preEditContent: Uint8Array | undefined;
1728
+ if (op === "update") {
1729
+ try {
1730
+ preEditContent = await fs.promises.readFile(resolvedPath);
1731
+ } catch (err) {
1732
+ if (!isEnoent(err)) throw err;
1733
+ }
1734
+ }
1735
+
1717
1736
  const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff };
1718
1737
  const patchFileSystem = new LspFileSystem(writethrough, signal, batchRequest, beginDeferredDiagnosticsForPath);
1719
1738
  const result = await applyPatch(input, {
@@ -1723,6 +1742,33 @@ export async function executePatchSingle(
1723
1742
  allowFuzzy,
1724
1743
  });
1725
1744
 
1745
+ // Post-write verification: only meaningful for in-place updates where the
1746
+ // patch actually changes content and the file is not being renamed away.
1747
+ if (
1748
+ result.change.type === "update" &&
1749
+ !result.change.newPath &&
1750
+ preEditContent !== undefined &&
1751
+ result.change.oldContent !== undefined &&
1752
+ result.change.newContent !== undefined &&
1753
+ result.change.oldContent !== result.change.newContent
1754
+ ) {
1755
+ let postEditContent: Uint8Array | undefined;
1756
+ try {
1757
+ postEditContent = await fs.promises.readFile(resolvedPath);
1758
+ } catch (err) {
1759
+ if (!isEnoent(err)) throw err;
1760
+ }
1761
+ const unchanged =
1762
+ postEditContent !== undefined &&
1763
+ postEditContent.length === preEditContent.length &&
1764
+ postEditContent.every((b, i) => b === preEditContent[i]);
1765
+ if (unchanged) {
1766
+ throw new ToolError(`edit appeared successful but file content did not change on disk: ${resolvedPath}`, {
1767
+ path: resolvedPath,
1768
+ });
1769
+ }
1770
+ }
1771
+
1726
1772
  if (resolvedRename) {
1727
1773
  invalidateFsScanAfterRename(resolvedPath, resolvedRename);
1728
1774
  } else if (result.change.type === "delete") {
@@ -52,7 +52,7 @@ interface JsSession {
52
52
  }
53
53
 
54
54
  const sessions = new Map<string, JsSession>();
55
- const READY_TIMEOUT_MS = 5_000;
55
+ const READY_TIMEOUT_MS_DEFAULT = 5_000;
56
56
 
57
57
  export async function executeInVmContext(options: {
58
58
  sessionKey: string;
@@ -68,10 +68,11 @@ export async function executeInVmContext(options: {
68
68
  if (options.reset) {
69
69
  await resetVmContext(options.sessionKey);
70
70
  }
71
- const session = await acquireSession(options.sessionKey, {
72
- cwd: options.cwd,
73
- sessionId: options.sessionId,
74
- });
71
+ const session = await acquireSession(
72
+ options.sessionKey,
73
+ { cwd: options.cwd, sessionId: options.sessionId },
74
+ options.timeoutMs,
75
+ );
75
76
  return await runQueued(session, () => runOnce(session, options));
76
77
  }
77
78
 
@@ -158,7 +159,7 @@ async function runOnce(
158
159
  }
159
160
  }
160
161
 
161
- async function acquireSession(sessionKey: string, snapshot: SessionSnapshot): Promise<JsSession> {
162
+ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, timeoutMs?: number): Promise<JsSession> {
162
163
  const existing = sessions.get(sessionKey);
163
164
  if (existing && existing.state === "alive") return existing;
164
165
 
@@ -186,7 +187,10 @@ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot): Pr
186
187
  handleSessionMessage(session, msg);
187
188
  });
188
189
  try {
189
- await raceWithTimeout(readyPromise, READY_TIMEOUT_MS, "Timed out initializing JS eval worker");
190
+ // Cold-start can exceed 5s on slow hosts. Let the caller's per-cell timeout dominate so
191
+ // users can grant more headroom when they raise `timeout` on a cell.
192
+ const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
193
+ await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
190
194
  } catch (error) {
191
195
  unsubscribe();
192
196
  await worker.terminate().catch(() => undefined);
@@ -303,15 +303,27 @@ function returnFinalExpression(code: string): { source: string; returned: boolea
303
303
  let lastIndex = body.length - 1;
304
304
  while (lastIndex >= 0 && body[lastIndex]?.type === "EmptyStatement") lastIndex--;
305
305
  const last = lastIndex >= 0 ? body[lastIndex] : undefined;
306
- if (last?.type !== "ExpressionStatement") return { source: code, returned: false };
307
-
308
- const expression = last as BabelExpressionStatement;
309
- const prefix = code.slice(0, expression.start);
310
- const statement = code.slice(expression.start, expression.end);
311
- const suffix = code.slice(expression.end);
312
- const semicolonMatch = statement.match(/;\s*$/);
313
- const trimmedStatement = semicolonMatch ? statement.slice(0, semicolonMatch.index) : statement;
314
- return { source: `${prefix}__omp_set_final_expr__((${trimmedStatement}));${suffix}`, returned: true };
306
+ if (last?.type === "ExpressionStatement") {
307
+ const expression = last as BabelExpressionStatement;
308
+ const prefix = code.slice(0, expression.start);
309
+ const statement = code.slice(expression.start, expression.end);
310
+ const suffix = code.slice(expression.end);
311
+ const semicolonMatch = statement.match(/;\s*$/);
312
+ const trimmedStatement = semicolonMatch ? statement.slice(0, semicolonMatch.index) : statement;
313
+ return { source: `${prefix}__omp_set_final_expr__((${trimmedStatement}));${suffix}`, returned: true };
314
+ }
315
+ if (last?.type === "ReturnStatement") {
316
+ // Top-level `return value;` is otherwise swallowed: it forces the cell into an async IIFE
317
+ // wrapper that discards the returned value. Rewrite into `__omp_set_final_expr__((expr))`
318
+ // so the runtime can surface the value to the caller just like a trailing expression.
319
+ const ret = last as unknown as { start: number; end: number; argument?: { start: number; end: number } | null };
320
+ if (!ret.argument) return { source: code, returned: false };
321
+ const prefix = code.slice(0, ret.start);
322
+ const suffix = code.slice(ret.end);
323
+ const expr = code.slice(ret.argument.start, ret.argument.end);
324
+ return { source: `${prefix}__omp_set_final_expr__((${expr}));${suffix}`, returned: true };
325
+ }
326
+ return { source: code, returned: false };
315
327
  }
316
328
 
317
329
  function isExecutionBoundary(type: string): boolean {
@@ -165,7 +165,8 @@ export class JsRuntime {
165
165
  const finalValue = this.#finalExpressionValue;
166
166
  this.#finalExpressionSet = false;
167
167
  this.#finalExpressionValue = undefined;
168
- return await awaitMaybePromise(finalValue);
168
+ const resolved = await awaitMaybePromise(finalValue);
169
+ return resolved;
169
170
  }
170
171
  return awaited;
171
172
  }
@@ -136,21 +136,24 @@ export const HL_BODY_SEP = "|";
136
136
  /** Regex-escaped form of {@link HL_BODY_SEP}, safe for embedding inside a regex. */
137
137
  export const HL_BODY_SEP_RE_RAW = regexEscape(HL_BODY_SEP);
138
138
 
139
- const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
140
-
141
139
  /**
142
140
  * Compute a 2-character hash of a single line via xxHash32 mod 647 over
143
- * {@link HL_BIGRAMS}. Lines with no letter or digit mix the line number
144
- * into the seed so adjacent identical punctuation-only lines (e.g. brace-only
145
- * lines) get distinct hashes; lines with significant content stay
146
- * line-number-independent so a line is identifiable across small shifts.
141
+ * {@link HL_BIGRAMS}. The hash depends only on the line's content (after
142
+ * stripping CR and trailing whitespace); the `idx` parameter is accepted
143
+ * for call-site symmetry with line numbers but is intentionally unused so
144
+ * that anchors remain stable across line shifts caused by sibling edits.
147
145
  *
148
146
  * The line input should not include a trailing newline.
149
147
  */
150
148
  export function computeLineHash(idx: number, line: string): string {
149
+ void idx;
151
150
  line = line.replace(/\r/g, "").trimEnd();
152
- const seed = RE_SIGNIFICANT.test(line) ? 0 : idx;
153
- return HL_BIGRAMS[Bun.hash.xxHash32(line, seed) % HL_BIGRAMS_COUNT];
151
+ // Seed is fixed so the hash depends only on line content. Earlier we mixed
152
+ // in `idx` for blank/punctuation-only lines, but that meant any line shift
153
+ // (e.g. from a sibling edit in the same batch) invalidated anchors whose
154
+ // content had not changed. Identical blank lines are intentionally allowed
155
+ // to collide — the edit op's line number disambiguates them.
156
+ return HL_BIGRAMS[Bun.hash.xxHash32(line, 0) % HL_BIGRAMS_COUNT];
154
157
  }
155
158
 
156
159
  /**
@@ -74,19 +74,29 @@ export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
74
74
  if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
75
75
  return cursor;
76
76
  }
77
+ /** Returns true when every non-empty payload line starts with `${sep} ` (sep + one space). */
78
+ function hasUniformSeparatorPadding(payload: string[]): boolean {
79
+ let any = false;
80
+ for (const text of payload) {
81
+ if (text.length === 0) continue;
82
+ if (!text.startsWith(" ")) return false;
83
+ any = true;
84
+ }
85
+ return any;
86
+ }
77
87
 
78
88
  function collectPayload(
79
89
  lines: string[],
80
90
  startIndex: number,
81
91
  opLineNum: number,
82
92
  requirePayload: boolean,
83
- ): { payload: string[]; nextIndex: number } {
93
+ ): { payload: string[]; nextIndex: number; paddingWarning?: string } {
84
94
  const payload: string[] = [];
85
95
  let index = startIndex;
86
96
  while (index < lines.length) {
87
97
  const line = lines[index];
88
98
  if (line.startsWith(HL_EDIT_SEP)) {
89
- payload.push(line.slice(1).trimEnd());
99
+ payload.push(line.slice(HL_EDIT_SEP.length).trimEnd());
90
100
  index++;
91
101
  continue;
92
102
  }
@@ -115,7 +125,11 @@ function collectPayload(
115
125
  if (payload.length === 0 && requirePayload) {
116
126
  throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
117
127
  }
118
- return { payload, nextIndex: index };
128
+ const paddingWarning = hasUniformSeparatorPadding(payload)
129
+ ? `line ${opLineNum}: all payload lines start with "${HL_EDIT_SEP} " (separator + space). ` +
130
+ `The space becomes file content. Remove it unless the target file requires leading spaces.`
131
+ : undefined;
132
+ return { payload, nextIndex: index, paddingWarning };
119
133
  }
120
134
 
121
135
  export function parseHashline(diff: string): HashlineEdit[] {
@@ -158,7 +172,8 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
158
172
  const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
159
173
  if (insertBeforeMatch) {
160
174
  const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
161
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
175
+ const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true);
176
+ if (paddingWarning) warnings.push(paddingWarning);
162
177
  for (const text of payload) pushInsert(cursor, text, lineNum);
163
178
  i = nextIndex;
164
179
  continue;
@@ -167,7 +182,8 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
167
182
  const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
168
183
  if (insertAfterMatch) {
169
184
  const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
170
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
185
+ const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true);
186
+ if (paddingWarning) warnings.push(paddingWarning);
171
187
  for (const text of payload) pushInsert(cursor, text, lineNum);
172
188
  i = nextIndex;
173
189
  continue;
@@ -185,7 +201,8 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
185
201
  const replaceMatch = REPLACE_OP_RE.exec(line);
186
202
  if (replaceMatch) {
187
203
  const range = parseRange(replaceMatch[1], lineNum);
188
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
204
+ const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, false);
205
+ if (paddingWarning) warnings.push(paddingWarning);
189
206
  // `= A..B` with no payload blanks the range to a single empty line.
190
207
  const replacement = payload.length === 0 ? [""] : payload;
191
208
  for (const text of replacement) {