topchester-ai 0.9.0 → 0.10.0

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/dist/cli.mjs CHANGED
@@ -28,13 +28,20 @@ function toAiSdkToolSet(definitions) {
28
28
  //#region src/knowledge/session-overlay.ts
29
29
  const overlays = /* @__PURE__ */ new Map();
30
30
  function recordAgentFileEdit(workspaceRoot, event) {
31
+ return recordAgentFileMutation(workspaceRoot, event);
32
+ }
33
+ function recordAgentFileCreate(workspaceRoot, event) {
34
+ return recordAgentFileMutation(workspaceRoot, event);
35
+ }
36
+ function recordAgentFileMutation(workspaceRoot, event) {
31
37
  const overlay = getOrCreateOverlay(resolve(workspaceRoot));
32
38
  const previous = overlay.dirtyFiles.get(event.path);
33
39
  overlay.drift = "dirty_known";
34
40
  overlay.kbState = "needs_sync";
35
41
  overlay.needsSync = true;
36
42
  overlay.updatedAt = event.timestamp;
37
- overlay.editEvents.push(event);
43
+ overlay.mutationEvents.push(event);
44
+ if (event.kind === "file_edit") overlay.editEvents.push(event);
38
45
  overlay.dirtyFiles.set(event.path, {
39
46
  path: event.path,
40
47
  source: "agent",
@@ -42,7 +49,7 @@ function recordAgentFileEdit(workspaceRoot, event) {
42
49
  kbState: "needs_sync",
43
50
  l1State: "stale",
44
51
  derivedState: "suspect",
45
- beforeHash: previous?.beforeHash ?? event.beforeHash,
52
+ beforeHash: previous?.beforeHash ?? (event.kind === "file_create" ? void 0 : event.beforeHash),
46
53
  afterHash: event.afterHash,
47
54
  firstChangedLine: event.firstChangedLine,
48
55
  lastEditedAt: event.timestamp,
@@ -59,7 +66,8 @@ function getOrCreateOverlay(workspaceRoot) {
59
66
  kbState: "current",
60
67
  needsSync: false,
61
68
  dirtyFiles: /* @__PURE__ */ new Map(),
62
- editEvents: []
69
+ editEvents: [],
70
+ mutationEvents: []
63
71
  };
64
72
  overlays.set(workspaceRoot, overlay);
65
73
  return overlay;
@@ -72,6 +80,7 @@ function snapshotOverlay(overlay) {
72
80
  needsSync: overlay.needsSync,
73
81
  dirtyFiles: [...overlay.dirtyFiles.values()].map((file) => ({ ...file })),
74
82
  editEvents: overlay.editEvents.map((event) => ({ ...event })),
83
+ mutationEvents: overlay.mutationEvents.map((event) => ({ ...event })),
75
84
  updatedAt: overlay.updatedAt
76
85
  };
77
86
  }
@@ -109,16 +118,16 @@ const editFileTool = defineTool({
109
118
  execute: (context, args) => editWorkspaceFile(context.workspaceRoot, args, { logger: context.logger })
110
119
  });
111
120
  async function editWorkspaceFile(workspaceRoot, args, options = {}) {
112
- const scopedPath = resolveWorkspaceScopedPath$3(workspaceRoot, args.path);
121
+ const scopedPath = resolveWorkspaceScopedPath$4(workspaceRoot, args.path);
113
122
  return enqueueFileMutation(scopedPath.path, async () => {
114
123
  const fileStat = await statExistingFile(scopedPath.path, args.path);
115
124
  const beforeBytes = await readFile(scopedPath.path);
116
- const beforeHash = hashBytes(beforeBytes);
125
+ const beforeHash = hashBytes$1(beforeBytes);
117
126
  if (args.expected_hash && args.expected_hash !== beforeHash) throw new Error(`edit_file expected_hash did not match ${scopedPath.relativePath}.`);
118
- const result = applyExactEdits(decodeUtf8(scopedPath.relativePath, beforeBytes), args.edits, scopedPath.relativePath);
127
+ const result = applyExactEdits(decodeUtf8$1(scopedPath.relativePath, beforeBytes), args.edits, scopedPath.relativePath);
119
128
  const afterBytes = Buffer.from(result.newContent, "utf8");
120
- const afterHash = hashBytes(afterBytes);
121
- await writeFileAtomically(scopedPath.path, afterBytes, fileStat.mode);
129
+ const afterHash = hashBytes$1(afterBytes);
130
+ await writeFileAtomically$1(scopedPath.path, afterBytes, fileStat.mode);
122
131
  const editEvent = {
123
132
  kind: "file_edit",
124
133
  source: "agent",
@@ -172,7 +181,7 @@ function applyExactEdits(content, edits, path = "file") {
172
181
  firstChangedLine: getFirstChangedLine(document.body, newBody)
173
182
  };
174
183
  }
175
- function resolveWorkspaceScopedPath$3(workspaceRoot, path) {
184
+ function resolveWorkspaceScopedPath$4(workspaceRoot, path) {
176
185
  if (path.includes("\0")) throw new Error("edit_file path is invalid.");
177
186
  const resolvedWorkspace = resolve(workspaceRoot);
178
187
  const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
@@ -189,13 +198,13 @@ async function statExistingFile(resolvedPath, originalPath) {
189
198
  try {
190
199
  fileStat = await stat(resolvedPath);
191
200
  } catch (error) {
192
- if (isNodeError$2(error) && error.code === "ENOENT") throw new Error(`edit_file can only edit existing files: ${originalPath}`);
201
+ if (isNodeError$3(error) && error.code === "ENOENT") throw new Error(`edit_file can only edit existing files: ${originalPath}`);
193
202
  throw error;
194
203
  }
195
204
  if (!fileStat.isFile()) throw new Error(`edit_file can only edit regular files: ${originalPath}`);
196
205
  return fileStat;
197
206
  }
198
- function decodeUtf8(path, bytes) {
207
+ function decodeUtf8$1(path, bytes) {
199
208
  try {
200
209
  return new TextDecoder("utf-8", {
201
210
  fatal: true,
@@ -205,7 +214,7 @@ function decodeUtf8(path, bytes) {
205
214
  throw new Error(`edit_file can only edit UTF-8 text files: ${path}`);
206
215
  }
207
216
  }
208
- async function writeFileAtomically(path, content, mode) {
217
+ async function writeFileAtomically$1(path, content, mode) {
209
218
  const temporaryPath = resolve(dirname(path), `.${basename(path)}.topchester-${process.pid}-${randomUUID()}.tmp`);
210
219
  try {
211
220
  await writeFile(temporaryPath, content, {
@@ -218,7 +227,7 @@ async function writeFileAtomically(path, content, mode) {
218
227
  throw error;
219
228
  }
220
229
  }
221
- function hashBytes(bytes) {
230
+ function hashBytes$1(bytes) {
222
231
  return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
223
232
  }
224
233
  function summarizeDiff(diff) {
@@ -231,7 +240,7 @@ function summarizeDiff(diff) {
231
240
  }
232
241
  return `+${added}/-${removed}`;
233
242
  }
234
- function isNodeError$2(error) {
243
+ function isNodeError$3(error) {
235
244
  return error instanceof Error && "code" in error;
236
245
  }
237
246
  function normalizeEdits(edits) {
@@ -376,7 +385,7 @@ const findFileTool = defineTool({
376
385
  })
377
386
  });
378
387
  async function findWorkspaceFilesByName(workspaceRoot, args, options = {}) {
379
- const scopedPath = resolveWorkspaceScopedPath$2(workspaceRoot, args.path);
388
+ const scopedPath = resolveWorkspaceScopedPath$3(workspaceRoot, args.path);
380
389
  const matches = (await collectWorkspaceFiles(scopedPath.workspaceRoot, scopedPath.path, scopedPath.relativePath, options)).map((path) => {
381
390
  const score = scoreFileMatch(args.query, path);
382
391
  return score > 0 ? {
@@ -574,7 +583,7 @@ function scoreSubsequenceMatch(query, value) {
574
583
  function normalize(value) {
575
584
  return value.trim().toLowerCase().replaceAll("\\", "/");
576
585
  }
577
- function resolveWorkspaceScopedPath$2(workspaceRoot, path) {
586
+ function resolveWorkspaceScopedPath$3(workspaceRoot, path) {
578
587
  const resolvedWorkspace = resolve(workspaceRoot);
579
588
  const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
580
589
  const relativePath = relative(resolvedWorkspace, resolvedPath);
@@ -618,6 +627,714 @@ function getExitCode$1(error) {
618
627
  function isRecord$3(value) {
619
628
  return typeof value === "object" && value !== null;
620
629
  }
630
+ //#endregion
631
+ //#region src/agent/tools/git-runner.ts
632
+ const DEFAULT_TIMEOUT_MS = 1e4;
633
+ const DEFAULT_MAX_OUTPUT_BYTES = 4e4;
634
+ const GIT_FIELD_SEPARATOR = "";
635
+ async function runGit(options) {
636
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
637
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
638
+ const args = [
639
+ "--no-optional-locks",
640
+ "-c",
641
+ "core.quotepath=false",
642
+ "-c",
643
+ "core.fsmonitor=false",
644
+ ...options.args
645
+ ];
646
+ return new Promise((resolveCommand) => {
647
+ const child = spawn("git", args, {
648
+ cwd: options.cwd,
649
+ env: {
650
+ ...process.env,
651
+ PATH: options.pathEnv ?? process.env.PATH ?? "",
652
+ GIT_OPTIONAL_LOCKS: "0",
653
+ GIT_PAGER: "cat",
654
+ PAGER: "cat",
655
+ LESS: "-F -X"
656
+ },
657
+ stdio: [
658
+ "ignore",
659
+ "pipe",
660
+ "pipe"
661
+ ]
662
+ });
663
+ const stdoutChunks = [];
664
+ const stderrChunks = [];
665
+ let stdoutBytes = 0;
666
+ let stderrBytes = 0;
667
+ let truncated = false;
668
+ let timedOut = false;
669
+ let settled = false;
670
+ const timer = setTimeout(() => {
671
+ timedOut = true;
672
+ child.kill("SIGTERM");
673
+ setTimeout(() => {
674
+ if (!settled) child.kill("SIGKILL");
675
+ }, 500).unref();
676
+ }, timeoutMs);
677
+ child.stdout.on("data", (chunk) => {
678
+ const appended = appendBoundedChunk(stdoutChunks, stdoutBytes, chunk, maxOutputBytes);
679
+ stdoutBytes = appended.bytes;
680
+ truncated = truncated || appended.truncated;
681
+ });
682
+ child.stderr.on("data", (chunk) => {
683
+ const appended = appendBoundedChunk(stderrChunks, stderrBytes, chunk, Math.min(8e3, maxOutputBytes));
684
+ stderrBytes = appended.bytes;
685
+ truncated = truncated || appended.truncated;
686
+ });
687
+ child.once("error", (error) => {
688
+ clearTimeout(timer);
689
+ settled = true;
690
+ resolveCommand({
691
+ stdout: "",
692
+ stderr: error.code === "ENOENT" ? "git is not available on PATH.\n" : `${error.message}\n`,
693
+ exitCode: 127,
694
+ timedOut: false,
695
+ truncated: false,
696
+ binary: false,
697
+ missingGit: error.code === "ENOENT"
698
+ });
699
+ });
700
+ child.once("close", (code) => {
701
+ clearTimeout(timer);
702
+ settled = true;
703
+ const stdout = Buffer.concat(stdoutChunks);
704
+ const stderr = Buffer.concat(stderrChunks);
705
+ const binary = !options.allowNulOutput && (containsNul(stdout) || containsNul(stderr));
706
+ resolveCommand({
707
+ stdout: binary ? "" : stdout.toString("utf8"),
708
+ stderr: binary ? "git output contained binary data.\n" : stderr.toString("utf8"),
709
+ exitCode: code ?? (timedOut ? 124 : 1),
710
+ timedOut,
711
+ truncated,
712
+ binary,
713
+ missingGit: false
714
+ });
715
+ });
716
+ });
717
+ }
718
+ function ensureInsideWorkspace(workspaceRoot, path) {
719
+ if (path.length === 0 || path.includes("\0")) throw new Error("Git path is invalid.");
720
+ const resolvedWorkspace = resolve(workspaceRoot);
721
+ const absolutePath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
722
+ const relativePath = relative(resolvedWorkspace, absolutePath);
723
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`Git path must stay inside the workspace: ${path}`);
724
+ return {
725
+ workspaceRoot: resolvedWorkspace,
726
+ absolutePath,
727
+ relativePath: relativePath || "."
728
+ };
729
+ }
730
+ async function getRepoInfo(workspaceRoot, pathEnv) {
731
+ const workspace = await realpath(resolve(workspaceRoot));
732
+ const root = await runGit({
733
+ cwd: workspace,
734
+ pathEnv,
735
+ args: ["rev-parse", "--show-toplevel"],
736
+ maxOutputBytes: 8e3
737
+ });
738
+ if (root.missingGit) return {
739
+ available: false,
740
+ missingGit: true,
741
+ repoRoot: null,
742
+ repoRootAbsolute: null,
743
+ branch: null,
744
+ head: null,
745
+ hasHead: false,
746
+ message: "git is not available on PATH."
747
+ };
748
+ if (root.exitCode !== 0) return {
749
+ available: false,
750
+ missingGit: false,
751
+ repoRoot: null,
752
+ repoRootAbsolute: null,
753
+ branch: null,
754
+ head: null,
755
+ hasHead: false,
756
+ message: "This workspace is not inside a Git repository."
757
+ };
758
+ const repoRootAbsolute = root.stdout.trim();
759
+ const repoRoot = relative(workspace, repoRootAbsolute) || ".";
760
+ const hasHead = (await runGit({
761
+ cwd: workspace,
762
+ pathEnv,
763
+ args: [
764
+ "rev-parse",
765
+ "--verify",
766
+ "HEAD"
767
+ ],
768
+ maxOutputBytes: 8e3
769
+ })).exitCode === 0;
770
+ const branchResult = await runGit({
771
+ cwd: workspace,
772
+ pathEnv,
773
+ args: [
774
+ "symbolic-ref",
775
+ "--quiet",
776
+ "--short",
777
+ "HEAD"
778
+ ],
779
+ maxOutputBytes: 8e3
780
+ });
781
+ const headResult = hasHead ? await runGit({
782
+ cwd: workspace,
783
+ pathEnv,
784
+ args: [
785
+ "rev-parse",
786
+ "--short",
787
+ "HEAD"
788
+ ],
789
+ maxOutputBytes: 8e3
790
+ }) : void 0;
791
+ return {
792
+ available: true,
793
+ missingGit: false,
794
+ repoRoot,
795
+ repoRootAbsolute,
796
+ branch: branchResult.exitCode === 0 ? branchResult.stdout.trim() : null,
797
+ head: headResult?.exitCode === 0 ? headResult.stdout.trim() : null,
798
+ hasHead
799
+ };
800
+ }
801
+ function parsePorcelainStatus(output) {
802
+ return output.split("\0").filter(Boolean).map((entry) => {
803
+ const indexStatus = entry[0] ?? " ";
804
+ const worktreeStatus = entry[1] ?? " ";
805
+ const path = entry.slice(3);
806
+ const untracked = indexStatus === "?" && worktreeStatus === "?";
807
+ const staged = !untracked && indexStatus !== " ";
808
+ const unstaged = !untracked && worktreeStatus !== " ";
809
+ return {
810
+ path,
811
+ indexStatus,
812
+ worktreeStatus,
813
+ status: classifyStatus(indexStatus, worktreeStatus),
814
+ staged,
815
+ unstaged,
816
+ untracked
817
+ };
818
+ });
819
+ }
820
+ function parseGitLog(output) {
821
+ return output.split("\n").filter(Boolean).map((line) => {
822
+ const [sha = "", shortSha = "", timestamp = "0", authorName = "", subject = ""] = line.split(GIT_FIELD_SEPARATOR);
823
+ return {
824
+ sha,
825
+ shortSha,
826
+ timestamp: Number(timestamp),
827
+ authorName,
828
+ subject
829
+ };
830
+ });
831
+ }
832
+ function truncateText(content, maxBytes) {
833
+ if (Buffer.byteLength(content) <= maxBytes) return {
834
+ content,
835
+ truncated: false
836
+ };
837
+ return {
838
+ content: Buffer.from(content, "utf8").subarray(0, maxBytes).toString("utf8"),
839
+ truncated: true
840
+ };
841
+ }
842
+ function gitLogPrettyFormat() {
843
+ return `%H%x1f%h%x1f%ct%x1f%an%x1f%s`;
844
+ }
845
+ function classifyStatus(indexStatus, worktreeStatus) {
846
+ if (indexStatus === "?" && worktreeStatus === "?") return "untracked";
847
+ if (indexStatus === "U" || worktreeStatus === "U" || indexStatus === "A" && worktreeStatus === "A") return "conflicted";
848
+ if (indexStatus === "R") return "renamed";
849
+ if (indexStatus === "C") return "copied";
850
+ if (indexStatus === "D" || worktreeStatus === "D") return "deleted";
851
+ if (indexStatus === "A") return "added";
852
+ if (indexStatus === "M" || worktreeStatus === "M") return "modified";
853
+ return "unknown";
854
+ }
855
+ function appendBoundedChunk(chunks, currentBytes, chunk, maxBytes) {
856
+ if (currentBytes >= maxBytes) return {
857
+ bytes: currentBytes,
858
+ truncated: true
859
+ };
860
+ const remaining = maxBytes - currentBytes;
861
+ const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
862
+ chunks.push(slice);
863
+ return {
864
+ bytes: currentBytes + slice.length,
865
+ truncated: chunk.length > remaining
866
+ };
867
+ }
868
+ function containsNul(buffer) {
869
+ return buffer.includes(0);
870
+ }
871
+ //#endregion
872
+ //#region src/agent/tools/git.ts
873
+ const gitStatusValueSchema = z.enum([
874
+ "added",
875
+ "modified",
876
+ "deleted",
877
+ "renamed",
878
+ "copied",
879
+ "untracked",
880
+ "conflicted",
881
+ "unknown"
882
+ ]);
883
+ const gitStatusArgsSchema = z.object({
884
+ path: z.string().optional().default("."),
885
+ include_untracked: z.boolean().optional().default(true)
886
+ });
887
+ const gitDiffArgsSchema = z.object({
888
+ scope: z.enum([
889
+ "all",
890
+ "unstaged",
891
+ "staged"
892
+ ]).optional().default("all"),
893
+ path: z.string().optional(),
894
+ include_untracked: z.boolean().optional().default(false),
895
+ context_lines: z.number().int().min(0).max(20).optional().default(3),
896
+ max_bytes: z.number().int().min(1e3).max(2e5).optional().default(4e4)
897
+ });
898
+ const gitLogArgsSchema = z.object({
899
+ limit: z.number().int().min(1).max(50).optional().default(10),
900
+ path: z.string().optional()
901
+ });
902
+ const gitAddArgsSchema = z.object({
903
+ paths: z.array(z.string().min(1)).min(1),
904
+ expected_status: z.array(z.object({
905
+ path: z.string().min(1),
906
+ status: gitStatusValueSchema
907
+ })).min(1)
908
+ });
909
+ const gitCommitArgsSchema = z.object({
910
+ message: z.string().trim().min(1).max(500),
911
+ expected_staged_paths: z.array(z.string().min(1)).min(1)
912
+ });
913
+ const gitStatusTool = defineTool({
914
+ name: "git_status",
915
+ description: "Inspect structured Git branch and changed-file status inside the workspace.",
916
+ prompt: "git_status: inspect branch, head, clean state, staged, unstaged, and untracked files without parsing shell output. To use it, reply with only JSON: {\"tool\":\"git_status\",\"args\":{\"path\":\".\",\"include_untracked\":true}}",
917
+ argsSchema: gitStatusArgsSchema,
918
+ execute: (context, args) => inspectGitStatus(context, args)
919
+ });
920
+ const gitDiffTool = defineTool({
921
+ name: "git_diff",
922
+ description: "Inspect bounded Git diffs for staged, unstaged, and optionally untracked files.",
923
+ prompt: "git_diff: inspect a bounded Git diff; use scope \"all\", \"unstaged\", or \"staged\", and include_untracked:true only when untracked file patches are needed. To use it, reply with only JSON: {\"tool\":\"git_diff\",\"args\":{\"scope\":\"all\",\"include_untracked\":true}}",
924
+ argsSchema: gitDiffArgsSchema,
925
+ execute: (context, args) => inspectGitDiff(context, args)
926
+ });
927
+ const gitLogTool = defineTool({
928
+ name: "git_log",
929
+ description: "Inspect recent Git commits as bounded structured summaries.",
930
+ prompt: "git_log: inspect recent commits without parsing shell output. To use it, reply with only JSON: {\"tool\":\"git_log\",\"args\":{\"limit\":10,\"path\":\"src/agent/runtime.ts\"}}",
931
+ argsSchema: gitLogArgsSchema,
932
+ execute: (context, args) => inspectGitLog(context, args)
933
+ });
934
+ const gitAddTool = defineTool({
935
+ name: "git_add",
936
+ description: "Stage explicit changed paths after current Git status has been inspected.",
937
+ prompt: "git_add: stage only explicit paths the user asked to stage; first inspect git_status, reject broad paths, and pass expected_status for each path. To use it, reply with only JSON: {\"tool\":\"git_add\",\"args\":{\"paths\":[\"src/example.ts\"],\"expected_status\":[{\"path\":\"src/example.ts\",\"status\":\"modified\"}]}}",
938
+ argsSchema: gitAddArgsSchema,
939
+ execute: (context, args) => stageGitPaths(context, args)
940
+ });
941
+ const gitCommitTool = defineTool({
942
+ name: "git_commit",
943
+ description: "Create a Git commit from exactly the expected staged paths.",
944
+ prompt: "git_commit: commit only after the user explicitly asks and staged paths exactly match expected_staged_paths. To use it, reply with only JSON: {\"tool\":\"git_commit\",\"args\":{\"message\":\"Add feature\",\"expected_staged_paths\":[\"src/example.ts\"]}}",
945
+ argsSchema: gitCommitArgsSchema,
946
+ execute: (context, args) => commitGitPaths(context, args)
947
+ });
948
+ async function inspectGitStatus(context, args) {
949
+ const repo = await getRepoInfo(context.workspaceRoot, context.pathEnv);
950
+ const unavailable = formatUnavailable("git_status", repo);
951
+ if (unavailable) return {
952
+ ...unavailable,
953
+ branch: null,
954
+ head: null,
955
+ hasHead: false,
956
+ clean: true,
957
+ files: [],
958
+ truncated: false
959
+ };
960
+ const scopedPath = ensureInsideWorkspace(context.workspaceRoot, args.path);
961
+ const result = await runGit({
962
+ cwd: requireRepoRoot(repo),
963
+ pathEnv: context.pathEnv,
964
+ allowNulOutput: true,
965
+ args: [
966
+ "status",
967
+ "--porcelain=v1",
968
+ "-z",
969
+ "--no-renames",
970
+ args.include_untracked ? "--untracked-files=all" : "--untracked-files=no",
971
+ "--",
972
+ scopedPath.relativePath
973
+ ]
974
+ });
975
+ const files = parsePorcelainStatus(result.stdout);
976
+ return {
977
+ tool: "git_status",
978
+ path: scopedPath.relativePath,
979
+ content: formatGitStatusContent(repo, files),
980
+ repoRoot: repo.repoRoot,
981
+ branch: repo.branch,
982
+ head: repo.head,
983
+ hasHead: repo.hasHead,
984
+ clean: files.length === 0,
985
+ files,
986
+ truncated: result.truncated,
987
+ warning: result.truncated ? "git_status output was truncated." : void 0
988
+ };
989
+ }
990
+ async function inspectGitDiff(context, args) {
991
+ const repo = await getRepoInfo(context.workspaceRoot, context.pathEnv);
992
+ const unavailable = formatUnavailable("git_diff", repo);
993
+ if (unavailable) return {
994
+ ...unavailable,
995
+ scope: args.scope,
996
+ path: args.path,
997
+ fileCount: 0,
998
+ truncated: false
999
+ };
1000
+ const repoRoot = requireRepoRoot(repo);
1001
+ const path = args.path ? ensureInsideWorkspace(context.workspaceRoot, args.path).relativePath : void 0;
1002
+ const sections = [];
1003
+ let truncated = false;
1004
+ const changedFiles = /* @__PURE__ */ new Set();
1005
+ if (args.scope === "all" || args.scope === "staged") {
1006
+ const diff = await runDiff(repoRoot, [
1007
+ "diff",
1008
+ "--cached",
1009
+ "--no-ext-diff",
1010
+ "--no-textconv",
1011
+ "--no-renames"
1012
+ ], {
1013
+ context,
1014
+ path,
1015
+ contextLines: args.context_lines,
1016
+ maxBytes: args.max_bytes
1017
+ });
1018
+ appendSection(sections, "staged", diff.content);
1019
+ truncated = truncated || diff.truncated;
1020
+ for (const file of await diffNameOnly(repoRoot, true, context, path)) changedFiles.add(file);
1021
+ }
1022
+ if (args.scope === "all" || args.scope === "unstaged") {
1023
+ const diff = await runDiff(repoRoot, [
1024
+ "diff",
1025
+ "--no-ext-diff",
1026
+ "--no-textconv",
1027
+ "--no-renames"
1028
+ ], {
1029
+ context,
1030
+ path,
1031
+ contextLines: args.context_lines,
1032
+ maxBytes: args.max_bytes
1033
+ });
1034
+ appendSection(sections, "unstaged", diff.content);
1035
+ truncated = truncated || diff.truncated;
1036
+ for (const file of await diffNameOnly(repoRoot, false, context, path)) changedFiles.add(file);
1037
+ }
1038
+ if (args.include_untracked && args.scope !== "staged") {
1039
+ const untracked = await getUntrackedFiles(repoRoot, context, path);
1040
+ for (const file of untracked) {
1041
+ const diff = await runDiff(repoRoot, [
1042
+ "diff",
1043
+ "--no-index",
1044
+ "--no-ext-diff",
1045
+ "--no-textconv",
1046
+ "--no-renames"
1047
+ ], {
1048
+ context,
1049
+ path: void 0,
1050
+ extraPathspecs: ["/dev/null", file],
1051
+ contextLines: args.context_lines,
1052
+ maxBytes: args.max_bytes
1053
+ });
1054
+ appendSection(sections, `untracked ${file}`, diff.content);
1055
+ truncated = truncated || diff.truncated;
1056
+ changedFiles.add(file);
1057
+ }
1058
+ }
1059
+ const bounded = truncateText(sections.join("\n").trimEnd() || "No diff.", args.max_bytes);
1060
+ return {
1061
+ tool: "git_diff",
1062
+ path: path ?? void 0,
1063
+ content: bounded.content,
1064
+ repoRoot: repo.repoRoot,
1065
+ scope: args.scope,
1066
+ fileCount: changedFiles.size,
1067
+ truncated: truncated || bounded.truncated,
1068
+ warning: truncated || bounded.truncated ? "git_diff output was truncated." : void 0
1069
+ };
1070
+ }
1071
+ async function inspectGitLog(context, args) {
1072
+ const repo = await getRepoInfo(context.workspaceRoot, context.pathEnv);
1073
+ const unavailable = formatUnavailable("git_log", repo);
1074
+ if (unavailable) return {
1075
+ ...unavailable,
1076
+ commits: [],
1077
+ truncated: false
1078
+ };
1079
+ if (!repo.hasHead) return {
1080
+ tool: "git_log",
1081
+ path: args.path,
1082
+ content: "This Git repository has no commits yet.",
1083
+ repoRoot: repo.repoRoot,
1084
+ commits: [],
1085
+ truncated: false,
1086
+ warning: "Git repository has no commits."
1087
+ };
1088
+ const repoRoot = requireRepoRoot(repo);
1089
+ const path = args.path ? ensureInsideWorkspace(context.workspaceRoot, args.path).relativePath : void 0;
1090
+ const result = await runGit({
1091
+ cwd: repoRoot,
1092
+ pathEnv: context.pathEnv,
1093
+ args: [
1094
+ "log",
1095
+ "-n",
1096
+ String(args.limit),
1097
+ `--pretty=format:${gitLogPrettyFormat()}`,
1098
+ "--",
1099
+ ...path ? [path] : []
1100
+ ]
1101
+ });
1102
+ const commits = parseGitLog(result.stdout);
1103
+ return {
1104
+ tool: "git_log",
1105
+ path,
1106
+ content: commits.length === 0 ? "No commits matched." : commits.map(formatCommitSummary).join("\n"),
1107
+ repoRoot: repo.repoRoot,
1108
+ commits,
1109
+ truncated: result.truncated,
1110
+ warning: result.truncated ? "git_log output was truncated." : void 0
1111
+ };
1112
+ }
1113
+ async function stageGitPaths(context, args) {
1114
+ const repo = await requireAvailableRepo(context);
1115
+ const repoRoot = requireRepoRoot(repo);
1116
+ const paths = normalizeExplicitPaths(context.workspaceRoot, args.paths);
1117
+ const expected = new Map(args.expected_status.map((entry) => [entry.path, entry.status]));
1118
+ const status = await currentStatus(repoRoot, context, true);
1119
+ const statusByPath = new Map(status.map((file) => [file.path, file]));
1120
+ for (const path of paths) {
1121
+ const file = statusByPath.get(path);
1122
+ const expectedStatus = expected.get(path);
1123
+ if (!expectedStatus) throw new Error(`git_add expected_status is required for ${path}.`);
1124
+ if (!file) throw new Error(`git_add can only stage paths present in git_status: ${path}`);
1125
+ if (file.status !== expectedStatus) throw new Error(`git_add expected ${path} to be ${expectedStatus}, but it is ${file.status}.`);
1126
+ }
1127
+ const result = await runGit({
1128
+ cwd: repoRoot,
1129
+ pathEnv: context.pathEnv,
1130
+ args: [
1131
+ "add",
1132
+ "--",
1133
+ ...paths
1134
+ ]
1135
+ });
1136
+ if (result.exitCode !== 0) throw new Error(`git_add failed: ${result.stderr || result.stdout}`.trim());
1137
+ const files = await currentStatus(repoRoot, context, true);
1138
+ const stagedPaths = files.filter((file) => file.staged).map((file) => file.path);
1139
+ return {
1140
+ tool: "git_add",
1141
+ content: [
1142
+ `staged_paths: ${paths.join(", ")}`,
1143
+ "",
1144
+ formatGitStatusFileList(files)
1145
+ ].join("\n").trimEnd(),
1146
+ repoRoot: repo.repoRoot,
1147
+ stagedPaths: paths,
1148
+ files,
1149
+ warning: paths.every((path) => stagedPaths.includes(path)) ? void 0 : "Some requested paths were not staged."
1150
+ };
1151
+ }
1152
+ async function commitGitPaths(context, args) {
1153
+ const repo = await requireAvailableRepo(context);
1154
+ const repoRoot = requireRepoRoot(repo);
1155
+ const expectedPaths = normalizeExplicitPaths(context.workspaceRoot, args.expected_staged_paths);
1156
+ const stagedFiles = (await currentStatus(repoRoot, context, true)).filter((file) => file.staged);
1157
+ const stagedPaths = stagedFiles.map((file) => file.path).sort();
1158
+ if (stagedPaths.length === 0) throw new Error("git_commit requires staged changes.");
1159
+ if (!sameStringSet(stagedPaths, [...expectedPaths].sort())) throw new Error(`git_commit staged paths did not match expected_staged_paths. staged=${stagedPaths.join(", ")} expected=${expectedPaths.join(", ")}`);
1160
+ const stagedWithUnstagedChanges = stagedFiles.filter((file) => file.unstaged).map((file) => file.path);
1161
+ if (stagedWithUnstagedChanges.length > 0) throw new Error(`git_commit refuses paths with both staged and unstaged changes: ${stagedWithUnstagedChanges.join(", ")}`);
1162
+ const stat = await runGit({
1163
+ cwd: repoRoot,
1164
+ pathEnv: context.pathEnv,
1165
+ args: [
1166
+ "diff",
1167
+ "--cached",
1168
+ "--stat",
1169
+ "--"
1170
+ ]
1171
+ });
1172
+ const nameStatus = await runGit({
1173
+ cwd: repoRoot,
1174
+ pathEnv: context.pathEnv,
1175
+ args: [
1176
+ "diff",
1177
+ "--cached",
1178
+ "--name-status",
1179
+ "--"
1180
+ ]
1181
+ });
1182
+ const commit = await runGit({
1183
+ cwd: repoRoot,
1184
+ pathEnv: context.pathEnv,
1185
+ args: [
1186
+ "commit",
1187
+ "--no-gpg-sign",
1188
+ "-m",
1189
+ args.message
1190
+ ],
1191
+ timeoutMs: 3e4
1192
+ });
1193
+ if (commit.exitCode !== 0) throw new Error(`git_commit failed: ${commit.stderr || commit.stdout}`.trim());
1194
+ const commitSummary = parseGitLog((await runGit({
1195
+ cwd: repoRoot,
1196
+ pathEnv: context.pathEnv,
1197
+ args: [
1198
+ "log",
1199
+ "-n",
1200
+ "1",
1201
+ `--pretty=format:${gitLogPrettyFormat()}`
1202
+ ]
1203
+ })).stdout)[0];
1204
+ if (!commitSummary) throw new Error("git_commit succeeded but the new commit could not be read.");
1205
+ const remainingFiles = await currentStatus(repoRoot, context, true);
1206
+ return {
1207
+ tool: "git_commit",
1208
+ content: [
1209
+ `commit: ${commitSummary.shortSha} ${commitSummary.subject}`,
1210
+ `staged_paths: ${stagedPaths.join(", ")}`,
1211
+ "",
1212
+ "stat:",
1213
+ stat.stdout.trim() || "(none)",
1214
+ "",
1215
+ "remaining:",
1216
+ formatGitStatusFileList(remainingFiles) || "clean"
1217
+ ].join("\n"),
1218
+ repoRoot: repo.repoRoot,
1219
+ commit: commitSummary,
1220
+ stagedPaths,
1221
+ remainingFiles,
1222
+ stat: stat.stdout,
1223
+ nameStatus: nameStatus.stdout
1224
+ };
1225
+ }
1226
+ async function requireAvailableRepo(context) {
1227
+ const repo = await getRepoInfo(context.workspaceRoot, context.pathEnv);
1228
+ if (!repo.available) throw new Error(repo.message ?? "Git repository is unavailable.");
1229
+ return repo;
1230
+ }
1231
+ async function currentStatus(repoRoot, context, includeUntracked) {
1232
+ return parsePorcelainStatus((await runGit({
1233
+ cwd: repoRoot,
1234
+ pathEnv: context.pathEnv,
1235
+ allowNulOutput: true,
1236
+ args: [
1237
+ "status",
1238
+ "--porcelain=v1",
1239
+ "-z",
1240
+ "--no-renames",
1241
+ includeUntracked ? "--untracked-files=all" : "--untracked-files=no",
1242
+ "--"
1243
+ ]
1244
+ })).stdout);
1245
+ }
1246
+ async function runDiff(repoRoot, baseArgs, options) {
1247
+ const result = await runGit({
1248
+ cwd: repoRoot,
1249
+ pathEnv: options.context.pathEnv,
1250
+ args: [
1251
+ ...baseArgs,
1252
+ `--unified=${options.contextLines}`,
1253
+ "--",
1254
+ ...options.extraPathspecs ?? (options.path ? [options.path] : [])
1255
+ ],
1256
+ maxOutputBytes: options.maxBytes,
1257
+ allowExitCodeOne: true
1258
+ });
1259
+ if (result.binary) return {
1260
+ content: "Binary diff omitted.",
1261
+ truncated: result.truncated
1262
+ };
1263
+ return {
1264
+ content: result.stdout,
1265
+ truncated: result.truncated
1266
+ };
1267
+ }
1268
+ async function diffNameOnly(repoRoot, staged, context, path) {
1269
+ return (await runGit({
1270
+ cwd: repoRoot,
1271
+ pathEnv: context.pathEnv,
1272
+ args: [
1273
+ "diff",
1274
+ staged ? "--cached" : "--no-ext-diff",
1275
+ "--name-only",
1276
+ "--",
1277
+ ...path ? [path] : []
1278
+ ]
1279
+ })).stdout.split("\n").filter(Boolean);
1280
+ }
1281
+ async function getUntrackedFiles(repoRoot, context, path) {
1282
+ return (await runGit({
1283
+ cwd: repoRoot,
1284
+ pathEnv: context.pathEnv,
1285
+ args: [
1286
+ "ls-files",
1287
+ "--others",
1288
+ "--exclude-standard",
1289
+ "--",
1290
+ ...path ? [path] : []
1291
+ ]
1292
+ })).stdout.split("\n").filter(Boolean);
1293
+ }
1294
+ function appendSection(sections, label, content) {
1295
+ if (content.trim().length === 0) return;
1296
+ sections.push(`## ${label}\n${content.trimEnd()}\n`);
1297
+ }
1298
+ function formatUnavailable(tool, repo) {
1299
+ if (repo.available) return;
1300
+ return {
1301
+ tool,
1302
+ repoRoot: null,
1303
+ content: repo.message ?? "Git repository is unavailable.",
1304
+ warning: repo.message
1305
+ };
1306
+ }
1307
+ function formatGitStatusContent(repo, files) {
1308
+ return [
1309
+ `branch: ${repo.branch ?? "(detached)"}`,
1310
+ `head: ${repo.head ?? "(none)"}`,
1311
+ `clean: ${files.length === 0}`,
1312
+ "",
1313
+ formatGitStatusFileList(files) || "No changed files."
1314
+ ].join("\n");
1315
+ }
1316
+ function formatGitStatusFileList(files) {
1317
+ return files.map((file) => `${file.indexStatus}${file.worktreeStatus} ${file.path}`).sort((left, right) => left.localeCompare(right)).join("\n");
1318
+ }
1319
+ function formatCommitSummary(commit) {
1320
+ return `${commit.shortSha} ${commit.subject} (${commit.authorName}, ${(/* @__PURE__ */ new Date(commit.timestamp * 1e3)).toISOString()})`;
1321
+ }
1322
+ function requireRepoRoot(repo) {
1323
+ if (!repo.repoRootAbsolute) throw new Error("Git repository root is unavailable.");
1324
+ return repo.repoRootAbsolute;
1325
+ }
1326
+ function normalizeExplicitPaths(workspaceRoot, paths) {
1327
+ const normalized = paths.map((path) => {
1328
+ if (path === "." || path.includes("*") || path.includes("?") || path.includes("[") || path.includes("]")) throw new Error(`Git mutation tools require explicit file paths, not broad pathspecs: ${path}`);
1329
+ const scoped = ensureInsideWorkspace(workspaceRoot, path);
1330
+ if (scoped.relativePath === ".") throw new Error("Git mutation tools require explicit file paths.");
1331
+ return scoped.relativePath;
1332
+ });
1333
+ return [...new Set(normalized)];
1334
+ }
1335
+ function sameStringSet(left, right) {
1336
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1337
+ }
621
1338
  const grepTool = defineTool({
622
1339
  name: "grep",
623
1340
  description: "Search text inside the workspace.",
@@ -632,7 +1349,7 @@ const grepTool = defineTool({
632
1349
  })
633
1350
  });
634
1351
  async function grepWorkspace(workspaceRoot, args, options = {}) {
635
- const scopedPath = resolveWorkspaceScopedPath$1(workspaceRoot, args.path ?? ".");
1352
+ const scopedPath = resolveWorkspaceScopedPath$2(workspaceRoot, args.path ?? ".");
636
1353
  const executable = await findSearchExecutable(options.pathEnv);
637
1354
  if (!executable) {
638
1355
  const warning = "grep could not run because neither rg nor grep is available on PATH.";
@@ -696,7 +1413,7 @@ async function grepWorkspace(workspaceRoot, args, options = {}) {
696
1413
  content: truncateToolOutput(result.stdout.trimEnd() || "No matches.")
697
1414
  };
698
1415
  }
699
- function resolveWorkspaceScopedPath$1(workspaceRoot, path) {
1416
+ function resolveWorkspaceScopedPath$2(workspaceRoot, path) {
700
1417
  const resolvedWorkspace = resolve(workspaceRoot);
701
1418
  const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
702
1419
  const relativePath = relative(resolvedWorkspace, resolvedPath);
@@ -1518,7 +2235,7 @@ const listFilesTool = defineTool({
1518
2235
  execute: (context, args) => listWorkspaceFiles(context.workspaceRoot, args)
1519
2236
  });
1520
2237
  async function listWorkspaceFiles(workspaceRoot, args) {
1521
- const scopedPath = resolveWorkspaceScopedPath(workspaceRoot, args.path);
2238
+ const scopedPath = resolveWorkspaceScopedPath$1(workspaceRoot, args.path);
1522
2239
  if (!(await stat(scopedPath.path)).isDirectory()) throw new Error(`list_files can only list directories inside the workspace: ${args.path}`);
1523
2240
  const entries = args.recursive ? await collectRecursiveEntries(scopedPath.workspaceRoot, scopedPath.path, args.limit) : await collectTopLevelEntries(scopedPath.workspaceRoot, scopedPath.path, args.limit);
1524
2241
  const truncated = entries.truncated;
@@ -1582,7 +2299,7 @@ async function sortedDirectoryEntries(path) {
1582
2299
  function formatEntryPath(path, isDirectory) {
1583
2300
  return isDirectory ? `${path}/` : path;
1584
2301
  }
1585
- function resolveWorkspaceScopedPath(workspaceRoot, path) {
2302
+ function resolveWorkspaceScopedPath$1(workspaceRoot, path) {
1586
2303
  const resolvedWorkspace = resolve(workspaceRoot);
1587
2304
  const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
1588
2305
  const relativePath = relative(resolvedWorkspace, resolvedPath);
@@ -1614,6 +2331,215 @@ async function readWorkspaceFile(workspaceRoot, path) {
1614
2331
  hash: `sha256:${createHash("sha256").update(bytes).digest("hex")}`
1615
2332
  };
1616
2333
  }
2334
+ const writeFileTool = defineTool({
2335
+ name: "write_file",
2336
+ description: "Create a new UTF-8 file inside the workspace, or explicitly replace one with an expected hash.",
2337
+ prompt: "write_file: create a new UTF-8 file inside the workspace by default; use edit_file for targeted changes to existing files, pass create_parent_dirs:true only when creating the folder path is intended, and replace an existing whole file only with overwrite:true and expected_hash from read_file. To use it, reply with only JSON: {\"tool\":\"write_file\",\"args\":{\"path\":\"test/example.test.ts\",\"content\":\"import { it, expect } from \\\"vitest\\\";\\n\\nit(\\\"works\\\", () => {\\n expect(true).toBe(true);\\n});\\n\",\"create_parent_dirs\":true}}",
2338
+ argsSchema: z.object({
2339
+ path: z.string(),
2340
+ content: z.string(),
2341
+ create_parent_dirs: z.boolean().optional(),
2342
+ overwrite: z.boolean().optional(),
2343
+ expected_hash: z.string().optional()
2344
+ }),
2345
+ execute: (context, args) => writeWorkspaceFile(context.workspaceRoot, args, { logger: context.logger })
2346
+ });
2347
+ async function writeWorkspaceFile(workspaceRoot, args, options = {}) {
2348
+ const scopedPath = resolveWorkspaceScopedPath(workspaceRoot, args.path);
2349
+ return enqueueFileMutation(scopedPath.path, async () => {
2350
+ const existingTarget = await statTarget(scopedPath.path);
2351
+ if (args.overwrite) return overwriteExistingFile(workspaceRoot, scopedPath, args, existingTarget, options);
2352
+ if (existingTarget) throw new Error(`write_file can only create new files: ${scopedPath.relativePath}`);
2353
+ const createdParentDirs = await ensureParentDirectory(scopedPath.workspaceRoot, scopedPath.path, scopedPath.relativePath, Boolean(args.create_parent_dirs));
2354
+ const bytes = encodeUtf8Text(args.content);
2355
+ const hash = hashBytes(bytes);
2356
+ const lineCount = countLogicalLines$1(args.content);
2357
+ await writeFileAtomically(scopedPath.path, bytes);
2358
+ const writeEvent = {
2359
+ kind: "file_create",
2360
+ source: "agent",
2361
+ path: scopedPath.relativePath,
2362
+ afterHash: hash,
2363
+ firstChangedLine: 1,
2364
+ writeSummary: `created +${lineCount}`,
2365
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2366
+ };
2367
+ const overlayState = recordAgentFileCreate(workspaceRoot, writeEvent);
2368
+ options.logger?.debug({
2369
+ event: "file_create",
2370
+ ...writeEvent,
2371
+ bytesWritten: bytes.length,
2372
+ lineCount,
2373
+ createdParentDirs,
2374
+ kbState: overlayState.kbState,
2375
+ dirtyFileCount: overlayState.dirtyFiles.length
2376
+ }, "file create");
2377
+ const content = [
2378
+ `Created ${scopedPath.relativePath}`,
2379
+ `hash: ${hash}`,
2380
+ `bytes_written: ${bytes.length}`,
2381
+ `line_count: ${lineCount}`,
2382
+ `kb_state: ${overlayState.kbState}`,
2383
+ createdParentDirs.length > 0 ? `created_parent_dirs: ${createdParentDirs.join(", ")}` : void 0
2384
+ ].filter(Boolean).join("\n");
2385
+ return {
2386
+ tool: "write_file",
2387
+ path: scopedPath.relativePath,
2388
+ content,
2389
+ hash,
2390
+ bytesWritten: bytes.length,
2391
+ lineCount,
2392
+ createdParentDirs,
2393
+ kbState: "needs_sync",
2394
+ writeEvent
2395
+ };
2396
+ });
2397
+ }
2398
+ async function overwriteExistingFile(workspaceRoot, scopedPath, args, existingTarget, options) {
2399
+ if (!args.expected_hash) throw new Error(`write_file overwrite requires expected_hash for ${scopedPath.relativePath}.`);
2400
+ if (!existingTarget) throw new Error(`write_file overwrite requires an existing file: ${scopedPath.relativePath}`);
2401
+ if (!existingTarget.isFile()) throw new Error(`write_file overwrite requires a regular file: ${scopedPath.relativePath}`);
2402
+ const beforeBytes = await readFile(scopedPath.path);
2403
+ const beforeHash = hashBytes(beforeBytes);
2404
+ if (args.expected_hash !== beforeHash) throw new Error(`write_file expected_hash did not match ${scopedPath.relativePath}.`);
2405
+ const beforeLineCount = countLogicalLines$1(decodeUtf8(scopedPath.relativePath, beforeBytes));
2406
+ const afterBytes = encodeUtf8Text(args.content);
2407
+ const afterHash = hashBytes(afterBytes);
2408
+ const afterLineCount = countLogicalLines$1(args.content);
2409
+ const bytesChanged = afterBytes.length - beforeBytes.length;
2410
+ const lineDelta = afterLineCount - beforeLineCount;
2411
+ await writeFileAtomically(scopedPath.path, afterBytes, Number(existingTarget.mode));
2412
+ const writeEvent = {
2413
+ kind: "file_overwrite",
2414
+ source: "agent",
2415
+ path: scopedPath.relativePath,
2416
+ beforeHash,
2417
+ afterHash,
2418
+ firstChangedLine: 1,
2419
+ writeSummary: `overwritten +${afterLineCount}/-${beforeLineCount}`,
2420
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2421
+ };
2422
+ const overlayState = recordAgentFileMutation(workspaceRoot, writeEvent);
2423
+ options.logger?.debug({
2424
+ event: "file_overwrite",
2425
+ ...writeEvent,
2426
+ bytesWritten: afterBytes.length,
2427
+ bytesChanged,
2428
+ lineCount: afterLineCount,
2429
+ lineDelta,
2430
+ kbState: overlayState.kbState,
2431
+ dirtyFileCount: overlayState.dirtyFiles.length
2432
+ }, "file overwrite");
2433
+ const content = [
2434
+ `Overwrote ${scopedPath.relativePath}`,
2435
+ `before_hash: ${beforeHash}`,
2436
+ `after_hash: ${afterHash}`,
2437
+ `bytes_written: ${afterBytes.length}`,
2438
+ `bytes_changed: ${bytesChanged}`,
2439
+ `line_count: ${afterLineCount}`,
2440
+ `line_delta: ${lineDelta}`,
2441
+ `kb_state: ${overlayState.kbState}`
2442
+ ].join("\n");
2443
+ return {
2444
+ tool: "write_file",
2445
+ path: scopedPath.relativePath,
2446
+ content,
2447
+ hash: afterHash,
2448
+ beforeHash,
2449
+ bytesWritten: afterBytes.length,
2450
+ bytesChanged,
2451
+ lineCount: afterLineCount,
2452
+ lineDelta,
2453
+ createdParentDirs: [],
2454
+ kbState: "needs_sync",
2455
+ writeEvent
2456
+ };
2457
+ }
2458
+ function resolveWorkspaceScopedPath(workspaceRoot, path) {
2459
+ if (path.includes("\0") || path.length === 0) throw new Error("write_file path is invalid.");
2460
+ const resolvedWorkspace = resolve(workspaceRoot);
2461
+ const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
2462
+ const relativePath = relative(resolvedWorkspace, resolvedPath);
2463
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`write_file can only write files inside the workspace: ${path}`);
2464
+ if (relativePath === "") throw new Error("write_file path must point to a file inside the workspace.");
2465
+ return {
2466
+ workspaceRoot: resolvedWorkspace,
2467
+ path: resolvedPath,
2468
+ relativePath
2469
+ };
2470
+ }
2471
+ async function statTarget(path) {
2472
+ try {
2473
+ return await stat(path);
2474
+ } catch (error) {
2475
+ if (isNodeError$2(error) && error.code === "ENOENT") return;
2476
+ throw error;
2477
+ }
2478
+ }
2479
+ async function ensureParentDirectory(workspaceRoot, path, relativePath, createParentDirs) {
2480
+ const parent = dirname(path);
2481
+ try {
2482
+ if (!(await stat(parent)).isDirectory()) throw new Error(`write_file parent path is not a directory: ${dirname(relativePath)}`);
2483
+ return [];
2484
+ } catch (error) {
2485
+ if (!isNodeError$2(error) || error.code !== "ENOENT") throw error;
2486
+ }
2487
+ if (!createParentDirs) throw new Error(`write_file parent directory does not exist: ${dirname(relativePath)}`);
2488
+ const createdParentDirs = await collectMissingParentDirs(workspaceRoot, parent);
2489
+ await mkdir(parent, { recursive: true });
2490
+ return createdParentDirs;
2491
+ }
2492
+ async function collectMissingParentDirs(workspaceRoot, parent) {
2493
+ const missing = [];
2494
+ let current = parent;
2495
+ while (current !== workspaceRoot) try {
2496
+ if (!(await stat(current)).isDirectory()) throw new Error(`write_file parent path is not a directory: ${relative(workspaceRoot, current)}`);
2497
+ break;
2498
+ } catch (error) {
2499
+ if (!isNodeError$2(error) || error.code !== "ENOENT") throw error;
2500
+ missing.push(relative(workspaceRoot, current));
2501
+ current = dirname(current);
2502
+ }
2503
+ return missing.reverse();
2504
+ }
2505
+ function encodeUtf8Text(content) {
2506
+ if (content.includes("\0")) throw new Error("write_file content must not contain NUL bytes.");
2507
+ return Buffer.from(content, "utf8");
2508
+ }
2509
+ function decodeUtf8(path, bytes) {
2510
+ try {
2511
+ return new TextDecoder("utf-8", {
2512
+ fatal: true,
2513
+ ignoreBOM: true
2514
+ }).decode(bytes);
2515
+ } catch {
2516
+ throw new Error(`write_file overwrite requires the existing file to be UTF-8 text: ${path}`);
2517
+ }
2518
+ }
2519
+ async function writeFileAtomically(path, content, mode = 438) {
2520
+ const temporaryPath = resolve(dirname(path), `.${basename(path)}.topchester-${process.pid}-${randomUUID()}.tmp`);
2521
+ try {
2522
+ await writeFile(temporaryPath, content, {
2523
+ flag: "wx",
2524
+ mode: mode & 511
2525
+ });
2526
+ await rename(temporaryPath, path);
2527
+ } catch (error) {
2528
+ await rm(temporaryPath, { force: true });
2529
+ throw error;
2530
+ }
2531
+ }
2532
+ function hashBytes(bytes) {
2533
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
2534
+ }
2535
+ function countLogicalLines$1(content) {
2536
+ if (content.length === 0) return 0;
2537
+ const withoutTrailingLineEnding = content.replace(/\r?\n$/u, "");
2538
+ return withoutTrailingLineEnding.length === 0 ? 1 : withoutTrailingLineEnding.split(/\r?\n/u).length;
2539
+ }
2540
+ function isNodeError$2(error) {
2541
+ return error instanceof Error && "code" in error;
2542
+ }
1617
2543
  //#endregion
1618
2544
  //#region src/agent/tools/registry.ts
1619
2545
  const toolRegistry = {
@@ -1622,6 +2548,12 @@ const toolRegistry = {
1622
2548
  [grepTool.name]: grepTool,
1623
2549
  [findFileTool.name]: findFileTool,
1624
2550
  [editFileTool.name]: editFileTool,
2551
+ [writeFileTool.name]: writeFileTool,
2552
+ [gitStatusTool.name]: gitStatusTool,
2553
+ [gitDiffTool.name]: gitDiffTool,
2554
+ [gitLogTool.name]: gitLogTool,
2555
+ [gitAddTool.name]: gitAddTool,
2556
+ [gitCommitTool.name]: gitCommitTool,
1625
2557
  [inspectCommandTool.name]: inspectCommandTool
1626
2558
  };
1627
2559
  function isToolName(name) {
@@ -3589,6 +4521,201 @@ function assertSafeResetPath(workspaceRoot, path) {
3589
4521
  function isNodeError(error) {
3590
4522
  return error instanceof Error && "code" in error;
3591
4523
  }
4524
+ //#endregion
4525
+ //#region src/tui/markdown.ts
4526
+ const codeFenceSentinel = "topchester-code-fence";
4527
+ function renderMarkdown(text, width) {
4528
+ const lines = new Markdown(unwrapMarkdownCodeFences(text), 0, 0, getMarkdownTheme()).render(width);
4529
+ const rendered = [];
4530
+ let inCodeBlock = false;
4531
+ for (const line of lines) {
4532
+ if (line.includes(codeFenceSentinel)) {
4533
+ inCodeBlock = !inCodeBlock;
4534
+ continue;
4535
+ }
4536
+ rendered.push(inCodeBlock ? applyCodeBlockBackground(line) : line);
4537
+ }
4538
+ return rendered;
4539
+ }
4540
+ function unwrapMarkdownCodeFences(text) {
4541
+ return text.replace(/^```(?:markdown|md)\s*\n([\s\S]*?)\n```$/gim, "$1");
4542
+ }
4543
+ function getMarkdownTheme() {
4544
+ return {
4545
+ heading: ui.label,
4546
+ link: ui.label,
4547
+ linkUrl: ui.muted,
4548
+ code: ui.ok,
4549
+ codeBlock: (text) => text,
4550
+ codeBlockBorder: (text) => `${codeFenceSentinel}${ui.muted(text)}`,
4551
+ quote: ui.warn,
4552
+ quoteBorder: ui.warn,
4553
+ hr: ui.muted,
4554
+ listBullet: ui.label,
4555
+ bold: (text) => decorate(text, "\x1B[1m", "\x1B[22m"),
4556
+ italic: (text) => decorate(text, "\x1B[3m", "\x1B[23m"),
4557
+ strikethrough: (text) => decorate(text, "\x1B[9m", "\x1B[29m"),
4558
+ underline: (text) => decorate(text, "\x1B[4m", "\x1B[24m"),
4559
+ codeBlockIndent: " ",
4560
+ highlightCode(code, lang) {
4561
+ const validLanguage = lang && supportsLanguage(lang) ? lang : void 0;
4562
+ if (!validLanguage) return code.split("\n");
4563
+ try {
4564
+ return highlight(code, {
4565
+ language: validLanguage,
4566
+ ignoreIllegals: true
4567
+ }).split("\n");
4568
+ } catch {
4569
+ return code.split("\n");
4570
+ }
4571
+ }
4572
+ };
4573
+ }
4574
+ function decorate(text, open, close) {
4575
+ if (!shouldUseAnsi()) return text;
4576
+ return `${open}${text}${close}`;
4577
+ }
4578
+ function applyCodeBlockBackground(line) {
4579
+ if (!shouldUseAnsi()) return line;
4580
+ const background = "\x1B[48;5;235m";
4581
+ const reset = "\x1B[0m";
4582
+ return `${background}${line.split(reset).join(`${reset}${background}`)}${reset}`;
4583
+ }
4584
+ function shouldUseAnsi() {
4585
+ if (process.env.NO_COLOR) return false;
4586
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
4587
+ return stdout.isTTY === true;
4588
+ }
4589
+ //#endregion
4590
+ //#region src/tui/messages.ts
4591
+ function systemMessage(text) {
4592
+ return {
4593
+ kind: "system",
4594
+ text
4595
+ };
4596
+ }
4597
+ function userMessage(text) {
4598
+ return {
4599
+ kind: "user",
4600
+ text
4601
+ };
4602
+ }
4603
+ function agentMessage(text, meta) {
4604
+ return {
4605
+ kind: "agent",
4606
+ text,
4607
+ meta
4608
+ };
4609
+ }
4610
+ function toolCallMessage(call, label, resultSummary) {
4611
+ return resultSummary === void 0 ? {
4612
+ kind: "tool_call",
4613
+ call,
4614
+ label
4615
+ } : {
4616
+ kind: "tool_call",
4617
+ call,
4618
+ label,
4619
+ resultSummary
4620
+ };
4621
+ }
4622
+ function modalMessage(message) {
4623
+ return {
4624
+ kind: "modal",
4625
+ ...message
4626
+ };
4627
+ }
4628
+ function renderChatMessage(message, options = {}) {
4629
+ if (message.kind === "modal") return renderChatModal(message, options.selectedActionIndex);
4630
+ if (message.kind === "tool_call") return renderToolCallMessage(message);
4631
+ if (message.text.length === 0) return [""];
4632
+ const lines = message.kind === "agent" && options.width !== void 0 ? renderMarkdown(message.text, Math.max(1, options.width - getPrefix(message.kind).length)) : message.text.split("\n");
4633
+ if (message.kind === "user") return renderUserMessage(lines);
4634
+ if (message.kind === "system") return renderSystemMessage(lines);
4635
+ const prefix = getPrefix(message.kind);
4636
+ const rendered = prefix.length === 0 ? lines : lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`);
4637
+ if (message.meta) {
4638
+ const metaText = `↳ ${message.meta}`;
4639
+ rendered.push(` ${ui.label("─".repeat(metaText.length))}`, ` ${ui.label(metaText)}`);
4640
+ }
4641
+ return rendered;
4642
+ }
4643
+ function renderUserMessage(lines) {
4644
+ const border = "▌";
4645
+ const rendered = lines.map((line) => `${border} ${line}`);
4646
+ return [
4647
+ `${border} `,
4648
+ ...rendered,
4649
+ `${border} `
4650
+ ];
4651
+ }
4652
+ function renderSystemMessage(lines) {
4653
+ const bodyPrefix = " ";
4654
+ return [` ${ui.ok("✦")} ${ui.label("System")}:`, ...lines.map((line) => `${bodyPrefix}${formatSystemBodyLine(line)}`)];
4655
+ }
4656
+ function formatSystemBodyLine(line) {
4657
+ return expandTabs(line).replace(/\(changed \+\d+\/-\d+\)$/u, (summary) => ui.muted(summary)).replace(/\(created \+\d+\)$/u, (summary) => ui.muted(summary));
4658
+ }
4659
+ function renderToolCallMessage(message) {
4660
+ const visibleLabel = message.resultSummary && !message.label.includes(message.resultSummary) ? `${message.label} ${message.resultSummary}` : message.label;
4661
+ return [` ${ui.muted(expandTabs(visibleLabel))}`];
4662
+ }
4663
+ function expandTabs(line) {
4664
+ let column = 0;
4665
+ let expanded = "";
4666
+ for (const char of line) {
4667
+ if (char === " ") {
4668
+ const spaces = 4 - column % 4;
4669
+ expanded += " ".repeat(spaces);
4670
+ column += spaces;
4671
+ continue;
4672
+ }
4673
+ expanded += char;
4674
+ column += 1;
4675
+ }
4676
+ return expanded;
4677
+ }
4678
+ function getPrefix(kind) {
4679
+ switch (kind) {
4680
+ case "agent": return " ";
4681
+ case "user": return `${ui.label("You")}: `;
4682
+ case "system": return `${ui.label("System")}: `;
4683
+ }
4684
+ }
4685
+ function renderChatModal(message, selectedActionIndex) {
4686
+ const icon = message.tone === "warning" ? "⚠️" : "ℹ️";
4687
+ const title = message.tone === "warning" ? ui.warn(message.title) : ui.label(message.title);
4688
+ const bodyLines = message.body ? ["", ...message.body.split("\n")] : [];
4689
+ const actionLines = message.actions.map((action, index) => {
4690
+ return `${selectedActionIndex === index ? ">" : " "} ${index + 1}) ${action.label}`;
4691
+ });
4692
+ const contentLines = [
4693
+ `${icon} ${title}:`,
4694
+ ...bodyLines,
4695
+ "",
4696
+ ...actionLines
4697
+ ];
4698
+ const contentWidth = Math.max(...contentLines.map(stripAnsi$1).map((line) => line.length), 1);
4699
+ const top = `╭${"─".repeat(contentWidth + 2)}╮`;
4700
+ const bottom = `╰${"─".repeat(contentWidth + 2)}╯`;
4701
+ return [
4702
+ top,
4703
+ ...contentLines.map((line) => `│ ${line}${" ".repeat(contentWidth - stripAnsi$1(line).length)} │`),
4704
+ bottom
4705
+ ];
4706
+ }
4707
+ function stripAnsi$1(text) {
4708
+ let plain = "";
4709
+ for (let index = 0; index < text.length; index += 1) {
4710
+ if (text.charCodeAt(index) === 27 && text[index + 1] === "[") {
4711
+ index += 2;
4712
+ while (index < text.length && text[index] !== "m") index += 1;
4713
+ continue;
4714
+ }
4715
+ plain += text[index];
4716
+ }
4717
+ return plain;
4718
+ }
3592
4719
  const isoTimestampSchema = z.string().datetime({ offset: true });
3593
4720
  const jsonValueSchema = z.lazy(() => z.union([
3594
4721
  z.string(),
@@ -3740,10 +4867,7 @@ function rehydrateSession(events) {
3740
4867
  if (event.role === "user") visibleOnlyActionValues = /* @__PURE__ */ new Set();
3741
4868
  break;
3742
4869
  case "tool_call":
3743
- messages.push({
3744
- kind: "system",
3745
- text: event.label
3746
- });
4870
+ messages.push(toolCallMessage(event.call, event.label));
3747
4871
  break;
3748
4872
  case "knowledge_status": break;
3749
4873
  case "choice":
@@ -4140,189 +5264,6 @@ async function executeKbCommand(args, context) {
4140
5264
  return { messages: ["Usage: /kb init, /kb compile, /kb sync, /kb reset, or /kb status"] };
4141
5265
  }
4142
5266
  //#endregion
4143
- //#region src/tui/markdown.ts
4144
- const codeFenceSentinel = "topchester-code-fence";
4145
- function renderMarkdown(text, width) {
4146
- const lines = new Markdown(unwrapMarkdownCodeFences(text), 0, 0, getMarkdownTheme()).render(width);
4147
- const rendered = [];
4148
- let inCodeBlock = false;
4149
- for (const line of lines) {
4150
- if (line.includes(codeFenceSentinel)) {
4151
- inCodeBlock = !inCodeBlock;
4152
- continue;
4153
- }
4154
- rendered.push(inCodeBlock ? applyCodeBlockBackground(line) : line);
4155
- }
4156
- return rendered;
4157
- }
4158
- function unwrapMarkdownCodeFences(text) {
4159
- return text.replace(/^```(?:markdown|md)\s*\n([\s\S]*?)\n```$/gim, "$1");
4160
- }
4161
- function getMarkdownTheme() {
4162
- return {
4163
- heading: ui.label,
4164
- link: ui.label,
4165
- linkUrl: ui.muted,
4166
- code: ui.ok,
4167
- codeBlock: (text) => text,
4168
- codeBlockBorder: (text) => `${codeFenceSentinel}${ui.muted(text)}`,
4169
- quote: ui.warn,
4170
- quoteBorder: ui.warn,
4171
- hr: ui.muted,
4172
- listBullet: ui.label,
4173
- bold: (text) => decorate(text, "\x1B[1m", "\x1B[22m"),
4174
- italic: (text) => decorate(text, "\x1B[3m", "\x1B[23m"),
4175
- strikethrough: (text) => decorate(text, "\x1B[9m", "\x1B[29m"),
4176
- underline: (text) => decorate(text, "\x1B[4m", "\x1B[24m"),
4177
- codeBlockIndent: " ",
4178
- highlightCode(code, lang) {
4179
- const validLanguage = lang && supportsLanguage(lang) ? lang : void 0;
4180
- if (!validLanguage) return code.split("\n");
4181
- try {
4182
- return highlight(code, {
4183
- language: validLanguage,
4184
- ignoreIllegals: true
4185
- }).split("\n");
4186
- } catch {
4187
- return code.split("\n");
4188
- }
4189
- }
4190
- };
4191
- }
4192
- function decorate(text, open, close) {
4193
- if (!shouldUseAnsi()) return text;
4194
- return `${open}${text}${close}`;
4195
- }
4196
- function applyCodeBlockBackground(line) {
4197
- if (!shouldUseAnsi()) return line;
4198
- const background = "\x1B[48;5;235m";
4199
- const reset = "\x1B[0m";
4200
- return `${background}${line.split(reset).join(`${reset}${background}`)}${reset}`;
4201
- }
4202
- function shouldUseAnsi() {
4203
- if (process.env.NO_COLOR) return false;
4204
- if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
4205
- return stdout.isTTY === true;
4206
- }
4207
- //#endregion
4208
- //#region src/tui/messages.ts
4209
- function systemMessage(text) {
4210
- return {
4211
- kind: "system",
4212
- text
4213
- };
4214
- }
4215
- function userMessage(text) {
4216
- return {
4217
- kind: "user",
4218
- text
4219
- };
4220
- }
4221
- function agentMessage(text, meta) {
4222
- return {
4223
- kind: "agent",
4224
- text,
4225
- meta
4226
- };
4227
- }
4228
- function modalMessage(message) {
4229
- return {
4230
- kind: "modal",
4231
- ...message
4232
- };
4233
- }
4234
- function renderChatMessage(message, options = {}) {
4235
- if (message.kind === "modal") return renderChatModal(message, options.selectedActionIndex);
4236
- if (message.text.length === 0) return [""];
4237
- const lines = message.kind === "agent" && options.width !== void 0 ? renderMarkdown(message.text, Math.max(1, options.width - getPrefix(message.kind).length)) : message.text.split("\n");
4238
- if (message.kind === "user") return renderUserMessage(lines);
4239
- if (message.kind === "system") return renderSystemMessage(lines);
4240
- const prefix = getPrefix(message.kind);
4241
- const rendered = prefix.length === 0 ? lines : lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`);
4242
- if (message.meta) {
4243
- const metaText = `↳ ${message.meta}`;
4244
- rendered.push(` ${ui.label("─".repeat(metaText.length))}`, ` ${ui.label(metaText)}`);
4245
- }
4246
- return rendered;
4247
- }
4248
- function renderUserMessage(lines) {
4249
- const border = "▌";
4250
- const rendered = lines.map((line) => `${border} ${line}`);
4251
- return [
4252
- `${border} `,
4253
- ...rendered,
4254
- `${border} `
4255
- ];
4256
- }
4257
- function renderSystemMessage(lines) {
4258
- const bodyPrefix = " ";
4259
- return [` ${ui.ok("✦")} ${ui.label("System")}:`, ...lines.map((line) => `${bodyPrefix}${formatSystemBodyLine(line)}`)];
4260
- }
4261
- function formatSystemBodyLine(line) {
4262
- const expandedLine = expandTabs(line);
4263
- if (isToolCallLine(line)) return ui.muted(expandedLine);
4264
- return expandedLine.replace(/\(changed \+\d+\/-\d+\)$/u, (summary) => ui.muted(summary));
4265
- }
4266
- function isToolCallLine(line) {
4267
- return /^(read_file|list_files|grep|find_file|edit_file|inspect_command): /u.test(line);
4268
- }
4269
- function expandTabs(line) {
4270
- let column = 0;
4271
- let expanded = "";
4272
- for (const char of line) {
4273
- if (char === " ") {
4274
- const spaces = 4 - column % 4;
4275
- expanded += " ".repeat(spaces);
4276
- column += spaces;
4277
- continue;
4278
- }
4279
- expanded += char;
4280
- column += 1;
4281
- }
4282
- return expanded;
4283
- }
4284
- function getPrefix(kind) {
4285
- switch (kind) {
4286
- case "agent": return " ";
4287
- case "user": return `${ui.label("You")}: `;
4288
- case "system": return `${ui.label("System")}: `;
4289
- }
4290
- }
4291
- function renderChatModal(message, selectedActionIndex) {
4292
- const icon = message.tone === "warning" ? "⚠️" : "ℹ️";
4293
- const title = message.tone === "warning" ? ui.warn(message.title) : ui.label(message.title);
4294
- const bodyLines = message.body ? ["", ...message.body.split("\n")] : [];
4295
- const actionLines = message.actions.map((action, index) => {
4296
- return `${selectedActionIndex === index ? ">" : " "} ${index + 1}) ${action.label}`;
4297
- });
4298
- const contentLines = [
4299
- `${icon} ${title}:`,
4300
- ...bodyLines,
4301
- "",
4302
- ...actionLines
4303
- ];
4304
- const contentWidth = Math.max(...contentLines.map(stripAnsi$1).map((line) => line.length), 1);
4305
- const top = `╭${"─".repeat(contentWidth + 2)}╮`;
4306
- const bottom = `╰${"─".repeat(contentWidth + 2)}╯`;
4307
- return [
4308
- top,
4309
- ...contentLines.map((line) => `│ ${line}${" ".repeat(contentWidth - stripAnsi$1(line).length)} │`),
4310
- bottom
4311
- ];
4312
- }
4313
- function stripAnsi$1(text) {
4314
- let plain = "";
4315
- for (let index = 0; index < text.length; index += 1) {
4316
- if (text.charCodeAt(index) === 27 && text[index + 1] === "[") {
4317
- index += 2;
4318
- while (index < text.length && text[index] !== "m") index += 1;
4319
- continue;
4320
- }
4321
- plain += text[index];
4322
- }
4323
- return plain;
4324
- }
4325
- //#endregion
4326
5267
  //#region src/tui/keys.ts
4327
5268
  function isUpKey(data) {
4328
5269
  return matchesKey(data, "up") || data === "\x1B[A";
@@ -4599,15 +5540,19 @@ var ChatLayout = class {
4599
5540
  }
4600
5541
  getConversationTurns() {
4601
5542
  return this.messages.flatMap((message) => {
4602
- if (message.kind === "user" && message.modelContext !== false) return [{
4603
- role: "user",
4604
- text: message.text
4605
- }];
4606
- if (message.kind === "agent" && message.text !== "ready" && message.modelContext !== false) return [{
4607
- role: "assistant",
4608
- text: message.text
4609
- }];
4610
- return [];
5543
+ switch (message.kind) {
5544
+ case "user": return message.modelContext === false ? [] : [{
5545
+ role: "user",
5546
+ text: message.text
5547
+ }];
5548
+ case "agent": return message.text === "ready" || message.modelContext === false ? [] : [{
5549
+ role: "assistant",
5550
+ text: message.text
5551
+ }];
5552
+ case "system":
5553
+ case "tool_call":
5554
+ case "modal": return [];
5555
+ }
4611
5556
  });
4612
5557
  }
4613
5558
  get focused() {
@@ -4975,6 +5920,14 @@ async function executeToolCall(workspaceRoot, call, options = {}) {
4975
5920
  }
4976
5921
  }
4977
5922
  function summarizeToolArgs(call) {
5923
+ if (call.tool === "write_file") return {
5924
+ path: call.args.path,
5925
+ contentLength: call.args.content.length,
5926
+ lineCount: countLogicalLines(call.args.content),
5927
+ createParentDirs: Boolean(call.args.create_parent_dirs),
5928
+ overwrite: Boolean(call.args.overwrite),
5929
+ expectedHashProvided: Boolean(call.args.expected_hash)
5930
+ };
4978
5931
  if (call.tool !== "edit_file") return call.args;
4979
5932
  return {
4980
5933
  path: call.args.path,
@@ -4994,6 +5947,50 @@ function summarizeToolResult(result) {
4994
5947
  stdoutLength: result.stdout.length,
4995
5948
  stderrLength: result.stderr.length
4996
5949
  };
5950
+ if (result.tool === "git_status") return {
5951
+ repoRoot: result.repoRoot,
5952
+ branch: result.branch,
5953
+ head: result.head,
5954
+ hasHead: result.hasHead,
5955
+ clean: result.clean,
5956
+ fileCount: result.files.length,
5957
+ truncated: result.truncated
5958
+ };
5959
+ if (result.tool === "git_diff") return {
5960
+ repoRoot: result.repoRoot,
5961
+ scope: result.scope,
5962
+ path: result.path,
5963
+ fileCount: result.fileCount,
5964
+ truncated: result.truncated
5965
+ };
5966
+ if (result.tool === "git_log") return {
5967
+ repoRoot: result.repoRoot,
5968
+ commitCount: result.commits.length,
5969
+ truncated: result.truncated
5970
+ };
5971
+ if (result.tool === "git_add") return {
5972
+ repoRoot: result.repoRoot,
5973
+ stagedPathCount: result.stagedPaths.length,
5974
+ postStatusFileCount: result.files.length
5975
+ };
5976
+ if (result.tool === "git_commit") return {
5977
+ repoRoot: result.repoRoot,
5978
+ commit: result.commit.shortSha,
5979
+ stagedPathCount: result.stagedPaths.length,
5980
+ remainingFileCount: result.remainingFiles.length,
5981
+ statLength: result.stat.length,
5982
+ nameStatusLength: result.nameStatus.length
5983
+ };
5984
+ if (result.tool === "write_file") return {
5985
+ hash: result.hash,
5986
+ bytesWritten: result.bytesWritten,
5987
+ lineCount: result.lineCount,
5988
+ bytesChanged: result.bytesChanged,
5989
+ lineDelta: result.lineDelta,
5990
+ createdParentDirs: result.createdParentDirs,
5991
+ kbState: result.kbState,
5992
+ writeEvent: result.writeEvent
5993
+ };
4997
5994
  if (result.tool !== "edit_file") return {};
4998
5995
  return {
4999
5996
  beforeHash: result.beforeHash,
@@ -5004,6 +6001,11 @@ function summarizeToolResult(result) {
5004
6001
  editEvent: result.editEvent
5005
6002
  };
5006
6003
  }
6004
+ function countLogicalLines(content) {
6005
+ if (content.length === 0) return 0;
6006
+ const withoutTrailingLineEnding = content.replace(/\r?\n$/u, "");
6007
+ return withoutTrailingLineEnding.length === 0 ? 1 : withoutTrailingLineEnding.split(/\r?\n/u).length;
6008
+ }
5007
6009
  //#endregion
5008
6010
  //#region src/agent/prompts.ts
5009
6011
  function getChatSystemPrompt() {
@@ -5030,10 +6032,15 @@ function getChatSystemPrompt() {
5030
6032
  "- Use read/search tools when the user asks about files, code, symbols, usages, tests, or project behavior.",
5031
6033
  "- Use find_file for path or filename lookup. Use grep for text inside files. If grep output mentions another path, treat that mentioned path as content until find_file or read_file confirms it exists.",
5032
6034
  "- Use list_files, grep, find_file, and read_file for exact file listing, search, lookup, and reading tasks.",
6035
+ "- Use git_status, git_diff, and git_log for Git state, diffs, and history. Prefer these over inspect_command for Git workflow inspection.",
6036
+ "- Use git_add and git_commit only when the user explicitly asks to stage or commit. Never stage unrelated files, never stage '.', and never commit unless staged paths exactly match the user's request.",
5033
6037
  "- Use inspect_command only for quick read-only repo orientation when a short familiar command chain is clearer than several dedicated tool calls.",
5034
6038
  "- inspect_command is not a shell. Unsafe commands, shell expansion, scripts, installs, builds, tests, network access, and file mutation are not available through it.",
5035
6039
  "- Use read_file before editing a file so your edit is based on current file content and hash metadata.",
5036
6040
  "- Use edit_file for targeted edits to existing files. Make multiple disjoint edits for the same file in one call when possible.",
6041
+ "- Use write_file to create new files by default. It fails when the file already exists unless you are replacing the whole file with overwrite:true and expected_hash from read_file.",
6042
+ "- Pass write_file create_parent_dirs:true only when the user intent clearly includes creating that folder path.",
6043
+ "- Do not use inspect_command for file creation or file mutation.",
5037
6044
  "- Keep edit_file old_text small but unique. Do not include line labels or grep prefixes in old_text; use exact file text only.",
5038
6045
  "- Use edit/write tools when they are available and the user asks you to implement, fix, add, update, or refactor code.",
5039
6046
  "- Use command/test tools when they are available and you need to inspect the environment, run tests, format, lint, typecheck, or verify behavior.",
@@ -5226,6 +6233,18 @@ function formatToolResultForPrompt(result) {
5226
6233
  result.diff,
5227
6234
  "```"
5228
6235
  ].join("\n");
6236
+ if (result.tool === "write_file") return [
6237
+ `Tool result from ${result.tool}${path}:`,
6238
+ result.beforeHash ? `before_hash: ${result.beforeHash}` : "",
6239
+ `after_hash: ${result.hash}`,
6240
+ `bytes_written: ${result.bytesWritten}`,
6241
+ result.bytesChanged !== void 0 ? `bytes_changed: ${result.bytesChanged}` : "",
6242
+ `line_count: ${result.lineCount}`,
6243
+ result.lineDelta !== void 0 ? `line_delta: ${result.lineDelta}` : "",
6244
+ `kb_state: ${result.kbState}`,
6245
+ result.createdParentDirs.length > 0 ? `created_parent_dirs: ${result.createdParentDirs.join(", ")}` : "",
6246
+ `summary: ${result.writeEvent.writeSummary}`
6247
+ ].filter(Boolean).join("\n");
5229
6248
  if (result.tool === "inspect_command") return [
5230
6249
  `Tool result from ${result.tool} via ${result.command}:`,
5231
6250
  `cwd: ${result.cwd}`,
@@ -5238,6 +6257,61 @@ function formatToolResultForPrompt(result) {
5238
6257
  result.content,
5239
6258
  "```"
5240
6259
  ].filter(Boolean).join("\n");
6260
+ if (result.tool === "git_status") return [
6261
+ `Tool result from ${result.tool}${path}:`,
6262
+ `repo_root: ${result.repoRoot ?? "(none)"}`,
6263
+ `branch: ${result.branch ?? "(detached)"}`,
6264
+ `head: ${result.head ?? "(none)"}`,
6265
+ `has_head: ${result.hasHead}`,
6266
+ `clean: ${result.clean}`,
6267
+ `changed_file_count: ${result.files.length}`,
6268
+ `truncated: ${result.truncated}`,
6269
+ warning ? warning.trimStart() : "",
6270
+ "```",
6271
+ result.content,
6272
+ "```"
6273
+ ].filter(Boolean).join("\n");
6274
+ if (result.tool === "git_diff") return [
6275
+ `Tool result from ${result.tool}${path}:`,
6276
+ `repo_root: ${result.repoRoot ?? "(none)"}`,
6277
+ `scope: ${result.scope}`,
6278
+ `path: ${result.path ?? "(all)"}`,
6279
+ `file_count: ${result.fileCount}`,
6280
+ `truncated: ${result.truncated}`,
6281
+ warning ? warning.trimStart() : "",
6282
+ "```diff",
6283
+ result.content,
6284
+ "```"
6285
+ ].filter(Boolean).join("\n");
6286
+ if (result.tool === "git_log") return [
6287
+ `Tool result from ${result.tool}${path}:`,
6288
+ `repo_root: ${result.repoRoot ?? "(none)"}`,
6289
+ `commit_count: ${result.commits.length}`,
6290
+ `truncated: ${result.truncated}`,
6291
+ warning ? warning.trimStart() : "",
6292
+ "```",
6293
+ result.content,
6294
+ "```"
6295
+ ].filter(Boolean).join("\n");
6296
+ if (result.tool === "git_add") return [
6297
+ `Tool result from ${result.tool}:`,
6298
+ `repo_root: ${result.repoRoot ?? "(none)"}`,
6299
+ `staged_paths: ${result.stagedPaths.join(", ")}`,
6300
+ warning ? warning.trimStart() : "",
6301
+ "```",
6302
+ result.content,
6303
+ "```"
6304
+ ].filter(Boolean).join("\n");
6305
+ if (result.tool === "git_commit") return [
6306
+ `Tool result from ${result.tool}:`,
6307
+ `repo_root: ${result.repoRoot ?? "(none)"}`,
6308
+ `commit: ${result.commit.shortSha} ${result.commit.subject}`,
6309
+ `staged_paths: ${result.stagedPaths.join(", ")}`,
6310
+ `remaining_changed_file_count: ${result.remainingFiles.length}`,
6311
+ "```",
6312
+ result.content,
6313
+ "```"
6314
+ ].join("\n");
5241
6315
  return [
5242
6316
  `Tool result from ${result.tool}${path}${command}:${warning}`,
5243
6317
  "```",
@@ -5255,13 +6329,27 @@ function formatToolCallMessage(call, result) {
5255
6329
  case "grep": return `grep: ${call.args.pattern} in ${call.args.path ?? "."}`;
5256
6330
  case "find_file": return `find_file: ${call.args.query} in ${call.args.path}`;
5257
6331
  case "edit_file": return `edit_file: ${call.args.path}${formatEditFileChangeSummary(result)}`;
6332
+ case "write_file": return `write_file: ${call.args.path}${formatWriteFileChangeSummary(result)}`;
6333
+ case "git_status": return `git_status: ${result?.tool === "git_status" ? `${result.files.length} changed` : call.args.path}`;
6334
+ case "git_diff": return `git_diff: ${formatGitDiffCallSummary(call, result)}`;
6335
+ case "git_log": return `git_log: ${result?.tool === "git_log" ? `${result.commits.length} commits` : `${call.args.limit} commits`}`;
6336
+ case "git_add": return `git_add: ${result?.tool === "git_add" ? `${result.stagedPaths.length} files staged` : `${call.args.paths.length} files`}`;
6337
+ case "git_commit": return `git_commit: ${result?.tool === "git_commit" ? `${result.commit.shortSha} ${result.commit.subject}` : call.args.message}`;
5258
6338
  case "inspect_command": return `inspect_command: ${call.args.command}`;
5259
6339
  }
5260
6340
  }
6341
+ function formatGitDiffCallSummary(call, result) {
6342
+ if (result?.tool === "git_diff") return `${result.scope} (${result.fileCount} files${result.truncated ? ", truncated" : ""})`;
6343
+ return call.args.scope;
6344
+ }
5261
6345
  function formatEditFileChangeSummary(result) {
5262
6346
  if (result?.tool !== "edit_file") return "";
5263
6347
  return ` (changed ${result.editEvent.diffSummary})`;
5264
6348
  }
6349
+ function formatWriteFileChangeSummary(result) {
6350
+ if (result?.tool !== "write_file") return "";
6351
+ return ` (${result.writeEvent.writeSummary})`;
6352
+ }
5265
6353
  function formatAgentMessageMeta(model, durationMs) {
5266
6354
  return `${model} · ${formatDuration$1(durationMs)}`;
5267
6355
  }
@@ -5285,7 +6373,7 @@ function formatNumber(value, fractionDigits) {
5285
6373
  function renderRuntimeEvent(event) {
5286
6374
  switch (event.type) {
5287
6375
  case "message": return [event.role === "assistant" ? agentMessage(event.text, event.meta) : systemMessage(event.text)];
5288
- case "tool_call": return [systemMessage(event.label)];
6376
+ case "tool_call": return [toolCallMessage(event.call, event.label)];
5289
6377
  case "knowledge_status": return [systemMessage([`KB status: ${formatKnowledgePathStatus(event.status)}${formatKbPathSource(event.status)}`, event.guidance].filter(Boolean).join("\n"))];
5290
6378
  case "choice": return [modalMessage({
5291
6379
  tone: event.tone,
@@ -5501,9 +6589,14 @@ async function persistMessagesWithWarning(session, messages, warningTarget = mes
5501
6589
  }
5502
6590
  }
5503
6591
  function chatMessageToSessionPayload(message) {
5504
- if (message.kind === "system" || message.kind === "user" || message.kind === "agent") return {
6592
+ if (message.kind === "system" || message.kind === "user") return {
6593
+ kind: "message",
6594
+ role: message.kind,
6595
+ text: message.text
6596
+ };
6597
+ if (message.kind === "agent") return {
5505
6598
  kind: "message",
5506
- role: message.kind === "agent" ? "assistant" : message.kind,
6599
+ role: "assistant",
5507
6600
  text: message.text,
5508
6601
  ...message.meta === void 0 ? {} : { meta: message.meta }
5509
6602
  };
@@ -5514,6 +6607,11 @@ function chatMessageToSessionPayload(message) {
5514
6607
  ...message.body === void 0 ? {} : { body: message.body },
5515
6608
  actions: message.actions
5516
6609
  };
6610
+ if (message.kind === "tool_call") return {
6611
+ kind: "tool_call",
6612
+ label: message.label,
6613
+ call: message.call
6614
+ };
5517
6615
  }
5518
6616
  function runtimeEventToSessionPayload(event) {
5519
6617
  switch (event.type) {
@@ -5738,16 +6836,19 @@ async function resolveRunSession(workspaceRoot, resume) {
5738
6836
  }
5739
6837
  async function loadConversation(workspaceRoot, resume) {
5740
6838
  return rehydrateSession((await loadSession(workspaceRoot, resume)).events).messages.flatMap((message) => {
5741
- if (message.kind === "modal" || message.modelContext === false) return [];
5742
- if (message.kind === "user") return [{
5743
- role: "user",
5744
- text: message.text
5745
- }];
5746
- if (message.kind === "agent") return [{
5747
- role: "assistant",
5748
- text: message.text
5749
- }];
5750
- return [];
6839
+ switch (message.kind) {
6840
+ case "user": return message.modelContext === false ? [] : [{
6841
+ role: "user",
6842
+ text: message.text
6843
+ }];
6844
+ case "agent": return message.modelContext === false ? [] : [{
6845
+ role: "assistant",
6846
+ text: message.text
6847
+ }];
6848
+ case "system":
6849
+ case "tool_call":
6850
+ case "modal": return [];
6851
+ }
5751
6852
  });
5752
6853
  }
5753
6854
  async function persistStartupMessages(session, context) {