@oh-my-pi/pi-coding-agent 15.3.0 → 15.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.3.2] - 2026-05-25
6
+ ### Added
7
+
8
+ - Added inline `|TEXT` payload support to `»` and `«` hashline insert operations, allowing single-line inserts on the op line and still supporting additional payload lines
9
+ - Added support for using inline payloads with BOF/EOF inserts so `|TEXT` is treated as inserted content at file boundaries
10
+ - Added live nested-`task` rendering: while a subagent is mid-flight, the parent UI now surfaces both completed nested `task` sub-calls and the in-flight nested snapshot (forwarded from `tool_execution_update`), matching the finished-result tree
11
+ - Added `omp auth-gateway check` (and matching `GET /v1/credentials/check` endpoint) — probes each broker-supplied credential against its provider's auth-verifying usage endpoint and prints per-credential health, so when a multi-account pool starts returning 401s you can identify which row in the broker is the bad one. The existing `/v1/usage` endpoint silently drops failed credentials, which is the wrong shape for diagnosing auth — the new endpoint captures errors and surfaces the credential's id, provider, type, email/accountId, and the upstream error string. CLI groups results per-provider, exits non-zero when any credential failed, and supports `--json` for scripting. The probe also exercises OAuth refresh on expired tokens, so a working refresh + working access reports as `ok` and a revoked refresh token reports as `oauth refresh failed: …` instead of being masked by the cached expired access token.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed parsing of inline `|TEXT` payloads containing whitespace on `»` and `«` inserts, which previously failed with unrecognized-op errors
16
+ - Fixed anchored insert handling so an inline `|TEXT` body matching the anchor line is treated as anchor decoration and no longer inserted as a duplicate
17
+
5
18
  ## [15.3.0] - 2026-05-25
6
19
 
7
20
  ### Added
@@ -9,6 +22,8 @@
9
22
  - Added `OMP_NO_WEBP` environment variable to disable WebP encoding in image resize, fixing HTTP 400 errors when attaching browser snapshots to vision models running on local llama.cpp (which uses STB library that lacks WebP support)
10
23
  - Fixed loop mode submitting the next prompt while a background async-job delivery turn (idle flush) was still pending, which could cause the job result to be silently dropped and make the session appear to keep firing while work was ongoing ([#1294](https://github.com/can1357/oh-my-pi/issues/1294))
11
24
  - Fixed clipboard image paste (Ctrl+V) silently failing on WSL2 by routing image reads through a `powershell.exe` bridge when WSL interop is detected, since `arboard` returns `ContentNotAvailable` under WSLg ([#1280](https://github.com/can1357/oh-my-pi/issues/1280))
25
+ - Fixed config-only marketplace LSP plugins such as `csharp-lsp` not registering servers with the CLI when the plugin cache has only marketplace metadata and no package code ([#1352](https://github.com/can1357/oh-my-pi/issues/1352)).
26
+ - Fixed JTD-to-JSON-Schema conversion treating user-named properties as nested JTD forms when their keys collided with JTD keywords like `ref`, which broke the built-in explore agent's output validator with `schema_violation: files.0.ref: must not be present` ([#1345](https://github.com/can1357/oh-my-pi/issues/1345))
12
27
  - Fixed extension `ctx.ui.notify()` messages emitted during `session_start` being cleared before the first interactive render ([#1316](https://github.com/can1357/oh-my-pi/issues/1316)).
13
28
  - Fixed append-only context mode not being recomputed after model switches — the mode was frozen at session construction time using the initial model's provider, so `provider.appendOnlyContext=auto` left append-only enabled after switching away from DeepSeek (or disabled after switching to DeepSeek) for the rest of the session
14
29
 
@@ -1,4 +1,4 @@
1
- export type AuthGatewayAction = "serve" | "token" | "status";
1
+ export type AuthGatewayAction = "serve" | "token" | "status" | "check";
2
2
  export interface AuthGatewayCommandArgs {
3
3
  action: AuthGatewayAction;
4
4
  flags: {
@@ -213,6 +213,32 @@ export declare const ModelsConfigFile: ConfigFile<{
213
213
  exclude?: string[] | undefined;
214
214
  } | undefined;
215
215
  }>;
216
+ /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */
217
+ interface ProviderOverride {
218
+ baseUrl?: string;
219
+ headers?: Record<string, string>;
220
+ apiKey?: string;
221
+ authHeader?: boolean;
222
+ compat?: Model<Api>["compat"];
223
+ transport?: Model<Api>["transport"];
224
+ }
225
+ /**
226
+ * Merge a freshly discovered model with the matching bundled/configured entry
227
+ * (or a runtime provider override when no bundled entry exists).
228
+ *
229
+ * `baseUrl` resolution priority:
230
+ * 1. User-set `providerOverride.baseUrl` (explicit override in models.json)
231
+ * 2. Discovered baseUrl (xiaomi `tp-` token-plan keys resolve to
232
+ * `token-plan-sgp.xiaomimimo.com` at discovery time)
233
+ * 3. Existing bundled baseUrl (the host baked into `models.json`)
234
+ *
235
+ * Without (1), the user's override would lose to discovery; without (2)
236
+ * preferred over (3), the bundled `api.xiaomimimo.com` would shadow the
237
+ * tp- token-plan host and produce 401s on the first stream call.
238
+ * See `xiaomi-tp-discovery-merge.test.ts` and the `refresh()` baseUrl-override
239
+ * regression in `model-registry.test.ts`.
240
+ */
241
+ export declare function mergeDiscoveredModel<TApi extends Api>(model: Model<TApi>, existing: Model<Api> | undefined, providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "transport">): Model<TApi>;
216
242
  export type ProviderDiscoveryStatus = "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated";
217
243
  export interface ProviderDiscoveryState {
218
244
  provider: string;
@@ -53,6 +53,12 @@ export declare class StatusLineComponent implements Component {
53
53
  watchBranch(onBranchChange: () => void): void;
54
54
  dispose(): void;
55
55
  invalidate(): void;
56
+ /**
57
+ * Background-refresh the Anthropic OAuth quota report. Guarded by a 5-min
58
+ * TTL on both success (cache lifetime) and error (backoff). Exposed
59
+ * (non-private) so unit tests can verify the backoff invariant.
60
+ */
61
+ refreshUsageInBackground(): void;
56
62
  /**
57
63
  * Compute the (cached) used-tokens / context-window totals for the
58
64
  * status-line context% segment. Exposed (non-private) so unit tests can
@@ -214,6 +214,16 @@ declare class RecentSessionInfo {
214
214
  /** Human-readable relative time (e.g., "2 hours ago") */
215
215
  get timeAgo(): string;
216
216
  }
217
+ /**
218
+ * Promote orphaned `<basename>.jsonl.<snowflake>.bak` backups created by
219
+ * `#replaceSessionFileAfterEperm` back to their primary path when the primary
220
+ * is missing. This runs once per session-dir scan, before the main `*.jsonl`
221
+ * glob, so a crash between the two renames in the EPERM-rewrite path does not
222
+ * leave the user's last good state stranded outside the loader's view.
223
+ *
224
+ * Exported for testing.
225
+ */
226
+ export declare function recoverOrphanedBackups(sessionDir: string, storage: SessionStorage): Promise<void>;
217
227
  /** Exported for testing */
218
228
  export declare function findMostRecentSession(sessionDir: string, storage?: SessionStorage): Promise<string | null>;
219
229
  /** Get recent sessions for display in welcome screen */
@@ -214,6 +214,14 @@ export interface AgentProgress {
214
214
  attempt: number;
215
215
  errorMessage: string;
216
216
  };
217
+ /**
218
+ * Snapshot of the most recent `task` tool call's in-flight `TaskToolDetails`,
219
+ * captured from `tool_execution_update`. Lets the parent UI surface live
220
+ * nested-subagent progress while this agent is still inside its own `task`
221
+ * call. Cleared when the call ends — finalized data lives in
222
+ * `extractedToolData.task` after that.
223
+ */
224
+ inflightTaskDetails?: TaskToolDetails;
217
225
  }
218
226
  /** Result from a single agent execution */
219
227
  export interface SingleResult {
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.3.0",
4
+ "version": "15.3.2",
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.3.0",
51
- "@oh-my-pi/pi-agent-core": "15.3.0",
52
- "@oh-my-pi/pi-ai": "15.3.0",
53
- "@oh-my-pi/pi-natives": "15.3.0",
54
- "@oh-my-pi/pi-tui": "15.3.0",
55
- "@oh-my-pi/pi-utils": "15.3.0",
50
+ "@oh-my-pi/omp-stats": "15.3.2",
51
+ "@oh-my-pi/pi-agent-core": "15.3.2",
52
+ "@oh-my-pi/pi-ai": "15.3.2",
53
+ "@oh-my-pi/pi-natives": "15.3.2",
54
+ "@oh-my-pi/pi-tui": "15.3.2",
55
+ "@oh-my-pi/pi-utils": "15.3.2",
56
56
  "@puppeteer/browsers": "^2.13.0",
57
57
  "@types/turndown": "5.0.6",
58
58
  "@xterm/headless": "^6.0.0",
@@ -32,7 +32,7 @@ import { getConfigRootDir, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
32
32
  import chalk from "chalk";
33
33
  import { type AuthBrokerClientConfig, resolveAuthBrokerConfig } from "../session/auth-broker-config";
34
34
 
35
- export type AuthGatewayAction = "serve" | "token" | "status";
35
+ export type AuthGatewayAction = "serve" | "token" | "status" | "check";
36
36
 
37
37
  export interface AuthGatewayCommandArgs {
38
38
  action: AuthGatewayAction;
@@ -49,7 +49,7 @@ export interface AuthGatewayCommandArgs {
49
49
  };
50
50
  }
51
51
 
52
- const ACTIONS: readonly AuthGatewayAction[] = ["serve", "token", "status"];
52
+ const ACTIONS: readonly AuthGatewayAction[] = ["serve", "token", "status", "check"];
53
53
 
54
54
  function getTokenFilePath(): string {
55
55
  return path.join(getConfigRootDir(), "auth-gateway.token");
@@ -332,6 +332,9 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
332
332
  case "status":
333
333
  await runStatus(cmd.flags);
334
334
  return;
335
+ case "check":
336
+ await runCheck(cmd.flags);
337
+ return;
335
338
  default: {
336
339
  const _exhaustive: never = cmd.action;
337
340
  throw new Error(`Unknown auth-gateway action: ${String(_exhaustive)}`);
@@ -339,4 +342,70 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
339
342
  }
340
343
  }
341
344
 
345
+ /**
346
+ * `omp auth-gateway check` — probe each broker-supplied credential and print
347
+ * per-credential auth health. Use this when the gateway is returning 401s and
348
+ * you need to find which row in a multi-account pool is the bad one. The
349
+ * aggregate `/v1/usage` endpoint silently drops failed credentials, so a
350
+ * dedicated diagnostic is the only way to see which credentials failed.
351
+ */
352
+ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
353
+ const brokerConfig = await resolveAuthBrokerConfig();
354
+ if (!brokerConfig) {
355
+ throw new Error(
356
+ "`omp auth-gateway check` requires OMP_AUTH_BROKER_URL (or `auth.broker.url`/`auth.broker.token` in config.yml). It probes the same credentials the gateway would serve.",
357
+ );
358
+ }
359
+
360
+ const client = createBrokerClient(brokerConfig);
361
+ const initialSnapshot = await fetchBrokerSnapshot(client);
362
+ const store = new RemoteAuthCredentialStore({ client, initialSnapshot });
363
+ const storage = new AuthStorage(store, { sourceLabel: `broker ${brokerConfig.url}` });
364
+ try {
365
+ await storage.reload();
366
+ const results = await storage.checkCredentials();
367
+
368
+ if (flags.json) {
369
+ process.stdout.write(`${JSON.stringify({ broker: brokerConfig.url, credentials: results }, null, 2)}\n`);
370
+ } else {
371
+ const grouped = new Map<string, typeof results>();
372
+ for (const row of results) {
373
+ const list = grouped.get(row.provider) ?? [];
374
+ list.push(row);
375
+ grouped.set(row.provider, list);
376
+ }
377
+ const providers = [...grouped.keys()].sort();
378
+ process.stdout.write(`broker: ${brokerConfig.url}\n`);
379
+ for (const provider of providers) {
380
+ const rows = grouped.get(provider) ?? [];
381
+ process.stdout.write(`\n${chalk.bold(provider)} (${rows.length})\n`);
382
+ for (const row of rows) {
383
+ const status =
384
+ row.ok === true
385
+ ? chalk.green("ok ")
386
+ : row.ok === false
387
+ ? chalk.red("FAIL ")
388
+ : chalk.yellow("unknown ");
389
+ const identity =
390
+ row.email ?? row.accountId ?? (row.type === "api_key" ? "(api key)" : "(no identity on credential)");
391
+ const remote = row.remoteRefresh ? chalk.dim(" [remote-refresh]") : "";
392
+ const reason = row.reason ? chalk.dim(` — ${row.reason}`) : "";
393
+ process.stdout.write(
394
+ ` ${status} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
395
+ );
396
+ }
397
+ }
398
+ const failed = results.filter(row => row.ok === false).length;
399
+ const unverifiable = results.filter(row => row.ok === null).length;
400
+ const passing = results.filter(row => row.ok === true).length;
401
+ process.stdout.write(
402
+ `\n${chalk.green(`${passing} ok`)}, ${chalk.red(`${failed} failed`)}, ${chalk.yellow(`${unverifiable} unverifiable`)}, ${results.length} total\n`,
403
+ );
404
+ if (failed > 0) process.exitCode = 1;
405
+ }
406
+ } finally {
407
+ storage.close();
408
+ }
409
+ }
410
+
342
411
  export { ACTIONS as AUTH_GATEWAY_ACTIONS };
@@ -38,6 +38,8 @@ export default class AuthGateway extends Command {
38
38
  "# Rotate the gateway bearer token\n omp auth-gateway token --regenerate",
39
39
  "# Run on loopback without any bearer (anyone on this host can call)\n omp auth-gateway serve --no-auth",
40
40
  "# Show local gateway + broker config status\n omp auth-gateway status",
41
+ "# Probe each broker credential to see which one is producing 401s\n omp auth-gateway check",
42
+ "# Same, machine-readable for scripts\n omp auth-gateway check --json",
41
43
  ];
42
44
 
43
45
  async run(): Promise<void> {
@@ -252,6 +252,45 @@ interface ProviderOverride {
252
252
  transport?: Model<Api>["transport"];
253
253
  }
254
254
 
255
+ /**
256
+ * Merge a freshly discovered model with the matching bundled/configured entry
257
+ * (or a runtime provider override when no bundled entry exists).
258
+ *
259
+ * `baseUrl` resolution priority:
260
+ * 1. User-set `providerOverride.baseUrl` (explicit override in models.json)
261
+ * 2. Discovered baseUrl (xiaomi `tp-` token-plan keys resolve to
262
+ * `token-plan-sgp.xiaomimimo.com` at discovery time)
263
+ * 3. Existing bundled baseUrl (the host baked into `models.json`)
264
+ *
265
+ * Without (1), the user's override would lose to discovery; without (2)
266
+ * preferred over (3), the bundled `api.xiaomimimo.com` would shadow the
267
+ * tp- token-plan host and produce 401s on the first stream call.
268
+ * See `xiaomi-tp-discovery-merge.test.ts` and the `refresh()` baseUrl-override
269
+ * regression in `model-registry.test.ts`.
270
+ */
271
+ export function mergeDiscoveredModel<TApi extends Api>(
272
+ model: Model<TApi>,
273
+ existing: Model<Api> | undefined,
274
+ providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "transport">,
275
+ ): Model<TApi> {
276
+ if (existing) {
277
+ return {
278
+ ...model,
279
+ baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
280
+ headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
281
+ };
282
+ }
283
+ if (providerOverride) {
284
+ return {
285
+ ...model,
286
+ baseUrl: providerOverride.baseUrl ?? model.baseUrl,
287
+ headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers,
288
+ ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
289
+ };
290
+ }
291
+ return model;
292
+ }
293
+
255
294
  interface DiscoveryProviderConfig {
256
295
  provider: string;
257
296
  api: Api;
@@ -1182,27 +1221,13 @@ export class ModelRegistry {
1182
1221
  return;
1183
1222
  }
1184
1223
  const discoveredModels = this.#applyHardcodedModelPolicies(
1185
- discovered.map(model => {
1186
- const existing = this.find(model.provider, model.id);
1187
- if (existing) {
1188
- return {
1189
- ...model,
1190
- baseUrl: existing.baseUrl,
1191
- headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
1192
- };
1193
- }
1194
- const providerOverride = this.#providerOverrides.get(model.provider);
1195
- return providerOverride
1196
- ? {
1197
- ...model,
1198
- baseUrl: providerOverride.baseUrl ?? model.baseUrl,
1199
- headers: providerOverride.headers
1200
- ? { ...model.headers, ...providerOverride.headers }
1201
- : model.headers,
1202
- ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
1203
- }
1204
- : model;
1205
- }),
1224
+ discovered.map(model =>
1225
+ mergeDiscoveredModel(
1226
+ model,
1227
+ this.find(model.provider, model.id),
1228
+ this.#providerOverrides.get(model.provider),
1229
+ ),
1230
+ ),
1206
1231
  );
1207
1232
  const resolved = this.#mergeResolvedModels(this.#models, discoveredModels);
1208
1233
  const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays);
@@ -10,7 +10,7 @@ import * as fs from "node:fs/promises";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
 
13
- import { isEnoent, logger } from "@oh-my-pi/pi-utils";
13
+ import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
14
14
 
15
15
  import { cachePlugin } from "./cache";
16
16
  import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
@@ -290,6 +290,7 @@ export class MarketplaceManager {
290
290
  try {
291
291
  version = await this.#resolvePluginVersion(pluginEntry, sourcePath);
292
292
  cachePath = await cachePlugin(sourcePath, this.#opts.pluginsCacheDir, marketplace, name, version);
293
+ await this.#writeEmbeddedLspConfig(pluginEntry, cachePath);
293
294
  } finally {
294
295
  // Clean up temp clone dirs created by resolvePluginSource; leave user-supplied local dirs alone
295
296
  if (tempCloneRoot) {
@@ -342,6 +343,24 @@ export class MarketplaceManager {
342
343
  return installedEntry;
343
344
  }
344
345
 
346
+ async #writeEmbeddedLspConfig(entry: MarketplacePluginEntry, cachePath: string): Promise<void> {
347
+ const lspServers = entry.lspServers;
348
+ if (!lspServers) return;
349
+
350
+ const targetPath = path.join(cachePath, ".lsp.json");
351
+ if (typeof lspServers === "string") {
352
+ const sourcePath = path.resolve(cachePath, lspServers);
353
+ if (!pathIsWithin(cachePath, sourcePath)) {
354
+ throw new Error(`Plugin "${entry.name}" lspServers path escapes the plugin directory`);
355
+ }
356
+ const content = await Bun.file(sourcePath).text();
357
+ await Bun.write(targetPath, content);
358
+ return;
359
+ }
360
+
361
+ await Bun.write(targetPath, `${JSON.stringify({ servers: lspServers }, null, 2)}\n`);
362
+ }
363
+
345
364
  /**
346
365
  * Resolve plugin version from multiple sources:
347
366
  * 1. Catalog entry version (if set)
@@ -1,6 +1,8 @@
1
1
  import { ABORT_MARKER, ABORT_WARNING, BEGIN_PATCH_MARKER, END_PATCH_MARKER, RANGE_INTERIOR_HASH } from "./constants";
2
2
  import {
3
+ computeLineHash,
3
4
  describeAnchorExamples,
5
+ HL_BODY_SEP_RE_RAW,
4
6
  HL_FILE_PREFIX,
5
7
  HL_HASH_CAPTURE_RE_RAW,
6
8
  HL_OP_CHARS,
@@ -10,7 +12,12 @@ import {
10
12
  } from "./hash";
11
13
  import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
12
14
 
13
- const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`);
15
+ // Leniently accept anchors copied from read/search output:
16
+ // - optional leading line-marker decoration (`*`, `>`, `+`, `-`)
17
+ // - the required `LINE+HASH`
18
+ // - an optional trailing `|TEXT` body (or anything after the hash) so users
19
+ // can paste a full `LINE+HASH|TEXT` line verbatim.
20
+ const LID_CAPTURE_RE = new RegExp(`^\\s*[>+\\-*]*\\s*${HL_HASH_CAPTURE_RE_RAW}(?:\\|.*)?\\s*$`);
14
21
  const regexEscape = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
22
 
16
23
  function parseLid(raw: string, lineNum: number): Anchor {
@@ -69,8 +76,36 @@ function parseInsertTarget(raw: string, lineNum: number, kind: "before" | "after
69
76
  return { kind: cursorKind, anchor: parseLid(raw, lineNum) };
70
77
  }
71
78
 
72
- const INSERT_BEFORE_OP_RE = new RegExp(`^${regexEscape(HL_OP_INSERT_BEFORE)}\\s*(\\S+)\\s*$`);
73
- const INSERT_AFTER_OP_RE = new RegExp(`^${regexEscape(HL_OP_INSERT_AFTER)}\\s*(\\S+)\\s*$`);
79
+ /**
80
+ * Decide how to interpret the optional `|TEXT` body captured on an insert
81
+ * op line:
82
+ * - For BOF/EOF cursors the body is always treated as an inline payload
83
+ * line (there's no anchor hash to compare against).
84
+ * - For anchored cursors, compute the hash of TEXT at the anchor's line
85
+ * number. If it matches the anchor's hash, the body is just a verbatim
86
+ * copy of the anchored line — discard it, payload must come from the
87
+ * following lines as usual.
88
+ * - Otherwise the body is the first (or only) payload line for this op.
89
+ */
90
+ function resolveInlineInsertBody(cursor: HashlineCursor, body: string | undefined): string | undefined {
91
+ if (body === undefined) return undefined;
92
+ if (cursor.kind !== "before_anchor" && cursor.kind !== "after_anchor") return body;
93
+ const { line, hash } = cursor.anchor;
94
+ if (computeLineHash(line, body) === hash) return undefined;
95
+ return body;
96
+ }
97
+
98
+ // Insert ops leniently accept a trailing `|TEXT` body on the op line itself
99
+ // (e.g. `»502zk|\tconst foo = ...`). The anchor token excludes `|` so the body
100
+ // is captured separately; resolveInlineInsertBody decides whether to treat the
101
+ // captured text as a verbatim anchor decoration (when its hash matches the
102
+ // anchor's) or as an inline payload line.
103
+ const INSERT_BEFORE_OP_RE = new RegExp(
104
+ `^${regexEscape(HL_OP_INSERT_BEFORE)}\\s*([^|\\s]+)(?:${HL_BODY_SEP_RE_RAW}(.*))?\\s*$`,
105
+ );
106
+ const INSERT_AFTER_OP_RE = new RegExp(
107
+ `^${regexEscape(HL_OP_INSERT_AFTER)}\\s*([^|\\s]+)(?:${HL_BODY_SEP_RE_RAW}(.*))?\\s*$`,
108
+ );
74
109
  const REPLACE_OP_RE = new RegExp(`^${regexEscape(HL_OP_REPLACE)}\\s*([^\\s+<\\-=]\\S*)\\s*$`);
75
110
 
76
111
  function isEnvelopeOrAbortMarkerLine(line: string): boolean {
@@ -153,7 +188,9 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
153
188
  const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
154
189
  if (insertBeforeMatch) {
155
190
  const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
156
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
191
+ const inlineBody = resolveInlineInsertBody(cursor, insertBeforeMatch[2]);
192
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, inlineBody === undefined);
193
+ if (inlineBody !== undefined) pushInsert(cursor, inlineBody, lineNum);
157
194
  for (const text of payload) pushInsert(cursor, text, lineNum);
158
195
  i = nextIndex;
159
196
  continue;
@@ -162,7 +199,9 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
162
199
  const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
163
200
  if (insertAfterMatch) {
164
201
  const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
165
- const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, true);
202
+ const inlineBody = resolveInlineInsertBody(cursor, insertAfterMatch[2]);
203
+ const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, inlineBody === undefined);
204
+ if (inlineBody !== undefined) pushInsert(cursor, inlineBody, lineNum);
166
205
  for (const text of payload) pushInsert(cursor, text, lineNum);
167
206
  i = nextIndex;
168
207
  continue;