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/memory.js CHANGED
@@ -1,9 +1,34 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from "fs";
1
+ import { readFileSync, mkdirSync, existsSync, renameSync } from "fs";
2
2
  import path from "path";
3
- import { randomUUID } from "crypto";
4
- import { getConfigDir } from "./config.js";
3
+ import { getConfigDir, getProjectConfigDir } from "./config.js";
4
+ import { atomicWriteFileSync } from "./tools/atomic-file.js";
5
5
  import { tool, jsonSchema } from "ai";
6
- function getMemoryFile() {
6
+ export function readMemoryScope(value) {
7
+ if (value === undefined || value === null || value === "")
8
+ return { ok: true, scope: undefined };
9
+ if (value === "global" || value === "project")
10
+ return { ok: true, scope: value };
11
+ return { ok: false };
12
+ }
13
+ export function takeScopeFlags(tokens) {
14
+ let scope;
15
+ const rest = [];
16
+ for (const t of tokens) {
17
+ if (t === "--project")
18
+ scope = "project";
19
+ else if (t === "--global")
20
+ scope = "global";
21
+ else
22
+ rest.push(t);
23
+ }
24
+ return { scope, rest };
25
+ }
26
+ export function defaultMemoryScope() {
27
+ return existsSync(getProjectConfigDir()) ? "project" : "global";
28
+ }
29
+ function getMemoryFile(scope) {
30
+ if (scope === "project")
31
+ return path.join(getProjectConfigDir(), "memory.json");
7
32
  return path.join(getConfigDir(), "memory.json");
8
33
  }
9
34
  /** Preserve corrupt data instead of letting it be overwritten and lost forever. */
@@ -15,8 +40,8 @@ function backupCorrupt(file) {
15
40
  // nothing else we can do with an unreadable file
16
41
  }
17
42
  }
18
- export function loadMemories() {
19
- const file = getMemoryFile();
43
+ export function loadMemories(scope = "global") {
44
+ const file = getMemoryFile(scope);
20
45
  if (!existsSync(file))
21
46
  return [];
22
47
  let data;
@@ -34,55 +59,40 @@ export function loadMemories() {
34
59
  return data.filter((m) => typeof m === "object" && m !== null && typeof m.content === "string");
35
60
  }
36
61
  /** Atomic replace via temp file + rename, so a crash never leaves a truncated memory.json. */
37
- function saveMemories(memories) {
38
- const file = getMemoryFile();
62
+ function saveMemories(memories, scope) {
63
+ const file = getMemoryFile(scope);
39
64
  mkdirSync(path.dirname(file), { recursive: true });
40
- const tmp = `${file}.tmp-${randomUUID()}`;
41
- try {
42
- writeFileSync(tmp, JSON.stringify(memories, null, 2), "utf-8");
43
- renameSync(tmp, file);
44
- }
45
- catch (err) {
46
- try {
47
- if (existsSync(tmp))
48
- unlinkSync(tmp);
49
- }
50
- catch { }
51
- throw err;
52
- }
65
+ atomicWriteFileSync(file, JSON.stringify(memories, null, 2));
53
66
  }
54
67
  // read-modify-write runs fully synchronously, so concurrent tool calls in
55
68
  // this process cannot interleave between load and save.
56
- export function addMemory(content, tags = []) {
57
- const memories = loadMemories();
69
+ export function addMemory(content, tags = [], scope = "global") {
70
+ const memories = loadMemories(scope);
58
71
  const memory = {
59
72
  content,
60
73
  tags,
61
74
  created: new Date().toISOString(),
62
75
  };
63
76
  memories.push(memory);
64
- saveMemories(memories);
77
+ saveMemories(memories, scope);
65
78
  return memory;
66
79
  }
67
- export function deleteMemory(index) {
68
- const memories = loadMemories();
80
+ export function deleteMemory(index, scope = "global") {
81
+ const memories = loadMemories(scope);
69
82
  if (index < 0 || index >= memories.length)
70
83
  return false;
71
84
  memories.splice(index, 1);
72
- saveMemories(memories);
85
+ saveMemories(memories, scope);
73
86
  return true;
74
87
  }
75
- export function searchMemories(query) {
76
- const memories = loadMemories();
88
+ export function searchMemories(query, scope = "global") {
89
+ const memories = loadMemories(scope);
77
90
  const lower = query.toLowerCase();
78
91
  return memories
79
92
  .map((m, i) => ({ ...m, index: i }))
80
- .filter((m) => m.content.toLowerCase().includes(lower) ||
81
- m.tags.some((t) => t.toLowerCase().includes(lower)));
93
+ .filter((m) => m.content.toLowerCase().includes(lower) || m.tags.some((t) => t.toLowerCase().includes(lower)));
82
94
  }
83
- /** Build a system prompt section from stored memories (newest first, capped). */
84
- export function getMemorySystemPrompt() {
85
- const memories = loadMemories();
95
+ function formatMemoryBlock(memories, heading) {
86
96
  if (memories.length === 0)
87
97
  return "";
88
98
  const MAX_MEMORIES = 30;
@@ -98,19 +108,31 @@ export function getMemorySystemPrompt() {
98
108
  const capped = memories.length > MAX_MEMORIES
99
109
  ? `\n(${memories.length - MAX_MEMORIES} older memories not shown — use memory_search to find them.)`
100
110
  : "";
101
- return [
111
+ return [heading, ...items, capped].filter(Boolean).join("\n");
112
+ }
113
+ /** Build a system prompt section from stored memories (newest first, capped). */
114
+ export function getMemorySystemPrompt() {
115
+ const project = loadMemories("project");
116
+ const global = loadMemories("global");
117
+ if (project.length === 0 && global.length === 0)
118
+ return "";
119
+ const intro = [
102
120
  "## Memories",
103
121
  "The following are things you have remembered from previous conversations. Use them to provide better, personalized responses.",
104
122
  "You can save new memories with the memory_save tool when the user tells you something worth remembering (preferences, project details, conventions, etc).",
123
+ 'Use scope "project" for repo-specific facts and "global" for preferences that apply everywhere.',
105
124
  "",
106
- ...items,
107
- capped,
108
- ].join("\n");
125
+ ];
126
+ const sections = [
127
+ formatMemoryBlock(project, "### This project"),
128
+ formatMemoryBlock(global, project.length > 0 ? "### Global" : ""),
129
+ ].filter((s) => s.length > 0);
130
+ return [...intro, ...sections].join("\n");
109
131
  }
110
132
  /** Create the memory tools for the agent */
111
133
  export function getMemoryTools() {
112
134
  const memorySave = tool({
113
- description: "Save a memory for future conversations. Use this when the user shares preferences, project conventions, important context, or asks you to remember something. Memories persist across sessions.",
135
+ description: "Save a memory for future conversations. Use this when the user shares preferences, project conventions, important context, or asks you to remember something. Memories persist across sessions. Use scope project for this repository, global for all projects.",
114
136
  inputSchema: jsonSchema({
115
137
  type: "object",
116
138
  properties: {
@@ -120,12 +142,18 @@ export function getMemoryTools() {
120
142
  items: { type: "string" },
121
143
  description: "Optional tags for categorization (e.g. 'preference', 'project', 'convention')",
122
144
  },
145
+ scope: {
146
+ type: "string",
147
+ enum: ["global", "project"],
148
+ description: "Where to store the memory. Defaults to project when .min-agent exists, otherwise global.",
149
+ },
123
150
  },
124
151
  required: ["content"],
125
152
  }),
126
- execute: async ({ content, tags }) => {
127
- const memory = addMemory(content, tags ?? []);
128
- return `Saved memory: "${content}" (tags: ${memory.tags.length > 0 ? memory.tags.join(", ") : "none"})`;
153
+ execute: async ({ content, tags, scope }) => {
154
+ const resolved = scope === "global" || scope === "project" ? scope : defaultMemoryScope();
155
+ const memory = addMemory(content, tags ?? [], resolved);
156
+ return `Saved ${resolved} memory: "${content}" (tags: ${memory.tags.length > 0 ? memory.tags.join(", ") : "none"})`;
129
157
  },
130
158
  });
131
159
  const memorySearch = tool({
@@ -134,35 +162,48 @@ export function getMemoryTools() {
134
162
  type: "object",
135
163
  properties: {
136
164
  query: { type: "string", description: "Search keyword or phrase" },
165
+ scope: {
166
+ type: "string",
167
+ enum: ["global", "project"],
168
+ description: "Limit search to one store. Omit to search both.",
169
+ },
137
170
  },
138
171
  required: ["query"],
139
172
  }),
140
- execute: async ({ query }) => {
141
- const results = searchMemories(query);
142
- if (results.length === 0)
173
+ execute: async ({ query, scope }) => {
174
+ const scopes = scope === "global" || scope === "project" ? [scope] : ["project", "global"];
175
+ const lines = [];
176
+ for (const s of scopes) {
177
+ for (const m of searchMemories(query, s)) {
178
+ const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : "";
179
+ lines.push(`${s} #${m.index + 1}: ${m.content}${tags} (${m.created.split("T")[0]})`);
180
+ }
181
+ }
182
+ if (lines.length === 0)
143
183
  return `No memories found matching "${query}"`;
144
- return results
145
- .map((m) => {
146
- const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : "";
147
- return `#${m.index + 1}: ${m.content}${tags} (${m.created.split("T")[0]})`;
148
- })
149
- .join("\n");
184
+ return lines.join("\n");
150
185
  },
151
186
  });
152
187
  const memoryDelete = tool({
153
- description: "Delete a memory by its number. Use memory_search or memory_list first to find the index.",
188
+ description: "Delete a memory by its number. Use memory_search first to find the index and scope.",
154
189
  inputSchema: jsonSchema({
155
190
  type: "object",
156
191
  properties: {
157
192
  index: { type: "number", description: "The memory number to delete (1-based)" },
193
+ scope: {
194
+ type: "string",
195
+ enum: ["global", "project"],
196
+ description: "Which store the number belongs to. Defaults to project when .min-agent exists, otherwise global.",
197
+ },
158
198
  },
159
199
  required: ["index"],
160
200
  }),
161
- execute: async ({ index }) => {
162
- const success = deleteMemory(index - 1);
201
+ execute: async ({ index, scope }) => {
202
+ const resolved = scope === "global" || scope === "project" ? scope : defaultMemoryScope();
203
+ const success = deleteMemory(index - 1, resolved);
163
204
  if (success)
164
- return `Memory #${index} deleted.`;
165
- return `Memory #${index} not found.`;
205
+ return `${resolved} memory #${index} deleted.`;
206
+ return `${resolved} memory #${index} not found.`;
166
207
  },
167
208
  });
168
209
  return {
package/dist/output.js CHANGED
@@ -30,7 +30,7 @@ const RESULT_PREVIEW_MAX_CHARS = 1200;
30
30
  * applied in one pass, so the "N more lines" count always matches what was
31
31
  * actually withheld.
32
32
  */
33
- export function printToolResult(name, result, isError = false) {
33
+ export function printToolResult(_name, result, isError = false) {
34
34
  const text = toolResultText(result).replace(/\s+$/, "");
35
35
  const lines = text === "" ? [] : text.split("\n");
36
36
  const kept = [];
@@ -53,13 +53,14 @@ export function printToolResult(name, result, isError = false) {
53
53
  const body = kept.join(`\n${COLORS.dim} `);
54
54
  console.log(`${marker} ${COLORS.dim}${body}${suffix}${COLORS.reset}\n`);
55
55
  }
56
- export function printDone(steps, usage, contextWindow) {
56
+ export function printDone(steps, usage, contextWindow, contextTokens) {
57
57
  const input = usage?.inputTokens ?? 0;
58
58
  const output = usage?.outputTokens ?? 0;
59
59
  const total = input + output;
60
+ const occupied = contextTokens !== undefined ? contextTokens : input;
60
61
  let contextInfo = "";
61
- if (input > 0 && contextWindow && contextWindow > 0) {
62
- const pct = Math.round((input / contextWindow) * 100);
62
+ if (occupied > 0 && contextWindow && contextWindow > 0) {
63
+ const pct = Math.round((occupied / contextWindow) * 100);
63
64
  const bar = renderBar(pct);
64
65
  contextInfo = ` | Context: ${bar} ${pct}%`;
65
66
  }
@@ -67,7 +68,8 @@ export function printDone(steps, usage, contextWindow) {
67
68
  }
68
69
  function renderBar(pct) {
69
70
  const width = 10;
70
- const filled = Math.round((pct / 100) * width);
71
+ const safePct = Math.max(0, Math.min(100, pct));
72
+ const filled = Math.round((safePct / 100) * width);
71
73
  const empty = width - filled;
72
74
  const color = pct >= 80 ? "\x1b[31m" : pct >= 50 ? "\x1b[33m" : "\x1b[32m";
73
75
  return `${color}${"█".repeat(filled)}${"░".repeat(empty)}\x1b[0m\x1b[90m`;
@@ -0,0 +1,43 @@
1
+ import { takeScopeFlags } from "./memory.js";
2
+ import { getPermissionSnapshot, parsePermissionMode, permissionModeLabel, setPermissionMode, } from "./config.js";
3
+ import { getPermissionOverride, setPermissionOverride } from "./confirm.js";
4
+ export const PERMISSION_CLI_USAGE = [
5
+ "Usage: min-agent permission [ask|accept-edits|allow-all] [--project|--global]",
6
+ " min-agent --permission ask|accept-edits|allow-all [--project|--global]",
7
+ ].join("\n");
8
+ function sourceLabel(source) {
9
+ return source === "cli" ? "this run" : source === "project" ? "project" : source === "global" ? "global" : "default";
10
+ }
11
+ function scopeLabel(scope) {
12
+ return scope === "project" ? "project" : "global";
13
+ }
14
+ export function runPermissionCli(input) {
15
+ const { scope: posScope, rest } = takeScopeFlags(input.positionals);
16
+ const scope = posScope ?? input.scope ?? "global";
17
+ if (rest[0] === "--help" || rest[0] === "-h") {
18
+ return { ok: true, lines: [PERMISSION_CLI_USAGE] };
19
+ }
20
+ if (rest.length === 1) {
21
+ const parsed = parsePermissionMode(rest[0]);
22
+ if (!parsed)
23
+ return { ok: false, lines: [PERMISSION_CLI_USAGE] };
24
+ setPermissionMode(parsed, scope);
25
+ setPermissionOverride(parsed);
26
+ return { ok: true, lines: [`✓ Permission set to ${permissionModeLabel(parsed)} (${scopeLabel(scope)})`] };
27
+ }
28
+ if (rest.length > 1)
29
+ return { ok: false, lines: [PERMISSION_CLI_USAGE] };
30
+ if (input.flagMode) {
31
+ setPermissionMode(input.flagMode, scope);
32
+ setPermissionOverride(input.flagMode);
33
+ return { ok: true, lines: [`✓ Permission set to ${permissionModeLabel(input.flagMode)} (${scopeLabel(scope)})`] };
34
+ }
35
+ const snap = getPermissionSnapshot();
36
+ const override = getPermissionOverride();
37
+ const mode = override ?? snap.permission;
38
+ const source = override ? "cli" : snap.source;
39
+ return {
40
+ ok: true,
41
+ lines: [`Current permission: ${permissionModeLabel(mode)} (${sourceLabel(source)})`, PERMISSION_CLI_USAGE],
42
+ };
43
+ }
package/dist/plugins.js CHANGED
@@ -3,11 +3,20 @@ import { existsSync, readdirSync, statSync } from "fs";
3
3
  import { pathToFileURL } from "url";
4
4
  import path from "path";
5
5
  import { getConfigDir } from "./config.js";
6
- const PLUGIN_DIRS = [
7
- path.join(process.cwd(), ".min-agent", "tools"),
8
- path.join(getConfigDir(), "tools"),
9
- ];
6
+ import { confirm, isAutoApprove } from "./confirm.js";
7
+ const PLUGIN_DIRS = [path.join(process.cwd(), ".min-agent", "tools"), path.join(getConfigDir(), "tools")];
10
8
  let cachedPlugins = null;
9
+ let pluginReadOnlyIds = new Set();
10
+ export function pluginNeedsConfirm(def) {
11
+ if (def.readOnly === true)
12
+ return false;
13
+ if (def.dangerous === false)
14
+ return false;
15
+ return true;
16
+ }
17
+ export function getPluginReadOnlyIds() {
18
+ return pluginReadOnlyIds;
19
+ }
11
20
  function pluginSignature(dirs) {
12
21
  return dirs
13
22
  .map((dir) => {
@@ -24,14 +33,17 @@ function pluginSignature(dirs) {
24
33
  export async function loadPluginTools(dirs) {
25
34
  const pluginDirs = dirs ?? PLUGIN_DIRS;
26
35
  const signature = pluginSignature(pluginDirs);
27
- if (cachedPlugins && cachedPlugins.signature === signature)
36
+ if (cachedPlugins && cachedPlugins.signature === signature) {
37
+ pluginReadOnlyIds = cachedPlugins.readOnlyIds;
28
38
  return cachedPlugins.tools;
39
+ }
29
40
  const tools = await loadPluginToolsUncached(pluginDirs);
30
- cachedPlugins = { signature, tools };
41
+ cachedPlugins = { signature, tools, readOnlyIds: pluginReadOnlyIds };
31
42
  return tools;
32
43
  }
33
44
  async function loadPluginToolsUncached(pluginDirs) {
34
45
  const tools = {};
46
+ const readOnlyIds = new Set();
35
47
  for (const dir of pluginDirs) {
36
48
  if (!existsSync(dir))
37
49
  continue;
@@ -52,9 +64,12 @@ async function loadPluginToolsUncached(pluginDirs) {
52
64
  const properties = {};
53
65
  const required = [];
54
66
  for (const [key, param] of Object.entries(def.parameters)) {
55
- properties[key] = { type: param.type, description: param.description };
67
+ const type = narrowJsonType(param.type);
68
+ properties[key] = { type, ...(param.description ? { description: param.description } : {}) };
56
69
  required.push(key);
57
70
  }
71
+ if (def.readOnly === true)
72
+ readOnlyIds.add(toolId);
58
73
  tools[toolId] = tool({
59
74
  description: def.description,
60
75
  inputSchema: jsonSchema({
@@ -63,9 +78,15 @@ async function loadPluginToolsUncached(pluginDirs) {
63
78
  required,
64
79
  }),
65
80
  execute: async (args) => {
81
+ if (pluginNeedsConfirm(def) && !isAutoApprove()) {
82
+ const preview = summarizePluginArgs(args);
83
+ const approved = await confirm(`运行自定义工具 ${toolId}${preview ? `\n${preview}` : ""}`);
84
+ if (!approved)
85
+ return "自定义工具已被拒绝。";
86
+ }
66
87
  try {
67
88
  const result = await def.execute(args);
68
- return typeof result === "string" ? result : JSON.stringify(result) ?? "undefined";
89
+ return typeof result === "string" ? result : (JSON.stringify(result) ?? "undefined");
69
90
  }
70
91
  catch (err) {
71
92
  return `Plugin error: ${String(err instanceof Error ? err.message : err)}`;
@@ -79,12 +100,29 @@ async function loadPluginToolsUncached(pluginDirs) {
79
100
  }
80
101
  }
81
102
  }
103
+ pluginReadOnlyIds = readOnlyIds;
82
104
  const count = Object.keys(tools).length;
83
105
  if (count > 0) {
84
106
  console.log(`\x1b[90m Plugins loaded: ${count} tool(s)\x1b[0m`);
85
107
  }
86
108
  return tools;
87
109
  }
110
+ function summarizePluginArgs(args) {
111
+ try {
112
+ const text = JSON.stringify(args);
113
+ if (!text || text === "{}")
114
+ return "";
115
+ return text.length > 400 ? `${text.slice(0, 400)}…` : text;
116
+ }
117
+ catch {
118
+ return "";
119
+ }
120
+ }
121
+ function narrowJsonType(type) {
122
+ if (type === "number" || type === "boolean" || type === "object" || type === "array")
123
+ return type;
124
+ return "string";
125
+ }
88
126
  function isPluginTool(value) {
89
127
  if (!value || typeof value !== "object")
90
128
  return false;
package/dist/pricing.js CHANGED
@@ -1,5 +1,6 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
1
+ import { readFileSync, mkdirSync, existsSync } from "fs";
2
2
  import path from "path";
3
+ import { atomicWriteFileSync } from "./tools/atomic-file.js";
3
4
  import { getConfigDir, loadConfig } from "./config.js";
4
5
  const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
5
6
  const memoryCache = new Map();
@@ -23,7 +24,7 @@ function loadDiskCache() {
23
24
  function saveCache(cache) {
24
25
  const file = cacheFile();
25
26
  mkdirSync(path.dirname(file), { recursive: true });
26
- writeFileSync(file, JSON.stringify(cache), "utf-8");
27
+ atomicWriteFileSync(file, JSON.stringify(cache));
27
28
  }
28
29
  function getCached(modelId) {
29
30
  const memory = memoryCache.get(modelId);
@@ -108,8 +109,7 @@ export async function getModelPrice(modelId) {
108
109
  export function estimateCost(usage, price) {
109
110
  if (!price)
110
111
  return null;
111
- const cost = (usage.inputTokens / 1_000_000) * price.inputPerMillion +
112
- (usage.outputTokens / 1_000_000) * price.outputPerMillion;
112
+ const cost = (usage.inputTokens / 1_000_000) * price.inputPerMillion + (usage.outputTokens / 1_000_000) * price.outputPerMillion;
113
113
  return cost > 0 ? cost : null;
114
114
  }
115
115
  export function formatCost(cost) {
package/dist/provider.js CHANGED
@@ -1,5 +1,21 @@
1
1
  import { createOpenAI } from "@ai-sdk/openai";
2
- import { loadConfig, getActiveProvider, normalizeOllamaBaseURL } from "./config.js";
2
+ import { getEffectiveConfig, getActiveProvider, normalizeOllamaBaseURL } from "./config.js";
3
+ import { createTimeoutFetch, DEFAULT_FIRST_BYTE_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, } from "./fetch-timeout.js";
4
+ function positiveMs(raw, fallback) {
5
+ const value = typeof raw === "string" ? Number(raw) : raw;
6
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
7
+ return fallback;
8
+ return value;
9
+ }
10
+ /** Timeout/trace-aware fetch shared by every provider client. */
11
+ export function modelFetch() {
12
+ const cfg = getEffectiveConfig();
13
+ return createTimeoutFetch({
14
+ firstByteTimeoutMs: positiveMs(process.env.MIN_AGENT_REQUEST_TIMEOUT_MS ?? cfg.agent?.requestTimeoutMs, DEFAULT_FIRST_BYTE_TIMEOUT_MS),
15
+ idleTimeoutMs: positiveMs(process.env.MIN_AGENT_STREAM_IDLE_TIMEOUT_MS ?? cfg.agent?.streamIdleTimeoutMs, DEFAULT_STREAM_IDLE_TIMEOUT_MS),
16
+ trace: process.env.MIN_AGENT_TRACE === "1" || process.env.MIN_AGENT_TRACE === "true",
17
+ });
18
+ }
3
19
  export function resolveModelForProvider(provider, modelId) {
4
20
  if (!provider.baseURL || !provider.apiKey) {
5
21
  throw new Error("Not configured. Run: min-agent setup");
@@ -8,15 +24,17 @@ export function resolveModelForProvider(provider, modelId) {
8
24
  if (!id) {
9
25
  throw new Error("No model specified. Run: min-agent setup");
10
26
  }
27
+ const fetchImpl = modelFetch();
11
28
  switch (provider.type ?? "openai-compatible") {
12
29
  case "openai": {
13
- const client = createOpenAI({ apiKey: provider.apiKey });
30
+ const client = createOpenAI({ apiKey: provider.apiKey, fetch: fetchImpl });
14
31
  return client.chat(id);
15
32
  }
16
33
  case "ollama": {
17
34
  const client = createOpenAI({
18
35
  baseURL: normalizeOllamaBaseURL(provider.baseURL),
19
36
  apiKey: provider.apiKey || "ollama",
37
+ fetch: fetchImpl,
20
38
  });
21
39
  return client.chat(id);
22
40
  }
@@ -24,6 +42,7 @@ export function resolveModelForProvider(provider, modelId) {
24
42
  const client = createOpenAI({
25
43
  baseURL: provider.baseURL,
26
44
  apiKey: provider.apiKey,
45
+ fetch: fetchImpl,
27
46
  });
28
47
  return client.chat(id);
29
48
  }
@@ -32,10 +51,8 @@ export function resolveModelForProvider(provider, modelId) {
32
51
  }
33
52
  }
34
53
  export function resolveModel(modelId, providerName) {
35
- const config = loadConfig();
36
- const provider = providerName
37
- ? config.providers?.find((p) => p.name === providerName)
38
- : getActiveProvider(config);
54
+ const config = getEffectiveConfig();
55
+ const provider = providerName ? config.providers?.find((p) => p.name === providerName) : getActiveProvider(config);
39
56
  if (!provider) {
40
57
  throw new Error(providerName ? `Provider "${providerName}" not found` : "Not configured. Run: min-agent setup");
41
58
  }
@@ -0,0 +1,60 @@
1
+ const OPTION_TEXT_KEYS = ["label", "description", "title", "text", "value", "content", "prompt"];
2
+ function firstText(rec, keys) {
3
+ const parts = [];
4
+ const seen = new Set();
5
+ for (const key of keys) {
6
+ const v = rec[key];
7
+ if (typeof v !== "string")
8
+ continue;
9
+ const text = v.trim();
10
+ if (!text || seen.has(text))
11
+ continue;
12
+ seen.add(text);
13
+ parts.push(text);
14
+ }
15
+ return parts;
16
+ }
17
+ /** Pull a user-visible label out of a model-supplied question option. */
18
+ export function optionText(item) {
19
+ if (item == null)
20
+ return null;
21
+ if (typeof item === "string") {
22
+ const text = item.trim();
23
+ return text.length > 0 ? text : null;
24
+ }
25
+ if (typeof item === "number" || typeof item === "boolean")
26
+ return String(item);
27
+ if (typeof item !== "object" || Array.isArray(item))
28
+ return null;
29
+ const rec = item;
30
+ const parts = firstText(rec, OPTION_TEXT_KEYS);
31
+ if (parts.length > 0)
32
+ return parts.join(" — ");
33
+ const key = rec.key;
34
+ return typeof key === "string" && key.trim() ? key.trim() : null;
35
+ }
36
+ export function normalizeQuestionPrompt(question) {
37
+ if (typeof question === "string")
38
+ return question;
39
+ return optionText(question) ?? "";
40
+ }
41
+ /**
42
+ * Models often send `{label, description, key}` (Claude-style) instead of
43
+ * `string[]`. Normalize to display strings so the TUI never calls string
44
+ * methods on objects.
45
+ */
46
+ export function normalizeQuestionOptions(options) {
47
+ if (options == null)
48
+ return [];
49
+ const list = Array.isArray(options) ? options : [options];
50
+ const out = [];
51
+ const seen = new Set();
52
+ for (const item of list) {
53
+ const text = optionText(item);
54
+ if (!text || seen.has(text))
55
+ continue;
56
+ seen.add(text);
57
+ out.push(text);
58
+ }
59
+ return out;
60
+ }
@@ -0,0 +1,82 @@
1
+ import { takeScopeFlags } from "./memory.js";
2
+ import { getSandboxSnapshot, setSandboxConfig } from "./config.js";
3
+ import { parseNetworkPolicy, parseSandboxMode, getEffectiveSandboxPolicy, sandboxEnforcementCaveat, sandboxModeLabel, sandboxStatusLabel, setSandboxOverride, } from "./sandbox.js";
4
+ export const SANDBOX_CLI_USAGE = [
5
+ "Usage: min-agent sandbox [off|workspace|strict] [--project|--global]",
6
+ " min-agent sandbox network allow|deny [--project|--global]",
7
+ " min-agent --sandbox off|workspace|strict [--project|--global]",
8
+ ].join("\n");
9
+ function sourceLabel(source) {
10
+ return source === "cli"
11
+ ? "this run"
12
+ : source === "env"
13
+ ? "environment variable"
14
+ : source === "project"
15
+ ? "project"
16
+ : source === "global"
17
+ ? "global"
18
+ : "default";
19
+ }
20
+ function scopeLabel(scope) {
21
+ return scope === "project" ? "project" : "global";
22
+ }
23
+ export function runSandboxCli(input) {
24
+ const { scope: posScope, rest } = takeScopeFlags(input.positionals);
25
+ const scope = posScope ?? input.scope ?? "global";
26
+ if (rest[0] === "--help" || rest[0] === "-h") {
27
+ return { ok: true, lines: [SANDBOX_CLI_USAGE] };
28
+ }
29
+ if (rest[0] === "network") {
30
+ const parsed = parseNetworkPolicy(rest[1]);
31
+ if (!parsed || rest.length > 2)
32
+ return { ok: false, lines: [SANDBOX_CLI_USAGE] };
33
+ setSandboxConfig({ network: parsed }, scope);
34
+ setSandboxOverride({ network: parsed });
35
+ return { ok: true, lines: [`✓ Network set to ${parsed === "deny" ? "denied" : "allowed"} (${scopeLabel(scope)})`] };
36
+ }
37
+ if (rest.length === 1) {
38
+ const parsed = parseSandboxMode(rest[0]);
39
+ if (!parsed)
40
+ return { ok: false, lines: [SANDBOX_CLI_USAGE] };
41
+ setSandboxConfig({ mode: parsed }, scope);
42
+ setSandboxOverride({ mode: parsed });
43
+ const caveat = sandboxEnforcementCaveat(parsed);
44
+ return {
45
+ ok: true,
46
+ lines: [`✓ Sandbox set to ${sandboxModeLabel(parsed)} (${scopeLabel(scope)})`, ...(caveat ? [caveat] : [])],
47
+ };
48
+ }
49
+ if (rest.length > 1)
50
+ return { ok: false, lines: [SANDBOX_CLI_USAGE] };
51
+ if (input.flagMode || input.flagNetwork) {
52
+ setSandboxConfig({
53
+ ...(input.flagMode ? { mode: input.flagMode } : {}),
54
+ ...(input.flagNetwork ? { network: input.flagNetwork } : {}),
55
+ }, scope);
56
+ setSandboxOverride({
57
+ ...(input.flagMode ? { mode: input.flagMode } : {}),
58
+ ...(input.flagNetwork ? { network: input.flagNetwork } : {}),
59
+ });
60
+ const parts = [
61
+ ...(input.flagMode ? [`sandbox set to ${sandboxModeLabel(input.flagMode)}`] : []),
62
+ ...(input.flagNetwork ? [`network set to ${input.flagNetwork === "deny" ? "denied" : "allowed"}`] : []),
63
+ ];
64
+ const caveat = input.flagMode ? sandboxEnforcementCaveat(input.flagMode) : null;
65
+ return { ok: true, lines: [`✓ ${parts.join(", ")} (${scopeLabel(scope)})`, ...(caveat ? [caveat] : [])] };
66
+ }
67
+ const snap = getSandboxSnapshot();
68
+ const extras = [
69
+ ...(snap.extraWriteRoots.length > 0 ? [`Extra writable dirs: ${snap.extraWriteRoots.join(", ")}`] : []),
70
+ ...(snap.extraReadRoots.length > 0 ? [`Extra readable dirs: ${snap.extraReadRoots.join(", ")}`] : []),
71
+ ];
72
+ const caveat = sandboxEnforcementCaveat(getEffectiveSandboxPolicy().mode);
73
+ return {
74
+ ok: true,
75
+ lines: [
76
+ `Current sandbox: ${sandboxStatusLabel()} (${sourceLabel(snap.source)})`,
77
+ ...extras,
78
+ ...(caveat ? [caveat] : []),
79
+ SANDBOX_CLI_USAGE,
80
+ ],
81
+ };
82
+ }