@oh-my-pi/pi-coding-agent 14.9.7 → 14.9.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.
- package/CHANGELOG.md +17 -0
- package/package.json +7 -7
- package/scripts/generate-template.ts +4 -3
- package/src/export/html/index.ts +5 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.macro.ts +4 -3
- package/src/modes/components/read-tool-group.ts +9 -0
- package/src/prompts/tools/read.md +1 -0
- package/src/tools/conflict-detect.ts +661 -0
- package/src/tools/index.ts +6 -0
- package/src/tools/path-utils.ts +1 -1
- package/src/tools/read.ts +130 -0
- package/src/tools/write.ts +204 -0
package/src/tools/read.ts
CHANGED
|
@@ -33,6 +33,17 @@ import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "
|
|
|
33
33
|
import { convertFileWithMarkit } from "../utils/markit";
|
|
34
34
|
import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
|
|
35
35
|
import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader";
|
|
36
|
+
import {
|
|
37
|
+
type ConflictEntry,
|
|
38
|
+
type ConflictScope,
|
|
39
|
+
formatConflictSummary,
|
|
40
|
+
formatConflictWarning,
|
|
41
|
+
getConflictHistory,
|
|
42
|
+
parseConflictUri,
|
|
43
|
+
renderConflictRegion,
|
|
44
|
+
scanConflictLines,
|
|
45
|
+
scanFileForConflicts,
|
|
46
|
+
} from "./conflict-detect";
|
|
36
47
|
import {
|
|
37
48
|
executeReadUrl,
|
|
38
49
|
isReadableUrlPath,
|
|
@@ -455,6 +466,8 @@ export interface ReadToolDetails {
|
|
|
455
466
|
* so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
|
|
456
467
|
displayContent?: { text: string; startLine: number };
|
|
457
468
|
summary?: { lines: number; elidedSpans: number };
|
|
469
|
+
/** Number of unresolved git conflicts surfaced by this read (TUI uses for inline `⚠ N` badge). */
|
|
470
|
+
conflictCount?: number;
|
|
458
471
|
}
|
|
459
472
|
|
|
460
473
|
type ReadParams = ReadToolInput;
|
|
@@ -463,6 +476,7 @@ type ReadParams = ReadToolInput;
|
|
|
463
476
|
type ParsedSelector =
|
|
464
477
|
| { kind: "none" }
|
|
465
478
|
| { kind: "raw" }
|
|
479
|
+
| { kind: "conflicts" }
|
|
466
480
|
| { kind: "lines"; startLine: number; endLine: number | undefined; raw?: boolean };
|
|
467
481
|
|
|
468
482
|
const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
|
|
@@ -521,6 +535,7 @@ function parseSel(sel: string | undefined): ParsedSelector {
|
|
|
521
535
|
}
|
|
522
536
|
|
|
523
537
|
if (sel.toLowerCase() === "raw") return { kind: "raw" };
|
|
538
|
+
if (sel.toLowerCase() === "conflicts") return { kind: "conflicts" };
|
|
524
539
|
const range = parseLineRangeChunk(sel);
|
|
525
540
|
if (range) {
|
|
526
541
|
return { kind: "lines", startLine: range.startLine, endLine: range.endLine };
|
|
@@ -1153,6 +1168,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1153
1168
|
if (readPath.startsWith("file://")) {
|
|
1154
1169
|
readPath = expandPath(readPath);
|
|
1155
1170
|
}
|
|
1171
|
+
|
|
1172
|
+
const conflictUri = parseConflictUri(readPath);
|
|
1173
|
+
if (conflictUri) {
|
|
1174
|
+
if (conflictUri.id === "*") {
|
|
1175
|
+
throw new ToolError(
|
|
1176
|
+
"`read conflict://*` is not supported — wildcards are write-only. Use `read <path>:conflicts` for the full list of conflicts in a file, or `read conflict://<N>` to inspect a single block.",
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
return this.#readConflictRegion(conflictUri.id, conflictUri.scope);
|
|
1180
|
+
}
|
|
1156
1181
|
const displayMode = resolveFileDisplayMode(this.session);
|
|
1157
1182
|
|
|
1158
1183
|
const parsedUrlTarget = parseReadUrlTarget(readPath);
|
|
@@ -1257,6 +1282,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1257
1282
|
return dirResult;
|
|
1258
1283
|
}
|
|
1259
1284
|
|
|
1285
|
+
if (parsed.kind === "conflicts") {
|
|
1286
|
+
return this.#readFileConflicts(absolutePath, suffixResolution, signal);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1260
1289
|
const imageMetadata = await readImageMetadata(absolutePath);
|
|
1261
1290
|
const mimeType = imageMetadata?.mimeType;
|
|
1262
1291
|
const ext = path.extname(absolutePath).toLowerCase();
|
|
@@ -1517,6 +1546,38 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1517
1546
|
details.displayContent = capturedDisplayContent;
|
|
1518
1547
|
}
|
|
1519
1548
|
|
|
1549
|
+
if (!firstLineExceedsLimit && collectedLines.length > 0) {
|
|
1550
|
+
const blocks = scanConflictLines(collectedLines, startLineDisplay);
|
|
1551
|
+
if (blocks.length > 0) {
|
|
1552
|
+
const history = getConflictHistory(this.session);
|
|
1553
|
+
const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd);
|
|
1554
|
+
const entries = blocks.map(block =>
|
|
1555
|
+
history.register({
|
|
1556
|
+
absolutePath,
|
|
1557
|
+
displayPath: displayPathForWarning,
|
|
1558
|
+
...block,
|
|
1559
|
+
}),
|
|
1560
|
+
);
|
|
1561
|
+
// Cheap full-file scan only when the window already showed
|
|
1562
|
+
// at least one conflict — otherwise pay nothing on clean files.
|
|
1563
|
+
let totalInFile = entries.length;
|
|
1564
|
+
let scanTruncated = false;
|
|
1565
|
+
try {
|
|
1566
|
+
const fileScan = await scanFileForConflicts(absolutePath);
|
|
1567
|
+
totalInFile = Math.max(entries.length, fileScan.blocks.length);
|
|
1568
|
+
scanTruncated = fileScan.scanTruncated;
|
|
1569
|
+
} catch {
|
|
1570
|
+
// Best-effort enrichment; fall back to window-only count.
|
|
1571
|
+
}
|
|
1572
|
+
outputText += formatConflictWarning(entries, {
|
|
1573
|
+
totalInFile,
|
|
1574
|
+
displayPath: displayPathForWarning,
|
|
1575
|
+
scanTruncated,
|
|
1576
|
+
});
|
|
1577
|
+
details.conflictCount = entries.length;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1520
1581
|
content = [{ type: "text", text: outputText }];
|
|
1521
1582
|
}
|
|
1522
1583
|
}
|
|
@@ -1542,6 +1603,71 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1542
1603
|
return resultBuilder.done();
|
|
1543
1604
|
}
|
|
1544
1605
|
|
|
1606
|
+
/**
|
|
1607
|
+
* Render a `conflict://<N>` (or `conflict://<N>/<scope>`) region as
|
|
1608
|
+
* regular file content. The lines are emitted with their original
|
|
1609
|
+
* file line numbers so hashline anchors line up with the source
|
|
1610
|
+
* file, and no truncation footer is appended.
|
|
1611
|
+
*/
|
|
1612
|
+
async #readConflictRegion(id: number, scope: ConflictScope | undefined): Promise<AgentToolResult<ReadToolDetails>> {
|
|
1613
|
+
const entry: ConflictEntry | undefined = getConflictHistory(this.session).get(id);
|
|
1614
|
+
if (!entry) {
|
|
1615
|
+
throw new ToolError(
|
|
1616
|
+
`Conflict #${id} not found. Conflict ids are registered when \`read\` surfaces a marker block; re-read the file to get a current id.`,
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
const region = renderConflictRegion(entry, scope);
|
|
1621
|
+
const displayMode = resolveFileDisplayMode(this.session);
|
|
1622
|
+
const shouldAddHashLines = displayMode.hashLines;
|
|
1623
|
+
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
1624
|
+
|
|
1625
|
+
const rawText = region.lines.join("\n");
|
|
1626
|
+
const formattedText = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers);
|
|
1627
|
+
|
|
1628
|
+
const details: ReadToolDetails = {
|
|
1629
|
+
resolvedPath: entry.absolutePath,
|
|
1630
|
+
displayContent: { text: rawText, startLine: region.startLine },
|
|
1631
|
+
};
|
|
1632
|
+
return toolResult<ReadToolDetails>(details).text(formattedText).sourcePath(entry.absolutePath).done();
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* Implement `read <path>:conflicts`: scan the whole file once, register
|
|
1637
|
+
* every block in the session's conflict history, and return a compact
|
|
1638
|
+
* `#N L_a-L_b` index instead of file content. Designed for heavily
|
|
1639
|
+
* conflicted files where dumping every body would be wasteful.
|
|
1640
|
+
*/
|
|
1641
|
+
async #readFileConflicts(
|
|
1642
|
+
absolutePath: string,
|
|
1643
|
+
suffixResolution: { from: string; to: string } | undefined,
|
|
1644
|
+
signal: AbortSignal | undefined,
|
|
1645
|
+
): Promise<AgentToolResult<ReadToolDetails>> {
|
|
1646
|
+
throwIfAborted(signal);
|
|
1647
|
+
const scan = await scanFileForConflicts(absolutePath);
|
|
1648
|
+
const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
|
|
1649
|
+
const history = getConflictHistory(this.session);
|
|
1650
|
+
const entries = scan.blocks.map(block =>
|
|
1651
|
+
history.register({
|
|
1652
|
+
absolutePath,
|
|
1653
|
+
displayPath,
|
|
1654
|
+
...block,
|
|
1655
|
+
}),
|
|
1656
|
+
);
|
|
1657
|
+
|
|
1658
|
+
const summary =
|
|
1659
|
+
entries.length === 0
|
|
1660
|
+
? `No unresolved git merge conflicts in ${displayPath}.`
|
|
1661
|
+
: formatConflictSummary(entries, { displayPath, scanTruncated: scan.scanTruncated });
|
|
1662
|
+
|
|
1663
|
+
const details: ReadToolDetails = {
|
|
1664
|
+
resolvedPath: absolutePath,
|
|
1665
|
+
suffixResolution,
|
|
1666
|
+
conflictCount: entries.length,
|
|
1667
|
+
};
|
|
1668
|
+
return toolResult<ReadToolDetails>(details).text(summary).sourcePath(absolutePath).done();
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1545
1671
|
/**
|
|
1546
1672
|
* Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://).
|
|
1547
1673
|
* Supports pagination via offset/limit but rejects them when query extraction is used.
|
|
@@ -1763,6 +1889,10 @@ export const readToolRenderer = {
|
|
|
1763
1889
|
if (details?.summary) {
|
|
1764
1890
|
title += ` (summary: ${details.summary.elidedSpans} elided span${details.summary.elidedSpans === 1 ? "" : "s"})`;
|
|
1765
1891
|
}
|
|
1892
|
+
if (details?.conflictCount && details.conflictCount > 0) {
|
|
1893
|
+
const n = details.conflictCount;
|
|
1894
|
+
title += ` ${uiTheme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
|
|
1895
|
+
}
|
|
1766
1896
|
let cachedWidth: number | undefined;
|
|
1767
1897
|
let cachedLines: string[] | undefined;
|
|
1768
1898
|
return {
|
package/src/tools/write.ts
CHANGED
|
@@ -16,6 +16,13 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth }
|
|
|
16
16
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
17
17
|
import { parseArchivePathCandidates } from "./archive-reader";
|
|
18
18
|
import { assertEditableFile } from "./auto-generated-guard";
|
|
19
|
+
import {
|
|
20
|
+
type ConflictEntry,
|
|
21
|
+
expandContentTokens,
|
|
22
|
+
getConflictHistory,
|
|
23
|
+
parseConflictUri,
|
|
24
|
+
spliceConflict,
|
|
25
|
+
} from "./conflict-detect";
|
|
19
26
|
import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
|
|
20
27
|
import { type OutputMeta, outputMeta } from "./output-meta";
|
|
21
28
|
import { formatPathRelativeToCwd } from "./path-utils";
|
|
@@ -423,6 +430,185 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
423
430
|
}
|
|
424
431
|
}
|
|
425
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Resolve a single `conflict://<N>` write by splicing the recorded
|
|
435
|
+
* marker region in the registered file with `replacementContent`,
|
|
436
|
+
* then routing the new file content through the normal writethrough
|
|
437
|
+
* pipeline so LSP format/diagnostics still run.
|
|
438
|
+
*
|
|
439
|
+
* Entry ids are session-stable: they keep working even after later
|
|
440
|
+
* writes resolve other blocks in the same file. The recorded range
|
|
441
|
+
* is re-validated on disk before splicing so an out-of-band edit
|
|
442
|
+
* surfaces as a clear error instead of corrupting the file.
|
|
443
|
+
*/
|
|
444
|
+
async #resolveConflict(
|
|
445
|
+
entry: ConflictEntry,
|
|
446
|
+
replacementContent: string,
|
|
447
|
+
stripped: boolean,
|
|
448
|
+
signal: AbortSignal | undefined,
|
|
449
|
+
context: AgentToolContext | undefined,
|
|
450
|
+
): Promise<AgentToolResult<WriteToolDetails>> {
|
|
451
|
+
const absolutePath = entry.absolutePath;
|
|
452
|
+
if (!(await fs.exists(absolutePath))) {
|
|
453
|
+
throw new ToolError(`Conflict #${entry.id} target '${entry.displayPath}' no longer exists.`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const expanded = expandContentTokens(replacementContent, entry);
|
|
457
|
+
const originalText = await Bun.file(absolutePath).text();
|
|
458
|
+
const newContent = spliceConflict(originalText, entry, expanded);
|
|
459
|
+
|
|
460
|
+
const batchRequest = getLspBatchRequest(context?.toolCall);
|
|
461
|
+
const diagnostics = await this.#writethrough(absolutePath, newContent, signal, undefined, batchRequest);
|
|
462
|
+
invalidateFsScanAfterWrite(absolutePath);
|
|
463
|
+
this.session.fileReadCache?.invalidate(absolutePath);
|
|
464
|
+
this.session.conflictHistory?.invalidate(entry.id);
|
|
465
|
+
|
|
466
|
+
const range =
|
|
467
|
+
entry.startLine === entry.endLine
|
|
468
|
+
? `line ${entry.startLine}`
|
|
469
|
+
: `lines ${entry.startLine}\u2013${entry.endLine}`;
|
|
470
|
+
let resultText = `Resolved conflict #${entry.id} at ${range} in ${entry.displayPath}.`;
|
|
471
|
+
if (stripped) {
|
|
472
|
+
resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!diagnostics) {
|
|
476
|
+
return {
|
|
477
|
+
content: [{ type: "text", text: resultText }],
|
|
478
|
+
details: {},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
content: [{ type: "text", text: resultText }],
|
|
483
|
+
details: {
|
|
484
|
+
diagnostics,
|
|
485
|
+
meta: outputMeta()
|
|
486
|
+
.diagnostics(diagnostics.summary, diagnostics.messages ?? [])
|
|
487
|
+
.get(),
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Bulk-resolve every registered conflict via `conflict://*`.
|
|
494
|
+
*
|
|
495
|
+
* Entries are grouped by file and applied bottom-up by recorded start
|
|
496
|
+
* line so each splice keeps later anchors valid. `content` tokens are
|
|
497
|
+
* expanded *per entry*, so `content: "@ours"` keeps each block's own
|
|
498
|
+
* ours side rather than collapsing every conflict to the first
|
|
499
|
+
* block's ours.
|
|
500
|
+
*
|
|
501
|
+
* All-or-nothing semantics within a file: if any splice for a file
|
|
502
|
+
* fails (stale anchors, missing base for `@base`, etc.), that file is
|
|
503
|
+
* left untouched and the error is surfaced. Files that succeed are
|
|
504
|
+
* still written. The result text reports per-file counts so the agent
|
|
505
|
+
* can re-read the failed files and retry.
|
|
506
|
+
*/
|
|
507
|
+
async #resolveAllConflicts(
|
|
508
|
+
replacementContent: string,
|
|
509
|
+
stripped: boolean,
|
|
510
|
+
signal: AbortSignal | undefined,
|
|
511
|
+
context: AgentToolContext | undefined,
|
|
512
|
+
): Promise<AgentToolResult<WriteToolDetails>> {
|
|
513
|
+
const history = getConflictHistory(this.session);
|
|
514
|
+
const allEntries = history.entries();
|
|
515
|
+
if (allEntries.length === 0) {
|
|
516
|
+
throw new ToolError(
|
|
517
|
+
"`conflict://*` has nothing to resolve — no conflicts are currently registered. Re-read the file(s) with conflicts first.",
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const byFile = new Map<string, ConflictEntry[]>();
|
|
522
|
+
for (const entry of allEntries) {
|
|
523
|
+
const bucket = byFile.get(entry.absolutePath) ?? [];
|
|
524
|
+
bucket.push(entry);
|
|
525
|
+
byFile.set(entry.absolutePath, bucket);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const batchRequest = getLspBatchRequest(context?.toolCall);
|
|
529
|
+
const allDiagnostics: FileDiagnosticsResult[] = [];
|
|
530
|
+
const succeededFiles: { displayPath: string; count: number }[] = [];
|
|
531
|
+
const failedFiles: { displayPath: string; count: number; error: string }[] = [];
|
|
532
|
+
let totalResolvedIds = 0;
|
|
533
|
+
|
|
534
|
+
for (const [absolutePath, fileEntries] of byFile) {
|
|
535
|
+
const sample = fileEntries[0]!;
|
|
536
|
+
if (!(await fs.exists(absolutePath))) {
|
|
537
|
+
failedFiles.push({
|
|
538
|
+
displayPath: sample.displayPath,
|
|
539
|
+
count: fileEntries.length,
|
|
540
|
+
error: "file no longer exists",
|
|
541
|
+
});
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
fileEntries.sort((a, b) => b.startLine - a.startLine);
|
|
546
|
+
|
|
547
|
+
let text: string;
|
|
548
|
+
try {
|
|
549
|
+
text = await Bun.file(absolutePath).text();
|
|
550
|
+
for (const entry of fileEntries) {
|
|
551
|
+
const expanded = expandContentTokens(replacementContent, entry);
|
|
552
|
+
text = spliceConflict(text, entry, expanded);
|
|
553
|
+
}
|
|
554
|
+
} catch (error) {
|
|
555
|
+
failedFiles.push({
|
|
556
|
+
displayPath: sample.displayPath,
|
|
557
|
+
count: fileEntries.length,
|
|
558
|
+
error: error instanceof Error ? error.message : String(error),
|
|
559
|
+
});
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const diagnostics = await this.#writethrough(absolutePath, text, signal, undefined, batchRequest);
|
|
564
|
+
invalidateFsScanAfterWrite(absolutePath);
|
|
565
|
+
this.session.fileReadCache?.invalidate(absolutePath);
|
|
566
|
+
for (const entry of fileEntries) history.invalidate(entry.id);
|
|
567
|
+
succeededFiles.push({ displayPath: sample.displayPath, count: fileEntries.length });
|
|
568
|
+
totalResolvedIds += fileEntries.length;
|
|
569
|
+
if (diagnostics) allDiagnostics.push(diagnostics);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const summaryLines: string[] = [];
|
|
573
|
+
const fileWord = (n: number) => (n === 1 ? "file" : "files");
|
|
574
|
+
const conflictWord = (n: number) => (n === 1 ? "conflict" : "conflicts");
|
|
575
|
+
if (succeededFiles.length > 0) {
|
|
576
|
+
summaryLines.push(
|
|
577
|
+
`Resolved ${totalResolvedIds} ${conflictWord(totalResolvedIds)} across ${succeededFiles.length} ${fileWord(succeededFiles.length)}:`,
|
|
578
|
+
);
|
|
579
|
+
for (const file of succeededFiles) {
|
|
580
|
+
summaryLines.push(` ${file.displayPath}: ${file.count} ${conflictWord(file.count)}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (failedFiles.length > 0) {
|
|
584
|
+
summaryLines.push(
|
|
585
|
+
`Failed to resolve ${failedFiles.length} ${fileWord(failedFiles.length)} — registered entries left intact for retry:`,
|
|
586
|
+
);
|
|
587
|
+
for (const file of failedFiles) {
|
|
588
|
+
summaryLines.push(` ${file.displayPath}: ${file.count} ${conflictWord(file.count)} (${file.error})`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (stripped) {
|
|
592
|
+
summaryLines.push("Note: auto-stripped hashline display prefixes from content before writing.");
|
|
593
|
+
}
|
|
594
|
+
const resultText = summaryLines.join("\n");
|
|
595
|
+
|
|
596
|
+
if (allDiagnostics.length === 0) {
|
|
597
|
+
if (failedFiles.length > 0 && succeededFiles.length === 0) {
|
|
598
|
+
throw new ToolError(resultText);
|
|
599
|
+
}
|
|
600
|
+
return { content: [{ type: "text", text: resultText }], details: {} };
|
|
601
|
+
}
|
|
602
|
+
const mergedSummary = allDiagnostics.map(d => d.summary).join("\n");
|
|
603
|
+
const mergedMessages = allDiagnostics.flatMap(d => d.messages ?? []);
|
|
604
|
+
return {
|
|
605
|
+
content: [{ type: "text", text: resultText }],
|
|
606
|
+
details: {
|
|
607
|
+
meta: outputMeta().diagnostics(mergedSummary, mergedMessages).get(),
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
426
612
|
async execute(
|
|
427
613
|
_toolCallId: string,
|
|
428
614
|
{ path, content }: WriteParams,
|
|
@@ -433,6 +619,24 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
433
619
|
return untilAborted(signal, async () => {
|
|
434
620
|
// Strip hashline display prefixes (LINE+ID|) if the model copied them from read output
|
|
435
621
|
const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
|
|
622
|
+
const conflictUri = parseConflictUri(path);
|
|
623
|
+
if (conflictUri) {
|
|
624
|
+
if (conflictUri.scope) {
|
|
625
|
+
throw new ToolError(
|
|
626
|
+
`Conflict URI scope '/${conflictUri.scope}' is read-only — use \`read conflict://${conflictUri.id}/${conflictUri.scope}\` to inspect that side. To write, drop the scope (\`conflict://${conflictUri.id}\`) and put the chosen content (or shorthand like \`@${conflictUri.scope}\`) in \`content\`.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (conflictUri.id === "*") {
|
|
630
|
+
return this.#resolveAllConflicts(cleanContent, stripped, signal, context);
|
|
631
|
+
}
|
|
632
|
+
const entry = getConflictHistory(this.session).get(conflictUri.id);
|
|
633
|
+
if (!entry) {
|
|
634
|
+
throw new ToolError(
|
|
635
|
+
`Conflict #${conflictUri.id} not found. Conflict ids are registered when \`read\` surfaces a marker block; re-read the file to get a current id.`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
return this.#resolveConflict(entry, cleanContent, stripped, signal, context);
|
|
639
|
+
}
|
|
436
640
|
const resolvedArchivePath = await this.#resolveArchiveWritePath(path);
|
|
437
641
|
if (resolvedArchivePath) {
|
|
438
642
|
enforcePlanModeWrite(this.session, resolvedArchivePath.archivePath, {
|