@oh-my-pi/pi-coding-agent 14.5.1 → 14.5.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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.5.2] - 2026-04-26
6
+ ### Breaking Changes
7
+
8
+ - Removed support for sed-style string expressions and required `sed` to be specified as an object with `pat` and `rep` (and optional `g`, `F`, `i` flags)
9
+
10
+ ### Changed
11
+
12
+ - Changed atom `sed` replacements to be global by default and require `g:false` for first-match-only replacements
13
+ - Changed anchor validation so multiple `sed` operations can target the same line and run sequentially
14
+ - Changed cross-entry conflict resolution so `del` edits on an anchor are ignored when that line is also replaced by `sed` or `splice` in another edit entry
15
+
16
+ ### Fixed
17
+
18
+ - Fixed zero-length regex `sed` patterns (for example `()`, `^`, `$`) to fall back to literal substring matching instead of producing insertion-like replacements
19
+ - Fixed `sed` chaining so each edit on the same anchor applies to the latest line state from prior replacements
20
+
5
21
  ## [14.5.1] - 2026-04-26
6
22
 
7
23
  ### Removed
@@ -762,6 +778,7 @@
762
778
  - Fixed PR checkout tool to resolve symlinks in worktree paths, ensuring consistent path references in results and metadata
763
779
  - Fixed `read` output for file-backed internal URLs like `local://...` to include hashline prefixes in hashline edit mode, preserving usable line refs for follow-up edits
764
780
  - Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen
781
+ - Fixed status line dropping git branch name when path is long by shrinking the path segment before dropping other segments
765
782
 
766
783
  ## [13.18.0] - 2026-04-02
767
784
 
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": "14.5.1",
4
+ "version": "14.5.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.20.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.5.1",
50
- "@oh-my-pi/pi-agent-core": "14.5.1",
51
- "@oh-my-pi/pi-ai": "14.5.1",
52
- "@oh-my-pi/pi-natives": "14.5.1",
53
- "@oh-my-pi/pi-tui": "14.5.1",
54
- "@oh-my-pi/pi-utils": "14.5.1",
49
+ "@oh-my-pi/omp-stats": "14.5.2",
50
+ "@oh-my-pi/pi-agent-core": "14.5.2",
51
+ "@oh-my-pi/pi-ai": "14.5.2",
52
+ "@oh-my-pi/pi-natives": "14.5.2",
53
+ "@oh-my-pi/pi-tui": "14.5.2",
54
+ "@oh-my-pi/pi-utils": "14.5.2",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -69,7 +69,7 @@
69
69
  "zod": "4.3.6"
70
70
  },
71
71
  "devDependencies": {
72
- "@types/bun": "^1.3.13",
72
+ "@types/bun": "^1.3",
73
73
  "@types/turndown": "5.0.6"
74
74
  },
75
75
  "engines": {
@@ -12,7 +12,7 @@
12
12
  * { path, loc: "5th", pre: [...], splice: [...], post: [...] }
13
13
  * { path, loc: "$", pre: [...] } // prepend to file
14
14
  * { path, loc: "$", post: [...] } // append to file
15
- * { path, loc: "$", sed: "s/foo/bar/" } // sed on every line
15
+ * { path, loc: "$", sed: { pat, rep, g?, F? } } // sed on every line
16
16
  *
17
17
  * `splice: []` on a single-anchor locator deletes that line. `splice:[""]` preserves
18
18
  * a blank line. Line ranges are not supported.
@@ -66,10 +66,17 @@ export const atomEditSchema = Type.Object(
66
66
  pre: Type.Optional(textSchema),
67
67
  post: Type.Optional(textSchema),
68
68
  sed: Type.Optional(
69
- Type.String({
70
- description: "sed-style substitution applied to the anchored line",
71
- examples: ["s/foo/bar/", "s|api|API|g", "s/<pat>/<rep>/F"],
72
- }),
69
+ Type.Object(
70
+ {
71
+ pat: Type.String({ description: "pattern to find" }),
72
+ rep: Type.String({ description: "replacement text" }),
73
+ g: Type.Optional(Type.Boolean({ description: "global replace", default: false })),
74
+ F: Type.Optional(Type.Boolean({ description: "literal replace", default: false })),
75
+ },
76
+ {
77
+ additionalProperties: false,
78
+ },
79
+ ),
73
80
  ),
74
81
  },
75
82
  { additionalProperties: false },
@@ -104,7 +111,6 @@ export interface SedSpec {
104
111
  pattern: string;
105
112
  replacement: string;
106
113
  global: boolean;
107
- ignoreCase: boolean;
108
114
  literal: boolean;
109
115
  }
110
116
 
@@ -258,70 +264,46 @@ function extractAnchorContentHint(raw: string): string | undefined {
258
264
  return hint;
259
265
  }
260
266
 
261
- function parseSedExpression(raw: string, editIndex: number): SedSpec {
262
- if (typeof raw !== "string" || raw.length < 3) {
263
- throw new Error(
264
- `Edit ${editIndex}: sed expression must start with "s" followed by a delimiter, e.g. "s/foo/bar/".`,
265
- );
267
+ function parseSedSpec(input: unknown, editIndex: number): SedSpec {
268
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
269
+ throw new Error(`Edit ${editIndex}: sed must be an object with shape {pat, rep, g?, F?}.`);
266
270
  }
267
- // Tolerate a missing leading `s`: models occasionally emit `/foo/bar/` directly.
268
- // As long as the first character is a valid delimiter, treat the expression as
269
- // if `s` was prepended.
270
- let bodyStart = 0;
271
- if (raw[0] === "s") {
272
- bodyStart = 1;
271
+ const obj = input as Record<string, unknown>;
272
+ const pat = obj.pat;
273
+ const rep = obj.rep;
274
+ if (typeof pat !== "string" || pat.length === 0) {
275
+ throw new Error(`Edit ${editIndex}: sed.pat must be a non-empty string.`);
273
276
  }
274
- const delim = raw[bodyStart]!;
275
- if (/[\sA-Za-z0-9\\]/.test(delim)) {
277
+ if (pat.includes("\n")) {
276
278
  throw new Error(
277
- `Edit ${editIndex}: sed delimiter must be a non-alphanumeric, non-whitespace, non-backslash character (got ${JSON.stringify(delim)}).`,
279
+ `Edit ${editIndex}: sed.pat must be a single line; contains a newline. Use \`splice\` to replace multiple lines, anchoring the first changed line and listing replacement lines in the array.`,
278
280
  );
279
281
  }
280
- const parts: [string, string] = ["", ""];
281
- let bucket: 0 | 1 = 0;
282
- let i = bodyStart + 1;
283
- while (i < raw.length) {
284
- const c = raw[i]!;
285
- if (c === "\\" && raw[i + 1] === delim) {
286
- parts[bucket] += delim;
287
- i += 2;
288
- continue;
289
- }
290
- if (c === delim) {
291
- if (bucket === 0) {
292
- bucket = 1;
293
- i += 1;
294
- continue;
295
- }
296
- i += 1;
297
- break;
298
- }
299
- parts[bucket] += c;
300
- i += 1;
301
- }
302
- if (bucket !== 1) {
303
- throw new Error(
304
- `Edit ${editIndex}: malformed sed expression ${JSON.stringify(raw)}. Expected three ${JSON.stringify(delim)} separators.`,
305
- );
282
+ if (typeof rep !== "string") {
283
+ throw new Error(`Edit ${editIndex}: sed.rep must be a string.`);
306
284
  }
307
- const flagsStr = raw.slice(i);
308
- let global = false;
309
- let ignoreCase = false;
310
- let literal = false;
311
- for (const f of flagsStr) {
312
- if (f === "g") global = true;
313
- else if (f === "i") ignoreCase = true;
314
- else if (f === "F") literal = true;
315
- else {
316
- throw new Error(
317
- `Edit ${editIndex}: unknown sed flag ${JSON.stringify(f)}. Supported flags: g (all), i (case-insensitive), F (literal).`,
318
- );
285
+ const readBool = (key: "g" | "F", defaultValue: boolean): boolean => {
286
+ const v = obj[key];
287
+ if (v === undefined) return defaultValue;
288
+ if (typeof v !== "boolean") {
289
+ throw new Error(`Edit ${editIndex}: sed.${key} must be a boolean when provided.`);
319
290
  }
320
- }
321
- if (parts[0] === "") {
322
- throw new Error(`Edit ${editIndex}: sed expression has empty pattern.`);
323
- }
324
- return { pattern: parts[0], replacement: parts[1], global, ignoreCase, literal };
291
+ return v;
292
+ };
293
+ const global = readBool("g", false);
294
+ const literal = readBool("F", false);
295
+ return { pattern: pat, replacement: rep, global, literal };
296
+ }
297
+
298
+ function formatSedExpression(spec: SedSpec): string {
299
+ const obj: { pat: string; rep: string; g?: boolean; F?: boolean } = {
300
+ pat: spec.pattern,
301
+ rep: spec.replacement,
302
+ };
303
+ // Only emit non-default flags so error messages stay compact (g defaults false).
304
+ if (spec.global) obj.g = true;
305
+ if (spec.literal) obj.F = true;
306
+ return JSON.stringify(obj);
325
307
  }
326
308
 
327
309
  function applyLiteralSed(currentLine: string, spec: SedSpec): { result: string; matched: boolean } {
@@ -345,7 +327,6 @@ function applySedToLine(
345
327
  }
346
328
  let flags = "";
347
329
  if (spec.global) flags += "g";
348
- if (spec.ignoreCase) flags += "i";
349
330
  let re: RegExp | undefined;
350
331
  let compileError: string | undefined;
351
332
  try {
@@ -357,18 +338,13 @@ function applySedToLine(
357
338
  re.lastIndex = 0;
358
339
  const probe = re.exec(currentLine);
359
340
  re.lastIndex = 0;
360
- if (probe && probe[0].length === 0) {
361
- // Zero-length matches (e.g. `()`, `(?=…)`, `^`, `$`) cause `String.replace`
362
- // to insert the replacement at the match position rather than substitute,
363
- // which is almost never what models intend. Reject with a pointer to the
364
- // dedicated insertion verbs.
365
- return {
366
- result: currentLine,
367
- matched: false,
368
- error: `pattern ${JSON.stringify(spec.pattern)} matches an empty string; use \`pre\`/\`post\`/\`splice\` to insert or replace whole lines, or use a non-empty pattern`,
369
- };
341
+ // Zero-length matches (e.g. `()`, `(?=…)`, `^`, `$`) cause `String.replace` to
342
+ // insert the replacement at the match position rather than substitute. When that
343
+ // happens, fall through to the literal-substring fallback below the model almost
344
+ // always meant the pattern literally (`()` is the parens, `^` is a caret, etc.).
345
+ if (!probe || probe[0].length > 0) {
346
+ return { result: currentLine.replace(re, spec.replacement), matched: true };
370
347
  }
371
- return { result: currentLine.replace(re, spec.replacement), matched: true };
372
348
  }
373
349
  // Fall back to literal substring match. Models frequently send sed patterns
374
350
  // containing unescaped regex metacharacters (parentheses, `?`, `.`) that they
@@ -417,8 +393,8 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
417
393
  resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
418
394
  }
419
395
  if (entry.sed !== undefined) {
420
- const spec = parseSedExpression(entry.sed, editIndex);
421
- resolved.push({ op: "sed_file", spec, expression: entry.sed });
396
+ const spec = parseSedSpec(entry.sed, editIndex);
397
+ resolved.push({ op: "sed_file", spec, expression: formatSedExpression(spec) });
422
398
  }
423
399
  return resolved;
424
400
  }
@@ -448,8 +424,8 @@ function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
448
424
  // matching `sed`. The explicit replacement wins; the redundant `sed` would
449
425
  // otherwise trigger a confusing `Conflicting ops` rejection.
450
426
  if (!spliceIsExplicitReplacement) {
451
- const spec = parseSedExpression(entry.sed, editIndex);
452
- resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
427
+ const spec = parseSedSpec(entry.sed, editIndex);
428
+ resolved.push({ op: "sed", pos: loc.pos, spec, expression: formatSedExpression(spec) });
453
429
  }
454
430
  }
455
431
  return resolved;
@@ -537,16 +513,18 @@ function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: s
537
513
  }
538
514
 
539
515
  function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
540
- // For each anchor line, at most one mutating op (splice/del).
541
- // `pre`/`post` (insert ops) may coexist with them they don't mutate the anchor line.
516
+ // For each anchor line, at most one mutating op (splice/del). Multiple `sed`
517
+ // ops on the same line are allowed and applied sequentially. `pre`/`post`
518
+ // (insert ops) may coexist with them — they don't mutate the anchor line.
542
519
  const mutatingPerLine = new Map<number, string>();
543
520
  for (const edit of edits) {
544
521
  if (edit.op !== "splice" && edit.op !== "del" && edit.op !== "sed") continue;
545
522
  const existing = mutatingPerLine.get(edit.pos.line);
546
523
  if (existing) {
524
+ if (existing === "sed" && edit.op === "sed") continue;
547
525
  throw new Error(
548
526
  `Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
549
- `At most one of splice/del/sed is allowed per anchor.`,
527
+ `At most one of splice/del is allowed per anchor.`,
550
528
  );
551
529
  }
552
530
  mutatingPerLine.set(edit.pos.line, edit.op);
@@ -586,7 +564,19 @@ export function applyAtomEdits(
586
564
  if (mismatches.length > 0) {
587
565
  throw new HashlineMismatchError(mismatches, fileLines);
588
566
  }
589
- validateNoConflictingAnchorOps(edits);
567
+ // When a `del` and a `sed`/`splice` target the same anchor (across separate edit
568
+ // entries), the `del` is almost always a hallucinated cleanup the model added on top
569
+ // of the real replacement. Drop the `del` silently so the replacement wins, matching
570
+ // the in-entry handling for `splice: []` paired with `sed`.
571
+ const replacedLines = new Set<number>();
572
+ for (const e of edits) {
573
+ if (e.op === "splice" || e.op === "sed") replacedLines.add(e.pos.line);
574
+ }
575
+ let effective = edits;
576
+ if (replacedLines.size > 0) {
577
+ effective = edits.filter(e => !(e.op === "del" && replacedLines.has(e.pos.line)));
578
+ }
579
+ validateNoConflictingAnchorOps(effective);
590
580
 
591
581
  const trackFirstChanged = (line: number) => {
592
582
  if (firstChangedLine === undefined || line < firstChangedLine) {
@@ -603,7 +593,7 @@ export function applyAtomEdits(
603
593
  const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
604
594
  const sedFileEdits: Indexed<Extract<AtomEdit, { op: "sed_file" }>>[] = [];
605
595
  const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
606
- edits.forEach((edit, idx) => {
596
+ effective.forEach((edit, idx) => {
607
597
  if (edit.op === "append_file") appendEdits.push({ edit, idx });
608
598
  else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
609
599
  else if (edit.op === "sed_file") sedFileEdits.push({ edit, idx });
@@ -659,13 +649,14 @@ export function applyAtomEdits(
659
649
  anchorMutated = true;
660
650
  break;
661
651
  case "sed": {
662
- const { result, matched, error, literalFallback } = applySedToLine(currentLine, edit.spec);
652
+ const input = replacementSet ? (replacement[0] ?? "") : currentLine;
653
+ const { result, matched, error, literalFallback } = applySedToLine(input, edit.spec);
663
654
  if (error) {
664
655
  throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} rejected: ${error}`);
665
656
  }
666
657
  if (!matched) {
667
658
  throw new Error(
668
- `Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(currentLine)}`,
659
+ `Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(input)}`,
669
660
  );
670
661
  }
671
662
  if (literalFallback) {
@@ -388,10 +388,12 @@ export class StatusLineComponent implements Component {
388
388
 
389
389
  // Collect visible segment contents
390
390
  const leftParts: string[] = [];
391
+ const leftSegIds: StatusLineSegmentId[] = [];
391
392
  for (const segId of effectiveSettings.leftSegments) {
392
393
  const rendered = renderSegment(segId, ctx);
393
394
  if (rendered.visible && rendered.content) {
394
395
  leftParts.push(rendered.content);
396
+ leftSegIds.push(segId);
395
397
  }
396
398
  }
397
399
 
@@ -434,8 +436,42 @@ export class StatusLineComponent implements Component {
434
436
  right.pop();
435
437
  rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
436
438
  }
439
+ // Shrink path before dropping left segments — path is the only elastic segment
440
+ const pathIdx = leftSegIds.indexOf("path");
441
+ if (pathIdx >= 0 && totalWidth() > topFillWidth) {
442
+ const overflow = totalWidth() - topFillWidth;
443
+ const currentPathVW = visibleWidth(left[pathIdx]);
444
+ const minPathVW = 8; // icon + ellipsis + a few chars
445
+ const shrinkable = currentPathVW - minPathVW;
446
+ if (shrinkable > 0) {
447
+ const shrinkBy = Math.min(shrinkable, overflow);
448
+ const currentMaxLen = ctx.options.path?.maxLength ?? 40;
449
+ let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
450
+ const pathCtx = (maxLen: number): SegmentContext => ({
451
+ ...ctx,
452
+ options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
453
+ });
454
+ let reRendered = renderSegment("path", pathCtx(newMaxLen));
455
+ if (reRendered.visible && reRendered.content) {
456
+ // maxLength governs path text, not icon prefix; iterate to compensate
457
+ for (let i = 0; i < 8; i++) {
458
+ const saved = currentPathVW - visibleWidth(reRendered.content);
459
+ if (saved >= shrinkBy) break;
460
+ const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
461
+ if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
462
+ newMaxLen = nextMaxLen;
463
+ const adjusted = renderSegment("path", pathCtx(newMaxLen));
464
+ if (!adjusted.visible || !adjusted.content) break;
465
+ reRendered = adjusted;
466
+ }
467
+ left[pathIdx] = reRendered.content;
468
+ leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
469
+ }
470
+ }
471
+ }
437
472
  while (totalWidth() > topFillWidth && left.length > 0) {
438
473
  left.pop();
474
+ leftSegIds.pop();
439
475
  leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
440
476
  }
441
477
  }
@@ -269,8 +269,21 @@ export class EventController {
269
269
  for (const content of this.ctx.streamingMessage.content) {
270
270
  if (content.type !== "toolCall") continue;
271
271
  const args = content.arguments;
272
- if (!args || typeof args !== "object" || !(INTENT_FIELD in args)) continue;
273
- this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
272
+ if (!args || typeof args !== "object") continue;
273
+ if (INTENT_FIELD in args) {
274
+ this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
275
+ continue;
276
+ }
277
+ const tool = this.ctx.session.getToolByName(content.name);
278
+ if (typeof tool?.intent !== "function") continue;
279
+ try {
280
+ const derived = tool.intent(args as never)?.trim();
281
+ if (derived) {
282
+ this.#updateWorkingMessageFromIntent(derived);
283
+ }
284
+ } catch {
285
+ // intent function must never break the UI
286
+ }
274
287
  }
275
288
 
276
289
  this.ctx.ui.requestRender();
@@ -168,7 +168,7 @@ Tools:
168
168
 
169
169
  {{#if intentTracing}}
170
170
  <intent-field>
171
- Every tool has a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period.
171
+ Most tools have a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period.
172
172
  </intent-field>
173
173
  {{/if}}
174
174
 
@@ -14,10 +14,12 @@ Verbs:
14
14
  - `splice: […]`: lines are spliced in at the anchor.
15
15
  - `pre: […]`: prepend before the anchor (or at BOF if `loc=$`)
16
16
  - `post: […]`: append after the anchor (or at EOF if `loc=$`)
17
- - `sed: "s/foo/bar/"` — sed-style substitution applied to the anchor line. **Prefer this over `splice` for token-level changes**
18
- Flags: `g` (all occurrences), `i` (case-insensitive), `F` (literal).
19
- Delimiter is whatever character follows `s`.
20
- You **MUST** keep the pattern as short as possible.
17
+ - `sed: { pat, rep, g?, F? }` — structured find/replace on the anchor line. **Prefer this over `splice` for token-level changes**
18
+ - `pat`: pattern to find (regex by default)
19
+ - `rep`: replacement (regex back-refs like `$1`, `$&` available)
20
+ - `g`: global replace every occurrence (default `false`; pass `true` to replace all)
21
+ - `F`: literal — treat `pat` as a literal substring (no regex). Use this whenever `pat` contains `||`, `.`, `(`, `?`, `\`, etc. you mean literally.
22
+ You **MUST** keep `pat` as short as possible.
21
23
 
22
24
  Combination rules:
23
25
  - On a single-anchor `loc`, you may combine `pre`, `splice`, and `post` in the same entry.
@@ -55,12 +57,15 @@ All examples below reference the same file:
55
57
  `{path:"a.ts",edits:[{loc:{{href 3 "function beta(x) {"}},pre:["function gamma() {","\tvalidate();","}",""]}]}`
56
58
 
57
59
  # Substitute one token with `sed` (regex) — preferred for token-level edits
58
- Use the smallest pattern that uniquely identifies the change.
59
- `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"s/\\|\\|/??/"}]}`
60
+ Use the smallest `pat` that uniquely identifies the change.
61
+ `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:{pat:"\\|\\|",rep:"??"}}]}`
60
62
 
61
- # Substitute every occurrence with `sed` (literal/fixed-string)
62
- Use the `F` flag to disable regex; the delimiter can be any non-alphanumeric char.
63
- `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"s|data|input|gF"}]}`
63
+ # Substitute literal text set `F:true` so `pat` is not parsed as regex
64
+ `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:{pat:"data",rep:"input",F:true}}]}`
65
+
66
+ # Comment out a line by capturing the whole content with a regex
67
+ Use `$&` (the entire match) inside `rep` to keep the original text and prepend `// `.
68
+ `{path:"a.ts",edits:[{loc:{{href 7 "\treturn null;"}},sed:{pat:".+",rep:"// $&"}}]}`
64
69
 
65
70
  # Prepend / append at file edges
66
71
  `{path:"a.ts",edits:[{loc:"$",pre:["// Copyright (c) 2026",""]}]}`
@@ -74,7 +79,7 @@ Use the `F` flag to disable regex; the delimiter can be any non-alphanumeric cha
74
79
  The 2nd array element matches existing line 5, which is **not** overwritten, it shifts, so return statement ends up duplicated.
75
80
 
76
81
  # RIGHT: split into separate edits
77
- - `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},sed:"s/x/x \\&\\& ready/"},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},post:["\t\t//unreachable"]}]}`
82
+ - `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},sed:{pat:"x",rep:"x && ready",g:false}},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},post:["\t\t//unreachable"]}]}`
78
83
  OR
79
84
  - `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},splice:["\tif (x && ready) {"]},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},splice:["\t\treturn parse(data) ?? fallback;","\t\t//unreachable"]}]}`
80
85
  </examples>
@@ -88,7 +93,7 @@ OR
88
93
  - `splice: []` deletes the anchored line. `splice:[""]` preserves a blank line.
89
94
  - Within a single request you may submit edits in any order — the runtime applies them bottom-up so they don't shift each other. After any request that mutates a file, anchors below the mutation are stale on disk; re-read before issuing more edits to that file.
90
95
  - `splice` operations target the current file content only. Do not try to reference old line text after the file has changed.
91
- - For **small** in-line edits (renaming a token, flipping an operator, tweaking a literal), prefer `sed` over `splice`. The `loc` anchor already pins the line — repeating the entire line in a `splice` array invites hallucinated content. Use the smallest `sed` pattern that uniquely identifies the change on that line; do not pad it with surrounding text just to feel safe. For multi-line restructuring (wrapping logic, adding new branches, inserting blocks), use `splice`/`pre`/`post` — do **not** stretch `sed` into a rewrite tool.
96
+ - For **small** in-line edits (renaming a token, flipping an operator, tweaking a literal), prefer `sed` over `splice`. The `loc` anchor already pins the line — repeating the entire line in a `splice` array invites hallucinated content. Use the smallest `pat` that uniquely identifies the change on that line; do not pad it with surrounding text just to feel safe. When `pat` contains regex metacharacters you mean literally (e.g. `||`, `.`, `(`, `?`, `\`), set `F:true` to disable regex. `g` is `false` by default — pass `g:true` to replace every occurrence. For multi-line restructuring (wrapping logic, adding new branches, inserting blocks), use `splice`/`pre`/`post` — do **not** stretch `sed` into a rewrite tool.
92
97
  - When you do use `splice`, re-read the anchored line first and copy it verbatim, changing only the required token(s). Anchor identity does not verify line content, so a hallucinated replacement will silently corrupt the file.
93
98
  - Anchors are pin points, not region markers. One anchor pins exactly one line. If your change touches N distinct source lines, that is N edits with N anchors — not one big `splice` array intended to cover the whole region. `splice` cannot "replace lines 4 through 7"; it can only splice content in at one anchor.
94
99
  - You **MUST NOT** include lines in `splice`/`pre`/`post` that already exist immediately adjacent to the anchor in the current file. `splice` does not overwrite the lines below — they shift down — so any neighbor you re-type in your array becomes a duplicate. If your intended replacement contains content that is already on neighboring source lines, split into multiple edits at each real change site instead of one fat `splice`.
@@ -2970,6 +2970,30 @@ export class AgentSession {
2970
2970
  attribution: "user",
2971
2971
  timestamp: Date.now(),
2972
2972
  });
2973
+ // When fully idle AND the session is in a resumable assistant-ended state,
2974
+ // schedule an immediate continue so the queued follow-up is delivered
2975
+ // without waiting for the next user turn. We gate on isStreaming (model
2976
+ // actively producing), isRetrying (auto-retry backoff is sleeping between
2977
+ // attempts, #retryPromise set), and the last message being assistant —
2978
+ // agent.continue() only dequeues follow-ups from an assistant-ended state;
2979
+ // resuming from user/toolResult state runs an extra model call on the
2980
+ // stale prompt before draining the queue.
2981
+ if (this.#canAutoContinueForFollowUp()) {
2982
+ this.#scheduleAgentContinue({
2983
+ shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
2984
+ });
2985
+ }
2986
+ }
2987
+
2988
+ /**
2989
+ * Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale.
2990
+ */
2991
+ #canAutoContinueForFollowUp(): boolean {
2992
+ if (this.isStreaming) return false;
2993
+ if (this.isRetrying) return false;
2994
+ const messages = this.agent.state.messages;
2995
+ const last = messages[messages.length - 1];
2996
+ return last?.role === "assistant";
2973
2997
  }
2974
2998
 
2975
2999
  queueDeferredMessage(message: CustomMessage): void {
@@ -52,6 +52,7 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
52
52
  readonly description: string;
53
53
  readonly parameters = checkpointSchema;
54
54
  readonly strict = true;
55
+ readonly intent = (args: Partial<CheckpointParams>) => args.goal;
55
56
 
56
57
  constructor(private readonly session: ToolSession) {
57
58
  this.description = prompt.render(checkpointDescription);
@@ -94,6 +95,7 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
94
95
  readonly description: string;
95
96
  readonly parameters = rewindSchema;
96
97
  readonly strict = true;
98
+ readonly intent = (): string => "Rewinding to checkpoint";
97
99
 
98
100
  constructor(private readonly session: ToolSession) {
99
101
  this.description = prompt.render(rewindDescription);
@@ -46,6 +46,7 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
46
46
  readonly parameters = exitPlanModeSchema;
47
47
  readonly strict = true;
48
48
  readonly concurrency = "exclusive";
49
+ readonly intent = (): string => "Exiting plan mode";
49
50
 
50
51
  constructor(private readonly session: ToolSession) {
51
52
  this.description = prompt.render(exitPlanModeDescription);
package/src/tools/read.ts CHANGED
@@ -456,6 +456,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
456
456
  readonly parameters = readSchema;
457
457
  readonly nonAbortable = true;
458
458
  readonly strict = true;
459
+ readonly intent = (args: Partial<ReadParams>): string => {
460
+ const p = typeof args.path === "string" ? args.path.trim() : "";
461
+ if (!p) return "Reading";
462
+ const isUrl = /^(https?|ftp):\/\//i.test(p);
463
+ return isUrl ? `Fetching ${p}` : `Reading ${p}`;
464
+ };
459
465
 
460
466
  readonly #autoResizeImages: boolean;
461
467
  readonly #defaultLimit: number;
@@ -60,6 +60,7 @@ export function createReportToolIssueTool(session: ToolSession): AgentTool {
60
60
  strict: false,
61
61
  description: "Report unexpected tool behavior for automated QA tracking.",
62
62
  parameters: ReportToolIssueParams,
63
+ intent: "omit",
63
64
  async execute(_toolCallId, rawParams) {
64
65
  try {
65
66
  const params = rawParams as { tool: string; report: string };
@@ -110,6 +110,8 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
110
110
  readonly description: string;
111
111
  readonly parameters = resolveSchema;
112
112
  readonly strict = true;
113
+ readonly intent = (args: Partial<ResolveParams>) =>
114
+ args.action === "discard" ? "Discarding pending action" : "Applying pending action";
113
115
 
114
116
  constructor(private readonly session: ToolSession) {
115
117
  this.description = prompt.render(resolveDescription);
@@ -135,6 +135,7 @@ export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFind
135
135
  label: "Report Finding",
136
136
  description: "Report a code review finding. Use this for each issue found. Call yield when done.",
137
137
  parameters: ReportFindingParams,
138
+ intent: "omit",
138
139
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
139
140
  const { title, body, priority, confidence, file_path, line_start, line_end } = params;
140
141
  const location = `${file_path}:${line_start}${line_end !== line_start ? `-${line_end}` : ""}`;
@@ -583,6 +583,7 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
583
583
  readonly parameters = todoWriteSchema;
584
584
  readonly concurrency = "exclusive";
585
585
  readonly strict = true;
586
+ readonly intent = "omit" as const;
586
587
 
587
588
  constructor(private readonly session: ToolSession) {
588
589
  this.description = prompt.render(todoWriteDescription);
@@ -49,6 +49,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
49
49
  "The `data`/`error` wrapper is required — do not put your output directly in `result`.";
50
50
  readonly parameters: TSchema;
51
51
  strict = true;
52
+ readonly intent = "omit" as const;
52
53
  lenientArgValidation = true;
53
54
 
54
55
  readonly #validate?: ValidateFunction;
@@ -20,7 +20,12 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
20
20
  return { type: "function", name: toolName };
21
21
  }
22
22
 
23
- if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") {
23
+ if (
24
+ model.api === "google-generative-ai" ||
25
+ model.api === "google-gemini-cli" ||
26
+ model.api === "google-vertex" ||
27
+ model.api === "ollama-chat"
28
+ ) {
24
29
  return "required";
25
30
  }
26
31