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

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 (55) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/config/settings-schema.d.ts +15 -7
  3. package/dist/types/edit/streaming.d.ts +7 -0
  4. package/dist/types/hashline/hash.d.ts +4 -4
  5. package/dist/types/hashline/recovery.d.ts +5 -0
  6. package/dist/types/lsp/edits.d.ts +8 -1
  7. package/dist/types/session/agent-session.d.ts +16 -0
  8. package/dist/types/session/client-bridge.d.ts +1 -0
  9. package/dist/types/tools/find.d.ts +4 -0
  10. package/dist/types/tools/resolve.d.ts +5 -0
  11. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  12. package/package.json +7 -7
  13. package/src/config/settings-schema.ts +22 -7
  14. package/src/dap/session.ts +58 -5
  15. package/src/edit/modes/patch.ts +46 -0
  16. package/src/edit/streaming.ts +145 -4
  17. package/src/eval/js/context-manager.ts +11 -7
  18. package/src/eval/js/shared/rewrite-imports.ts +21 -9
  19. package/src/eval/js/shared/runtime.ts +2 -1
  20. package/src/hashline/hash.ts +11 -8
  21. package/src/hashline/parser.ts +23 -6
  22. package/src/hashline/recovery.ts +44 -3
  23. package/src/lsp/edits.ts +92 -38
  24. package/src/lsp/index.ts +110 -7
  25. package/src/lsp/utils.ts +13 -0
  26. package/src/modes/acp/acp-client-bridge.ts +1 -0
  27. package/src/modes/components/status-line/segments.ts +1 -1
  28. package/src/modes/components/tool-execution.ts +46 -1
  29. package/src/modes/interactive-mode.ts +33 -7
  30. package/src/prompts/tools/bash.md +14 -0
  31. package/src/prompts/tools/debug.md +4 -1
  32. package/src/prompts/tools/find.md +10 -0
  33. package/src/prompts/tools/hashline.md +5 -3
  34. package/src/prompts/tools/resolve.md +1 -1
  35. package/src/prompts/tools/search.md +2 -1
  36. package/src/prompts/tools/task.md +4 -0
  37. package/src/prompts/tools/todo-write.md +2 -0
  38. package/src/session/agent-session.ts +116 -8
  39. package/src/session/client-bridge.ts +1 -0
  40. package/src/slash-commands/builtin-registry.ts +1 -1
  41. package/src/task/index.ts +33 -5
  42. package/src/task/render.ts +4 -1
  43. package/src/tools/browser/tab-supervisor.ts +23 -3
  44. package/src/tools/browser/tab-worker.ts +4 -2
  45. package/src/tools/browser.ts +1 -1
  46. package/src/tools/debug.ts +19 -2
  47. package/src/tools/find.ts +80 -24
  48. package/src/tools/read.ts +3 -6
  49. package/src/tools/resolve.ts +54 -22
  50. package/src/tools/search.ts +31 -0
  51. package/src/tools/todo-write.ts +11 -4
  52. package/src/tools/tool-timeouts.ts +1 -1
  53. package/src/utils/tools-manager.ts +29 -22
  54. package/src/web/search/providers/codex.ts +3 -0
  55. package/src/web/search/providers/perplexity.ts +24 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.8] - 2026-05-20
6
+
7
+ ### Fixed
8
+
9
+ - Fixed streaming edit previews for `apply_patch` and `hashline` jittering as the model typed `+added` lines. Two root causes addressed: (1) the trailing partial line of the streaming text input is now trimmed at each tick so a half-typed `+added` line no longer flickers; (2) the preview is rendered in the model's input order during streaming instead of re-deriving a unified diff via `Diff.structuredPatch`, whose coalescing previously reshuffled existing `+added` lines downward each time a new `-removed` line arrived. Existing additions now stay put and the preview only grows at the bottom while streaming. A residual trailing `-removed`/hunk-header block whose matching `+added` companion has not yet arrived is also suppressed until the additions land.
10
+ - Fixed Perplexity web search appearing "logged out" roughly an hour after `omp auth login perplexity`. The search provider's `findOAuthToken` was honoring the bogus `expires = login_time + 1h` written by older logins (Perplexity JWTs typically omit `exp` because sessions are server-side) and silently dropping the credential. The loader now decodes the JWT's `exp` claim directly and only skips when the JWT itself is expired; tokens without an `exp` claim are treated as non-expiring.
11
+
12
+ ## [15.1.7] - 2026-05-19
13
+
14
+ ### Fixed
15
+
16
+ - 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))
17
+ - 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))
18
+ - 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))
19
+ - 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.
20
+
21
+ ### Added
22
+
23
+ - 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.
24
+
25
+ ### Changed
26
+
27
+ - 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.
28
+
5
29
  ## [15.1.6] - 2026-05-19
6
30
 
7
31
  ### 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
  };
@@ -26,6 +26,13 @@ export interface StreamingDiffContext {
26
26
  fuzzyThreshold?: number;
27
27
  allowFuzzy?: boolean;
28
28
  hashlineAutoDropPureInsertDuplicates?: boolean;
29
+ /**
30
+ * True while the tool's arguments are still streaming in. Strategies that
31
+ * accept free-form text input (apply_patch, hashline) trim the trailing
32
+ * partial line so per-character growth of an in-flight `+added` line does
33
+ * not flicker in the preview.
34
+ */
35
+ isStreaming?: boolean;
29
36
  }
30
37
  export interface EditStreamingStrategy<Args = unknown> {
31
38
  /**
@@ -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.8",
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.8",
51
+ "@oh-my-pi/pi-agent-core": "15.1.8",
52
+ "@oh-my-pi/pi-ai": "15.1.8",
53
+ "@oh-my-pi/pi-natives": "15.1.8",
54
+ "@oh-my-pi/pi-tui": "15.1.8",
55
+ "@oh-my-pi/pi-utils": "15.1.8",
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") {
@@ -45,6 +45,13 @@ export interface StreamingDiffContext {
45
45
  fuzzyThreshold?: number;
46
46
  allowFuzzy?: boolean;
47
47
  hashlineAutoDropPureInsertDuplicates?: boolean;
48
+ /**
49
+ * True while the tool's arguments are still streaming in. Strategies that
50
+ * accept free-form text input (apply_patch, hashline) trim the trailing
51
+ * partial line so per-character growth of an in-flight `+added` line does
52
+ * not flicker in the preview.
53
+ */
54
+ isStreaming?: boolean;
48
55
  }
49
56
 
50
57
  export interface EditStreamingStrategy<Args = unknown> {
@@ -274,21 +281,146 @@ interface HashlineArgs {
274
281
  __partialJson?: string;
275
282
  }
276
283
 
284
+ /**
285
+ * While streaming a free-form text payload (apply_patch envelope, hashline
286
+ * input), trim the trailing partial line so per-character growth of an
287
+ * in-flight `+added` line does not cause the diff preview to flicker. The
288
+ * full line will show on the next streaming tick once its `\n` arrives.
289
+ * Returns `text` unchanged when not streaming or when no newline is present.
290
+ */
291
+ function trimTrailingPartialLine(text: string, isStreaming: boolean | undefined): string {
292
+ if (!isStreaming) return text;
293
+ const idx = text.lastIndexOf("\n");
294
+ if (idx === -1) return "";
295
+ return text.slice(0, idx + 1);
296
+ }
297
+
298
+ /**
299
+ * Build a per-file diff preview directly from a partial `apply_patch`
300
+ * envelope by emitting its body lines in *input order*. This bypasses the
301
+ * file-state re-diff (`computePatchDiff` → `Diff.structuredPatch`) whose
302
+ * coalescing reorders the model's `-old +new -old +new` stream into
303
+ * `-old -old +new +new` and visibly shifts existing `+added` lines
304
+ * downward each time a new `-` arrives. The preview therefore grows
305
+ * monotonically at the bottom while streaming and only becomes a real
306
+ * unified diff once the args are complete.
307
+ */
308
+ function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[] | null {
309
+ const lines = input.split("\n");
310
+ const groups = new Map<string, string[]>();
311
+ let currentPath: string | undefined;
312
+ const ensure = (path: string): string[] => {
313
+ let bucket = groups.get(path);
314
+ if (!bucket) {
315
+ bucket = [];
316
+ groups.set(path, bucket);
317
+ }
318
+ return bucket;
319
+ };
320
+ for (const raw of lines) {
321
+ const trimmedEnd = raw.trimEnd();
322
+ if (trimmedEnd === BEGIN_PATCH_MARKER || trimmedEnd === END_PATCH_MARKER || trimmedEnd === ABORT_MARKER) {
323
+ continue;
324
+ }
325
+ if (trimmedEnd.startsWith("*** Add File: ")) {
326
+ currentPath = trimmedEnd.slice("*** Add File: ".length);
327
+ ensure(currentPath);
328
+ continue;
329
+ }
330
+ if (trimmedEnd.startsWith("*** Delete File: ")) {
331
+ currentPath = trimmedEnd.slice("*** Delete File: ".length);
332
+ ensure(currentPath);
333
+ continue;
334
+ }
335
+ if (trimmedEnd.startsWith("*** Update File: ")) {
336
+ currentPath = trimmedEnd.slice("*** Update File: ".length);
337
+ ensure(currentPath);
338
+ continue;
339
+ }
340
+ if (trimmedEnd.startsWith("*** Move to:") || trimmedEnd.startsWith("*** End of File")) {
341
+ continue;
342
+ }
343
+ if (!currentPath) continue;
344
+ // Diff body: keep `-/+/space`-prefixed lines and `@@` hunk headers in
345
+ // input order. parseDiffLine accepts the no-line-number legacy form so
346
+ // the renderer styles them as additions/removals/context naturally.
347
+ if (raw.startsWith("+") || raw.startsWith("-") || raw.startsWith(" ") || raw.startsWith("@@")) {
348
+ ensure(currentPath).push(raw);
349
+ }
350
+ }
351
+ if (groups.size === 0) return null;
352
+ const previews: PerFileDiffPreview[] = [];
353
+ for (const [path, body] of groups) {
354
+ if (body.length === 0) continue;
355
+ previews.push({ path, diff: body.join("\n") });
356
+ }
357
+ return previews.length > 0 ? previews : null;
358
+ }
359
+
360
+ /**
361
+ * Hashline equivalent: emit each section's `~payload` lines as `+added`
362
+ * lines in the order the model typed them. We deliberately omit op headers
363
+ * and removal targets from the streaming preview because their content
364
+ * lives in the file and would require a costly re-apply per tick; the
365
+ * complete unified diff is shown once streaming finishes.
366
+ */
367
+ function buildHashlineNaturalOrderPreviews(
368
+ input: string,
369
+ defaultPath: string | undefined,
370
+ ): PerFileDiffPreview[] | null {
371
+ const lines = input.split("\n");
372
+ const groups = new Map<string, string[]>();
373
+ let currentPath = defaultPath ?? "";
374
+ const ensure = (path: string): string[] => {
375
+ let bucket = groups.get(path);
376
+ if (!bucket) {
377
+ bucket = [];
378
+ groups.set(path, bucket);
379
+ }
380
+ return bucket;
381
+ };
382
+ for (const raw of lines) {
383
+ if (isHashlineEnvelopeMarkerLine(raw)) continue;
384
+ if (isHashlineHeaderLine(raw)) {
385
+ currentPath = raw.trimEnd().slice(1).trim();
386
+ if (currentPath) ensure(currentPath);
387
+ continue;
388
+ }
389
+ if (raw.startsWith("~")) {
390
+ ensure(currentPath).push(`+${raw.slice(1)}`);
391
+ }
392
+ }
393
+ if (groups.size === 0) return null;
394
+ const previews: PerFileDiffPreview[] = [];
395
+ for (const [path, body] of groups) {
396
+ if (body.length === 0) continue;
397
+ previews.push({ path, diff: body.join("\n") });
398
+ }
399
+ return previews.length > 0 ? previews : null;
400
+ }
401
+
277
402
  const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
278
403
  extractCompleteEdits(args) {
279
404
  return args;
280
405
  },
281
406
  async computeDiffPreview(args, ctx) {
282
407
  if (typeof args.input !== "string" || args.input.length === 0) return null;
408
+ const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
409
+ if (input.length === 0) return null;
410
+ if (ctx.isStreaming) {
411
+ // Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
412
+ // reordering by showing the model's `~payload` lines in input order.
413
+ return buildHashlineNaturalOrderPreviews(input, args.path);
414
+ }
283
415
  ctx.signal.throwIfAborted();
284
416
 
285
417
  let sections: HashlineInputSection[];
286
418
  try {
287
- sections = splitHashlineInputs(args.input, { cwd: ctx.cwd, path: args.path });
419
+ sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
288
420
  } catch {
289
421
  // Single-section fallback keeps the original error rendering for the
290
422
  // "haven't typed `@@ PATH` yet" case.
291
- const result = await computeHashlineDiff({ input: args.input, path: args.path }, ctx.cwd, {
423
+ const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
292
424
  autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
293
425
  });
294
426
  ctx.signal.throwIfAborted();
@@ -340,12 +472,21 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
340
472
  },
341
473
  async computeDiffPreview(args, ctx) {
342
474
  if (typeof args.input !== "string" || args.input.length === 0) return null;
475
+ const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
476
+ if (input.length === 0) return null;
477
+ if (ctx.isStreaming) {
478
+ // Render the envelope's diff body in input order so newly streamed
479
+ // `+added` lines append at the bottom instead of being shuffled
480
+ // upward as later `-removed` lines arrive and reorder the unified
481
+ // diff that `Diff.structuredPatch` would otherwise produce.
482
+ return buildApplyPatchNaturalOrderPreviews(input);
483
+ }
343
484
  let entries: ApplyPatchEntry[];
344
485
  try {
345
- entries = expandApplyPatchToEntries({ input: args.input });
486
+ entries = expandApplyPatchToEntries({ input });
346
487
  } catch {
347
488
  try {
348
- entries = expandApplyPatchToPreviewEntries({ input: args.input });
489
+ entries = expandApplyPatchToPreviewEntries({ input });
349
490
  } catch (err) {
350
491
  return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
351
492
  }