min-agent 0.2.1 → 0.4.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.
Files changed (137) hide show
  1. package/README.md +242 -31
  2. package/dist/agent.js +1233 -485
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli/commands/chat.js +10 -0
  5. package/dist/cli/commands/exec.js +32 -0
  6. package/dist/cli/commands/history.js +58 -0
  7. package/dist/cli/commands/index.js +224 -0
  8. package/dist/cli/commands/init.js +18 -0
  9. package/dist/cli/commands/mcp.js +173 -0
  10. package/dist/cli/commands/memory.js +69 -0
  11. package/dist/cli/commands/models.js +21 -0
  12. package/dist/cli/commands/permission.js +12 -0
  13. package/dist/cli/commands/rules.js +33 -0
  14. package/dist/cli/commands/sandbox.js +13 -0
  15. package/dist/cli/commands/serve.js +9 -0
  16. package/dist/cli/commands/setup.js +4 -0
  17. package/dist/cli/commands/shared.js +16 -0
  18. package/dist/cli/commands/skills.js +119 -0
  19. package/dist/cli/commands/update.js +7 -0
  20. package/dist/cli/commands/write-config.js +30 -0
  21. package/dist/cli/errors.js +36 -0
  22. package/dist/cli/exec-prompt.js +26 -0
  23. package/dist/cli/option-helpers.js +53 -0
  24. package/dist/cli/program.js +180 -0
  25. package/dist/cli.js +7 -632
  26. package/dist/clipboard.js +59 -23
  27. package/dist/code-mode.js +35 -17
  28. package/dist/compaction.js +457 -169
  29. package/dist/config.js +298 -38
  30. package/dist/confirm.js +105 -9
  31. package/dist/context-window.js +156 -75
  32. package/dist/doom-loop.js +268 -26
  33. package/dist/fetch-timeout.js +152 -0
  34. package/dist/http-approvals.js +60 -0
  35. package/dist/http.js +119 -0
  36. package/dist/instructions.js +72 -33
  37. package/dist/logger.js +95 -0
  38. package/dist/markdown.js +35 -50
  39. package/dist/mcp.js +847 -102
  40. package/dist/memory.js +128 -45
  41. package/dist/output.js +42 -31
  42. package/dist/paste-handler.js +3 -3
  43. package/dist/permission-cli.js +43 -0
  44. package/dist/plugins.js +76 -11
  45. package/dist/pricing.js +119 -0
  46. package/dist/provider.js +34 -15
  47. package/dist/question-format.js +60 -0
  48. package/dist/sandbox-cli.js +82 -0
  49. package/dist/sandbox.js +403 -0
  50. package/dist/save-throttle.js +45 -0
  51. package/dist/serve/common.js +404 -0
  52. package/dist/serve/routes-chat.js +347 -0
  53. package/dist/serve/routes-mcp.js +212 -0
  54. package/dist/serve/routes-memory.js +66 -0
  55. package/dist/serve/routes-meta.js +205 -0
  56. package/dist/serve/routes-sessions.js +61 -0
  57. package/dist/serve/routes-skills.js +70 -0
  58. package/dist/serve.js +74 -635
  59. package/dist/sessions.js +197 -15
  60. package/dist/skills.js +531 -77
  61. package/dist/synthetic.js +7 -0
  62. package/dist/title-gen.js +9 -2
  63. package/dist/token-display.js +36 -0
  64. package/dist/tool-display.js +178 -0
  65. package/dist/tool-output.js +53 -46
  66. package/dist/tools/apply_patch.js +265 -0
  67. package/dist/tools/atomic-file.js +35 -0
  68. package/dist/tools/backend.js +61 -0
  69. package/dist/tools/bash.js +186 -71
  70. package/dist/tools/code_search.js +13 -6
  71. package/dist/tools/edit.js +26 -9
  72. package/dist/tools/explore.js +144 -16
  73. package/dist/tools/glob.js +7 -3
  74. package/dist/tools/grep.js +153 -14
  75. package/dist/tools/index.js +9 -24
  76. package/dist/tools/question.js +31 -30
  77. package/dist/tools/read.js +77 -15
  78. package/dist/tools/search-searxng.js +223 -0
  79. package/dist/tools/search-serper.js +189 -0
  80. package/dist/tools/task.js +100 -33
  81. package/dist/tools/todo.js +178 -67
  82. package/dist/tools/web_fetch.js +158 -46
  83. package/dist/tools/web_search.js +217 -29
  84. package/dist/tools/write.js +34 -11
  85. package/dist/tui/App.js +89 -6
  86. package/dist/tui/ConfirmBar.js +57 -4
  87. package/dist/tui/InputBar.js +504 -44
  88. package/dist/tui/MessageList.js +674 -20
  89. package/dist/tui/ModelPicker.js +113 -0
  90. package/dist/tui/QuestionBar.js +136 -0
  91. package/dist/tui/SessionPicker.js +79 -0
  92. package/dist/tui/StatusBar.js +14 -12
  93. package/dist/tui/agent-runner.js +223 -0
  94. package/dist/tui/caret-pos.js +177 -0
  95. package/dist/tui/caret.js +69 -0
  96. package/dist/tui/click-count.js +13 -0
  97. package/dist/tui/diff-view.js +61 -0
  98. package/dist/tui/drag-state.js +49 -0
  99. package/dist/tui/hydrate.js +129 -0
  100. package/dist/tui/index.js +189 -31
  101. package/dist/tui/input-history.js +125 -0
  102. package/dist/tui/layout.js +88 -0
  103. package/dist/tui/mouse.js +46 -0
  104. package/dist/tui/prompt-queue.js +24 -0
  105. package/dist/tui/selection.js +226 -0
  106. package/dist/tui/session-switch.js +28 -0
  107. package/dist/tui/slash-commands.js +106 -0
  108. package/dist/tui/slash-handler.js +545 -0
  109. package/dist/tui/text-width.js +113 -0
  110. package/dist/tui/theme.js +12 -0
  111. package/dist/tui/token-info.js +7 -0
  112. package/dist/tui/tool-children.js +19 -0
  113. package/dist/tui/undo-stack.js +14 -0
  114. package/dist/tui/use-sgr-mouse.js +29 -0
  115. package/dist/tui-chat.js +346 -330
  116. package/dist/updater.js +116 -0
  117. package/dist/xml-search.js +194 -0
  118. package/docs/API.md +410 -32
  119. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  120. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  121. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  122. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  123. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  124. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  125. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  126. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  127. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  128. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  129. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  130. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  131. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  132. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  133. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  134. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  135. package/package.json +12 -8
  136. package/skills/self-config/SKILL.md +90 -0
  137. package/skills/self-config/reference.md +149 -0
package/dist/title-gen.js CHANGED
@@ -16,7 +16,10 @@ export async function generateTitle(model, messages) {
16
16
  const userContent = typeof userMsg.content === "string"
17
17
  ? userMsg.content
18
18
  : Array.isArray(userMsg.content)
19
- ? userMsg.content.filter((p) => "text" in p).map((p) => p.text).join(" ")
19
+ ? userMsg.content
20
+ .filter((p) => "text" in p)
21
+ .map((p) => p.text ?? "")
22
+ .join(" ")
20
23
  : "";
21
24
  if (!userContent.trim())
22
25
  return null;
@@ -38,8 +41,12 @@ export async function generateTitle(model, messages) {
38
41
  { role: "user", content: context },
39
42
  ],
40
43
  temperature: 0.5,
44
+ maxOutputTokens: 30,
41
45
  });
42
- const title = result.text.trim().replace(/^["']|["']$/g, "").slice(0, 60);
46
+ const title = result.text
47
+ .trim()
48
+ .replace(/^["']|["']$/g, "")
49
+ .slice(0, 60);
43
50
  return title || null;
44
51
  }
45
52
  catch {
@@ -0,0 +1,36 @@
1
+ export function formatTokenCount(n) {
2
+ const v = Math.max(0, Math.round(n));
3
+ if (v < 1000)
4
+ return String(v);
5
+ if (v < 100_000)
6
+ return `${(v / 1000).toFixed(1).replace(/\.0$/, "")}k`;
7
+ if (v < 1_000_000)
8
+ return `${Math.round(v / 1000)}k`;
9
+ if (v < 10_000_000)
10
+ return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
11
+ return `${Math.round(v / 1_000_000)}M`;
12
+ }
13
+ export function contextUsagePercent(context, contextWindow) {
14
+ if (context <= 0 || contextWindow <= 0)
15
+ return null;
16
+ return Math.round((context / contextWindow) * 100);
17
+ }
18
+ export function tokenInfoFromTracker(tracker, contextWindow, estimatedContext = 0) {
19
+ return {
20
+ context: tracker.lastInputTokens > 0 ? tracker.lastInputTokens : estimatedContext,
21
+ input: tracker.totalInputTokens,
22
+ output: tracker.totalOutputTokens,
23
+ contextWindow,
24
+ };
25
+ }
26
+ export function formatTokenStatusLabel(info) {
27
+ const parts = [];
28
+ const pct = contextUsagePercent(info.context, info.contextWindow);
29
+ if (pct != null) {
30
+ parts.push(`${formatTokenCount(info.context)}/${formatTokenCount(info.contextWindow)} ${pct}%`);
31
+ }
32
+ if (info.input > 0 || info.output > 0) {
33
+ parts.push(`累计 ${formatTokenCount(info.input)} in · ${formatTokenCount(info.output)} out`);
34
+ }
35
+ return parts.join(" · ");
36
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * One-line summaries of tool calls and their results, shared by the TUI
3
+ * (`⚡ search_web "query" (news)`) and the plain CLI output. Keeping both
4
+ * surfaces on this module means a tool only needs a summary rule once.
5
+ *
6
+ * Summaries are neutral/English because tool names, arguments and paths are.
7
+ */
8
+ /** Truncate by code point (not UTF-16 unit) so surrogate pairs never split. */
9
+ export function truncateDisplay(text, max) {
10
+ const chars = Array.from(text);
11
+ if (chars.length <= max)
12
+ return text;
13
+ return chars.slice(0, Math.max(1, max - 1)).join("") + "…";
14
+ }
15
+ /** Collapse whitespace so a multi-line value stays on one row. */
16
+ function oneLine(text) {
17
+ return text.replace(/\s+/g, " ").trim();
18
+ }
19
+ function asRecord(input) {
20
+ return typeof input === "object" && input !== null && !Array.isArray(input)
21
+ ? input
22
+ : null;
23
+ }
24
+ function str(rec, key) {
25
+ const v = rec[key];
26
+ return typeof v === "string" ? oneLine(v) : "";
27
+ }
28
+ /** Shorten a path to its last two segments (`src/tools/web_search.ts` → `tools/web_search.ts`). */
29
+ function shortPath(p) {
30
+ const parts = p.split("/").filter(Boolean);
31
+ return parts.length <= 2 ? p : `…/${parts.slice(-2).join("/")}`;
32
+ }
33
+ /** Generic `key=value` rendering, used for tools without a specific rule. */
34
+ export function formatToolArgs(input, maxValueLen = 60) {
35
+ const rec = asRecord(input);
36
+ if (!rec)
37
+ return "";
38
+ const parts = [];
39
+ for (const [k, v] of Object.entries(rec)) {
40
+ if (v === undefined || v === null)
41
+ continue;
42
+ const raw = typeof v === "string" ? oneLine(v) : JSON.stringify(v);
43
+ if (raw === undefined)
44
+ continue;
45
+ parts.push(`${k}=${truncateDisplay(raw, maxValueLen)}`);
46
+ }
47
+ return parts.join(" ");
48
+ }
49
+ /**
50
+ * Summarize a tool call for a single row. `maxLen` bounds the whole summary;
51
+ * the caller still truncates to the terminal width.
52
+ */
53
+ export function summarizeToolCall(toolName, input, maxLen = 120) {
54
+ const rec = asRecord(input);
55
+ if (!rec)
56
+ return "";
57
+ const summary = specificCallSummary(toolName, rec);
58
+ return truncateDisplay(summary || formatToolArgs(rec, 60), maxLen);
59
+ }
60
+ function specificCallSummary(toolName, rec) {
61
+ switch (toolName) {
62
+ case "search_web": {
63
+ const query = str(rec, "query");
64
+ if (!query)
65
+ return "";
66
+ const facets = [];
67
+ const categories = str(rec, "categories");
68
+ if (categories && categories !== "general")
69
+ facets.push(categories);
70
+ const language = str(rec, "language");
71
+ if (language)
72
+ facets.push(language);
73
+ const range = str(rec, "time_range");
74
+ if (range)
75
+ facets.push(`past ${range}`);
76
+ const engines = str(rec, "engines");
77
+ if (engines)
78
+ facets.push(`via ${engines}`);
79
+ return `"${query}"${facets.length > 0 ? ` (${facets.join(", ")})` : ""}`;
80
+ }
81
+ case "web_fetch": {
82
+ const url = str(rec, "url");
83
+ const method = str(rec, "method");
84
+ return url ? `${method && method.toUpperCase() !== "GET" ? `${method.toUpperCase()} ` : ""}${url}` : "";
85
+ }
86
+ case "read": {
87
+ const p = str(rec, "filePath");
88
+ if (!p)
89
+ return "";
90
+ const start = rec.startLine;
91
+ const end = rec.endLine;
92
+ const range = typeof start === "number" || typeof end === "number" ? `:${start ?? 1}-${end ?? ""}` : "";
93
+ return `${shortPath(p)}${range}`;
94
+ }
95
+ case "write":
96
+ case "edit":
97
+ case "apply_patch": {
98
+ const p = str(rec, "filePath") || str(rec, "path");
99
+ return p ? shortPath(p) : "";
100
+ }
101
+ case "bash":
102
+ return str(rec, "command");
103
+ case "grep": {
104
+ const pattern = str(rec, "pattern");
105
+ if (!pattern)
106
+ return "";
107
+ const where = str(rec, "path");
108
+ const include = str(rec, "include");
109
+ const scope = [where, include].filter(Boolean).join(" ");
110
+ return `${pattern}${scope ? ` in ${scope}` : ""}`;
111
+ }
112
+ case "glob":
113
+ return str(rec, "pattern");
114
+ case "todo": {
115
+ const todos = rec.todos;
116
+ return Array.isArray(todos) ? `${todos.length} item${todos.length === 1 ? "" : "s"}` : "";
117
+ }
118
+ case "skill":
119
+ return str(rec, "name");
120
+ case "question":
121
+ return str(rec, "question");
122
+ case "task":
123
+ return str(rec, "description");
124
+ case "explore":
125
+ case "codesearch":
126
+ return str(rec, "query");
127
+ default:
128
+ return "";
129
+ }
130
+ }
131
+ /** Text form of a tool result, for the expanded view. */
132
+ export function toolResultText(output) {
133
+ if (typeof output === "string")
134
+ return output;
135
+ if (output === undefined || output === null)
136
+ return "";
137
+ try {
138
+ return JSON.stringify(output, null, 2);
139
+ }
140
+ catch {
141
+ return String(output);
142
+ }
143
+ }
144
+ /**
145
+ * One-line result summary. `isError` comes from the tool-error stream event;
146
+ * error strings produced by tools themselves are also recognized.
147
+ */
148
+ export function summarizeToolResult(toolName, output, isError = false, maxLen = 100) {
149
+ const text = toolResultText(output).trim();
150
+ if (text === "")
151
+ return isError ? "error" : "(empty)";
152
+ const firstLine = oneLine(text.split("\n", 1)[0] ?? "");
153
+ if (isError || /^(error|search error)\b/i.test(firstLine)) {
154
+ return truncateDisplay(firstLine.replace(/^Error:\s*/i, ""), maxLen);
155
+ }
156
+ if (toolName === "search_web") {
157
+ const found = /^Found (\d+) results? for /m.exec(text);
158
+ if (found)
159
+ return `${found[1]} results`;
160
+ if (/^No search results found/m.test(text))
161
+ return "no results";
162
+ const titles = text.match(/^\d+\.\s+Title\s*:/gm);
163
+ if (titles && titles.length > 0)
164
+ return `${titles.length} results`;
165
+ if (/^Search results for /m.test(text))
166
+ return "results";
167
+ }
168
+ if (toolName === "skill") {
169
+ const loaded = /^<skill_content name="([^"]*)"/m.exec(text);
170
+ if (loaded)
171
+ return truncateDisplay(`loaded ${loaded[1]} (${text.split("\n").length} lines)`, maxLen);
172
+ return truncateDisplay(firstLine, maxLen);
173
+ }
174
+ const lineCount = text.split("\n").length;
175
+ if (lineCount === 1)
176
+ return truncateDisplay(firstLine, maxLen);
177
+ return truncateDisplay(`${firstLine} (${lineCount} lines)`, maxLen);
178
+ }
@@ -6,13 +6,18 @@ import { getConfigDir } from "./config.js";
6
6
  export const TOOL_OUTPUT_MAX_LINES = 2000;
7
7
  export const TOOL_OUTPUT_MAX_BYTES = 50 * 1024;
8
8
  const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
9
+ const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
10
+ let lastCleanup = 0;
9
11
  function toolOutputDir() {
10
12
  return path.join(getConfigDir(), "tool-output");
11
13
  }
12
14
  function cleanupOldToolOutputs(dir) {
15
+ const now = Date.now();
16
+ if (now - lastCleanup < CLEANUP_INTERVAL_MS)
17
+ return;
18
+ lastCleanup = now;
13
19
  if (!existsSync(dir))
14
20
  return;
15
- const now = Date.now();
16
21
  try {
17
22
  for (const f of readdirSync(dir)) {
18
23
  if (!f.startsWith("tool-") || !f.endsWith(".txt"))
@@ -42,60 +47,64 @@ export function writeFullToolOutput(fullText) {
42
47
  return filePath;
43
48
  }
44
49
  const hint = (filePath) => `The tool output was truncated. Full output saved to: ${filePath}\nUse the read tool with startLine/endLine, or grep, to inspect further.`;
45
- /** Keep end of text within line/byte limits (good for shell logs). */
46
- export function tailPreview(text, maxLines, maxBytes) {
47
- const lines = text.split("\n");
48
- const totalBytes = Buffer.byteLength(text, "utf-8");
50
+ /** Keep start or end of text within line/byte limits (good for shell logs / files). */
51
+ function preview(text, maxLines, maxBytes, direction, lines, totalBytes) {
49
52
  if (lines.length <= maxLines && totalBytes <= maxBytes) {
50
53
  return { text, cut: false };
51
54
  }
52
55
  const out = [];
53
56
  let bytes = 0;
54
- for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
55
- const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0);
56
- if (bytes + size > maxBytes) {
57
- if (out.length === 0) {
58
- const buf = Buffer.from(lines[i], "utf-8");
59
- let start = buf.length - maxBytes;
60
- if (start < 0)
61
- start = 0;
62
- while (start < buf.length && (buf[start] & 0xc0) === 0x80)
63
- start++;
64
- out.unshift(buf.subarray(start).toString("utf-8"));
57
+ const pushLine = (i, first) => {
58
+ const size = Buffer.byteLength(lines[i], "utf-8") + (first ? 0 : 1);
59
+ if (bytes + size > maxBytes)
60
+ return false;
61
+ if (direction === "head")
62
+ out.push(lines[i]);
63
+ else
64
+ out.unshift(lines[i]);
65
+ bytes += size;
66
+ return true;
67
+ };
68
+ if (direction === "head") {
69
+ for (let i = 0; i < lines.length && out.length < maxLines; i++) {
70
+ if (!pushLine(i, out.length === 0)) {
71
+ if (out.length === 0) {
72
+ const buf = Buffer.from(lines[i], "utf-8");
73
+ let end = Math.min(maxBytes, buf.length);
74
+ while (end > 0 && (buf[end] & 0xc0) === 0x80)
75
+ end--;
76
+ out.push(buf.subarray(0, end).toString("utf-8"));
77
+ }
78
+ break;
65
79
  }
66
- break;
67
80
  }
68
- out.unshift(lines[i]);
69
- bytes += size;
70
81
  }
71
- return { text: out.join("\n"), cut: true };
72
- }
73
- /** Keep start of text within line/byte limits (good for files / HTTP bodies). */
74
- export function headPreview(text, maxLines, maxBytes) {
75
- const lines = text.split("\n");
76
- const totalBytes = Buffer.byteLength(text, "utf-8");
77
- if (lines.length <= maxLines && totalBytes <= maxBytes) {
78
- return { text, cut: false };
79
- }
80
- const out = [];
81
- let bytes = 0;
82
- for (let i = 0; i < lines.length && out.length < maxLines; i++) {
83
- const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0);
84
- if (bytes + size > maxBytes) {
85
- if (out.length === 0) {
86
- const buf = Buffer.from(lines[i], "utf-8");
87
- let end = Math.min(maxBytes, buf.length);
88
- while (end > 0 && (buf[end - 1] & 0xc0) === 0x80)
89
- end--;
90
- out.push(buf.subarray(0, end).toString("utf-8"));
82
+ else {
83
+ for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
84
+ if (!pushLine(i, out.length === 0)) {
85
+ if (out.length === 0) {
86
+ const buf = Buffer.from(lines[i], "utf-8");
87
+ let start = buf.length - maxBytes;
88
+ if (start < 0)
89
+ start = 0;
90
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80)
91
+ start++;
92
+ out.unshift(buf.subarray(start).toString("utf-8"));
93
+ }
94
+ break;
91
95
  }
92
- break;
93
96
  }
94
- out.push(lines[i]);
95
- bytes += size;
96
97
  }
97
98
  return { text: out.join("\n"), cut: true };
98
99
  }
100
+ /** Keep end of text within line/byte limits (good for shell logs). */
101
+ export function tailPreview(text, maxLines, maxBytes) {
102
+ return preview(text, maxLines, maxBytes, "tail", text.split("\n"), Buffer.byteLength(text, "utf-8"));
103
+ }
104
+ /** Keep start of text within line/byte limits (good for files / HTTP bodies). */
105
+ export function headPreview(text, maxLines, maxBytes) {
106
+ return preview(text, maxLines, maxBytes, "head", text.split("\n"), Buffer.byteLength(text, "utf-8"));
107
+ }
99
108
  /**
100
109
  * If text exceeds limits, write full text to disk and return a preview + path hint.
101
110
  * Otherwise returns the original string.
@@ -110,10 +119,8 @@ export function truncateToolOutput(text, options = {}) {
110
119
  return { content: text, truncated: false };
111
120
  }
112
121
  const filePath = writeFullToolOutput(text);
113
- const preview = direction === "tail" ? tailPreview(text, maxLines, maxBytes).text : headPreview(text, maxLines, maxBytes).text;
122
+ const pv = preview(text, maxLines, maxBytes, direction, lines, totalBytes);
114
123
  const header = "...output truncated...\n\n";
115
- const content = direction === "tail"
116
- ? `${header}${hint(filePath)}\n\n${preview}`
117
- : `${preview}\n\n${header}${hint(filePath)}`;
124
+ const content = direction === "tail" ? `${header}${hint(filePath)}\n\n${pv.text}` : `${pv.text}\n\n${header}${hint(filePath)}`;
118
125
  return { content, truncated: true, outputPath: filePath };
119
126
  }
@@ -0,0 +1,265 @@
1
+ import { tool, jsonSchema } from "ai";
2
+ import { readFileSync, existsSync } from "fs";
3
+ import { mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
4
+ import path from "path";
5
+ import { confirm, isEditAutoApprove } from "../confirm.js";
6
+ import { pathAccessError } from "../sandbox.js";
7
+ import { atomicWriteFile, tempPathFor } from "./atomic-file.js";
8
+ const OFFSET_TOLERANCE = 3;
9
+ export function parseUnifiedDiff(diff) {
10
+ const files = [];
11
+ let current = null;
12
+ let currentHunk = null;
13
+ let pendingHeader = null;
14
+ let remainingOld = 0;
15
+ let remainingNew = 0;
16
+ const lines = diff.replace(/\r\n/g, "\n").split("\n");
17
+ for (const raw of lines) {
18
+ if (currentHunk === null && raw.startsWith("--- ")) {
19
+ pendingHeader = raw.slice(4);
20
+ continue;
21
+ }
22
+ if (currentHunk === null && pendingHeader !== null && raw.startsWith("+++ ")) {
23
+ const target = raw.slice(4);
24
+ if (current)
25
+ files.push(current);
26
+ current = { path: stripPrefix(target), isNew: pendingHeader === "/dev/null", hunks: [] };
27
+ currentHunk = null;
28
+ pendingHeader = null;
29
+ continue;
30
+ }
31
+ if (currentHunk === null && raw.startsWith("@@ ")) {
32
+ const m = raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
33
+ if (m && current) {
34
+ currentHunk = {
35
+ oldStart: parseInt(m[1], 10),
36
+ oldCount: m[2] ? parseInt(m[2], 10) : 1,
37
+ newStart: parseInt(m[3], 10),
38
+ newCount: m[4] ? parseInt(m[4], 10) : 1,
39
+ lines: [],
40
+ };
41
+ current.hunks.push(currentHunk);
42
+ remainingOld = currentHunk.oldCount;
43
+ remainingNew = currentHunk.newCount;
44
+ }
45
+ continue;
46
+ }
47
+ if (currentHunk && current) {
48
+ if (raw.startsWith("+")) {
49
+ currentHunk.lines.push({ type: "add", text: raw.slice(1) });
50
+ remainingNew--;
51
+ }
52
+ else if (raw.startsWith("-")) {
53
+ currentHunk.lines.push({ type: "delete", text: raw.slice(1) });
54
+ remainingOld--;
55
+ }
56
+ else if (raw.startsWith(" ")) {
57
+ currentHunk.lines.push({ type: "context", text: raw.slice(1) });
58
+ remainingOld--;
59
+ remainingNew--;
60
+ }
61
+ if (remainingOld <= 0 && remainingNew <= 0)
62
+ currentHunk = null;
63
+ }
64
+ }
65
+ if (current)
66
+ files.push(current);
67
+ return files;
68
+ }
69
+ function stripPrefix(p) {
70
+ const trimmed = p.trim();
71
+ if (trimmed === "/dev/null")
72
+ return trimmed;
73
+ if (trimmed.startsWith("a/"))
74
+ return trimmed.slice(2);
75
+ if (trimmed.startsWith("b/"))
76
+ return trimmed.slice(2);
77
+ return trimmed;
78
+ }
79
+ function findMatch(lines, start, block) {
80
+ if (block.length === 0)
81
+ return Math.max(0, Math.min(start, lines.length));
82
+ for (let pos = Math.max(0, start - OFFSET_TOLERANCE); pos <= Math.min(lines.length - block.length, start + OFFSET_TOLERANCE); pos++) {
83
+ let ok = true;
84
+ for (let i = 0; i < block.length; i++) {
85
+ if (lines[pos + i] !== block[i]) {
86
+ ok = false;
87
+ break;
88
+ }
89
+ }
90
+ if (ok)
91
+ return pos;
92
+ }
93
+ return -1;
94
+ }
95
+ function applyHunk(lines, hunk) {
96
+ const block = hunk.lines.filter((l) => l.type !== "add").map((l) => l.text);
97
+ const pos = findMatch(lines, hunk.oldStart - 1, block);
98
+ if (pos === -1) {
99
+ return { error: `第 ${hunk.oldStart} 行附近的 hunk 未找到匹配(需要匹配 ${block.length} 行)` };
100
+ }
101
+ const result = [...lines.slice(0, pos)];
102
+ let src = pos;
103
+ for (const l of hunk.lines) {
104
+ if (l.type === "context") {
105
+ result.push(lines[src]);
106
+ src++;
107
+ }
108
+ else if (l.type === "delete") {
109
+ src++;
110
+ }
111
+ else {
112
+ result.push(l.text);
113
+ }
114
+ }
115
+ result.push(...lines.slice(src));
116
+ return { lines: result };
117
+ }
118
+ /** 在内存中应用全部文件;成功后返回各文件新内容(不写盘)。 */
119
+ export function buildPatchedFiles(files, cwd = process.cwd()) {
120
+ const contents = [];
121
+ for (const file of files) {
122
+ const target = path.resolve(cwd, file.path);
123
+ const exists = existsSync(target);
124
+ if (file.isNew && !exists) {
125
+ const lines = [];
126
+ for (const hunk of file.hunks) {
127
+ const r = applyHunk(lines, hunk);
128
+ if ("error" in r)
129
+ return { ok: false, error: `${file.path}: ${r.error}` };
130
+ lines.length = 0;
131
+ lines.push(...r.lines);
132
+ }
133
+ contents.push({ file, content: lines.join("\n") + (lines.length > 0 ? "\n" : ""), original: null });
134
+ continue;
135
+ }
136
+ if (!exists)
137
+ return { ok: false, error: `${file.path}: 文件不存在` };
138
+ let content;
139
+ try {
140
+ content = readFileSync(target, "utf-8");
141
+ }
142
+ catch (err) {
143
+ return { ok: false, error: `${file.path}: 读取失败 ${err instanceof Error ? err.message : String(err)}` };
144
+ }
145
+ const lines = content.split("\n");
146
+ for (const hunk of file.hunks) {
147
+ const r = applyHunk(lines, hunk);
148
+ if ("error" in r)
149
+ return { ok: false, error: `${file.path}: ${r.error}` };
150
+ lines.length = 0;
151
+ lines.push(...r.lines);
152
+ }
153
+ contents.push({ file, content: lines.join("\n"), original: content });
154
+ }
155
+ return { ok: true, contents };
156
+ }
157
+ function errMessage(err) {
158
+ return err instanceof Error ? err.message : String(err);
159
+ }
160
+ /**
161
+ * 事务化写盘:先把所有新内容写入目标旁的临时文件,再统一 rename 就位。
162
+ * 第一阶段失败时工作区零改动;rename 中途失败时回滚已就位的文件。
163
+ */
164
+ async function commitPatchedFiles(contents) {
165
+ const cwd = process.cwd();
166
+ const staged = [];
167
+ // 漂移检查:确认弹窗期间文件可能被用户或其他进程改动。
168
+ for (const c of contents) {
169
+ const target = path.resolve(cwd, c.file.path);
170
+ if (c.original === null) {
171
+ if (existsSync(target))
172
+ return { ok: false, error: `${c.file.path}: 文件已存在,未应用任何更改` };
173
+ continue;
174
+ }
175
+ let current;
176
+ try {
177
+ current = await readFile(target, "utf-8");
178
+ }
179
+ catch (err) {
180
+ return { ok: false, error: `${c.file.path}: 读取失败 ${errMessage(err)}` };
181
+ }
182
+ if (current !== c.original) {
183
+ return { ok: false, error: `${c.file.path}: 文件在确认期间已被修改,未应用任何更改` };
184
+ }
185
+ }
186
+ for (const c of contents) {
187
+ const target = path.resolve(cwd, c.file.path);
188
+ const tmp = tempPathFor(target);
189
+ try {
190
+ await mkdir(path.dirname(target), { recursive: true });
191
+ await writeFile(tmp, c.content, "utf-8");
192
+ }
193
+ catch (err) {
194
+ for (const s of staged)
195
+ await unlink(s.tmp).catch(() => { });
196
+ await unlink(tmp).catch(() => { });
197
+ return { ok: false, error: `${c.file.path}: 写入失败 ${errMessage(err)}` };
198
+ }
199
+ staged.push({ rel: c.file.path, target, tmp, original: c.original });
200
+ }
201
+ const renamed = [];
202
+ for (const s of staged) {
203
+ try {
204
+ await rename(s.tmp, s.target);
205
+ renamed.push(s);
206
+ }
207
+ catch (err) {
208
+ for (const d of [...renamed].reverse()) {
209
+ try {
210
+ if (d.original !== null)
211
+ await atomicWriteFile(d.target, d.original);
212
+ else
213
+ await unlink(d.target).catch(() => { });
214
+ }
215
+ catch { }
216
+ }
217
+ for (const s2 of staged.slice(renamed.length))
218
+ await unlink(s2.tmp).catch(() => { });
219
+ return { ok: false, error: `${s.rel}: 写入失败 ${errMessage(err)},已回滚其他文件` };
220
+ }
221
+ }
222
+ return { ok: true };
223
+ }
224
+ export const applyPatchTool = tool({
225
+ description: "Apply a unified diff to the working tree. Use this for precise multi-file edits or when you have a generated diff. " +
226
+ "Supports standard unified diff format (---/+++ headers, @@ hunks, context/delete/add lines). " +
227
+ "New files are created when the diff targets /dev/null. Conflicts are reported without partial writes.",
228
+ inputSchema: jsonSchema({
229
+ type: "object",
230
+ properties: {
231
+ diff: { type: "string", description: "The unified diff text to apply" },
232
+ },
233
+ required: ["diff"],
234
+ }),
235
+ execute: async ({ diff }) => {
236
+ const parsed = parseUnifiedDiff(diff);
237
+ if (parsed.length === 0)
238
+ return "Error: 无法解析 diff(缺少 ---/+++ 文件头)";
239
+ // 先鉴权再读盘:任何目标路径不可写都直接拒绝,不触碰文件内容。
240
+ for (const f of parsed) {
241
+ const denied = pathAccessError(f.path, "write");
242
+ if (denied)
243
+ return `Error: ${denied}`;
244
+ }
245
+ const built = buildPatchedFiles(parsed);
246
+ if (!built.ok)
247
+ return `Error: ${built.error}`;
248
+ const summary = built.contents
249
+ .map((c) => {
250
+ const del = c.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "delete").length, 0);
251
+ const add = c.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "add").length, 0);
252
+ return ` ${c.file.path} ${c.file.isNew ? "(new)" : ""} -${del}/+${add}`;
253
+ })
254
+ .join("\n");
255
+ if (!isEditAutoApprove()) {
256
+ const approved = await confirm(`Apply patch to ${built.contents.length} file(s)?\n${summary}`);
257
+ if (!approved)
258
+ return "Patch rejected by user.";
259
+ }
260
+ const committed = await commitPatchedFiles(built.contents);
261
+ if (!committed.ok)
262
+ return `Error: ${committed.error}`;
263
+ return `Applied patch:\n${summary}`;
264
+ },
265
+ });
@@ -0,0 +1,35 @@
1
+ import { writeFile, rename, unlink } from "fs/promises";
2
+ import { writeFileSync, renameSync, unlinkSync } from "fs";
3
+ import { randomBytes } from "crypto";
4
+ import path from "path";
5
+ /** Temp file path living next to the target so the rename stays on the same filesystem. */
6
+ export function tempPathFor(target) {
7
+ return path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
8
+ }
9
+ /** Write content to a sibling temp file, then atomically rename it over the target. */
10
+ export async function atomicWriteFile(target, content) {
11
+ const tmp = tempPathFor(target);
12
+ try {
13
+ await writeFile(tmp, content, "utf-8");
14
+ await rename(tmp, target);
15
+ }
16
+ catch (err) {
17
+ await unlink(tmp).catch(() => { });
18
+ throw err;
19
+ }
20
+ }
21
+ /** Sync variant for call sites that must stay synchronous (config/session stores). */
22
+ export function atomicWriteFileSync(target, content) {
23
+ const tmp = tempPathFor(target);
24
+ try {
25
+ writeFileSync(tmp, content, "utf-8");
26
+ renameSync(tmp, target);
27
+ }
28
+ catch (err) {
29
+ try {
30
+ unlinkSync(tmp);
31
+ }
32
+ catch { }
33
+ throw err;
34
+ }
35
+ }