minimal-agent 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-agent",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "最小化 Agent 系统 —— 10 工具 + 插件系统 + workflow DSL + 自动压缩 + OpenAI 兼容 + Ink TUI;NodeNext + tsc 原地编译,dev 用 Bun .ts、install 用 Node .js(学习/教学用)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Bill Wang <leiwang0359@gmail.com>",
@@ -54,6 +54,8 @@
54
54
  "clean": "bun scripts/clean-build.ts",
55
55
  "test": "bun test",
56
56
  "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
57
+ "compile:green": "bun scripts/build-green.ts",
58
+ "compile:green:host": "bun scripts/build-green.ts --host",
57
59
  "prepublishOnly": "bun run clean && bun run build"
58
60
  },
59
61
  "dependencies": {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ============================================================
3
+ * src/cli/args.ts —— CLI 参数解析纯函数
4
+ * ------------------------------------------------------------
5
+ * 零依赖、零副作用的命令行参数提取工具,供 main.tsx 的 -p 分支使用。
6
+ *
7
+ * 为什么单独成文件(而非塞进 main.tsx):
8
+ * main.tsx 顶层有 `main().catch(...)` 自调用,一旦被 import 就会启动整个
9
+ * 应用。把纯函数抽出来,单测可零副作用地 import 断言。main.tsx re-export
10
+ * 同名符号,对外仍是「main.tsx 提供 extractFlagValue」的语义。
11
+ *
12
+ * 不引 commander:手撸即可,逻辑足够简单且教学清晰。
13
+ * ============================================================
14
+ */
15
+ /**
16
+ * 从 args 里提取「带值标志」的值:找到 names 中任一标志,取其后**紧跟的下一个元素**当 value。
17
+ *
18
+ * - 命中多次:取**第一次**出现的值
19
+ * - 标志在末尾(后面没有元素):返回 undefined(args[i+1] 天然是 undefined)
20
+ * - 没命中任何 name:返回 undefined
21
+ *
22
+ * 例:extractFlagValue(['-p','--output-format','json','x'], ['--output-format']) === 'json'
23
+ *
24
+ * 用于 -p 模式提取 --output-format / --max-turns 的值。
25
+ */
26
+ export function extractFlagValue(args, names) {
27
+ for (let i = 0; i < args.length; i++) {
28
+ if (names.includes(args[i])) {
29
+ return args[i + 1]; // 末尾标志时 args[i+1] 为 undefined,符合语义
30
+ }
31
+ }
32
+ return undefined;
33
+ }
package/src/cli/print.js CHANGED
@@ -2,8 +2,12 @@
2
2
  * ============================================================
3
3
  * src/cli/print.ts —— CLI 非交互模式(-p / --print)
4
4
  * ------------------------------------------------------------
5
- * 不启动 Ink TUI,直接执行单次问答,流式输出到 stdout
6
- * 适合脚本集成、CI/CD、快速单次查询。
5
+ * 不启动 Ink TUI,直接执行单次问答,最后落盘 history
6
+ * 适合脚本集成、CI/CD、宿主程序(hermes/openclaw)spawn 调用。
7
+ *
8
+ * 两种输出格式(--output-format,默认 text):
9
+ * text —— 把最终回答原样打到 stdout(向后兼容,逐字节不变)
10
+ * json —— stdout 只输出**一行** JSON 结局契约,其它诊断全走 stderr
7
11
  *
8
12
  * v2 改进(对齐 kakadeai CLI 工程实践):
9
13
  * 1. SIGINT 先保存 context 再退出(防丢失)
@@ -15,9 +19,10 @@
15
19
  * minimal-agent -p "提示"
16
20
  * echo "提示" | minimal-agent -p
17
21
  * minimal-agent -p --verbose "提示"
22
+ * minimal-agent -p --output-format json "提示" # 结构化结局
18
23
  * ============================================================
19
24
  */
20
- import { saveContext } from '../context/persistContext.js';
25
+ import { getContextPath, saveContext } from '../context/persistContext.js';
21
26
  import { runWithPlugins } from '../plugins/pluginRunner.js';
22
27
  /** stderr 显示工具结果时的截断阈值;超过即末尾加 "..." */
23
28
  const TOOL_OUTPUT_PREVIEW_MAX = 200;
@@ -56,7 +61,13 @@ export function extractPromptArgs(args) {
56
61
  '--version',
57
62
  ]);
58
63
  // 带值标志:本身 + 后面紧跟的值都要跳过
59
- const FLAG_WITH_VALUE = new Set(['-d', '--cwd']);
64
+ // (--output-format / --max-turns 必须在此,否则它们的值会被当成 prompt 文本污染)
65
+ const FLAG_WITH_VALUE = new Set([
66
+ '-d',
67
+ '--cwd',
68
+ '--output-format',
69
+ '--max-turns',
70
+ ]);
60
71
  const result = [];
61
72
  for (let i = 0; i < args.length; i++) {
62
73
  const a = args[i];
@@ -71,12 +82,62 @@ export function extractPromptArgs(args) {
71
82
  return result;
72
83
  }
73
84
  /**
74
- * CLI 非交互模式:读取 prompt,执行 runQuery,流式输出到 stdout,最后落盘 history。
85
+ * 输出 `-p --output-format json` 的结局契约(**一行** JSON 到 stdout)。
86
+ *
87
+ * ⚠️ 诚实声明:`ok=true` 只代表「Agent 正常跑完(stop_reason==='end_turn')——
88
+ * 未报错、未被中断、未撞最大轮数」,**不代表任务目标真的达成**。工具失败会被
89
+ * 吞回 LLM 让它自行重试,LLM 完全可能在没产出有效结果的情况下正常收尾、给出
90
+ * end_turn。调用方(宿主程序)若要判断「任务是否真做成」,必须额外校验 `result`
91
+ * 文本内容 / 产物文件,不能只看 ok。
92
+ *
93
+ * 字段语义:
94
+ * ok = (stop_reason === 'end_turn')
95
+ * result = 累积的 assistant 文本
96
+ * is_error = !ok
97
+ * stop_reason = end_turn | max_turns | error | interrupted | config_error
98
+ * num_turns = assistant_message 事件计数
99
+ * error = 失败文案(成功时 null)
100
+ * session_file(可选)= 当前对话落盘路径;拿得到才填,拿不到省略该键
101
+ *
102
+ * 导出供单测断言。
103
+ */
104
+ export function emitJsonResult(result, sessionFile) {
105
+ const stopReason = result.stopReason ?? 'error';
106
+ const ok = stopReason === 'end_turn';
107
+ console.log(JSON.stringify({
108
+ ok,
109
+ result: result.buffer,
110
+ is_error: !ok,
111
+ stop_reason: stopReason,
112
+ num_turns: result.numTurns,
113
+ error: result.error,
114
+ ...(sessionFile ? { session_file: sessionFile } : {}),
115
+ }));
116
+ }
117
+ /**
118
+ * 尝试拿当前会话落盘路径(给 json 契约的 session_file 字段)。
119
+ * 取不到(异常等)返回 undefined,emitJsonResult 据此省略该键(契约允许)。
120
+ */
121
+ function trySessionFile() {
122
+ try {
123
+ return getContextPath();
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ /**
130
+ * CLI 非交互模式:读取 prompt,执行 runQuery,最后落盘 history。
131
+ *
132
+ * - text 模式:流式 / 收尾把答案打到 stdout(逐字节兼容旧行为)
133
+ * - json 模式:stdout 只在退出时输出一行 JSON 结局契约(emitJsonResult)
75
134
  */
76
135
  export async function runPrintMode(provider, args, initialHistory, options) {
77
136
  // EPIPE 保护:管道断开时不崩溃(如 `bun -p "..." | head -1`)
78
137
  process.stdout.on('error', handleEPIPE(process.stdout));
79
138
  process.stderr.on('error', handleEPIPE(process.stderr));
139
+ const outputFormat = options.outputFormat ?? 'text';
140
+ const isJson = outputFormat === 'json';
80
141
  const promptArgs = extractPromptArgs(args);
81
142
  let prompt;
82
143
  if (promptArgs.length > 0) {
@@ -95,53 +156,97 @@ export async function runPrintMode(provider, args, initialHistory, options) {
95
156
  }
96
157
  const abortController = new AbortController();
97
158
  let interrupted = false;
159
+ const history = initialHistory;
160
+ // text 模式只关心 buffer;json 模式还要 numTurns / stopReason / error。
161
+ // 用同一个对象承载,handleEvent 按 outputFormat 决定写哪些字段。
162
+ const result = {
163
+ buffer: '',
164
+ numTurns: 0,
165
+ stopReason: undefined,
166
+ error: null,
167
+ };
98
168
  process.on('SIGINT', () => {
99
169
  if (interrupted)
100
170
  process.exit(130);
101
171
  interrupted = true;
102
172
  abortController.abort();
103
173
  console.error('\n已中断');
104
- void saveContext(initialHistory).finally(() => process.exit(130));
174
+ // json 模式:SIGINT 也要给出结构化结局(stop_reason='interrupted')
175
+ if (isJson) {
176
+ result.stopReason = 'interrupted';
177
+ void saveContext(history).finally(() => {
178
+ emitJsonResult(result, trySessionFile());
179
+ process.exit(130);
180
+ });
181
+ }
182
+ else {
183
+ void saveContext(history).finally(() => process.exit(130));
184
+ }
105
185
  });
106
- const history = initialHistory;
107
- const output = { buffer: '' };
108
186
  try {
109
187
  for await (const event of runWithPlugins(prompt, {
110
188
  provider,
111
189
  history,
112
190
  signal: abortController.signal,
191
+ maxTurns: options.maxTurns,
113
192
  })) {
114
193
  // event 是 LoopEvent | PluginEvent;handleEvent 只认 LoopEvent,未识别的插件私有事件走 default 静默忽略
115
- const result = handleEvent(event, output, options.verbose);
116
- if (result.exitCode !== undefined) {
194
+ const handled = handleEvent(event, result, options.verbose, outputFormat);
195
+ if (handled.exitCode !== undefined) {
117
196
  await saveContext(history);
118
- process.exit(result.exitCode);
197
+ if (isJson)
198
+ emitJsonResult(result, trySessionFile());
199
+ process.exit(handled.exitCode);
119
200
  }
120
201
  }
121
202
  }
122
203
  catch (e) {
123
204
  if (e.name === 'AbortError') {
124
205
  await saveContext(history);
206
+ if (isJson) {
207
+ result.stopReason = 'interrupted';
208
+ emitJsonResult(result, trySessionFile());
209
+ }
125
210
  process.exit(130);
126
211
  }
127
212
  console.error(`\n未捕获异常: ${e.message}`);
128
213
  await saveContext(history);
214
+ if (isJson) {
215
+ // 未分类异常按 error 收尾(stopReason 仍为空 → emitJsonResult 兜底 'error')
216
+ result.error = result.error ?? e.message;
217
+ emitJsonResult(result, trySessionFile());
218
+ }
129
219
  process.exit(1);
130
220
  }
131
221
  await saveContext(history);
222
+ // json 模式:for-await 正常跑完(没有触发任何 exitCode 退出点)也要 emit 一行结局。
223
+ if (isJson)
224
+ emitJsonResult(result, trySessionFile());
132
225
  }
133
226
  /**
134
227
  * 处理 runQuery yield 出来的 LoopEvent。
135
228
  * 纯事件分发函数,返回 { exitCode? } 由调用方决定是否退出。
229
+ *
230
+ * @param result 累积态(buffer 始终累积;json 模式还会写 numTurns/stopReason/error)
231
+ * @param verbose 是否把工具/压缩诊断打到 stderr
232
+ * @param outputFormat 'text'(默认)逐字节兼容旧行为;'json' 时 stdout 静默(只在退出时由 emitJsonResult 输出)
233
+ *
234
+ * ⚠️ 向后兼容:旧调用方 `handleEvent(ev, output, verbose)` 三参不传 outputFormat,
235
+ * 默认 'text',行为与改前逐字节一致。
136
236
  */
137
- export function handleEvent(event, output, verbose) {
237
+ export function handleEvent(event, result, verbose, outputFormat = 'text') {
238
+ const isJson = outputFormat === 'json';
138
239
  switch (event.type) {
139
240
  case 'text':
140
- if (verbose)
241
+ // text 模式:verbose 才流式写 stdout(与旧行为逐字节一致)。
242
+ // json 模式:绝不往 stdout 写任何东西(stdout 只允许最后那一行 JSON),仅累积 buffer。
243
+ if (verbose && !isJson)
141
244
  process.stdout.write(event.delta);
142
- output.buffer += event.delta;
245
+ result.buffer += event.delta;
143
246
  return {};
144
247
  case 'assistant_message':
248
+ // 一轮 assistant 消息完整组装 → 回合计数 +1(json 契约的 num_turns)
249
+ result.numTurns++;
145
250
  return {};
146
251
  case 'tool_start':
147
252
  if (verbose) {
@@ -168,16 +273,39 @@ export function handleEvent(event, output, verbose) {
168
273
  process.stderr.write(`${event.message}\n`);
169
274
  return {};
170
275
  case 'turn_done':
171
- if (!verbose)
172
- console.log(output.buffer);
276
+ // text 模式:非 verbose 时这里一次性把累积答案打到 stdout(旧行为,逐字节不变)。
277
+ // json 模式:只记 stopReason='end_turn',stdout 保持静默。
278
+ result.stopReason = 'end_turn';
279
+ if (!isJson && !verbose)
280
+ console.log(result.buffer);
173
281
  return {};
174
282
  case 'error':
283
+ // compact_failed 是「非致命」信号:loop 压缩失败后会继续硬上(不 return),
284
+ // 这里**不退出**,只 stderr 警告 —— 对齐 loop 语义。
285
+ // (这同时修了一个旧瑕疵:之前 -p 收到该 error 会被误当致命退出。)
286
+ if (event.code === 'compact_failed') {
287
+ process.stderr.write(`${event.error}\n`);
288
+ return {};
289
+ }
290
+ // 其余 error 都是终结性的。
291
+ if (isJson) {
292
+ result.stopReason = event.code === 'max_turns' ? 'max_turns' : 'error';
293
+ result.error = event.error;
294
+ // json 模式不在这里打 stdout/stderr;由外层 emitJsonResult 统一输出
295
+ return { exitCode: 1 };
296
+ }
297
+ // text 模式:保持旧行为(红字 stderr + exitCode 1)
175
298
  console.error(`\n错误: ${event.error}`);
176
299
  return { exitCode: 1 };
300
+ case 'interrupted':
301
+ // 用户中断:json 模式记 stop_reason;外层退出点(catch / for-await 收尾 / SIGINT)emit。
302
+ // text 模式无需特殊处理(与旧行为一致:静默忽略,靠 SIGINT handler 退出)。
303
+ if (isJson)
304
+ result.stopReason = 'interrupted';
305
+ return {};
177
306
  // 已知但 -p 模式无需呈现的框架事件:显式忽略,好让 default 只剩"真正未知"的插件私有事件。
178
307
  case 'user_message_committed':
179
308
  case 'tool_messages_committed':
180
- case 'interrupted':
181
309
  case 'plugin_progress':
182
310
  case 'reasoning':
183
311
  case 'stage_change':
@@ -20,18 +20,24 @@
20
20
  import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
21
21
  import { homedir } from 'node:os';
22
22
  import { dirname, join } from 'node:path';
23
- /** 配置文件路径(带环境变量覆盖,主要给测试用) */
23
+ import { execSiblingDir } from '../utils/greenRoot.js';
24
+ /**
25
+ * 配置文件的**写**路径(带环境变量覆盖,主要给测试用)。
26
+ * `saveConfig()` / 首次配置向导只写这里——env override 或 home,**绝不写 exe 同级**
27
+ * (U 盘可能只读,且不该污染产品目录)。读路径见 readSavedConfig 的分层。
28
+ */
24
29
  export function getConfigFilePath() {
25
30
  return (process.env.MINIMAL_AGENT_CONFIG_FILE ??
26
31
  join(homedir(), '.minimal-agent', 'config.json'));
27
32
  }
33
+ /** home 默认配置路径(读分层的最后一档)。 */
34
+ function homeConfigPath() {
35
+ return join(homedir(), '.minimal-agent', 'config.json');
36
+ }
28
37
  /**
29
- * 读取向导保存的配置。
30
- * - 文件不存在 / JSON 损坏 / 必填字段缺失 → 返回 null
31
- * - 成功 → 返回 SavedConfig
38
+ * 解析单个 config.json:不存在 / JSON 损坏 / 必填字段缺失 → null;成功 → SavedConfig。
32
39
  */
33
- export async function readSavedConfig() {
34
- const file = getConfigFilePath();
40
+ async function parseConfigFile(file) {
35
41
  try {
36
42
  const raw = await readFile(file, 'utf8');
37
43
  const data = JSON.parse(raw);
@@ -61,6 +67,30 @@ export async function readSavedConfig() {
61
67
  return null;
62
68
  }
63
69
  }
70
+ /**
71
+ * 读取已保存的 provider 配置(**分层**,按优先级返回第一个有效的):
72
+ * 1. `MINIMAL_AGENT_CONFIG_FILE` 显式覆盖 —— **只读这一个,不 fallthrough**
73
+ * (保持既有语义 + 测试隔离)
74
+ * 2. exe 同级 `config.json` —— **绿色版 / U 盘的卖家预置配置**(compile 后
75
+ * `execSiblingDir()` = 真实 exe 目录;dev/npm 下是 node/bun 二进制目录、无此文件 → 跳过)
76
+ * 3. `~/.minimal-agent/config.json` —— 首次配置向导写出的(原行为)
77
+ *
78
+ * 这层让卖家把配好的 config.json 放进绿色目录即完成预配置,客户端零配置开箱即用
79
+ * (loadProviderLayered 拿到 provider → 不进向导、`-p` 不报 config_error)。
80
+ *
81
+ * 加性保证:dev / npm 模式无 override 时,exe 同级无 config.json → 只命中 home → 行为不变。
82
+ */
83
+ export async function readSavedConfig() {
84
+ const override = process.env.MINIMAL_AGENT_CONFIG_FILE;
85
+ if (override)
86
+ return parseConfigFile(override);
87
+ for (const file of [join(execSiblingDir(), 'config.json'), homeConfigPath()]) {
88
+ const cfg = await parseConfigFile(file);
89
+ if (cfg)
90
+ return cfg;
91
+ }
92
+ return null;
93
+ }
64
94
  /**
65
95
  * 写入向导收集到的配置。
66
96
  * unix 上 chmod 600(仅本人可读写),Windows 上 chmod 是 no-op,靠 NTFS ACL。
package/src/loop.js CHANGED
@@ -70,7 +70,8 @@ export async function* runQuery(userInput, options) {
70
70
  timestamp: Date.now(),
71
71
  id: crypto.randomUUID(),
72
72
  });
73
- yield { type: 'error', error: '已被用户中断' };
73
+ // code='aborted':让 `-p --output-format json` 把终结原因映射成 stop_reason='interrupted'
74
+ yield { type: 'error', error: '已被用户中断', code: 'aborted' };
74
75
  return;
75
76
  }
76
77
  // 2. 自动压缩
@@ -88,10 +89,13 @@ export async function* runQuery(userInput, options) {
88
89
  }
89
90
  }
90
91
  catch (e) {
91
- // 压缩失败不要让整个对话挂掉
92
+ // 压缩失败不要让整个对话挂掉。
93
+ // code='compact_failed':标记为「非致命」——注意此处 yield error 后**不 return**,
94
+ // loop 继续往下跑(不压缩硬上)。`-p --output-format json` 据此只 stderr 警告、不退出。
92
95
  yield {
93
96
  type: 'error',
94
97
  error: `自动压缩失败(继续不压缩):${e.message}`,
98
+ code: 'compact_failed',
95
99
  };
96
100
  }
97
101
  // 3. 调 LLM 并组装 assistant 消息
@@ -179,7 +183,12 @@ export async function* runQuery(userInput, options) {
179
183
  }
180
184
  // reactive 也失败 → 退回正常错误路径
181
185
  }
182
- yield { type: 'error', error: `LLM 调用失败:${e.message}` };
186
+ // code='llm_error':终结性错误,stop_reason='error'
187
+ yield {
188
+ type: 'error',
189
+ error: `LLM 调用失败:${e.message}`,
190
+ code: 'llm_error',
191
+ };
183
192
  return;
184
193
  }
185
194
  const assistantMsg = {
@@ -312,9 +321,11 @@ export async function* runQuery(userInput, options) {
312
321
  }
313
322
  // 6. 继续 while 让模型看到 tool_result 后继续推理
314
323
  }
324
+ // code='max_turns':撞最大轮数上限而终结,stop_reason='max_turns'
315
325
  yield {
316
326
  type: 'error',
317
327
  error: `达到最大轮数 ${maxTurns},提前结束(防止失控)。如果合理可以提高 maxTurns。`,
328
+ code: 'max_turns',
318
329
  };
319
330
  }
320
331
  // ---------------- 辅助函数 ----------------
package/src/main.js CHANGED
@@ -5,7 +5,18 @@ import { existsSync, mkdirSync } from 'node:fs';
5
5
  import { createRequire } from 'node:module';
6
6
  import { resolve } from 'node:path';
7
7
  const require = createRequire(import.meta.url);
8
- const pkg = require('../package.json');
8
+ const pkg = (() => {
9
+ try {
10
+ return require('../package.json');
11
+ }
12
+ catch {
13
+ return {
14
+ version: typeof __GREEN_BUILD_VERSION__ === 'string'
15
+ ? __GREEN_BUILD_VERSION__
16
+ : '0.0.0',
17
+ };
18
+ }
19
+ })();
9
20
  import { extractCwdArg } from './bootstrap/cwdArg.js';
10
21
  import { initWorkingDir, getWorkingDir } from './bootstrap/workingDir.js';
11
22
  import { applyToolKeysToEnv, loadProviderLayered } from './config.js';
@@ -15,6 +26,10 @@ import { buildFullSystemPrompt } from './prompts/system.js';
15
26
  import { ALL_TOOLS } from './tools/index.js';
16
27
  import { Root } from './ui/Root.js';
17
28
  import { runPrintMode } from './cli/print.js';
29
+ import { extractFlagValue } from './cli/args.js';
30
+ // re-export:对外保持「main.tsx 提供 extractFlagValue」的语义;实现住在 cli/args.ts,
31
+ // 这样单测可零副作用 import(main.tsx 顶层 main() 自调用会启动整个 app,不可被测试 import)。
32
+ export { extractFlagValue };
18
33
  async function main() {
19
34
  // ★ -d/--cwd:在锁定工作目录之前先建目录 + chdir,让后续逻辑全在 -d 下运行
20
35
  // 不存在则自动 mkdir -p;后续 initWorkingDir() 自然拿到新 cwd
@@ -46,10 +61,30 @@ async function main() {
46
61
  }
47
62
  const isPrintMode = args.includes('-p') || args.includes('--print');
48
63
  if (isPrintMode) {
64
+ // 先把输出格式解析出来——provider 缺失分支也要按 json 契约给信号。
65
+ // 只接受 'text' | 'json',其它值(含拼错)一律回落 'text'(向后兼容)。
66
+ const rawFormat = extractFlagValue(args, ['--output-format']);
67
+ const outputFormat = rawFormat === 'json' ? 'json' : 'text';
68
+ // --max-turns:parseInt,非法(NaN)→ undefined(走 loop 默认 50)
69
+ const rawMaxTurns = extractFlagValue(args, ['--max-turns']);
70
+ const parsedMaxTurns = rawMaxTurns !== undefined ? Number.parseInt(rawMaxTurns, 10) : Number.NaN;
71
+ const maxTurns = Number.isNaN(parsedMaxTurns) ? undefined : parsedMaxTurns;
49
72
  // CLI 非交互模式:先 env,再 ~/.minimal-agent/config.json fallback;都没就退出
50
73
  // (宿主 spawn 子进程时通常没 export env,靠 TUI 向导写出的 config.json 持久化)
51
74
  const provider = await loadProviderLayered();
52
75
  if (!provider) {
76
+ // json 模式:先给宿主一行结构化信号(stop_reason='config_error'),再退出。
77
+ // 人类可读的中文提示仍走 stderr(json 契约只占 stdout 那一行)。
78
+ if (outputFormat === 'json') {
79
+ console.log(JSON.stringify({
80
+ ok: false,
81
+ result: '',
82
+ is_error: true,
83
+ stop_reason: 'config_error',
84
+ num_turns: 0,
85
+ error: 'provider config not found',
86
+ }));
87
+ }
53
88
  process.stderr.write(`\n未找到 provider 配置。\n\n` +
54
89
  `请二选一:\n` +
55
90
  ` 1. 设置环境变量 MINIMAL_AGENT_BASE_URL / MINIMAL_AGENT_API_KEY / MINIMAL_AGENT_MODEL\n` +
@@ -58,7 +93,7 @@ async function main() {
58
93
  }
59
94
  const initialHistory = await buildInitialHistory();
60
95
  const verbose = args.includes('--verbose') || args.includes('-v');
61
- await runPrintMode(provider, args, initialHistory, { verbose });
96
+ await runPrintMode(provider, args, initialHistory, { verbose, outputFormat, maxTurns });
62
97
  return;
63
98
  }
64
99
  // TUI 交互模式:env 不全则 fallback 到 ~/.minimal-agent/config.json;
@@ -96,6 +131,9 @@ minimal-agent - 轻量级 AI 编程助手
96
131
  -v, --verbose 显示详细输出(工具调用、压缩信息)
97
132
  -d, --cwd <dir> 指定工作目录(不存在自动创建);启动时 chdir 到这里,
98
133
  上下文文件、工具相对路径、.env 加载都以此为基准
134
+ --output-format <fmt> text(默认)= 纯文本答案;json = stdout 输出一行结构化
135
+ 结局契约(ok / result / stop_reason / num_turns / error)
136
+ --max-turns <n> 最大工具循环轮数(防失控;缺省 50)
99
137
  -h, --help 显示帮助信息
100
138
 
101
139
  会话记忆:
@@ -108,6 +146,7 @@ minimal-agent - 轻量级 AI 编程助手
108
146
  echo "解释代码" | minimal-agent -p
109
147
  minimal-agent -p --verbose "运行测试并报告结果"
110
148
  minimal-agent -p "处理资料" -d /tmp/job-123 # 工作目录隔离
149
+ minimal-agent -p --output-format json "做点事" # 宿主程序 spawn:一行 JSON 结局
111
150
  `);
112
151
  }
113
152
  main().catch((e) => {
@@ -24,9 +24,28 @@
24
24
  * ============================================================
25
25
  */
26
26
  import { existsSync, statSync } from 'node:fs';
27
- import { join } from 'node:path';
27
+ import { basename, join } from 'node:path';
28
28
  import { pathToFileURL } from 'node:url';
29
29
  const loaderCache = new Map();
30
+ /**
31
+ * 内置插件静态注册表(仅绿色版 compile 入口 entry.compiled.tsx 会填充)。
32
+ *
33
+ * 背景:`bun --compile` 后,rich 插件的 plugin.ts 顺链 import 的框架 SDK
34
+ * (`../../../src/plugin-sdk.js`)已被焊进 exe、磁盘上不存在 → 动态 import
35
+ * 外部 plugin.ts 必抛错。绿色版改为在 compile 入口**静态 import** 内置插件
36
+ * (`src/plugins/builtinRegistry.ts`),让 bun 把执行器整树打进 exe,再经
37
+ * setBuiltinPluginApi 注册到这里;loadPluginApi 在磁盘加载缺失/失败时回退到此表。
38
+ *
39
+ * dev / npm 模式:entry.compiled 永不被 import → 此表恒为空 → 回退分支不触发
40
+ * → loadPluginApi 行为与改动前逐字节一致。
41
+ *
42
+ * key = 插件目录名(`plugins/<id>/` 的 `<id>`,等于 `basename(pluginRoot)`)。
43
+ */
44
+ const builtinApis = new Map();
45
+ /** 由绿色版 compile 入口调用,注册内置插件执行器。dev/npm 不会调用。 */
46
+ export function setBuiltinPluginApi(key, api) {
47
+ builtinApis.set(key, api);
48
+ }
30
49
  function isPluginApi(value) {
31
50
  if (!value || typeof value !== 'object')
32
51
  return false;
@@ -62,8 +81,24 @@ export async function loadPluginApi(pluginRoot) {
62
81
  // stat 失败静默,不影响加载
63
82
  }
64
83
  }
84
+ // 绿色版回退:磁盘加载缺失/失败时,按目录名查 exe 内静态注册表。
85
+ // dev/npm 注册表恒空 → 永远返回 null → 不改变下方任何分支的最终结果。
86
+ const builtinKey = basename(pluginRoot);
87
+ const builtinFallback = () => {
88
+ const api = builtinApis.get(builtinKey);
89
+ if (api) {
90
+ loaderCache.set(pluginRoot, api);
91
+ return api;
92
+ }
93
+ return null;
94
+ };
65
95
  const pluginEntry = jsExists ? jsPath : tsExists ? tsPath : undefined;
66
96
  if (!pluginEntry) {
97
+ // 磁盘无 plugin.js/.ts:绿色目录只铺声明式部分(plugin.json + commands/),
98
+ // 执行器在 exe 内注册表 → 这里命中。dev/npm 注册表空 → 维持原 null。
99
+ const fb = builtinFallback();
100
+ if (fb)
101
+ return fb;
67
102
  loaderCache.set(pluginRoot, null);
68
103
  return null;
69
104
  }
@@ -79,6 +114,11 @@ export async function loadPluginApi(pluginRoot) {
79
114
  return candidate;
80
115
  }
81
116
  catch (err) {
117
+ // 绿色版:磁盘铺了 plugin.js 但它顺链 import 的框架 SDK 在 exe 内、磁盘没有
118
+ // → import 抛错 → 回退到 exe 内注册表。dev/npm 注册表空 → 维持原 warn + null。
119
+ const fb = builtinFallback();
120
+ if (fb)
121
+ return fb;
82
122
  console.warn(`[minimal-agent] failed to load plugin entry at ${pluginRoot}: ${err instanceof Error ? err.message : String(err)}`);
83
123
  loaderCache.set(pluginRoot, null);
84
124
  return null;
@@ -26,6 +26,7 @@
26
26
  import { spawn } from 'node:child_process';
27
27
  import { chmodSync, existsSync } from 'node:fs';
28
28
  import { resolve } from 'node:path';
29
+ import { execSiblingDir } from '../../utils/greenRoot.js';
29
30
  import { findPackageRoot } from '../../utils/packageRoot.js';
30
31
  let cached;
31
32
  /**
@@ -53,6 +54,15 @@ async function detect() {
53
54
  ensureExecutable(vendored);
54
55
  return vendored;
55
56
  }
57
+ // ②.5 绿色版(bun --compile 单文件):exe 同级 vendor/ripgrep/<arch>-<platform>/rg
58
+ // compile 后 import.meta.url 是虚拟路径,findPackageRoot 失效(② 命不中),
59
+ // 绿色目录把 vendor/ 摆在可执行文件旁边,从这里捞回来。dev/npm 下 exe 同级
60
+ // 没有 vendor/ → existsSync 过滤掉 → 不影响 ②/③/④ 原有优先级。
61
+ const sibling = resolve(execSiblingDir(), 'vendor', 'ripgrep', subdir(), exeName());
62
+ if (existsSync(sibling)) {
63
+ ensureExecutable(sibling);
64
+ return sibling;
65
+ }
56
66
  // ③ PATH 上的 rg —— 直接试 spawn rg --version
57
67
  if (await trySpawn('rg'))
58
68
  return 'rg';
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ============================================================
3
+ * src/utils/greenRoot.ts —— 绿色版(bun --compile 单文件)资源探测锚点
4
+ * ------------------------------------------------------------
5
+ * 问题:`bun build --compile` 把运行时 + 全部 JS 焊进单个可执行文件,
6
+ * 此时 `import.meta.url` 指向嵌入式虚拟路径(`/$bunfs/...`),磁盘上
7
+ * 既没有 package.json 也没有源码树 —— `findPackageRoot` 会抛错,靠它
8
+ * 定位的 `vendor/ripgrep/`、`skills/`、`plugins/`、`workflows/` 全失效。
9
+ *
10
+ * 方案:绿色版把这些外部资产与可执行文件**放在同一目录**,运行时用
11
+ * `dirname(process.execPath)` 找回来(exe 同级)。
12
+ *
13
+ * 为什么 dev / npm 模式零影响:
14
+ * - dev(`bun run src/main.tsx`):execPath = bun 二进制,其同级目录
15
+ * (如 `~/.bun/bin/`)没有 vendor/skills/plugins。
16
+ * - npm 全局安装(`node dist/main.js`):execPath = node 二进制,其
17
+ * 同级目录同样没有这些资源目录。
18
+ * - 故调用方一律对返回值做 `existsSync` 过滤:dev/npm 下命不中 → 被
19
+ * 丢弃 → 行为与现状逐字节一致。只有真·绿色版(资产摆在 exe 旁边)
20
+ * 才会命中。因此这里**无条件返回** execPath 目录即可,无需判别
21
+ * "是否 compiled"。
22
+ * ============================================================
23
+ */
24
+ import { dirname } from 'node:path';
25
+ /**
26
+ * 返回可执行文件所在目录(绿色版资产的同级锚点)。
27
+ *
28
+ * 调用方必须用 existsSync 过滤返回路径下的目标子目录 —— dev/npm 模式
29
+ * execPath 指向 node/bun 二进制,其同级没有资源目录,过滤后无副作用。
30
+ */
31
+ export function execSiblingDir() {
32
+ return dirname(process.execPath);
33
+ }
@@ -21,6 +21,7 @@
21
21
  import { existsSync } from 'node:fs';
22
22
  import { join, resolve } from 'node:path';
23
23
  import { getWorkingDir } from '../bootstrap/workingDir.js';
24
+ import { execSiblingDir } from './greenRoot.js';
24
25
  import { findPackageRoot } from './packageRoot.js';
25
26
  /**
26
27
  * 返回资源目录的搜索顺序数组(cwd 优先 + packageRoot fallback)。
@@ -35,6 +36,13 @@ export function getResourceSearchPaths(name, metaUrl) {
35
36
  if (existsSync(cwdPath)) {
36
37
  paths.push(cwdPath);
37
38
  }
39
+ // 绿色版(bun --compile 单文件):exe 同级 <name>/ —— compile 后 findPackageRoot
40
+ // 失效,资源目录摆在可执行文件旁边从这里命中。dev/npm 下 exe 同级(node/bun
41
+ // 二进制所在目录)没有资源目录 → existsSync 过滤 → 数组与现状一致。
42
+ const sibPath = resolve(join(execSiblingDir(), name));
43
+ if (!paths.includes(sibPath) && existsSync(sibPath)) {
44
+ paths.push(sibPath);
45
+ }
38
46
  let pkgPath;
39
47
  try {
40
48
  pkgPath = resolve(join(findPackageRoot(metaUrl), name));
@@ -42,7 +50,7 @@ export function getResourceSearchPaths(name, metaUrl) {
42
50
  catch {
43
51
  return paths;
44
52
  }
45
- if (pkgPath !== cwdPath && existsSync(pkgPath)) {
53
+ if (!paths.includes(pkgPath) && existsSync(pkgPath)) {
46
54
  paths.push(pkgPath);
47
55
  }
48
56
  return paths;