minimal-agent 0.5.6 → 0.6.1

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.5.6",
3
+ "version": "0.6.1",
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>",
@@ -13,23 +13,34 @@
13
13
  * or := and ( '||' and )*
14
14
  * and := not ( '&&' not )*
15
15
  * not := '!' not | comparison
16
- * comparison := primary ( ('==' | '!=' | '>=' | '<=' | '>' | '<') primary )?
17
- * primary := number | string | array | call | path | '(' expr ')'
16
+ * comparison := additive ( ('==' | '!=' | '>=' | '<=' | '>' | '<') additive )?
17
+ * additive := primary ( '+' primary )* // number 相加 / 否则 stringify 后拼接
18
+ * primary := number | string | array | call | path | interp | '(' expr ')'
18
19
  * array := '[' expr (',' expr)* ']'
19
20
  * call := IDENT '(' expr (',' expr)* ')' // 白名单
20
21
  * path := IDENT ('.' IDENT)* // 变量 + 点路径
22
+ * interp := '${' EXPR '}' // 取 EXPR 的"值"(任意类型,不字符串化)
23
+ * string := "…" | '…' // 内嵌 ${EXPR} 走 template-literal 插值
21
24
  *
22
25
  * 白名单函数:fileExists / length / lower / upper
23
26
  *
24
- * 插值:${EXPR} —— EXPR 求值后 toString 替换;对象/数组用 JSON.stringify。
25
- * interpolateDeep 递归处理对象/数组/字符串。
27
+ * P2 根治:表达式上下文(when/condition/over/assert/vote.input)直接 evalExpr,
28
+ * 不再"先 interpolate 再 eval"(旧双求值的 footgun 源头)。四种写法都 OK:
29
+ * 裸 ${x} == 'y' → ${x} 取值后比较(修掉旧 footgun:旧实现把 ${x} 替成裸字面量被当变量名)
30
+ * "a/${x}/b" → 字符串字面量内插值拼路径(向后兼容旧 fileExists("…${x}…") 写法)
31
+ * "a/" + x + "/b" → + 拼接(新增)
32
+ * x == 'y' → 裸变量名(向后兼容)
33
+ * 安全性也更好:变量的字符串内容不再被当作表达式二次执行(旧实现若插值结果落在引号外会被 eval)。
34
+ *
35
+ * 插值函数 interpolate(${EXPR}) 仍用于**模板上下文**(prompt / args / file_path)——
36
+ * 把 EXPR 求值后 toString 替换;对象/数组用 JSON.stringify。interpolateDeep 递归处理对象/数组/字符串。
26
37
  * ============================================================
27
38
  */
28
39
  import { existsSync } from 'node:fs';
29
40
  import { join, isAbsolute } from 'node:path';
30
41
  import { getWorkingDir } from '../../../src/plugin-sdk.js';
31
42
  const PUNCT2 = new Set(['==', '!=', '>=', '<=', '&&', '||']);
32
- const PUNCT1 = new Set(['(', ')', '[', ']', ',', '.', '!', '>', '<']);
43
+ const PUNCT1 = new Set(['(', ')', '[', ']', ',', '.', '!', '>', '<', '+']);
33
44
  function tokenize(src) {
34
45
  const out = [];
35
46
  let i = 0;
@@ -39,6 +50,25 @@ function tokenize(src) {
39
50
  i++;
40
51
  continue;
41
52
  }
53
+ // 独立 ${...} 取值 token。注意:字符串字面量内部的 ${} 不会到这里——
54
+ // 会被下面的字符串分支整体读进 str token,由 parsePrimary 在 eval 时插值。
55
+ if (c === '$' && src[i + 1] === '{') {
56
+ let depth = 1;
57
+ let j = i + 2;
58
+ while (j < src.length && depth > 0) {
59
+ if (src[j] === '{')
60
+ depth++;
61
+ else if (src[j] === '}')
62
+ depth--;
63
+ if (depth > 0)
64
+ j++;
65
+ }
66
+ if (depth !== 0)
67
+ throw new Error(`未闭合的 \${ 在表达式 "${src}"`);
68
+ out.push({ kind: 'interp', value: src.slice(i + 2, j) });
69
+ i = j + 1;
70
+ continue;
71
+ }
42
72
  // 字符串
43
73
  if (c === '"' || c === "'") {
44
74
  const quote = c;
@@ -161,11 +191,11 @@ function parseNot(c, vars) {
161
191
  return parseComparison(c, vars);
162
192
  }
163
193
  function parseComparison(c, vars) {
164
- const left = parsePrimary(c, vars);
194
+ const left = parseAdditive(c, vars);
165
195
  const t = peek(c);
166
196
  if (t && t.kind === 'punct' && ['==', '!=', '>=', '<=', '>', '<'].includes(t.value)) {
167
197
  eat(c);
168
- const right = parsePrimary(c, vars);
198
+ const right = parseAdditive(c, vars);
169
199
  switch (t.value) {
170
200
  case '==':
171
201
  // eslint-disable-next-line eqeqeq
@@ -185,6 +215,23 @@ function parseComparison(c, vars) {
185
215
  }
186
216
  return left;
187
217
  }
218
+ // 加法/拼接层:number + number → 相加;否则两边 stringify 后字符串拼接。
219
+ // 表达式语言原本无拼接符,导致拼路径只能"先插值再 eval"(footgun 源头);
220
+ // 有了 + 之后可写 fileExists("videos/" + inputs.book + "/x") 这种纯表达式。
221
+ function parseAdditive(c, vars) {
222
+ let left = parsePrimary(c, vars);
223
+ while (isPunct(c, '+')) {
224
+ eat(c);
225
+ const right = parsePrimary(c, vars);
226
+ left = addValues(left, right);
227
+ }
228
+ return left;
229
+ }
230
+ function addValues(l, r) {
231
+ if (typeof l === 'number' && typeof r === 'number')
232
+ return l + r;
233
+ return stringify(l) + stringify(r);
234
+ }
188
235
  function parsePrimary(c, vars) {
189
236
  const t = peek(c);
190
237
  if (!t)
@@ -218,7 +265,15 @@ function parsePrimary(c, vars) {
218
265
  }
219
266
  if (t.kind === 'str') {
220
267
  eat(c);
221
- return t.value;
268
+ // 字符串字面量支持内嵌 ${...} 插值(template-literal 语义):
269
+ // fileExists("videos/${inputs.book}/x") 不必预插值也能工作。无 ${} 时原样返回。
270
+ return interpolate(t.value, vars);
271
+ }
272
+ if (t.kind === 'interp') {
273
+ eat(c);
274
+ // 独立 ${EXPR}:求值为 EXPR 的"值"(任意类型,不字符串化)。
275
+ // 修掉"裸 ${x} == 'y' 被当成变量名 x"的 footgun。
276
+ return evalExpr(t.value, vars);
222
277
  }
223
278
  if (t.kind === 'ident') {
224
279
  eat(c);
@@ -165,14 +165,14 @@ export async function* runWorkflowFromCommand(rawArgs, opts) {
165
165
  if (def.inputs && positional.length > def.inputs.length) {
166
166
  const dropped = positional.slice(def.inputs.length);
167
167
  yield {
168
- type: 'workflow_warning',
168
+ type: 'warning',
169
169
  message: `⚠ 忽略了 ${dropped.length} 个多余位置参数: ${dropped.map((d) => JSON.stringify(d)).join(' ')}(workflow "${name}" 只声明了 ${def.inputs.length} 个 input)`,
170
170
  };
171
171
  }
172
172
  // warn:位置参数被 --input 覆盖
173
173
  if (overriddenByKV.length > 0) {
174
174
  yield {
175
- type: 'workflow_warning',
175
+ type: 'warning',
176
176
  message: `⚠ --input 优先级高于位置参数,以下位置参数被覆盖:${overriddenByKV.join('; ')}`,
177
177
  };
178
178
  }
@@ -256,6 +256,11 @@ function validateSteps(steps, file, pathPrefix) {
256
256
  if (typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type)) {
257
257
  throw new WorkflowLoadError(`${p}.output_schema 不能用在 type=${s.type} 控制流节点上`, file);
258
258
  }
259
+ // P3:output_schema 在 skill 节点上是静默 no-op(execSkillStep 无视它)。
260
+ // 与其悄悄丢弃,不如 loader 直接拒绝——仅 llm 节点支持 structured output。
261
+ if (s.skill !== undefined) {
262
+ throw new WorkflowLoadError(`${p}.output_schema 暂不支持 skill 节点(仅 llm 节点生效)`, file);
263
+ }
259
264
  }
260
265
  // context_files:[{ path, hint? }];不能用在控制流节点上
261
266
  if (s.context_files !== undefined) {
@@ -277,6 +282,11 @@ function validateSteps(steps, file, pathPrefix) {
277
282
  if (typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type)) {
278
283
  throw new WorkflowLoadError(`${p}.context_files 不能用在 type=${s.type} 控制流节点上`, file);
279
284
  }
285
+ // P3:context_files 同样是 skill 节点的静默 no-op(execSkillStep 无视它),
286
+ // 仅 llm 节点经 miniReAct 生效——loader 直接拒绝,避免悄悄丢弃。
287
+ if (s.skill !== undefined) {
288
+ throw new WorkflowLoadError(`${p}.context_files 暂不支持 skill 节点(仅 llm 节点生效)`, file);
289
+ }
280
290
  }
281
291
  // allowed_tools:string[];不能用在控制流节点上
282
292
  if (s.allowed_tools !== undefined) {
@@ -287,6 +297,10 @@ function validateSteps(steps, file, pathPrefix) {
287
297
  if (typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type)) {
288
298
  throw new WorkflowLoadError(`${p}.allowed_tools 不能用在 type=${s.type} 控制流节点上`, file);
289
299
  }
300
+ // P3:allowed_tools 与 context_files 配对,skill 节点上同样无效——一并拒绝。
301
+ if (s.skill !== undefined) {
302
+ throw new WorkflowLoadError(`${p}.allowed_tools 暂不支持 skill 节点(仅 llm 节点生效)`, file);
303
+ }
290
304
  }
291
305
  }
292
306
  }
@@ -28,6 +28,13 @@ import { execToolStep } from './stepExecutors/tool.js';
28
28
  import { VarStack } from './types.js';
29
29
  import { runVoter } from './voteHelpers.js';
30
30
  import { WorkflowState } from './workflowState.js';
31
+ /**
32
+ * 插件 ID:把私有 workflow_* 事件镜像成框架通用 plugin_progress 时挂这个 id。
33
+ * LiveArea 状态行会渲染成 "[plugin:workflow-runner] i/total <message>"
34
+ * (src/ui/LiveArea.tsx:123),让 TUI 里跑 workflow 不再"无声"。
35
+ * (-p 非交互模式另由 src/cli/print.ts 把通用 warning 事件落 stderr。)
36
+ */
37
+ const WF_PLUGIN_ID = 'workflow-runner';
31
38
  function stepKind(step) {
32
39
  if (step.type)
33
40
  return step.type;
@@ -65,9 +72,9 @@ async function* dispatchActionStep(step, vars, ctx) {
65
72
  if (step.type === 'assert')
66
73
  return execAssertStep(step, vars);
67
74
  if (step.tool)
68
- return await execToolStep(step, vars, ctx);
75
+ return yield* execToolStep(step, vars, ctx);
69
76
  if (step.llm)
70
- return await execLlmStep(step, vars, ctx);
77
+ return yield* execLlmStep(step, vars, ctx);
71
78
  if (step.skill)
72
79
  return yield* execSkillStep(step, vars, ctx);
73
80
  throw new Error(`step ${step.id}: 无可执行字段(tool/llm/skill/assert)`);
@@ -89,7 +96,7 @@ async function* runSteps(steps, vars, ctx, state) {
89
96
  if (step.when) {
90
97
  let pass = false;
91
98
  try {
92
- pass = Boolean(evalExpr(interpolate(step.when, vars), vars));
99
+ pass = Boolean(evalExpr(step.when, vars));
93
100
  }
94
101
  catch (e) {
95
102
  const errMsg = `when 表达式错误: ${e.message}`;
@@ -123,11 +130,21 @@ async function* runSteps(steps, vars, ctx, state) {
123
130
  index: i,
124
131
  total: steps.length,
125
132
  };
133
+ // P1 TUI 可见:把私有 workflow_step_start 镜像成框架通用 plugin_progress,
134
+ // LiveArea 状态行渲染成 "[plugin:workflow-runner] i/total step <id> (<kind>)"。
135
+ // (嵌套 block 的 i/total 相对当前 block,足够给"跑到哪一步"的实时反馈。)
136
+ yield {
137
+ type: 'plugin_progress',
138
+ pluginId: WF_PLUGIN_ID,
139
+ current: i + 1,
140
+ max: steps.length,
141
+ message: `step ${step.id} (${kind})`,
142
+ };
126
143
  // ---------- 控制流:branch ----------
127
144
  if (step.type === 'branch') {
128
145
  let cond = false;
129
146
  try {
130
- cond = Boolean(evalExpr(interpolate(step.condition ?? 'false', vars), vars));
147
+ cond = Boolean(evalExpr(step.condition ?? 'false', vars));
131
148
  }
132
149
  catch (e) {
133
150
  const errMsg = `branch 条件错误: ${e.message}`;
@@ -156,7 +173,7 @@ async function* runSteps(steps, vars, ctx, state) {
156
173
  if (step.type === 'loop') {
157
174
  let arr;
158
175
  try {
159
- arr = evalExpr(interpolate(step.over ?? '[]', vars), vars);
176
+ arr = evalExpr(step.over ?? '[]', vars);
160
177
  }
161
178
  catch (e) {
162
179
  const errMsg = `loop over 表达式错误: ${e.message}`;
@@ -208,6 +225,18 @@ async function* runSteps(steps, vars, ctx, state) {
208
225
  ? interpolate(step.prompt, vars)
209
226
  : 'pause(非交互模式直接跳过)';
210
227
  await state.appendProgress(`- ${step.id} pause: ${msg}`);
228
+ // P3:pause 在当前实现下**从不**真正等待人工(runner 是纯 AsyncGenerator,
229
+ // 没有把用户输入回灌进来的通道)。把"静默放行"变"响亮放行"——发通用 warning
230
+ // (-p 模式由 print.ts 落 stderr,权威通道)并镜像 plugin_progress(TUI 状态行可见),
231
+ // 避免审批闸门被悄悄跳过。真·HITL pause 属 P4,本轮不做。
232
+ const warnMsg = `⚠ pause "${step.id}" 在非交互模式被自动跳过,未真正等待人工确认——若这是审批闸门请勿依赖它。提示:${msg}`;
233
+ yield { type: 'warning', message: warnMsg };
234
+ yield {
235
+ type: 'plugin_progress',
236
+ pluginId: WF_PLUGIN_ID,
237
+ current: 0,
238
+ message: warnMsg,
239
+ };
211
240
  yield {
212
241
  type: 'workflow_step_skipped',
213
242
  id: step.id,
@@ -296,7 +325,7 @@ async function* runSteps(steps, vars, ctx, state) {
296
325
  const threshold = step.threshold ?? Math.ceil(voters_count / 2);
297
326
  let inputArr;
298
327
  try {
299
- inputArr = evalExpr(interpolate(step.input ?? '[]', vars), vars);
328
+ inputArr = evalExpr(step.input ?? '[]', vars);
300
329
  }
301
330
  catch (e) {
302
331
  const errMsg = `vote.input 求值失败: ${e.message}`;
@@ -408,10 +437,27 @@ export async function* runWorkflow(def, inputs, ctx) {
408
437
  name: def.name,
409
438
  totalSteps: def.steps.length,
410
439
  };
440
+ // P1 TUI 可见:开工时给 LiveArea 状态行一条 plugin_progress(0/total)。
441
+ yield {
442
+ type: 'plugin_progress',
443
+ pluginId: WF_PLUGIN_ID,
444
+ current: 0,
445
+ max: def.steps.length,
446
+ message: `▶ workflow ${def.name}`,
447
+ };
411
448
  try {
412
449
  const halted = yield* runSteps(def.steps, vars, ctx, state);
413
450
  if (halted)
414
451
  return;
452
+ // 完成态也给一条 plugin_progress(放在 workflow_done 之前,保证
453
+ // workflow_done 仍是事件流最后一条——e2e 测试依赖 events[last]===workflow_done)。
454
+ yield {
455
+ type: 'plugin_progress',
456
+ pluginId: WF_PLUGIN_ID,
457
+ current: def.steps.length,
458
+ max: def.steps.length,
459
+ message: `✓ workflow ${def.name} 完成`,
460
+ };
415
461
  yield { type: 'workflow_done', name: def.name, vars: vars.snapshot() };
416
462
  }
417
463
  finally {
@@ -12,8 +12,8 @@ export function execAssertStep(step, vars) {
12
12
  }
13
13
  let ok;
14
14
  try {
15
- const expanded = interpolate(step.condition, vars);
16
- ok = Boolean(evalExpr(expanded, vars));
15
+ // P2:表达式上下文直接 evalExpr(${} 由引擎原生处理),不再"先插值再 eval"。
16
+ ok = Boolean(evalExpr(step.condition, vars));
17
17
  }
18
18
  catch (e) {
19
19
  throw new Error(`assert 表达式求值失败: ${e.message}`);
@@ -5,7 +5,9 @@
5
5
  * 单轮 LLM 生成。关键设计:
6
6
  * - tools: [] —— 不开任何工具(工具调用应该写成 tool: step)
7
7
  * - 自带独立 system + user 两条消息,不污染主 history
8
- * - 流式累积文本(reasoning 字段在这里忽略;只关心最终文本)
8
+ * - AsyncGenerator:非 schema 模式把 text_delta 流式 yield 给 UI(答案是主角,
9
+ * 消除 TUI"无声"),最终仍 return StepResult;schema 模式静默累积 JSON 不回显。
10
+ * reasoning 字段在这里忽略;只关心最终文本
9
11
  *
10
12
  * capture:
11
13
  * - `{ text: var_name }` 绑生成文本(无 output_schema 时)
@@ -18,7 +20,7 @@
18
20
  * - system prompt 切换为严格 JSON 模式,禁止 markdown 代码块包裹
19
21
  * ============================================================
20
22
  */
21
- import { ALL_TOOLS, chat } from '../../../../src/plugin-sdk.js';
23
+ import { ALL_TOOLS, chat, } from '../../../../src/plugin-sdk.js';
22
24
  import { interpolate } from '../expressions.js';
23
25
  import { runMiniReAct } from '../miniReAct.js';
24
26
  const LLM_STEP_SYSTEM_PLAIN = '你正在被一个 workflow 调用。只输出本步骤要求的内容本身,不要寒暄、不要列要求复述、不要工具调用语法。';
@@ -37,7 +39,7 @@ const LLM_STEP_SYSTEM_WITH_TOOLS = `你正在被一个 workflow 调用。你被
37
39
  4. 完成后**不要**再调用工具,直接输出最终回答
38
40
 
39
41
  如果 workflow 要求结构化输出,请在最终回答里严格遵循 JSON Schema(无 markdown 代码块包裹)。`;
40
- export async function execLlmStep(step, vars, ctx) {
42
+ export async function* execLlmStep(step, vars, ctx) {
41
43
  if (typeof step.llm !== 'string')
42
44
  throw new Error(`step ${step.id}: 缺少 llm 字段`);
43
45
  const prompt = interpolate(step.llm, vars);
@@ -88,8 +90,13 @@ export async function execLlmStep(step, vars, ctx) {
88
90
  responseFormat,
89
91
  signal: ctx.signal,
90
92
  })) {
91
- if (ev.type === 'text_delta')
93
+ if (ev.type === 'text_delta') {
92
94
  text += ev.delta;
95
+ // 非 schema 模式:把答案流式 yield 给 UI(答案是主角,消除"无声")。
96
+ // schema 模式输出是原始 JSON,不当"答案文本"回显,避免屏幕闪 JSON。
97
+ if (!useSchema)
98
+ yield { type: 'text', delta: ev.delta };
99
+ }
93
100
  if (ev.type === 'done' && ev.stopReason === 'aborted') {
94
101
  throw new Error('用户中断');
95
102
  }
@@ -1,4 +1,6 @@
1
- // TODO ADR-05: output_schema 暂未在 skill 节点生效,仅 llm 节点支持;workflow 用 vote 时通过 runVoter 直接调 execLlmStep
1
+ // NOTE: output_schema 不支持 skill 节点——loader 现已直接拒绝 skill+output_schema 组合
2
+ // (见 loader.ts validateSteps,P3 修复:静默 no-op 改为加载期报错)。
3
+ // 仅 llm 节点支持 provider-native structured output;vote 通过 runVoter 调 execLlmStep(llm 虚拟步骤)。
2
4
  /**
3
5
  * ============================================================
4
6
  * plugins/workflow-runner/src/stepExecutors/skill.ts —— skill step 执行
@@ -5,17 +5,23 @@
5
5
  * 把 step.args 递归 ${var} 插值后 JSON.stringify,转发到 executeTool。
6
6
  * 失败(ok=false)抛 Error,让 runner 走 onError 处理。
7
7
  *
8
+ * v2(P1 TUI 可见):本执行器改为 AsyncGenerator,在调 executeTool 前后
9
+ * yield 通用 tool_start / tool_end LoopEvent —— 这样 workflow 的 tool 步骤
10
+ * 在 TUI 里像普通 ReAct 工具调用一样显示(runningTools spinner + 人话描述),
11
+ * 不再"无声"。**不** yield tool_messages_committed:workflow 步骤不进主 history,
12
+ * 其 live 态由 useChat 的 submit finally(resetTurnState) 在事件流末尾统一清理;
13
+ * tool_end 自身已把该 callId 从 runningTools 里摘除,无需额外 commit 语义。
14
+ *
8
15
  * capture 字段(支持点路径):
9
16
  * - `{ content: var_name }` 绑 ToolResult.content(工具回执文本)
10
17
  * - `{ result: var_name }` 同义,给"无结构化字段"工具用
11
18
  * - `{ args.content: var_name }` 绑插值后传给工具的入参(如真正写入的内容)
12
19
  * - `{ args.<任意字段>: var_name }` 绑入参的具名字段
13
- * P1 可扩展按工具特定字段(如 Read 的 fileSize)。
14
20
  * ============================================================
15
21
  */
16
- import { executeTool, getToolByName } from '../../../../src/plugin-sdk.js';
22
+ import { executeTool, getToolByName, } from '../../../../src/plugin-sdk.js';
17
23
  import { interpolateDeep } from '../expressions.js';
18
- export async function execToolStep(step, vars, ctx) {
24
+ export async function* execToolStep(step, vars, ctx) {
19
25
  if (!step.tool)
20
26
  throw new Error(`step ${step.id}: 缺少 tool 字段`);
21
27
  const tool = getToolByName(step.tool);
@@ -23,7 +29,23 @@ export async function execToolStep(step, vars, ctx) {
23
29
  throw new Error(`step ${step.id}: 未知 tool "${step.tool}"`);
24
30
  const args = interpolateDeep(step.args ?? {}, vars);
25
31
  const argsJson = JSON.stringify(args);
32
+ const toolCallId = `wf_${step.id}`;
33
+ const argsPreview = argsJson.length > 80 ? `${argsJson.slice(0, 80)}…` : argsJson;
34
+ yield {
35
+ type: 'tool_start',
36
+ toolName: tool.name,
37
+ toolCallId,
38
+ argsPreview,
39
+ argsFriendly: friendlyToolDesc(tool.name, args),
40
+ };
26
41
  const result = await executeTool(tool.name, argsJson, ctx.signal);
42
+ yield {
43
+ type: 'tool_end',
44
+ toolName: tool.name,
45
+ toolCallId,
46
+ ok: result.ok,
47
+ content: result.ok ? result.content : result.error,
48
+ };
27
49
  if (!result.ok) {
28
50
  throw new Error(result.error);
29
51
  }
@@ -33,3 +55,16 @@ export async function execToolStep(step, vars, ctx) {
33
55
  preview: content.length > 200 ? `${content.slice(0, 200)}...` : content,
34
56
  };
35
57
  }
58
+ /**
59
+ * 给 tool_start 生成人话描述(LiveArea spinner 行用)。
60
+ * 取常见入参键(file_path/command/query/...)拼成 "Write greetings/a/hello.txt"
61
+ * 这样的短句;都没命中就回退到工具名本身。
62
+ */
63
+ function friendlyToolDesc(toolName, args) {
64
+ const key = ['file_path', 'command', 'query', 'pattern', 'path', 'url'].find((k) => typeof args[k] === 'string');
65
+ if (!key)
66
+ return toolName;
67
+ const val = String(args[key]);
68
+ const short = val.length > 60 ? `${val.slice(0, 60)}…` : val;
69
+ return `${toolName} ${short}`;
70
+ }
@@ -41,7 +41,13 @@ export async function runVoter(rules, item, vars, ctx) {
41
41
  output_schema: VERDICT_SCHEMA,
42
42
  };
43
43
  try {
44
- const result = await execLlmStep(virtualStep, vars, ctx);
44
+ // execLlmStep 现为 AsyncGenerator:手动 drain return 值。voter 必须静默——
45
+ // N 个 voter 在 Promise.all 里并行,若把它们的 text 事件向上转发会在 UI 互相打架。
46
+ const gen = execLlmStep(virtualStep, vars, ctx);
47
+ let res = await gen.next();
48
+ while (!res.done)
49
+ res = await gen.next();
50
+ const result = res.value;
45
51
  const verdict = extractVerdict(result.raw);
46
52
  if (verdict)
47
53
  return verdict;
@@ -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,14 +19,24 @@
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;
24
29
  /** stdin 读取超时(ms),防止非 TTY + 无管道时永久挂起 */
25
30
  const STDIN_TIMEOUT_MS = 3000;
31
+ /** 已告警过的未知事件类型(去重,避免高频插件私有事件刷屏 stderr)。 */
32
+ const warnedUnknownEventTypes = new Set();
33
+ function warnUnknownEventType(type) {
34
+ const t = typeof type === 'string' ? type : String(type);
35
+ if (warnedUnknownEventTypes.has(t))
36
+ return;
37
+ warnedUnknownEventTypes.add(t);
38
+ process.stderr.write(`[minimal-agent] 忽略未识别事件类型 "${t}"(插件私有事件;若是 yield 时拼错的 LoopEvent type,请检查插件)\n`);
39
+ }
26
40
  function handleEPIPE(stream) {
27
41
  return (err) => {
28
42
  if (err.code === 'EPIPE')
@@ -47,7 +61,13 @@ export function extractPromptArgs(args) {
47
61
  '--version',
48
62
  ]);
49
63
  // 带值标志:本身 + 后面紧跟的值都要跳过
50
- 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
+ ]);
51
71
  const result = [];
52
72
  for (let i = 0; i < args.length; i++) {
53
73
  const a = args[i];
@@ -62,12 +82,62 @@ export function extractPromptArgs(args) {
62
82
  return result;
63
83
  }
64
84
  /**
65
- * 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)
66
134
  */
67
135
  export async function runPrintMode(provider, args, initialHistory, options) {
68
136
  // EPIPE 保护:管道断开时不崩溃(如 `bun -p "..." | head -1`)
69
137
  process.stdout.on('error', handleEPIPE(process.stdout));
70
138
  process.stderr.on('error', handleEPIPE(process.stderr));
139
+ const outputFormat = options.outputFormat ?? 'text';
140
+ const isJson = outputFormat === 'json';
71
141
  const promptArgs = extractPromptArgs(args);
72
142
  let prompt;
73
143
  if (promptArgs.length > 0) {
@@ -86,53 +156,97 @@ export async function runPrintMode(provider, args, initialHistory, options) {
86
156
  }
87
157
  const abortController = new AbortController();
88
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
+ };
89
168
  process.on('SIGINT', () => {
90
169
  if (interrupted)
91
170
  process.exit(130);
92
171
  interrupted = true;
93
172
  abortController.abort();
94
173
  console.error('\n已中断');
95
- 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
+ }
96
185
  });
97
- const history = initialHistory;
98
- const output = { buffer: '' };
99
186
  try {
100
187
  for await (const event of runWithPlugins(prompt, {
101
188
  provider,
102
189
  history,
103
190
  signal: abortController.signal,
191
+ maxTurns: options.maxTurns,
104
192
  })) {
105
- // event 是 LoopEvent | PluginEvent;handleEvent 识别 LoopEvent + workflow_warning,其它走 default 静默忽略
106
- const result = handleEvent(event, output, options.verbose);
107
- if (result.exitCode !== undefined) {
193
+ // event 是 LoopEvent | PluginEvent;handleEvent 只认 LoopEvent,未识别的插件私有事件走 default 静默忽略
194
+ const handled = handleEvent(event, result, options.verbose, outputFormat);
195
+ if (handled.exitCode !== undefined) {
108
196
  await saveContext(history);
109
- process.exit(result.exitCode);
197
+ if (isJson)
198
+ emitJsonResult(result, trySessionFile());
199
+ process.exit(handled.exitCode);
110
200
  }
111
201
  }
112
202
  }
113
203
  catch (e) {
114
204
  if (e.name === 'AbortError') {
115
205
  await saveContext(history);
206
+ if (isJson) {
207
+ result.stopReason = 'interrupted';
208
+ emitJsonResult(result, trySessionFile());
209
+ }
116
210
  process.exit(130);
117
211
  }
118
212
  console.error(`\n未捕获异常: ${e.message}`);
119
213
  await saveContext(history);
214
+ if (isJson) {
215
+ // 未分类异常按 error 收尾(stopReason 仍为空 → emitJsonResult 兜底 'error')
216
+ result.error = result.error ?? e.message;
217
+ emitJsonResult(result, trySessionFile());
218
+ }
120
219
  process.exit(1);
121
220
  }
122
221
  await saveContext(history);
222
+ // json 模式:for-await 正常跑完(没有触发任何 exitCode 退出点)也要 emit 一行结局。
223
+ if (isJson)
224
+ emitJsonResult(result, trySessionFile());
123
225
  }
124
226
  /**
125
227
  * 处理 runQuery yield 出来的 LoopEvent。
126
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',行为与改前逐字节一致。
127
236
  */
128
- export function handleEvent(event, output, verbose) {
237
+ export function handleEvent(event, result, verbose, outputFormat = 'text') {
238
+ const isJson = outputFormat === 'json';
129
239
  switch (event.type) {
130
240
  case 'text':
131
- if (verbose)
241
+ // text 模式:verbose 才流式写 stdout(与旧行为逐字节一致)。
242
+ // json 模式:绝不往 stdout 写任何东西(stdout 只允许最后那一行 JSON),仅累积 buffer。
243
+ if (verbose && !isJson)
132
244
  process.stdout.write(event.delta);
133
- output.buffer += event.delta;
245
+ result.buffer += event.delta;
134
246
  return {};
135
247
  case 'assistant_message':
248
+ // 一轮 assistant 消息完整组装 → 回合计数 +1(json 契约的 num_turns)
249
+ result.numTurns++;
136
250
  return {};
137
251
  case 'tool_start':
138
252
  if (verbose) {
@@ -153,19 +267,54 @@ export function handleEvent(event, output, verbose) {
153
267
  process.stderr.write(`📝 压缩完成: ${event.before} → ${event.after} tokens\n`);
154
268
  }
155
269
  return {};
156
- case 'workflow_warning':
157
- // workflow 引擎警告(如多余位置参数、--input 覆盖位置参数等)始终走 stderr,
270
+ case 'warning':
271
+ // 通用非致命警告(如 workflow 多余参数、pause 被自动跳过等)始终走 stderr,
158
272
  // 不受 verbose 控制 —— 这是用户必须看到的信号,避免静默丢弃语义被忽略。
159
273
  process.stderr.write(`${event.message}\n`);
160
274
  return {};
161
275
  case 'turn_done':
162
- if (!verbose)
163
- 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);
164
281
  return {};
165
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)
166
298
  console.error(`\n错误: ${event.error}`);
167
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 {};
306
+ // 已知但 -p 模式无需呈现的框架事件:显式忽略,好让 default 只剩"真正未知"的插件私有事件。
307
+ case 'user_message_committed':
308
+ case 'tool_messages_committed':
309
+ case 'plugin_progress':
310
+ case 'reasoning':
311
+ case 'stage_change':
312
+ return {};
168
313
  default:
314
+ // 落到这里 = 插件私有事件(PluginEvent,type 任意字符串)。正常静默忽略;
315
+ // verbose 下打一行 stderr 帮插件作者发现 yield 了拼错的 type(如 tool_strat)。
316
+ if (verbose)
317
+ warnUnknownEventType(event.type);
169
318
  return {};
170
319
  }
171
320
  }
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
@@ -15,6 +15,10 @@ import { buildFullSystemPrompt } from './prompts/system.js';
15
15
  import { ALL_TOOLS } from './tools/index.js';
16
16
  import { Root } from './ui/Root.js';
17
17
  import { runPrintMode } from './cli/print.js';
18
+ import { extractFlagValue } from './cli/args.js';
19
+ // re-export:对外保持「main.tsx 提供 extractFlagValue」的语义;实现住在 cli/args.ts,
20
+ // 这样单测可零副作用 import(main.tsx 顶层 main() 自调用会启动整个 app,不可被测试 import)。
21
+ export { extractFlagValue };
18
22
  async function main() {
19
23
  // ★ -d/--cwd:在锁定工作目录之前先建目录 + chdir,让后续逻辑全在 -d 下运行
20
24
  // 不存在则自动 mkdir -p;后续 initWorkingDir() 自然拿到新 cwd
@@ -46,10 +50,30 @@ async function main() {
46
50
  }
47
51
  const isPrintMode = args.includes('-p') || args.includes('--print');
48
52
  if (isPrintMode) {
53
+ // 先把输出格式解析出来——provider 缺失分支也要按 json 契约给信号。
54
+ // 只接受 'text' | 'json',其它值(含拼错)一律回落 'text'(向后兼容)。
55
+ const rawFormat = extractFlagValue(args, ['--output-format']);
56
+ const outputFormat = rawFormat === 'json' ? 'json' : 'text';
57
+ // --max-turns:parseInt,非法(NaN)→ undefined(走 loop 默认 50)
58
+ const rawMaxTurns = extractFlagValue(args, ['--max-turns']);
59
+ const parsedMaxTurns = rawMaxTurns !== undefined ? Number.parseInt(rawMaxTurns, 10) : Number.NaN;
60
+ const maxTurns = Number.isNaN(parsedMaxTurns) ? undefined : parsedMaxTurns;
49
61
  // CLI 非交互模式:先 env,再 ~/.minimal-agent/config.json fallback;都没就退出
50
62
  // (宿主 spawn 子进程时通常没 export env,靠 TUI 向导写出的 config.json 持久化)
51
63
  const provider = await loadProviderLayered();
52
64
  if (!provider) {
65
+ // json 模式:先给宿主一行结构化信号(stop_reason='config_error'),再退出。
66
+ // 人类可读的中文提示仍走 stderr(json 契约只占 stdout 那一行)。
67
+ if (outputFormat === 'json') {
68
+ console.log(JSON.stringify({
69
+ ok: false,
70
+ result: '',
71
+ is_error: true,
72
+ stop_reason: 'config_error',
73
+ num_turns: 0,
74
+ error: 'provider config not found',
75
+ }));
76
+ }
53
77
  process.stderr.write(`\n未找到 provider 配置。\n\n` +
54
78
  `请二选一:\n` +
55
79
  ` 1. 设置环境变量 MINIMAL_AGENT_BASE_URL / MINIMAL_AGENT_API_KEY / MINIMAL_AGENT_MODEL\n` +
@@ -58,7 +82,7 @@ async function main() {
58
82
  }
59
83
  const initialHistory = await buildInitialHistory();
60
84
  const verbose = args.includes('--verbose') || args.includes('-v');
61
- await runPrintMode(provider, args, initialHistory, { verbose });
85
+ await runPrintMode(provider, args, initialHistory, { verbose, outputFormat, maxTurns });
62
86
  return;
63
87
  }
64
88
  // TUI 交互模式:env 不全则 fallback 到 ~/.minimal-agent/config.json;
@@ -96,6 +120,9 @@ minimal-agent - 轻量级 AI 编程助手
96
120
  -v, --verbose 显示详细输出(工具调用、压缩信息)
97
121
  -d, --cwd <dir> 指定工作目录(不存在自动创建);启动时 chdir 到这里,
98
122
  上下文文件、工具相对路径、.env 加载都以此为基准
123
+ --output-format <fmt> text(默认)= 纯文本答案;json = stdout 输出一行结构化
124
+ 结局契约(ok / result / stop_reason / num_turns / error)
125
+ --max-turns <n> 最大工具循环轮数(防失控;缺省 50)
99
126
  -h, --help 显示帮助信息
100
127
 
101
128
  会话记忆:
@@ -108,6 +135,7 @@ minimal-agent - 轻量级 AI 编程助手
108
135
  echo "解释代码" | minimal-agent -p
109
136
  minimal-agent -p --verbose "运行测试并报告结果"
110
137
  minimal-agent -p "处理资料" -d /tmp/job-123 # 工作目录隔离
138
+ minimal-agent -p --output-format json "做点事" # 宿主程序 spawn:一行 JSON 结局
111
139
  `);
112
140
  }
113
141
  main().catch((e) => {
@@ -11,6 +11,15 @@ function stripQuotes(s) {
11
11
  }
12
12
  return trimmed;
13
13
  }
14
+ /**
15
+ * 跨平台取路径最后一段。Windows 上 join() 产出反斜杠路径,单纯 split('/')
16
+ * 不切分会让整条绝对路径变成"目录名"。同时吃 \\ 与 /,与
17
+ * hookEngine.pluginNameFromRoot 的归一逻辑保持一致。
18
+ */
19
+ export function baseName(p) {
20
+ const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
21
+ return parts[parts.length - 1] || p;
22
+ }
14
23
  function parseMarkdownFrontmatter(content) {
15
24
  const match = content.match(/^---\n([\s\S]*?)\n---/);
16
25
  if (!match)
@@ -28,7 +37,7 @@ function parseMarkdownFrontmatter(content) {
28
37
  return frontmatter;
29
38
  }
30
39
  async function loadPlugin(pluginDirPath) {
31
- const dirName = pluginDirPath.split('/').pop() ?? pluginDirPath;
40
+ const dirName = baseName(pluginDirPath);
32
41
  const manifestPath = join(pluginDirPath, '.claude-plugin', 'plugin.json');
33
42
  let manifestName = dirName;
34
43
  let manifestVersion;
@@ -101,6 +110,9 @@ export async function discoverPlugins() {
101
110
  discoveryDone = true;
102
111
  // 双源扫描:cwd 优先 + packageRoot fallback;按数组顺序处理,已有 manifestName 跳过
103
112
  const searchPaths = getResourceSearchPaths('plugins', import.meta.url);
113
+ // 命令名 → 首个声明它的插件名。resolveCommand 按插入顺序返回第一个命中,
114
+ // 所以两个不同插件声明同名命令时,先注册者生效、后者被影 —— 不再静默,warn 一次。
115
+ const commandOwners = new Map();
104
116
  for (const root of searchPaths) {
105
117
  try {
106
118
  const entries = await readdir(root, { withFileTypes: true });
@@ -115,6 +127,16 @@ export async function discoverPlugins() {
115
127
  if (pluginCache.has(plugin.name))
116
128
  continue; // cwd 已注册的 manifestName,packageRoot 同名跳过
117
129
  pluginCache.set(plugin.name, plugin);
130
+ for (const cmd of plugin.commands) {
131
+ const owner = commandOwners.get(cmd.name);
132
+ if (owner && owner !== plugin.name) {
133
+ console.warn(`[minimal-agent] 命令名冲突:/${cmd.name} 同时由插件 "${owner}" 与 "${plugin.name}" 声明;` +
134
+ `先注册的 "${owner}" 生效,"${plugin.name}" 的同名命令被忽略。`);
135
+ }
136
+ else if (!owner) {
137
+ commandOwners.set(cmd.name, plugin.name);
138
+ }
139
+ }
118
140
  }
119
141
  }
120
142
  catch {
@@ -147,9 +169,11 @@ export function buildCommandInput(resolved) {
147
169
  const { cmd, arguments: args } = resolved;
148
170
  let input = cmd.promptBody;
149
171
  input = input.replaceAll('${CLAUDE_PLUGIN_ROOT}', cmd.pluginRoot);
172
+ // 模板是否自己引用了参数占位符。引用了就别再补尾注,否则参数出现两遍。
173
+ const hadArgsPlaceholder = input.includes('$ARGUMENTS') || input.includes('${ARGUMENTS}');
150
174
  input = input.replaceAll('$ARGUMENTS', args);
151
175
  input = input.replaceAll('${ARGUMENTS}', args);
152
- if (args.trim()) {
176
+ if (!hadArgsPlaceholder && args.trim()) {
153
177
  input += `\n\n用户参数: ${args.trim()}`;
154
178
  }
155
179
  return input;
@@ -567,6 +567,11 @@ export function handleEvent(ev, setters) {
567
567
  setters.setError(e.error);
568
568
  setters.bump();
569
569
  break;
570
+ case 'warning':
571
+ // 通用非致命警告。TUI 不把它当红色 error 渲染;workflow 这类插件已另发
572
+ // plugin_progress 把同一文案镜像到状态行(LiveArea)。这里显式 no-op 让它
573
+ // 成为"已识别事件",不触发 default 分支对未知事件的 dev 告警(见 default)。
574
+ break;
570
575
  case 'plugin_progress':
571
576
  setters.setPluginProgress({
572
577
  pluginId: e.pluginId,
@@ -588,5 +593,10 @@ export function handleEvent(ev, setters) {
588
593
  // 工具集合的清空时机是各自 tool_end
589
594
  setters.setProgressStage(e.stage);
590
595
  break;
596
+ default:
597
+ // 落到这里 = 插件私有事件(PluginEvent,type 任意字符串),TUI 不识别即忽略。
598
+ // 这里**故意不**写 console.warn —— Ink 渲染期间写 stdout/stderr 会撕裂终端画面。
599
+ // 要排查插件 yield 了拼错的事件 type,请用 `minimal-agent -p --verbose`(print.ts 落 stderr)。
600
+ break;
591
601
  }
592
602
  }
@@ -73,7 +73,7 @@
73
73
  "prompt": { "type": "string" },
74
74
  "output_schema": {
75
75
  "type": "object",
76
- "description": "⚠ ADR-05 新增:LLM 输出 JSON Schema 强约束,走 provider-native structured output。仅 llm/skill 节点生效;详见 docs/05-advanced-workflow-integration.md。"
76
+ "description": "⚠ ADR-05 新增:LLM 输出 JSON Schema 强约束,走 provider-native structured output。仅 llm 节点生效(skill 节点 loader 拒绝);详见 docs/workflow-design.md。"
77
77
  },
78
78
  "fork": {
79
79
  "type": "array",
@@ -105,7 +105,7 @@
105
105
  "hint": { "type": "string" }
106
106
  }
107
107
  },
108
- "description": "⚠ ADR-05 新增:llm/skill 节点声明可用的上下文文件清单(path + hint),runner 拼进 prompt 并开放 Read 子循环,不 inline 内容。详见 docs/05-advanced-workflow-integration.md。"
108
+ "description": "⚠ ADR-05 新增:仅 llm 节点声明可用的上下文文件清单(path + hint),runner 拼进 prompt 并开放 Read 子循环,不 inline 内容(skill 节点 loader 拒绝)。详见 docs/workflow-design.md。"
109
109
  },
110
110
  "allowed_tools": {
111
111
  "type": "array",