min-agent 0.3.0 → 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 (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
package/dist/sessions.js CHANGED
@@ -1,12 +1,40 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, openSync, readSync, closeSync, statSync, } from "fs";
1
+ import { readFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, openSync, readSync, closeSync, statSync, } from "fs";
2
2
  import path from "path";
3
3
  import { randomUUID } from "crypto";
4
+ import { atomicWriteFileSync } from "./tools/atomic-file.js";
4
5
  import { getConfigDir } from "./config.js";
5
6
  import { generateTitle } from "./title-gen.js";
6
7
  import { isSyntheticMessage } from "./synthetic.js";
8
+ import { parseTaskState } from "./tools/todo.js";
7
9
  function getSessionsDir() {
8
10
  return path.join(getConfigDir(), "sessions");
9
11
  }
12
+ /**
13
+ * In-process per-session mutex serializing read-modify-write cycles
14
+ * (load → append → run → save) so concurrent requests on the same session
15
+ * cannot lose messages. Cross-process safety is NOT provided.
16
+ */
17
+ const sessionLocks = new Map();
18
+ export async function withSessionLock(sessionId, fn) {
19
+ if (!sessionId)
20
+ return fn();
21
+ const prev = sessionLocks.get(sessionId) ?? Promise.resolve();
22
+ let release;
23
+ const ours = new Promise((r) => {
24
+ release = r;
25
+ });
26
+ const chained = prev.then(() => ours);
27
+ sessionLocks.set(sessionId, chained);
28
+ await prev;
29
+ try {
30
+ return await fn();
31
+ }
32
+ finally {
33
+ release();
34
+ if (sessionLocks.get(sessionId) === chained)
35
+ sessionLocks.delete(sessionId);
36
+ }
37
+ }
10
38
  /** Session ids are UUIDs; a strict whitelist blocks path traversal via `/v1/sessions/..%2F..%2Fconfig`. */
11
39
  const SESSION_ID_RE = /^[a-zA-Z0-9_-]+$/;
12
40
  function sessionPath(id) {
@@ -81,7 +109,10 @@ function generateId() {
81
109
  function textOfContent(content) {
82
110
  if (typeof content === "string")
83
111
  return content;
84
- return content.map((p) => (p.type === "text" ? p.text : "")).join(" ").trim();
112
+ return content
113
+ .map((p) => (p.type === "text" ? p.text : ""))
114
+ .join(" ")
115
+ .trim();
85
116
  }
86
117
  function deriveTitle(messages) {
87
118
  const first = messages.find((m) => m.role === "user");
@@ -118,23 +149,26 @@ export function sanitizeForStorage(messages) {
118
149
  return { ...msg, content };
119
150
  });
120
151
  }
121
- export function saveSession(messages, existingId, title, created) {
152
+ export function saveSession(messages, existingId, title, created, taskState) {
122
153
  const dir = getSessionsDir();
123
154
  mkdirSync(dir, { recursive: true });
124
155
  const id = existingId && SESSION_ID_RE.test(existingId) ? existingId : generateId();
125
156
  const now = new Date().toISOString();
126
- const existing = existingId ? readMeta(id) : null;
157
+ const existingMeta = existingId ? readMeta(id) : null;
158
+ const preservedState = taskState === undefined && existingId ? loadSession(id)?.taskState : undefined;
159
+ const resolvedState = taskState ?? preservedState;
127
160
  const data = {
128
161
  meta: {
129
162
  id,
130
- title: title ?? existing?.title ?? deriveTitle(messages),
131
- created: created ?? existing?.created ?? now,
163
+ title: title ?? existingMeta?.title ?? deriveTitle(messages),
164
+ created: created ?? existingMeta?.created ?? now,
132
165
  updated: now,
133
166
  messageCount: messages.length,
134
167
  },
168
+ ...(resolvedState ? { taskState: resolvedState } : {}),
135
169
  messages: sanitizeForStorage(messages),
136
170
  };
137
- writeFileSync(sessionPath(id), JSON.stringify(data, null, 2), "utf-8");
171
+ atomicWriteFileSync(sessionPath(id), JSON.stringify(data, null, 2));
138
172
  return id;
139
173
  }
140
174
  /** Read only the meta head to recover the original created/title. */
@@ -165,12 +199,22 @@ export function loadSession(id) {
165
199
  if (!existsSync(file))
166
200
  return null;
167
201
  try {
168
- return JSON.parse(readFileSync(file, "utf-8"));
202
+ const data = JSON.parse(readFileSync(file, "utf-8"));
203
+ const taskState = parseTaskState(data.taskState);
204
+ return taskState ? { ...data, taskState } : data;
169
205
  }
170
206
  catch {
171
207
  return null;
172
208
  }
173
209
  }
210
+ export function loadExecHistory(resumeSessionId) {
211
+ if (!resumeSessionId)
212
+ return { ok: true, messages: [], sessionId: undefined };
213
+ const session = loadSession(resumeSessionId);
214
+ if (!session)
215
+ return { ok: false, error: `会话不存在: ${resumeSessionId}` };
216
+ return { ok: true, messages: [...session.messages], sessionId: resumeSessionId, taskState: session.taskState };
217
+ }
174
218
  export function listSessions() {
175
219
  const dir = getSessionsDir();
176
220
  if (!existsSync(dir))
@@ -212,7 +256,7 @@ export function renameSession(id, title) {
212
256
  const data = JSON.parse(readFileSync(file, "utf-8"));
213
257
  data.meta.title = title;
214
258
  data.meta.updated = new Date().toISOString();
215
- writeFileSync(file, JSON.stringify(data, null, 2), "utf-8");
259
+ atomicWriteFileSync(file, JSON.stringify(data, null, 2));
216
260
  return true;
217
261
  }
218
262
  catch {
package/dist/skills.js CHANGED
@@ -2,9 +2,26 @@ import { tool, jsonSchema } from "ai";
2
2
  import { readFileSync, existsSync, readdirSync, realpathSync } from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
5
+ import { fileURLToPath } from "url";
5
6
  import { globSync } from "glob";
6
7
  import { loadConfig, saveConfig, loadProjectConfig, saveProjectConfig } from "./config.js";
7
8
  import { truncateToolOutput } from "./tool-output.js";
9
+ /** Packaged self-management skill; keep this name in sync with `skills/self-config/SKILL.md`. */
10
+ export const SELF_CONFIG_SKILL = "self-config";
11
+ function getPackageRoot() {
12
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ }
14
+ export function getBuiltinSkillsDir() {
15
+ return path.join(getPackageRoot(), "skills");
16
+ }
17
+ function builtinSkillsAllowed() {
18
+ const value = process.env.MIN_AGENT_NO_BUILTIN_SKILLS?.trim().toLowerCase();
19
+ return value !== "1" && value !== "true";
20
+ }
21
+ function isUnderDir(root, filePath) {
22
+ const rel = path.relative(root, filePath);
23
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
24
+ }
8
25
  /** Coerce a config skill list to an array of names, tolerating hand-edited configs. */
9
26
  function skillNames(value) {
10
27
  return Array.isArray(value) ? value.filter((n) => typeof n === "string") : [];
@@ -48,7 +65,8 @@ const SKILL_CONTENT_MAX_BYTES = 32 * 1024;
48
65
  * Skill scan order: later entries win on duplicate `name` in frontmatter.
49
66
  * Global user skills first, then project-local dirs so repo skills override ~/.agents.
50
67
  * Computed lazily so process.cwd() changes are respected.
51
- * `MIN_AGENT_SKILLS_DIRS` (path-delimiter separated) replaces the default list.
68
+ * `MIN_AGENT_SKILLS_DIRS` (path-delimiter separated) replaces the default user list.
69
+ * Packaged skills are scanned separately and win on name.
52
70
  */
53
71
  function getSkillDirs() {
54
72
  const override = process.env.MIN_AGENT_SKILLS_DIRS?.trim();
@@ -107,21 +125,32 @@ export function scanSkillDir(dir, warn = stderrWarn) {
107
125
  }
108
126
  return [...byName.values()];
109
127
  }
128
+ function mergeSkill(next, skill, warn) {
129
+ const previous = next[skill.name];
130
+ if (previous && sameFile(previous.location, skill.location)) {
131
+ if (skill.builtin && !previous.builtin)
132
+ next[skill.name] = { ...previous, builtin: true };
133
+ return;
134
+ }
135
+ if (previous) {
136
+ const versionNote = previous.version && skill.version && previous.version !== skill.version
137
+ ? ` (version ${previous.version} → ${skill.version})`
138
+ : "";
139
+ warn?.(`\x1b[33m ⚠ Duplicate skill "${skill.name}": ${skill.location} overrides ${previous.location}${versionNote}\x1b[0m`);
140
+ }
141
+ next[skill.name] = skill;
142
+ }
110
143
  export function discoverSkills(opts) {
111
144
  const warn = opts?.silent ? null : stderrWarn;
112
145
  const next = {};
113
146
  for (const dir of getSkillDirs()) {
114
- for (const skill of scanSkillDir(dir, warn)) {
115
- const previous = next[skill.name];
116
- if (previous && sameFile(previous.location, skill.location))
117
- continue;
118
- if (previous) {
119
- const versionNote = previous.version && skill.version && previous.version !== skill.version
120
- ? ` (version ${previous.version} ${skill.version})`
121
- : "";
122
- warn?.(`\x1b[33m ⚠ Duplicate skill "${skill.name}": ${skill.location} overrides ${previous.location}${versionNote}\x1b[0m`);
123
- }
124
- next[skill.name] = skill;
147
+ for (const skill of scanSkillDir(dir, warn))
148
+ mergeSkill(next, skill, warn);
149
+ }
150
+ if (builtinSkillsAllowed()) {
151
+ const builtinDir = getBuiltinSkillsDir();
152
+ for (const skill of scanSkillDir(builtinDir, warn)) {
153
+ mergeSkill(next, { ...skill, builtin: isUnderDir(builtinDir, skill.location) }, warn);
125
154
  }
126
155
  }
127
156
  const state = resolveSkillState();
@@ -231,7 +260,9 @@ function readRequiredBins(frontmatter) {
231
260
  function parseSkillFile(filePath, warn) {
232
261
  let raw;
233
262
  try {
234
- raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
263
+ raw = readFileSync(filePath, "utf-8")
264
+ .replace(/^\uFEFF/, "")
265
+ .replace(/\r\n/g, "\n");
235
266
  }
236
267
  catch (err) {
237
268
  warn?.(`\x1b[33m ⚠ Skill skipped (unreadable): ${filePath} — ${err.message}\x1b[0m`);
@@ -259,8 +290,17 @@ function parseSkillFile(filePath, warn) {
259
290
  allowedTools: readFrontmatterList(frontmatter, "allowed-tools"),
260
291
  requiredBins: readRequiredBins(frontmatter),
261
292
  version: readFrontmatterValue(frontmatter, "version") ?? undefined,
293
+ builtin: false,
294
+ alwaysLoad: readFrontmatterBool(frontmatter, "always-load"),
262
295
  };
263
296
  }
297
+ function readFrontmatterBool(frontmatter, key) {
298
+ const raw = readFrontmatterValue(frontmatter, key);
299
+ if (raw == null)
300
+ return false;
301
+ const value = raw.trim().toLowerCase();
302
+ return value === "true" || value === "yes" || value === "1";
303
+ }
264
304
  /** Every discovered skill, disabled ones included (`enabled` reflects config). */
265
305
  export function getAllSkills() {
266
306
  const state = resolveSkillState();
@@ -386,7 +426,9 @@ export function buildSkillContent(skill) {
386
426
  ? [
387
427
  "<skill_files>",
388
428
  ...entries.map((f) => ` <file>${escapeXml(f)}</file>`),
389
- total > entries.length ? ` <note>${total - entries.length} more entries not listed; use glob on the base directory</note>` : "",
429
+ total > entries.length
430
+ ? ` <note>${total - entries.length} more entries not listed; use glob on the base directory</note>`
431
+ : "",
390
432
  "</skill_files>",
391
433
  ]
392
434
  .filter(Boolean)
@@ -418,7 +460,9 @@ export function getSkillsTool(tracker = new Set()) {
418
460
  }),
419
461
  execute: async ({ name }) => {
420
462
  const state = resolveSkillState();
421
- const available = Object.keys(loadedSkills).filter((n) => !state.disabled.has(n)).sort();
463
+ const available = Object.keys(loadedSkills)
464
+ .filter((n) => !state.disabled.has(n))
465
+ .sort();
422
466
  if (state.disabled.has(name)) {
423
467
  return `Skill "${name}" is disabled (${state.scopeOf.get(name)} scope). Available skills: ${available.join(", ") || "none"}`;
424
468
  }
@@ -455,9 +499,13 @@ function unescapeXmlAttr(value) {
455
499
  export function buildSkillsPrompt(skills) {
456
500
  if (skills.length === 0)
457
501
  return "";
502
+ const hasAlwaysLoad = skills.some((s) => s.alwaysLoad);
458
503
  return [
459
504
  "Skills provide specialized instructions and workflows for specific tasks.",
460
505
  "Use the skill tool to load a skill when a task matches its description.",
506
+ hasAlwaysLoad
507
+ ? "Skills with always_load already have their full instructions in this prompt. Do not call the skill tool for those unless compaction dropped them."
508
+ : "",
461
509
  "",
462
510
  "<available_skills>",
463
511
  ...skills.flatMap((s) => [
@@ -465,10 +513,14 @@ export function buildSkillsPrompt(skills) {
465
513
  ` <name>${escapeXml(s.name)}</name>`,
466
514
  ` <description>${escapeXml(s.description)}</description>`,
467
515
  ` <location>${escapeXml(s.location)}</location>`,
516
+ s.alwaysLoad ? " <always_load>true</always_load>" : "",
517
+ s.builtin ? " <builtin>true</builtin>" : "",
468
518
  " </skill>",
469
519
  ]),
470
520
  "</available_skills>",
471
- ].join("\n");
521
+ ]
522
+ .filter((line) => line !== "")
523
+ .join("\n");
472
524
  }
473
525
  const SKILL_CONTENT_MARKER = /<skill_content name="([^"]*)"/g;
474
526
  function textOf(part) {
@@ -530,6 +582,18 @@ export function attachSkills(tools, tracker) {
530
582
  const skills = getSkills();
531
583
  if (skills.length === 0)
532
584
  return "";
533
- tools["skill"] = getSkillsTool(tracker);
534
- return buildSkillsPrompt(skills);
585
+ const loadTracker = tracker ?? new Set();
586
+ const auto = skills.filter((s) => s.alwaysLoad);
587
+ for (const skill of auto)
588
+ loadTracker.add(skill.name);
589
+ tools.skill = getSkillsTool(loadTracker);
590
+ const catalog = buildSkillsPrompt(skills);
591
+ if (auto.length === 0)
592
+ return catalog;
593
+ return [
594
+ catalog,
595
+ "",
596
+ "The following skills are already loaded for this conversation. Follow them without calling the skill tool again.",
597
+ ...auto.map(buildSkillContent),
598
+ ].join("\n");
535
599
  }
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;
@@ -40,7 +43,10 @@ export async function generateTitle(model, messages) {
40
43
  temperature: 0.5,
41
44
  maxOutputTokens: 30,
42
45
  });
43
- const title = result.text.trim().replace(/^["']|["']$/g, "").slice(0, 60);
46
+ const title = result.text
47
+ .trim()
48
+ .replace(/^["']|["']$/g, "")
49
+ .slice(0, 60);
44
50
  return title || null;
45
51
  }
46
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
+ }
@@ -159,6 +159,11 @@ export function summarizeToolResult(toolName, output, isError = false, maxLen =
159
159
  return `${found[1]} results`;
160
160
  if (/^No search results found/m.test(text))
161
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";
162
167
  }
163
168
  if (toolName === "skill") {
164
169
  const loaded = /^<skill_content name="([^"]*)"/m.exec(text);
@@ -121,8 +121,6 @@ export function truncateToolOutput(text, options = {}) {
121
121
  const filePath = writeFullToolOutput(text);
122
122
  const pv = preview(text, maxLines, maxBytes, direction, lines, totalBytes);
123
123
  const header = "...output truncated...\n\n";
124
- const content = direction === "tail"
125
- ? `${header}${hint(filePath)}\n\n${pv.text}`
126
- : `${pv.text}\n\n${header}${hint(filePath)}`;
124
+ const content = direction === "tail" ? `${header}${hint(filePath)}\n\n${pv.text}` : `${pv.text}\n\n${header}${hint(filePath)}`;
127
125
  return { content, truncated: true, outputPath: filePath };
128
126
  }
@@ -1,7 +1,10 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { readFileSync, existsSync } from "fs";
3
+ import { mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
3
4
  import path from "path";
4
- import { confirm, isAutoApprove } from "../confirm.js";
5
+ import { confirm, isEditAutoApprove } from "../confirm.js";
6
+ import { pathAccessError } from "../sandbox.js";
7
+ import { atomicWriteFile, tempPathFor } from "./atomic-file.js";
5
8
  const OFFSET_TOLERANCE = 3;
6
9
  export function parseUnifiedDiff(diff) {
7
10
  const files = [];
@@ -127,7 +130,7 @@ export function buildPatchedFiles(files, cwd = process.cwd()) {
127
130
  lines.length = 0;
128
131
  lines.push(...r.lines);
129
132
  }
130
- contents.push({ file, content: lines.join("\n") + (lines.length > 0 ? "\n" : "") });
133
+ contents.push({ file, content: lines.join("\n") + (lines.length > 0 ? "\n" : ""), original: null });
131
134
  continue;
132
135
  }
133
136
  if (!exists)
@@ -137,7 +140,7 @@ export function buildPatchedFiles(files, cwd = process.cwd()) {
137
140
  content = readFileSync(target, "utf-8");
138
141
  }
139
142
  catch (err) {
140
- return { ok: false, error: `${file.path}: 读取失败 ${err.message}` };
143
+ return { ok: false, error: `${file.path}: 读取失败 ${err instanceof Error ? err.message : String(err)}` };
141
144
  }
142
145
  const lines = content.split("\n");
143
146
  for (const hunk of file.hunks) {
@@ -147,10 +150,77 @@ export function buildPatchedFiles(files, cwd = process.cwd()) {
147
150
  lines.length = 0;
148
151
  lines.push(...r.lines);
149
152
  }
150
- contents.push({ file, content: lines.join("\n") });
153
+ contents.push({ file, content: lines.join("\n"), original: content });
151
154
  }
152
155
  return { ok: true, contents };
153
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
+ }
154
224
  export const applyPatchTool = tool({
155
225
  description: "Apply a unified diff to the working tree. Use this for precise multi-file edits or when you have a generated diff. " +
156
226
  "Supports standard unified diff format (---/+++ headers, @@ hunks, context/delete/add lines). " +
@@ -166,6 +236,12 @@ export const applyPatchTool = tool({
166
236
  const parsed = parseUnifiedDiff(diff);
167
237
  if (parsed.length === 0)
168
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
+ }
169
245
  const built = buildPatchedFiles(parsed);
170
246
  if (!built.ok)
171
247
  return `Error: ${built.error}`;
@@ -176,16 +252,14 @@ export const applyPatchTool = tool({
176
252
  return ` ${c.file.path} ${c.file.isNew ? "(new)" : ""} -${del}/+${add}`;
177
253
  })
178
254
  .join("\n");
179
- if (!isAutoApprove()) {
255
+ if (!isEditAutoApprove()) {
180
256
  const approved = await confirm(`Apply patch to ${built.contents.length} file(s)?\n${summary}`);
181
257
  if (!approved)
182
258
  return "Patch rejected by user.";
183
259
  }
184
- for (const c of built.contents) {
185
- const target = path.resolve(process.cwd(), c.file.path);
186
- mkdirSync(path.dirname(target), { recursive: true });
187
- writeFileSync(target, c.content, "utf-8");
188
- }
260
+ const committed = await commitPatchedFiles(built.contents);
261
+ if (!committed.ok)
262
+ return `Error: ${committed.error}`;
189
263
  return `Applied patch:\n${summary}`;
190
264
  },
191
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
+ }
@@ -39,9 +39,9 @@ export function normalizeBaseURL(raw, source) {
39
39
  */
40
40
  export function resolveBackendBase(spec, configValue) {
41
41
  const env = process.env[spec.envName];
42
- if (env && env.trim())
42
+ if (env?.trim())
43
43
  return normalizeBaseURL(env, spec.envName);
44
- if (configValue && configValue.trim())
44
+ if (configValue?.trim())
45
45
  return normalizeBaseURL(configValue, `"${spec.configKey}" in config.json`);
46
46
  return normalizeBaseURL(spec.fallback, "the built-in default backend URL");
47
47
  }