@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 +17 -0
- package/package.json +8 -8
- package/src/edit/modes/atom.ts +77 -86
- package/src/modes/components/status-line.ts +36 -0
- package/src/modes/controllers/event-controller.ts +15 -2
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/atom.md +16 -11
- package/src/session/agent-session.ts +24 -0
- package/src/tools/checkpoint.ts +2 -0
- package/src/tools/exit-plan-mode.ts +1 -0
- package/src/tools/read.ts +6 -0
- package/src/tools/report-tool-issue.ts +1 -0
- package/src/tools/resolve.ts +2 -0
- package/src/tools/review.ts +1 -0
- package/src/tools/todo-write.ts +1 -0
- package/src/tools/yield.ts +1 -0
- package/src/utils/tool-choice.ts +6 -1
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.
|
|
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.
|
|
50
|
-
"@oh-my-pi/pi-agent-core": "14.5.
|
|
51
|
-
"@oh-my-pi/pi-ai": "14.5.
|
|
52
|
-
"@oh-my-pi/pi-natives": "14.5.
|
|
53
|
-
"@oh-my-pi/pi-tui": "14.5.
|
|
54
|
-
"@oh-my-pi/pi-utils": "14.5.
|
|
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
|
|
72
|
+
"@types/bun": "^1.3",
|
|
73
73
|
"@types/turndown": "5.0.6"
|
|
74
74
|
},
|
|
75
75
|
"engines": {
|
package/src/edit/modes/atom.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
262
|
-
if (typeof
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
if (/[\sA-Za-z0-9\\]/.test(delim)) {
|
|
277
|
+
if (pat.includes("\n")) {
|
|
276
278
|
throw new Error(
|
|
277
|
-
`Edit ${editIndex}: sed
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return { pattern:
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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 =
|
|
421
|
-
resolved.push({ op: "sed_file", spec, expression:
|
|
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 =
|
|
452
|
-
resolved.push({ op: "sed", pos: loc.pos, spec, expression:
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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"
|
|
273
|
-
|
|
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
|
-
|
|
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:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
59
|
-
`{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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:"
|
|
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 `
|
|
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 {
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -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 };
|
package/src/tools/resolve.ts
CHANGED
|
@@ -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);
|
package/src/tools/review.ts
CHANGED
|
@@ -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}` : ""}`;
|
package/src/tools/todo-write.ts
CHANGED
|
@@ -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);
|
package/src/tools/yield.ts
CHANGED
|
@@ -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;
|
package/src/utils/tool-choice.ts
CHANGED
|
@@ -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 (
|
|
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
|
|