min-agent 0.2.0 → 0.3.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 (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
package/dist/http.js ADDED
@@ -0,0 +1,119 @@
1
+ import { lookup } from "dns/promises";
2
+ import { isIP } from "net";
3
+ function isPrivateIpv4(parts) {
4
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p)))
5
+ return true;
6
+ const [a, b] = parts;
7
+ if (a === 0 || a === 10 || a === 127)
8
+ return true;
9
+ if (a === 169 && b === 254)
10
+ return true;
11
+ if (a === 172 && b >= 16 && b <= 31)
12
+ return true;
13
+ if (a === 192 && b === 168)
14
+ return true;
15
+ if (a === 100 && b >= 64 && b <= 127)
16
+ return true;
17
+ if (a === 198 && (b === 18 || b === 19))
18
+ return true;
19
+ return false;
20
+ }
21
+ /**
22
+ * Decode the tail of an IPv4-mapped IPv6 address (`::ffff:7f00:1` or
23
+ * `::ffff:127.0.0.1`) into IPv4 octets, or return null.
24
+ */
25
+ function mappedIpv4Bytes(ip) {
26
+ const tail = ip.slice(7);
27
+ if (tail.includes(".")) {
28
+ return tail.split(".").map(Number);
29
+ }
30
+ const groups = tail.split(":");
31
+ if (groups.length !== 2)
32
+ return null;
33
+ const hi = parseInt(groups[0], 16);
34
+ const lo = parseInt(groups[1], 16);
35
+ if (Number.isNaN(hi) || Number.isNaN(lo))
36
+ return null;
37
+ return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff];
38
+ }
39
+ /** Check whether an IP address belongs to a private / reserved / loopback range. */
40
+ export function isPrivateAddress(ip) {
41
+ if (ip.startsWith("::ffff:")) {
42
+ const bytes = mappedIpv4Bytes(ip);
43
+ if (bytes)
44
+ return isPrivateIpv4(bytes);
45
+ return true;
46
+ }
47
+ if (isIP(ip) === 6) {
48
+ const lower = ip.toLowerCase();
49
+ if (lower === "::1" || lower.startsWith("::"))
50
+ return true;
51
+ if (lower.startsWith("fc") || lower.startsWith("fd"))
52
+ return true;
53
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb"))
54
+ return true;
55
+ if (lower.startsWith("2001:db8"))
56
+ return true;
57
+ return false;
58
+ }
59
+ return isPrivateIpv4(ip.split(".").map(Number));
60
+ }
61
+ /** Returns an error message if the URL is unsafe to fetch (SSRF protection), or null if safe. */
62
+ export async function assertSafeUrl(raw) {
63
+ let parsed;
64
+ try {
65
+ parsed = new URL(raw);
66
+ }
67
+ catch {
68
+ return "Invalid URL";
69
+ }
70
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
71
+ return "Only http/https URLs are allowed";
72
+ }
73
+ const host = parsed.hostname;
74
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
75
+ return `Blocked: local hostname "${host}"`;
76
+ }
77
+ if (isIP(host)) {
78
+ return isPrivateAddress(host) ? `Blocked: private/internal IP "${host}"` : null;
79
+ }
80
+ try {
81
+ const addresses = await lookup(host, { all: true });
82
+ for (const addr of addresses) {
83
+ if (isPrivateAddress(addr.address)) {
84
+ return `Blocked: "${host}" resolves to private address "${addr.address}"`;
85
+ }
86
+ }
87
+ }
88
+ catch {
89
+ // DNS failure — let fetch surface the real error
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Read a response body up to maxBytes. Bytes beyond the limit are dropped and
95
+ * the read is cancelled early so huge responses never fill memory.
96
+ */
97
+ export async function readBodyLimited(response, maxBytes) {
98
+ if (!response.body) {
99
+ return { text: "", truncated: false };
100
+ }
101
+ const reader = response.body.getReader();
102
+ const chunks = [];
103
+ let total = 0;
104
+ let truncated = false;
105
+ for (;;) {
106
+ const { done, value } = await reader.read();
107
+ if (done)
108
+ break;
109
+ total += value.byteLength;
110
+ if (total > maxBytes) {
111
+ chunks.push(Buffer.from(value.subarray(0, value.byteLength - (total - maxBytes))));
112
+ truncated = true;
113
+ await reader.cancel();
114
+ break;
115
+ }
116
+ chunks.push(Buffer.from(value));
117
+ }
118
+ return { text: Buffer.concat(chunks).toString("utf-8"), truncated };
119
+ }
@@ -16,6 +16,21 @@ import { getRulesFile, loadConfig } from "./config.js";
16
16
  * When the agent reads a file, nearby AGENTS.md/RULES.md are auto-loaded.
17
17
  */
18
18
  const PROJECT_FILES = ["AGENTS.md", "RULES.md", "CLAUDE.md"];
19
+ function isWithinDir(root, p) {
20
+ const rel = path.relative(root, p);
21
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
22
+ }
23
+ function walkUpDirs(startDir, minDir) {
24
+ const dirs = [];
25
+ let current = path.resolve(startDir);
26
+ while (current !== path.dirname(minDir) && isWithinDir(minDir, current)) {
27
+ dirs.push(current);
28
+ if (current === minDir)
29
+ break;
30
+ current = path.dirname(current);
31
+ }
32
+ return dirs;
33
+ }
19
34
  /** Tracks which instruction files have already been loaded to avoid duplicates */
20
35
  export class InstructionTracker {
21
36
  loaded = new Set();
@@ -33,10 +48,9 @@ export class InstructionTracker {
33
48
  resolveForFile(filepath) {
34
49
  const results = [];
35
50
  const root = process.cwd();
36
- let current = path.dirname(path.resolve(filepath));
37
- while (current.startsWith(root) && current !== path.dirname(root)) {
51
+ for (const dir of walkUpDirs(path.dirname(path.resolve(filepath)), root)) {
38
52
  for (const file of PROJECT_FILES) {
39
- const candidate = path.join(current, file);
53
+ const candidate = path.join(dir, file);
40
54
  if (existsSync(candidate) && !this.isLoaded(candidate)) {
41
55
  try {
42
56
  const content = readFileSync(candidate, "utf-8").trim();
@@ -50,32 +64,24 @@ export class InstructionTracker {
50
64
  }
51
65
  if (results.length > 0)
52
66
  break;
53
- current = path.dirname(current);
54
67
  }
55
68
  return results;
56
69
  }
57
70
  }
58
- function findProjectInstructions() {
71
+ export function findProjectInstructions() {
59
72
  const results = [];
60
- let current = process.cwd();
61
- const root = path.parse(current).root;
62
- while (current !== root) {
63
- // Check .min-agent/AGENTS.md in project
64
- const dotDir = path.join(current, ".min-agent", "AGENTS.md");
73
+ const root = path.parse(process.cwd()).root;
74
+ for (const dir of walkUpDirs(process.cwd(), root)) {
75
+ const dotDir = path.join(dir, ".min-agent", "AGENTS.md");
65
76
  if (existsSync(dotDir)) {
66
77
  results.push(dotDir);
67
78
  break;
68
79
  }
69
- for (const file of PROJECT_FILES) {
70
- const filepath = path.join(current, file);
71
- if (existsSync(filepath)) {
72
- results.push(filepath);
73
- break;
74
- }
75
- }
76
- if (results.length > 0)
80
+ const found = PROJECT_FILES.find((file) => existsSync(path.join(dir, file)));
81
+ if (found) {
82
+ results.push(path.join(dir, found));
77
83
  break;
78
- current = path.dirname(current);
84
+ }
79
85
  }
80
86
  return results;
81
87
  }
@@ -103,23 +109,35 @@ function resolveConfigInstructions() {
103
109
  }
104
110
  return results;
105
111
  }
106
- async function fetchRemoteInstructions() {
107
- const config = loadConfig();
108
- const instructions = config.instructions ?? [];
109
- const results = [];
110
- const urls = instructions.filter((i) => i.startsWith("http://") || i.startsWith("https://"));
111
- for (const url of urls) {
112
- try {
113
- const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
114
- if (response.ok) {
115
- const text = await response.text();
116
- if (text.trim())
117
- results.push(`Instructions from: ${url}\n${text}`);
112
+ const REMOTE_CACHE_TTL_MS = 60_000;
113
+ const remoteCache = new Map();
114
+ async function fetchRemoteInstruction(url) {
115
+ const cached = remoteCache.get(url);
116
+ if (cached && Date.now() - cached.fetchedAt < REMOTE_CACHE_TTL_MS)
117
+ return cached.text;
118
+ try {
119
+ const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
120
+ if (response.ok) {
121
+ const text = await response.text();
122
+ if (text.trim()) {
123
+ remoteCache.set(url, { fetchedAt: Date.now(), text });
124
+ return text;
118
125
  }
119
126
  }
120
- catch { }
121
127
  }
122
- return results;
128
+ catch { }
129
+ return null;
130
+ }
131
+ export async function fetchRemoteInstructions(urls) {
132
+ const config = loadConfig();
133
+ const instructions = urls ?? config.instructions ?? [];
134
+ const results = await Promise.all(instructions
135
+ .filter((i) => i.startsWith("http://") || i.startsWith("https://"))
136
+ .map(async (url) => {
137
+ const text = await fetchRemoteInstruction(url);
138
+ return text ? `Instructions from: ${url}\n${text}` : "";
139
+ }));
140
+ return results.filter(Boolean);
123
141
  }
124
142
  export async function loadInstructions() {
125
143
  const parts = [];
package/dist/logger.js ADDED
@@ -0,0 +1,66 @@
1
+ import { mkdirSync, readdirSync, unlinkSync, writeFileSync } from "fs";
2
+ import path from "path";
3
+ import { getConfigDir } from "./config.js";
4
+ const KEEP_DAYS = 7;
5
+ function logsDir() {
6
+ return path.join(getConfigDir(), "logs");
7
+ }
8
+ function logFilePath(date) {
9
+ const y = date.getFullYear();
10
+ const m = String(date.getMonth() + 1).padStart(2, "0");
11
+ const d = String(date.getDate()).padStart(2, "0");
12
+ return path.join(logsDir(), `min-agent.${y}-${m}-${d}.log`);
13
+ }
14
+ export function initLogger() {
15
+ try {
16
+ mkdirSync(logsDir(), { recursive: true });
17
+ const cutoff = Date.now() - KEEP_DAYS * 24 * 60 * 60 * 1000;
18
+ for (const f of readdirSync(logsDir())) {
19
+ const m = f.match(/^min-agent\.(\d{4})-(\d{2})-(\d{2})\.log$/);
20
+ if (!m)
21
+ continue;
22
+ const ts = new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00`).getTime();
23
+ if (ts < cutoff) {
24
+ try {
25
+ unlinkSync(path.join(logsDir(), f));
26
+ }
27
+ catch { }
28
+ }
29
+ }
30
+ }
31
+ catch { }
32
+ }
33
+ function writeLog(level, msg) {
34
+ try {
35
+ const now = new Date();
36
+ const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
37
+ writeFileSync(logFilePath(now), `[${time}] [${level}] ${scrubSecrets(msg)}\n`, { flag: "a" });
38
+ }
39
+ catch { }
40
+ }
41
+ const SECRET_PATTERNS = [
42
+ /\b(Bearer\s+)[A-Za-z0-9._~+/\-]{8,}/g,
43
+ /\b(api[_-]?key|apikey|token|secret|password|passwd)\s*[:=]\s*["']?[A-Za-z0-9._~+/\-]{6,}["']?/gi,
44
+ /\b(sk-[A-Za-z0-9_-]{6,})/g,
45
+ /\b(gh[pousr]_[A-Za-z0-9]{20,})/g,
46
+ ];
47
+ /** Mask obvious secrets before anything hits the log file. */
48
+ function scrubSecrets(msg) {
49
+ let out = msg;
50
+ for (const re of SECRET_PATTERNS)
51
+ out = out.replace(re, "$1***");
52
+ return out;
53
+ }
54
+ export function log(level, msg) {
55
+ writeLog(level, msg);
56
+ }
57
+ function summarize(v, max = 200) {
58
+ const s = typeof v === "string" ? v : JSON.stringify(v) ?? String(v);
59
+ return s.length > max ? s.slice(0, max) + "…" : s;
60
+ }
61
+ export function logToolCall(name, input) {
62
+ writeLog("info", `tool_call ${name} ${summarize(input)}`);
63
+ }
64
+ export function logToolResult(name, output) {
65
+ writeLog("info", `tool_result ${name} ${summarize(output)}`);
66
+ }
package/dist/markdown.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Lightweight streaming markdown renderer for terminal.
3
3
  * Tracks state across text deltas to apply ANSI formatting.
4
4
  */
5
+ import { displayWidth } from "./tui/text-width.js";
5
6
  function useColor() {
6
7
  if ("NO_COLOR" in process.env)
7
8
  return false;
@@ -128,51 +129,9 @@ export class MarkdownRenderer {
128
129
  parseTableRow(line) {
129
130
  return line.trim().slice(1, -1).split("|").map((cell) => cell.trim());
130
131
  }
131
- /** Get display width of a string (CJK/emoji chars = 2, others = 1) */
132
- displayWidth(str) {
133
- let width = 0;
134
- for (const ch of str) {
135
- const code = ch.codePointAt(0) ?? 0;
136
- if (
137
- // CJK
138
- (code >= 0x1100 && code <= 0x115f) ||
139
- (code >= 0x2e80 && code <= 0x303e) ||
140
- (code >= 0x3040 && code <= 0x33bf) ||
141
- (code >= 0x3400 && code <= 0x4dbf) ||
142
- (code >= 0x4e00 && code <= 0x9fff) ||
143
- (code >= 0xa000 && code <= 0xa4cf) ||
144
- (code >= 0xac00 && code <= 0xd7af) ||
145
- (code >= 0xf900 && code <= 0xfaff) ||
146
- (code >= 0xfe30 && code <= 0xfe6f) ||
147
- (code >= 0xff01 && code <= 0xff60) ||
148
- (code >= 0xffe0 && code <= 0xffe6) ||
149
- (code >= 0x20000 && code <= 0x2fffd) ||
150
- (code >= 0x30000 && code <= 0x3fffd) ||
151
- // Emoji
152
- (code >= 0x1f300 && code <= 0x1f9ff) || // Misc Symbols, Emoticons, Dingbats, etc.
153
- (code >= 0x1fa00 && code <= 0x1faff) || // Chess, Extended-A
154
- (code >= 0x2600 && code <= 0x27bf) || // Misc Symbols, Dingbats
155
- (code >= 0xfe00 && code <= 0xfe0f) || // Variation Selectors (skip width)
156
- (code >= 0x200d && code <= 0x200d) || // ZWJ (skip width)
157
- (code >= 0x1f1e0 && code <= 0x1f1ff) // Regional Indicators (flags)
158
- ) {
159
- // Variation selectors and ZWJ are zero-width joiners
160
- if ((code >= 0xfe00 && code <= 0xfe0f) || code === 0x200d) {
161
- width += 0;
162
- }
163
- else {
164
- width += 2;
165
- }
166
- }
167
- else {
168
- width += 1;
169
- }
170
- }
171
- return width;
172
- }
173
132
  /** Pad string to target display width */
174
133
  padToWidth(str, targetWidth) {
175
- const currentWidth = this.displayWidth(str);
134
+ const currentWidth = displayWidth(str);
176
135
  const padding = targetWidth - currentWidth;
177
136
  return padding > 0 ? str + " ".repeat(padding) : str;
178
137
  }
@@ -184,7 +143,7 @@ export class MarkdownRenderer {
184
143
  const widths = Array(colCount).fill(0);
185
144
  for (const row of this.tableRows) {
186
145
  for (let i = 0; i < row.length; i++) {
187
- widths[i] = Math.max(widths[i], this.displayWidth(row[i] ?? ""));
146
+ widths[i] = Math.max(widths[i], displayWidth(row[i] ?? ""));
188
147
  }
189
148
  }
190
149
  const lines = [];