@migor/cli 0.10.0 → 0.10.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/dist/AGENTS.md CHANGED
@@ -101,7 +101,8 @@ npx agentia --version # CLI 版本(= -v)
101
101
  | 文件 | 作用 |
102
102
  |---|---|
103
103
  | `src/app.ts` | **装配**:导出 `createAgentApp({ toolSources?, workdir? })` 工厂 + `CAPABILITY_DIRS` + `createSessionStore()`,并在模块顶部 `loadEnvFile()` 读 `.env` |
104
- | `src/main.ts` | **启动**:薄入口 —— `createAgentApp()` → `app.run(...)`,再处理 `result.error` |
104
+ | `src/main.ts` | **默认入口 = HTTP 服务**:`createAgentApp()` → `createHttpHandler` + `AsyncRunner` + `SqliteTaskStore`(崩溃续跑)→ SIGTERM/SIGINT 触发 `drain` 优雅停机。`npm run build && npm start` 即是带 `/healthz`、异步任务、可选 `AGENTIA_TOKEN` Bearer 鉴权的服务 |
105
+ | `src/batch.ts` | **一次性入口(Job 形态)**:薄入口 —— `createAgentApp()` → `app.run(...)`,再处理 `result.error`;`npm run start:batch -- "你的问题"` 跑一次就退出(cron / CI / 容器) |
105
106
  | `src/dev.config.ts` | **数据**(不是逻辑):开发期的声明,如 `multiTurn: ['trip-planner']`;见 §2.2 |
106
107
  | `src/session-store.ts` | `FileSessionStore`:把多轮对话落成 `.agentia/session.json`(原子写);见 §6.4「对话历史」 |
107
108
  | `src/registry.ts` | 显式注册表(`agentia g` 自动维护) |
@@ -109,7 +110,7 @@ npx agentia --version # CLI 版本(= -v)
109
110
 
110
111
  **为什么拆**:`agentia dev` 的调试环要**复用同一个工厂**,才能把「这次调哪个能力 / 工作目录是哪个」
111
112
  喂进 `createApp`。所以装配必须在 `app.ts` 里、以**函数**形态存在 —— 别把 `createApp(...)` 搬回
112
- `main.ts`(搬回去 dev 环就起不来了)。
113
+ 任一个入口(`main.ts` / `batch.ts`;搬回去 dev 环就起不来了)。
113
114
 
114
115
  > **脚手架 `src/app.ts` 怎么找这些目录**:按**本文件位置**解析(`fileURLToPath(new URL('tools/', import.meta.url))`),
115
116
  > 所以 dev 解析到 `src/`、`npm run build` 之后解析到 `dist/` —— 从任何目录启动都成立,也不受 cwd 影响。
@@ -181,8 +182,9 @@ npx agentia --version # CLI 版本(= -v)
181
182
  事件循环排空,进程还会以 `code=0` 干净退出(看起来像「用户代码自己跑完了」)。这条有 e2e 钉着
182
183
  (`scripts/e2e-dev.ts`)。
183
184
 
184
- **`.env` 由 `src/app.ts` 读**:runner 只 import `app.ts`、**从不执行 `main.ts`**,所以读 `.env`
185
- 的那一行必须在装配模块里 —— 放 `main.ts` 会让 `npm run dev` 静默读不到、而 `npm start` 读得到。
185
+ **`.env` 由 `src/app.ts` 读**:runner 只 import `app.ts`、**从不执行入口**(`main.ts` /
186
+ `batch.ts`),所以读 `.env` 的那一行必须在装配模块里 —— 放进入口会让 `npm run dev` 静默读不到、
187
+ 而 `npm start` 读得到。
186
188
 
187
189
  **安全**:面板只绑 `127.0.0.1`,并做两层校验 —— `Origin` 校验(缺失放行,`Origin: null` 拒绝)
188
190
  + **每次启动生成的一次性 token**(首帧 `?t=` 换成 `HttpOnly` cookie,后续请求带着走)。
@@ -198,6 +200,40 @@ token 的作用是挡住**本机其它进程**,别把它当网络边界:这
198
200
  当前用哪个 id 记在 `.agentia/dev-session-id`(**不是** `session.json` 的一部分):只放内存的话,
199
201
  重启 `npm run dev` 之后刚清空的对话会**自己回来**。有 run 在飞时不能清空(会 409)。
200
202
 
203
+ **每次 run 的 trace 都落盘**:收尾时追加一行到 `.agentia/traces.jsonl`(已被 `.gitignore` 挡住)。
204
+ 那个格式**恰好就是** `agentia report` / `diff` / `harvest` / `export` 四个命令的输入 ——
205
+ 所以面板里跑过的每一次 run,都可以直接:
206
+
207
+ ```bash
208
+ agentia report .agentia/traces.jsonl # 哪个能力慢 / 贵 / 爱失败
209
+ agentia diff .agentia/traces.jsonl ... # 改 prompt 前后逐 span 比对(有差异时退出码 1)
210
+ agentia harvest .agentia/traces.jsonl --failed # 失败的 run → eval 用例骨架
211
+ agentia export .agentia/traces.jsonl # → 训练数据集 JSONL
212
+ ```
213
+
214
+ 落盘**不替代**右栏的实时树:那是两条缝(落盘管「事后能查」,`onTraceEvent` 管「此刻看到」)。
215
+ 三条如实标注:① 文件**只增不减**(要清是你自己 `rm`);② 落盘是**同步写**,单条 trace 以百 KB
216
+ 计、且 `traceLimits.maxEvents` 本来兜上限;③ 面板的 run 列表是**内存** ring(50 条),
217
+ 重启就空了 —— 落盘让数据**没丢**,但面板不会替你回看历史。
218
+
219
+ **并排 A/B(比两条 run差在哪)**:run 列表里 **⌘/Ctrl + 单击**选两条(再点一次取消、
220
+ 选第三条挤掉最早那条),右栏切**双栏**并排两棵调用树,顶部一条差异摘要
221
+ (run 级 summary + 逐 span 字段级差异,与 `agentia diff` 逐字同源)。
222
+
223
+ ⚠️ **可比性分三档,显式不静默**:能力选择与工作目录是 **app 级**的(换它们要重启进程)⇒
224
+ 两条跨了能力选择的 run,配置整个变了,diff 出来的差异**多半与你改的那句话无关**。
225
+ 所以跨能力 / 跨目录 / 元信息缺失都给出**显式降级提示**(含可执行的出路:建议只改 prompt 后重跑),
226
+ 而不是静默给一份没意义的 diff。可比时**不显示**提示条(每次都飘噪声等于没有提示)。
227
+
228
+ ⚠️ **并排是「事后回看」,不是「同时跑」**:单飞闸没解除 —— 解除会连带改变「文件变更延后重启」
229
+ 的语义(两条判据今天刻意共用同一条忙闲判据),且 A/B 的收益全在「事后并排看差在哪」,
230
+ 同时跑还多烧一倍 token。普通单击任意一条即退出并排。
231
+
232
+ **重跑后自动补对**:已经选了一条(还没选第二条)时,新 run 一收尾就**自动**并排 ——
233
+ 面板会播一条 notice 说清「为什么屏幕变双栏了」。三种情况**不**自动:空选择(没表达
234
+ 对比意图,自动切双栏是替用户决定)、已选两条(再塞一条会挤掉你刚选的)、同一条。
235
+ 所以「改一句 prompt → 重跑 → 看差在哪」是**两步**,不是三步。
236
+
201
237
  **中止在飞的 run**:run 跑着的时候输入条上会多一个**「■ 中止」**按钮(没在飞时不显示)。
202
238
  点了之后:
203
239
  - run 在**回合边界**以 `stopReason='aborted'` **正常收尾**,**trace 照常落盘**(面板也会说「已中止」,
@@ -489,9 +525,10 @@ const app = await createApp({ ... });
489
525
 
490
526
  `agentia create` 生成的脚手架把 `.env`、`.env.example` 与 `app.ts` 里的 `loadEnvFile();` 都备好了,并在 `.gitignore` 里挡住 `.env` —— 生成 `.env` 却不 ignore,等于把 key 送进用户的第一个 commit。
491
527
 
492
- > ⚠️ **`loadEnvFile()` 必须在 `app.ts`,不能在 `main.ts`**:`agentia dev` 的 runner 只 import
493
- > `app.ts`、**从不执行 `main.ts`**。放错一侧的失败形状是静默的 —— `npm run dev` 读不到 `.env`
494
- > 而 `npm start` 读得到,用户只会看到「没配 key」然后去怀疑框架。这条有 e2e 钉着(`scripts/e2e-dev.ts`)。
528
+ > ⚠️ **`loadEnvFile()` 必须在 `app.ts`,不能在入口(`main.ts` / `batch.ts`)**:`agentia dev` 的
529
+ > runner 只 import `app.ts`、**从不执行入口**。放错一侧的失败形状是静默的 —— `npm run dev` 读不到
530
+ > `.env` 而 `npm start` 读得到,用户只会看到「没配 key」然后去怀疑框架。这条有 e2e 钉着
531
+ > (`scripts/e2e-dev.ts`)。
495
532
 
496
533
  > ⚠️ **本机 export 过 `ANTHROPIC_API_KEY` 的人**(比如同时用 Claude Code):按上面的优先级,脚手架 `.env` 里的 key 会被**静默压住**。改了 `.env` 却「没生效」时,先 `echo $ANTHROPIC_API_KEY` 看看环境里是不是已经有一份。
497
534
 
@@ -1083,7 +1120,7 @@ const app = await createApp({ /* … */ sinks: [jsonlTraceSink({ path: 'trace.js
1083
1120
  | API | 说明 |
1084
1121
  |---|---|
1085
1122
  | `createAnthropicClient` | 默认 ModelClient(Anthropic):自定义只传 `apiKey` / `baseURL`;框架**零运行时依赖**,不装厂商 SDK |
1086
- | `createOpenAIClient` | OpenAI 兼容端点适配(DeepSeek 等;**真流式**、图片块转 `image_url`、cache token 恒 0) |
1123
+ | `createOpenAIClient` | OpenAI 兼容端点适配(DeepSeek 等;**真流式**、图片块转 `image_url`、DeepSeek 的 `prompt_cache_hit_tokens` 映射进 `cache_read_input_tokens` 并从 input_tokens 拆出不双计,其余端点 cache token 恒 0) |
1087
1124
  | `InMemoryMemoryStore` | 跨 run 的**键值黑板**记忆(`{ store, keys }` 配 `executeRun`) |
1088
1125
  | `InMemorySessionStore` | 跨 run 的**对话历史**(`{ store, id }` 配 `executeRun` / `app.run`);与前者正交,可同时用 |
1089
1126
  | `traceToMessages` | 把 trace 还原成 messages(重放基底) |
@@ -1672,6 +1709,7 @@ const callable = {
1672
1709
  | 任务进度流的边界(内存 / 跨进程) | `GET /tasks/:id/stream` 的事件缓冲在**跑任务的进程内存**里:每任务最近 500 条(可用 `AsyncRunner` 的 `streamBufferEvents` 调,须为正整数,坏值构造期抛错),超限丢**最旧**并先发一帧 `stream.truncated`;终态流只留最近 16 条。**没跑完**的流另有一道**缓冲配额**(`streamNonTerminalBuffers`,缺省 32 条流;`0` = 不留):超出即从最旧、**没有订阅者**的流开始回收**回放历史** —— 表项与序号都留着,连上来的读者先收一帧 `stream.truncated` 再转实时(**不会**变成 `stream.unavailable`,那是对别的进程说的话);正在被订阅的流不受配额约束(不抽走正在读的数据)。**跨进程**(队列消费者在别的进程)时没有实时流:发一帧 `stream.unavailable` 后**立即收口** —— 任务已终态则补 `task.end`(随后关连接);**非终态**则补一帧 `stream.closed`(流级收尾,不是伪造终态)并关连接,客户端此后应转去轮询 `GET /tasks/:id`。要跨进程实时请用 `onTraceEvent` 把事件转发到宿主自己的总线(Redis Streams / Kafka) |
1673
1710
  | 异步任务的**排队段**边界(内存) | `concurrency` 管「同时在跑几个」,**不管**「跑不上、排在后面」那一段 —— 缺省**排队段无上限**:`submit` 总是收单、`POST /tasks` 总是 202,灌多少就在内存里排多少(每条 = 一条记录 + 一棵悬挂的 `#execute` promise + 一个槽位等待者)。要背压就配 `AsyncRunner({ maxQueued })`:超限 `submit` 抛 `TaskQueueFullError`、HTTP 回 **503 + `Retry-After`**(**只在 `submit` 判** —— 恢复路径推进的是已受理任务,拦下来等于把它搁死在 store 里;幂等键命中的重复提交也不吃 503)。`runner.queued` 是实时读数(与闸同一判据)。⚠️ 进程内拒单**不等于**集群级背压:多副本各算各的,要全局就得上队列 |
1674
1711
  | `traceLimits` 与 `maxEventChars` 各管一头 | `maxEventChars` 管**单个事件正文多长**(既有),`traceLimits.maxEvents` 管**整条 trace 记多少条记录事件**(本版)。两者正交、都「不设 = 不限」;超限即**停止记账**,并在 run 根留**两笔** `trace.truncated`:**起点标记** `{limit}`(断点在这里)+ **收尾摘要** `{droppedEvents, limit}`(丢了多少)—— 两笔**同时**进交付的 trace 与 `onTraceEvent` 增量流(⇒ 按 `seq` 折回仍逐字等于收尾那份;两笔簿记**不占** `maxEvents` 配额)。⚠️ 实测 `maxEventChars: false` + 大出参会让 trace 放大 **13.7×**(`npm run bench:trace` 可复现)—— 先收长度再谈采样,收益顺序比反过来大 |
1712
+ | 回给模型的 `tool_result` **不截断、也没有大小闸** | `maxEventChars` / `traceContent` 管的是**记账侧**(trace 记多长、记不记正文)—— 回给模型的工具结果**永远完整**,框架里没有「单条工具结果超过多大就裁」的那条缝。一次 `cat` 大文件、一次刷屏回显就能让**下一回合**的输入爆掉上下文窗口,以 api 错误收尾(长跑里尤其容易撞)。要设输出上限请在**工具实现内部**做(按行数 / 字节截断并附「已截断」标记)—— 框架不替你兜 |
1675
1713
  | **采样是导出决策,不是记账决策** | 采样在 `sink` 外做(配方见 `docs/observability.md` 2.3):被采样掉的 trace 在框架内**仍然完整记账**,只是没发给下游。所以别拿「有采样」当「可以少记账」;也正因如此,出站 `traceparent` 的 flags 恒 `00`(记录/导出决策发生在收尾之后,运行期不可知——不替下游声明) |
1676
1714
  | 菜单漂移只**标记**、不判失败 | 续跑时未决 tool_use 的工具不在当前菜单 ⇒ 三处信号(`menu.drift` 事件 / 父 span attribute / `console.warn`),run **照常收尾**(那些 tool_use 以 `unknown tool` 回给模型)。**射程如实标注**:判据只看**名字** —— 「工具还在、schema 变了」能被 `tools.menuHash` 比出来,但**不会**在续跑时报 `menu.drift`;**只改 `description`** 连签名都不变(有意:润色文案不算漂移)。严格模式(带具名 error 的收尾)与「结果级字段」记在 spec §11 开放项 |
1677
1715
  | `approve` 只收**待决的** id(多一个 → 400 整批拒) | `decisions` 的键必须都在该任务的 `pendingApprovals` 里(调用方从记录的 `pendingApprovals` 读)。多出任何一个 ⇒ **400 + 整批拒 + 记录一字不动**。不校验的代价(已修,2026-09-28 外部深评):多出来的键会写进 `TaskRecord.approvals`,随每次 `save` 全文重写、且终态也不清 ⇒ 一个**认证调用方**单次请求就能把记录撑大(实证:一次 5000 个无关键 = +419 KB),反复调用可无限叠加 |
@@ -1745,6 +1783,7 @@ const callable = {
1745
1783
  | Scheduler 调度表不落库 | `every` / `at` 的调度本身只在内存:已 submit 的任务记录能经 `resumePending` 续跑,但「未来某刻再触发」的调度在重启后不存在(远期单发由宿主自己的 cron 驱动)。另:`drain()` 不停 Scheduler —— 停机窗口内到点的 tick 会打一条触发失败日志(无害但吵),介意就 `scheduler.stop()` 先行 |
1746
1784
  | file store 的撕裂写只在启动时自愈 | 写入中途失败(磁盘满等)留下的残行由 `healTail` 在**构造期**修复;同进程内继续 append 会把新记录粘在残行尾部、下次启动时一起丢弃 —— 磁盘满告警后先恢复写入能力再继续依赖它 |
1747
1785
  | 终态落库失败 ⇒ 重启会重跑 | AsyncRunner 终态 `save` 失败**不遮罩主流程**(「不击穿业务」的代价):store 抖动时任务可能永远停在 `running`,重启后 `resumePending` 会重跑一个**实际已成功**(副作用已发生)的任务 —— 所以副作用工具必须自身幂等。**但失败本身不再静默**:`new AsyncRunner(app, { onPersistError })` 会收到 `{ record, error, phase }`(`phase: 'initial' \| 'outcome'`,后者就是这条)。⚠️ 框架**修不了**它(写不进去就是写不进去)—— 出口的职责是让你能对账、让「记录无声丢失」不再是默认行为(与 `createOtlpExporter({ onExportError })` 同因同形) |
1786
+ | 缺省**不裁剪**上下文(长跑每回合重发全量历史 ⇒ 成本随回合数平方增长) | `contextPolicy` 不注入就一个回合都不裁:每回合把**整段历史**重发给模型,于是回合数翻倍、总输入约翻两番。框架给了缝与一份参考实现(`createBudgetPolicy`),但**不替你决定** —— 裁剪有损(截在哪、留什么会影响答案),是宿主的策略。长任务(几十回合以上)请在 `createApp` 里显式注入 `contextPolicy` |
1748
1787
  | `contextPolicy` 不进子循环 | 应用级 `contextPolicy` / `onText` 只对主循环生效:@SubAgent / @Skill 的子运行不做上下文裁剪(长跑子 agent 撞上下文上限会以 api 错误收尾)。预算护栏(`maxTotalTokens` / `maxCostUsd`)正常透传 |
1749
1788
  | 记忆没有删除语义 | `MemoryStore` 只有 load/save:run 内 `ctx.delete` 掉的键回写时不会从 store 移除(下一轮水合会复活)。要真删请直接操作 store 实现 |
1750
1789
  | 同 keys 的并发回写不合并 | CAS(`loadWithRev` + `saveIfRev`)只做到「冲突时**不写 + 出声**」:框架**不合并、不重试**(合并语义是 store 与宿主的决定,配方见 §6.5)。只有 `load` / `save` 的 store 连冲突都**看不见** —— 版本号是唯一依据,没有它就检测不了覆盖;`InMemoryMemoryStore` 的版本粒度是**整店一个计数**(宁可多报冲突,不漏报真实覆盖) |
package/dist/create.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { appTs, CAPABILITY_DIR_LIST, cleanMjs, copyAssetsMjs, devConfigTs, emptyRegistryTemplate, mainTs, projectDotEnv, projectDotEnvExample, projectGitignore, projectPackageJson, projectReadme, projectTsconfig, readFileToolIndexTs, REGISTRY_PATH, sessionStoreTs, toolIndexTs, } from './templates.js';
5
+ import { appTs, batchTs, CAPABILITY_DIR_LIST, cleanMjs, copyAssetsMjs, devConfigTs, emptyRegistryTemplate, mainTs, projectDotEnv, projectDotEnvExample, projectGitignore, projectPackageJson, projectReadme, projectTsconfig, readFileToolIndexTs, REGISTRY_PATH, sessionStoreTs, toolIndexTs, } from './templates.js';
6
6
  import { registerCapability } from './registry.js';
7
7
  function write(dir, rel, content) {
8
8
  const file = join(dir, rel);
@@ -54,10 +54,14 @@ export function createProject(name, parent) {
54
54
  mkdirSync(dir, { recursive: true });
55
55
  write(dir, 'package.json', projectPackageJson(name));
56
56
  write(dir, 'tsconfig.json', projectTsconfig());
57
- // 装配(app.ts)与启动(main.ts)分离:dev 环要复用 app.ts 的工厂,
57
+ // 装配(app.ts)与启动分离:dev 环要复用 app.ts 的工厂,
58
58
  // 才能把「能力选择 / 工作目录」喂进 createApp(见 templates.ts 的 appTs 注释)。
59
59
  write(dir, 'src/app.ts', appTs(name));
60
+ // 默认入口 = **服务**(与 app.ts 的工厂同一个):build 之后 `npm start` 即得可上线的
61
+ // HTTP 服务(/healthz、异步任务、崩溃续跑、优雅停机、可选鉴权)
60
62
  write(dir, 'src/main.ts', mainTs(name));
63
+ // 一次性入口(cron / CI / 容器里的 Job 形态):`npm run start:batch -- "问题"`
64
+ write(dir, 'src/batch.ts', batchTs(name));
61
65
  // dev 环的**数据**声明(只有数据,没有逻辑;生产路径不读它)
62
66
  write(dir, 'src/dev.config.ts', devConfigTs());
63
67
  // 对话型能力用的文件后端会话存储(可选件,但生成出来省得用户自己写)
@@ -101,6 +105,9 @@ export function createProject(name, parent) {
101
105
 
102
106
  生产构建:npm run build && npm start(先清 dist/,再 tsc → dist/,.md 资产由 scripts/copy-assets.mjs 跟随拷贝)
103
107
  dev 跑 src/、start 跑 dist/ —— 能力目录按文件位置解析,两边都成立。
108
+ npm start 起的是**服务**(node dist/main.js:/healthz · POST /run · POST /tasks ·
109
+ 崩溃续跑 · 优雅停机 · AGENTIA_TOKEN 可选鉴权;需 Node ≥ 22.5)。
110
+ 跑一次就退出的批处理:npm run start:batch -- "你的问题"(node dist/batch.js)
104
111
 
105
112
  目录约定:src/tools/ · src/skills/ · src/prompts/ · src/subagents/(一能力一文件夹)
106
113
 
@@ -29,5 +29,19 @@ export const SESSION_REL = '.agentia/session.json';
29
29
  * 用户刚「清空」掉的对话会**自己回来**(那是最典型的静默不一致)。
30
30
  */
31
31
  export const SESSION_ID_REL = '.agentia/dev-session-id';
32
+ /**
33
+ * dev 环**落盘 trace** 的位置(相对项目根)。
34
+ *
35
+ * 为什么与 `session.json` 同目录:`.agentia/` 已被脚手架 gitignore(`templates/gitignore`),
36
+ * 它就是 dev 环的本机状态目录 —— 落在这里的东西不进版本库、不用再问一次「要不要 ignore」。
37
+ *
38
+ * 文件格式 = `jsonlTraceSink` 的格式(一行一条**裸** `Trace`),而那正是 CLI 四个命令的
39
+ * **输入**:`agentia report` / `diff` / `harvest` / `export`(`src/integrations/file-sink.ts`
40
+ * 的头注逐字点名了前三个,`export` 是R8-P3b 加的同族)。⇒ dev 环跑过的 run 直接可被它们读。
41
+ *
42
+ * ⚠️ **只增不减**:刻意不轮转、不自动清(dev 单条 trace 以百 KB 计;真要清是用户自己
43
+ * `rm`)。要长期开着一个 dev 环的得知道这件事 —— 写在 `usage-guide.md` §2.2。
44
+ */
45
+ export const TRACE_LOG_REL = '.agentia/traces.jsonl';
32
46
  /** dev 环**初始** sessionId(面板「清空」后依次变成 `dev-2` / `dev-3` …) */
33
47
  export const DEV_SESSION_ID = 'dev';
@@ -20,7 +20,7 @@ import { createRequire } from 'node:module';
20
20
  import { join } from 'node:path';
21
21
  import { pathToFileURL } from 'node:url';
22
22
  import { createInspectEventSink, createInspectSink, } from './inspector-sink.js';
23
- import { APP_ENTRY_REL, DEFAULT_BUDGET, DEV_CONFIG_REL, } from './dev-protocol.js';
23
+ import { APP_ENTRY_REL, DEFAULT_BUDGET, DEV_CONFIG_REL, TRACE_LOG_REL, } from './dev-protocol.js';
24
24
  const root = process.env.AGENTIA_DEV_ROOT || process.cwd();
25
25
  function send(msg) {
26
26
  process.send?.(msg);
@@ -82,6 +82,52 @@ function registerEventSink() {
82
82
  const token = process.env.AGENTIA_INSPECT_TOKEN || undefined;
83
83
  eventSink = createInspectEventSink({ port, ...(token ? { token } : {}) });
84
84
  }
85
+ /**
86
+ * ③ **落盘** trace(档A / `docs/plans/2026-10-05-dev-workbench.md` §2):每次 run 收尾往
87
+ * `.agentia/traces.jsonl` 追加一行裸Trace。
88
+ *
89
+ * 为什么值得挂:那个文件的格式**恰好就是** CLI 三个命令的输入(`jsonlTraceSink` 的头注
90
+ * 逐字点名了 `agentia report` / `diff` / `harvest`,加上 `export` 是四个)⇒ 挂上之后
91
+ * 面板里跑过的每一次 run,立刻变成「`agentia report` 看哪个能力慢/贵/爱失败」、
92
+ * 「`agentia diff` 改 prompt 后轨迹漂没漂」这两件事的**现成输入**。开发期最贵的
93
+ * 一件事是「拿不到刚才那次 run 的数据」,这一行就把它解决了。
94
+ *
95
+ * 三条纪律(每条都有代价,如实标注):
96
+ * - **写失败不阻断 run**(`catch` + `console.warn`):与 `registerTraceSink` 同款 ——
97
+ * 观测是增强项,磁盘满 / 目录只读不该让dev 起不来。
98
+ * - **不轮转、不清空**:dev 单条 trace 以百 KB 计,而 `.agentia/` 已被脚手架 gitignore。
99
+ * 代价是**长跑 dev 环会攒一个只增不减的文件**;要清就是用户自己 `rm`(文档里写一句)。
100
+ * - **单进程写者前提天然满足**:该 sink 的契约是「多进程写同一文件会交错」,而 dev环
101
+ * 只有 runner 这一个写者(父进程走HTTP 收,不碰这个文件)。
102
+ *
103
+ * ⚠️ **两条缝,不是替代关系**:落盘管「事后能查」,`onTraceEvent` 管「此刻看到」。
104
+ * 挂落盘**不碰** `opts.onTraceEvent`(那行仍在 `runOnce` 里),反之亦然。
105
+ *
106
+ * ⚠️ **顺序**:`createApp` 构造期对 `defaultSinks` 取快照 ⇒ 任何 sink 都必须早于
107
+ * 用户 app 的 import(`main()` 里 `registerTraceSink` 那条注释已为这件事把关)。
108
+ */
109
+ async function registerJsonlSink() {
110
+ try {
111
+ const req = createRequire(join(root, 'package.json'));
112
+ const entry = req.resolve('@migor/agentia');
113
+ const mod = (await import(pathToFileURL(entry).href));
114
+ if (typeof mod.jsonlTraceSink !== 'function') {
115
+ throw new Error('@migor/agentia 未导出 jsonlTraceSink(版本过旧?)');
116
+ }
117
+ // ⚠️ 两个导出都要,缺一不可:`jsonlTraceSink` 只是**工厂**(返回 `TraceSink`),
118
+ // 真正让框架每run 收尾调它的是 `registerDefaultTraceSink`(与上面 inspector 那条同款)。
119
+ // 只写前者 = 建了个 sink 变量没人用 ⇒ 静默不落盘(本仓最忌讳的那类假绿)。
120
+ if (typeof mod.registerDefaultTraceSink !== 'function') {
121
+ throw new Error('@migor/agentia 未导出 registerDefaultTraceSink(版本过旧?)');
122
+ }
123
+ mod.registerDefaultTraceSink(mod.jsonlTraceSink({ path: join(root, TRACE_LOG_REL) }));
124
+ }
125
+ catch (e) {
126
+ // 同 registerTraceSink:挂不上是增强项缺失,不阻断 dev。**不静默** —— 打在终端上,
127
+ // 否则「跑了一堆 run 却发现 report 没数据」会查不到原因。
128
+ console.warn(`[agentia] trace 未落盘(${TRACE_LOG_REL}):${e.message}`);
129
+ }
130
+ }
85
131
  // ---------- dev.config.ts(**数据**,不是逻辑) ----------
86
132
  /**
87
133
  * 读 `src/dev.config.ts`。
@@ -429,6 +475,8 @@ async function runOnce(msg) {
429
475
  async function main() {
430
476
  // ⚠️ 顺序不能换:sink 必须早于用户 app 的 import(createApp 构造期对 defaultSinks 快照)
431
477
  await registerTraceSink();
478
+ // 落盘 sink(档 A):同样必须早于 import —— 它也是 defaultSinks 的一员。
479
+ await registerJsonlSink();
432
480
  // 增量出口只是个 HTTP 出口(不碰框架注册表),但同样在这里建:一个进程一次。
433
481
  registerEventSink();
434
482
  let built;
@@ -72,6 +72,28 @@
72
72
  #ttl { flex: none; }
73
73
  #trace { flex: 1 1 auto; min-height: 140px; overflow: auto; }
74
74
  #summary, #reply, .usage { flex: none; max-height: 38vh; overflow: auto; }
75
+ /* 并排 A/B:两棵树各占一半、**各自独立滚**(一棵树很长时不该把另一棵推出屏幕 ——
76
+ 与「树是唯一无界部分」那条纪律同款,只是现在无界的部分有两块)。
77
+ `#trace2` 只在并排时存在(单run 时 hidden),此时 `#trace` 让出 50%。 */
78
+ .cmp-on #trace { flex: 1 1 50%; }
79
+ .tr-compare {
80
+ flex: 1 1 50%; min-height: 140px; overflow: auto;
81
+ border-left: 1px solid var(--border);
82
+ }
83
+ #diffbar { flex: none; max-height: 30vh; overflow: auto; padding-bottom: 6px; }
84
+ #diff-note {
85
+ padding: 5px 8px; margin-bottom: 6px; border-left: 2px solid var(--warn);
86
+ background: var(--pop); color: var(--text); border-radius: 0 4px 4px 0;
87
+ }
88
+ #diff-body .dfield { padding: 1px 0; color: var(--faint); }
89
+ #diff-body .dfield b { color: var(--text); font-weight: 500; }
90
+ #diff-body .dspan { padding: 1px 0 1px 12px; color: var(--faint); }
91
+ #diff-body .dpath { color: var(--ice); }
92
+ #diff-body .dempty { padding: 4px 0; color: var(--faint); }
93
+ /* run 列表里被选进对比的两条:给出**独立于 active** 的标记(active 是「正在看的那条」,
94
+ 两者可以不同 —— 正在看的可能不在对比里)。用左边框双线区分,不靠颜色。 */
95
+ .run.cmp { box-shadow: inset 2px 0 0 var(--warn); }
96
+ .run.cmp.active { box-shadow: inset 2px 0 0 var(--ice), inset 4px 0 0 var(--warn); }
75
97
  /* 窄屏:两栏并排已经太窄 ⇒ 回到单列 + 整页滚动(此时「整页滚」才是唯一合理的形态) */
76
98
  @media (max-width: 860px) {
77
99
  html, body { height: auto; }
@@ -79,9 +101,10 @@
79
101
  main { grid-template-columns: minmax(0, 1fr); }
80
102
  aside { overflow: visible; }
81
103
  section { overflow: visible; }
82
- #runs, #chat, #trace { overflow: visible; }
83
- #trace { min-height: 320px; }
84
- #summary, #reply, .usage { max-height: none; }
104
+ #runs, #chat, #trace, .tr-compare { overflow: visible; }
105
+ #trace, .tr-compare { min-height: 320px; }
106
+ .cmp-on #trace { flex: 1 1 auto; }
107
+ #summary, #reply, .usage, #diffbar { max-height: none; }
85
108
  }
86
109
  .run {
87
110
  display: block; width: 100%; text-align: left; cursor: pointer;
@@ -259,7 +282,21 @@
259
282
  </aside>
260
283
  <section>
261
284
  <div id="ttl"></div>
262
- <div id="trace"><div class="tr-empty">// 选一个 run 查看调用树</div></div>
285
+ <!--
286
+ 并排 A/B 的差异摘要(档 B)。默认 hidden,只有选满两条 run 才出现。
287
+ 三段结构:可比性提示(不可比时才有 —— 可比时**不显示**,见 comparability 的注释)、
288
+ run 级 summary、逐span 差异。
289
+ -->
290
+ <div id="diffbar" hidden>
291
+ <div id="diff-note" class="diff-note" hidden></div>
292
+ <div id="diff-body"></div>
293
+ </div>
294
+ <!--
295
+ 双栏容器:并排时这里是**两个** trace 视图,单run 时只有一个。
296
+ ⚠️ 不用「同一棵树改CSS 成两半」—— trace-view 的树是自绘的,两棵树必须两个容器。
297
+ -->
298
+ <div id="trace" class="tr-single"><div class="tr-empty">// 选一个 run 查看调用树</div></div>
299
+ <div id="trace2" class="tr-compare" hidden></div>
263
300
  <!-- 能力排行(G2):哪个能力慢 / 贵 / 爱失败 —— 调优从这里下手 -->
264
301
  <div id="summary"></div>
265
302
  <pre id="reply" hidden></pre>
@@ -320,9 +357,16 @@
320
357
  HISTORY_DRAFT,
321
358
  applyTraceEvent,
322
359
  browseTarget,
360
+ autoPairTarget,
323
361
  capabilityBadge,
324
362
  chatViewVisible,
325
363
  collapseDecision,
364
+ comparability,
365
+ COMPARE_MAX,
366
+ compareView,
367
+ diffSideLabel,
368
+ diffValueText,
369
+ diffFetchErrorText,
326
370
  emptyTraceAccumulator,
327
371
  filterSelected,
328
372
  formatToolSources,
@@ -339,6 +383,7 @@
339
383
  runDoneNotice,
340
384
  runIsFailure,
341
385
  shortenPath,
386
+ toggleCompare,
342
387
  turnKey,
343
388
  } from './panel-logic.js';
344
389
 
@@ -349,6 +394,10 @@
349
394
  const chatSidEl = $('chat-sid');
350
395
  const chatClearEl = $('chat-clear');
351
396
  const traceEl = $('trace');
397
+ const trace2El = $('trace2');
398
+ const diffbarEl = $('diffbar');
399
+ const diffNoteEl = $('diff-note');
400
+ const diffBodyEl = $('diff-body');
352
401
  const summaryEl = $('summary');
353
402
  const ttlEl = $('ttl');
354
403
  const liveEl = $('live');
@@ -376,6 +425,167 @@
376
425
  usage: { in: $('u-in'), out: $('u-out') },
377
426
  });
378
427
 
428
+ // 并排 A/B 的**第二棵**树。刻意不共用 `view`:`createTraceView` 绑一个容器 +
429
+ // 一个 usage 出口,两棵树塞进同一容器会互相清空(trace-view 的 `playTrace` 是
430
+ // 整容器替换式的)。共用 `u-in`/`u-out` 也无意义 —— 那两个读数属于「正在看的那条」。
431
+ const view2 = createTraceView(trace2El);
432
+
433
+ // ---------- 并排 A/B(档 B) ----------
434
+
435
+ /**
436
+ * 复位并排的 **DOM**(**不动** `state.compare`)—— 让 `exitCompare` 与
437
+ * `refreshCompare` 的 picking 分支共用同一份复位序列。
438
+ *
439
+ * 抽出来不是为了省行数:这两处一旦分叉,就只剩一处是对的 —— 评审实证的缺陷
440
+ * 正是「清了状态、没复位 DOM」的形态(`trace2` 留着上一对的树 + 差异摘要还是
441
+ * 旧的,而列表里的选中标记已经没了 ⇒ 用户按看到的读,读到的是张冠李戴)。
442
+ * ⚠️ 面板里**只许这一处**做复位序列;guard 在 `test/structure.test.mjs`(W4)。
443
+ */
444
+ function resetCompareDom() {
445
+ trace2El.replaceChildren();
446
+ trace2El.hidden = true;
447
+ diffbarEl.hidden = true;
448
+ document.body.classList.remove('cmp-on');
449
+ renderList();
450
+ }
451
+
452
+ /**
453
+ * 退出并排,回到单 run 视图(清选择 + 复位 DOM)。
454
+ *
455
+ * 为什么必须把 `trace2` **清空**而不是只 `hidden`:它内部是 trace-view 自己建的
456
+ * DOM,留着旧树而下一轮 `hidden` ⇒ 再进并排时若新树没画成功,用户看到的是
457
+ * **上一轮别的 run 的调用树**,却以为在比当前两条 —— 静默张冠李戴,最坏的一种。
458
+ */
459
+ function exitCompare() {
460
+ state.compare = [];
461
+ resetCompareDom();
462
+ }
463
+
464
+ /** 选满两条 ⇒ 拉差异 + 双栏;不足两条 ⇒ 回单 run 视图(三态判定在 panel-logic) */
465
+ async function refreshCompare() {
466
+ const mode = compareView(state.compare);
467
+ if (mode === 'single') {
468
+ exitCompare();
469
+ return;
470
+ }
471
+ if (mode === 'picking') {
472
+ // 只选了一条:双栏显示一条是误导(「并排」却只有一栏)⇒ 回单run 视图,
473
+ // 但**保留选择**(用户正在挑第二条,别把第一条也清了)。
474
+ resetCompareDom();
475
+ return;
476
+ }
477
+ const [a, b] = state.compare;
478
+ document.body.classList.add('cmp-on');
479
+ trace2El.hidden = false;
480
+ diffbarEl.hidden = false;
481
+ renderList();
482
+ for (const [id, v, el] of [
483
+ [a, view, traceEl],
484
+ [b, view2, trace2El],
485
+ ]) {
486
+ const res = await fetch('/api/runs/' + encodeURIComponent(id));
487
+ if (!res.ok) {
488
+ // ⚠️ **不能 `continue`(静默留白)**:那一栏会空着,看起来像「这条没有调用树」,
489
+ // 而真相是「这条 run 已不在内存 ring 里了」(`/api/runs` 只回 50 条)。
490
+ // 面板不解释 ⇒ 用户把它归因到别处(改坏了?)—— 与本档「不静默」口径同源。
491
+ el.replaceChildren();
492
+ const miss = document.createElement('div');
493
+ miss.className = 'tr-empty';
494
+ miss.textContent = `读不到这条 run 的调用树(可能已被淘汰):${id}`;
495
+ el.appendChild(miss);
496
+ continue;
497
+ }
498
+ const t = await res.json();
499
+ el.replaceChildren();
500
+ playTrace(v, t);
501
+ }
502
+ await loadDiff(a, b);
503
+ }
504
+
505
+ /**
506
+ * 拉并渲染差异摘要。
507
+ *
508
+ * ⚠️ **可比性提示必须先于差异显示** —— 用户看到一堆 span 差异却不知道这两条
509
+ * 根本不可比(跨了能力选择),结论会归因到错的地方。顺序即语义。
510
+ * 且可比时**不显示**提示条(`message` 为空串)—— 每次都飘一条噪声等于没有提示。
511
+ */
512
+ async function loadDiff(a, b) {
513
+ diffBodyEl.replaceChildren();
514
+ const res = await fetch(
515
+ `/api/runs/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`,
516
+ );
517
+ if (!res.ok) {
518
+ // ⚠️ 解析失败要**单独说**(`parseFailed`)—— 折成「HTTP 500」会把
519
+ // 「响应不是 JSON」(网关插页 / token 过期返 HTML)说成服务端故障。
520
+ let body = null;
521
+ let parseFailed = false;
522
+ try {
523
+ body = await res.json();
524
+ } catch {
525
+ parseFailed = true;
526
+ }
527
+ const d = document.createElement('div');
528
+ d.className = 'dempty';
529
+ d.textContent = diffFetchErrorText(res.status, body, parseFailed);
530
+ diffBodyEl.appendChild(d);
531
+ return;
532
+ }
533
+ const payload = await res.json();
534
+ if (payload.comparability.message) {
535
+ diffNoteEl.textContent = payload.comparability.message;
536
+ diffNoteEl.hidden = false;
537
+ } else {
538
+ diffNoteEl.textContent = '';
539
+ diffNoteEl.hidden = true;
540
+ }
541
+ renderDiff(payload.diff);
542
+ }
543
+
544
+ /** 差异摘要的渲染。**没有差异时如实说「无差异」**,不显示空区块。 */
545
+ function renderDiff(diff) {
546
+ diffBodyEl.replaceChildren();
547
+ if (diff.equal) {
548
+ const d = document.createElement('div');
549
+ d.className = 'dempty';
550
+ d.textContent = '两条 trace 等价(无结构与字段差异)';
551
+ diffBodyEl.appendChild(d);
552
+ return;
553
+ }
554
+ for (const s of diff.summary) {
555
+ const row = document.createElement('div');
556
+ row.className = 'dfield';
557
+ const name = document.createElement('b');
558
+ name.textContent = s.field;
559
+ row.append(
560
+ name,
561
+ document.createTextNode(` ${diffValueText(s.a)} → ${diffValueText(s.b)}`),
562
+ );
563
+ diffBodyEl.appendChild(row);
564
+ }
565
+ for (const sp of diff.spans) {
566
+ const row = document.createElement('div');
567
+ row.className = 'dspan';
568
+ const path = document.createElement('span');
569
+ path.className = 'dpath';
570
+ path.textContent = sp.path;
571
+ row.appendChild(path);
572
+ if (sp.missing) {
573
+ row.appendChild(
574
+ document.createTextNode(` (只在 ${diffSideLabel(sp.missing)} 侧)`),
575
+ );
576
+ }
577
+ for (const [k, v] of Object.entries(sp.fields)) {
578
+ const f = document.createElement('div');
579
+ f.className = 'dfield';
580
+ const key = document.createElement('b');
581
+ key.textContent = k;
582
+ f.append(key, document.createTextNode(` ${diffValueText(v.a)} → ${diffValueText(v.b)}`));
583
+ row.appendChild(f);
584
+ }
585
+ diffBodyEl.appendChild(row);
586
+ }
587
+ }
588
+
379
589
  // ---------- 状态 ----------
380
590
  // 只放「渲染要用到的东西」;所有判断都走 panel-logic(那份有单测,这份没有)
381
591
  const state = {
@@ -391,6 +601,13 @@
391
601
  tab: 'runs',
392
602
  list: [],
393
603
  current: null,
604
+ /**
605
+ * **并排 A/B 的选择**(最多 `COMPARE_MAX` 条traceId):⌘/Ctrl + 单击 run 条目
606
+ * 往里加、再点一次取消、超过两块挤掉最早那条(规则全在 panel-logic 的
607
+ * `toggleCompare`,那份有单测)。选满两条 ⇒ 右栏切双栏 + 顶部一条差异摘要。
608
+ *空数组 = 单run 视图(`state.current` 那一套照旧)。
609
+ */
610
+ compare: [],
394
611
  reply: '',
395
612
  /**
396
613
  * 这段回复是**哪个 traceId** 的(`run-done` 带回来的)。`open()` 靠它判断
@@ -539,7 +756,11 @@
539
756
  // runIsFailure,与通知条同一条语义)—— 给中止的 run 标红会让用户以为
540
757
  // 是自己把工程改坏了,而那正是「aborted 也带 error」这条引擎约定造成的误读。
541
758
  const tone = runIsFailure(r) ? ' err' : r.stopReason === 'aborted' ? ' aborted' : '';
542
- b.className = 'run' + tone + (r.traceId === state.current ? ' active' : '');
759
+ b.className =
760
+ 'run' +
761
+ tone +
762
+ (r.traceId === state.current ? ' active' : '') +
763
+ (state.compare.includes(r.traceId) ? ' cmp' : '');
543
764
  b.type = 'button';
544
765
 
545
766
  const r1 = document.createElement('span');
@@ -565,7 +786,23 @@
565
786
  r3.textContent = `${short(r.note.workdir)} · ${caps}${r.note.multiTurn ? ' · 多轮' : ''}`;
566
787
  b.appendChild(r3);
567
788
  }
568
- b.addEventListener('click', () => open(r.traceId));
789
+ // 普通单击 = 打开那一条;**⌘/Ctrl + 单击** = 进出并排 A/B 的选择。
790
+ // 为什么不做成「勾选框」:run 条目整行是 button,塞 checkbox 要改点击语义
791
+ //(点勾选框不该打开那条)—— 而「修饰键 + 单击」是列表多选的通用手势。
792
+ b.addEventListener('click', (ev) => {
793
+ if (ev.metaKey || ev.ctrlKey) {
794
+ state.compare = toggleCompare(state.compare, r.traceId);
795
+ void refreshCompare();
796
+ return;
797
+ }
798
+ // 普通单击切到别的 run ⇒ 退出并排(否则右栏两棵树与左栏高亮不一致,
799
+ // 用户点谁就以为在看谁)。
800
+ // ⚠️ **必须走 `exitCompare()`,不能只清 `state.compare`** —— 它顺带复位 DOM。
801
+ // 只清状态会让右栏与差异摘要**留在屏幕上**(内容还是上一对的),而列表里的选中
802
+ // 标记已经没了 ⇒ 用户按看到的读,读到的是张冠李戴(评审实证的形态)。
803
+ if (state.compare.length > 0) exitCompare();
804
+ open(r.traceId);
805
+ });
569
806
  runsEl.appendChild(b);
570
807
  }
571
808
  }
@@ -1174,6 +1411,21 @@
1174
1411
  await refreshSession();
1175
1412
  renderChat();
1176
1413
  await open(s.traceId);
1414
+ // 「改 prompt → 重跑 → 对照」这一跳(档 B 收尾):用户已经选了一条(picking 态),
1415
+ // 而新 run 刚收尾 ⇒ **自动**补上第二条并进双栏。判定在 panel-logic(带单测),
1416
+ // `null` 表示「不该配对」(空选择 / 已选两条 / 同一 id)。
1417
+ // ⚠️ **不静默**:播一条 notice 说清屏幕为什么变双栏了;退出 = 普通单击任意一条。
1418
+ const paired = autoPairTarget(state.compare, s.traceId);
1419
+ if (paired) {
1420
+ state.compare = paired;
1421
+ // ⚠️ `refreshCompare` 的排布是「左栏 = 第一条」⇒ 列表高亮必须跟着走,
1422
+ // 否则又出现「点谁以为在看谁」的失真(与守卫 W4 守的是同一类)。
1423
+ // 而 `#ttl` 与右上角 usage 读数**留在最新那条**(用户最关心刚跑的那次)——
1424
+ // 这个分工是有意的,别在「统一」的名义下把它抹平。
1425
+ state.current = paired[0];
1426
+ setNotice('已把新 run 与上一条并排(普通单击任意一条退出对比)', false);
1427
+ void refreshCompare();
1428
+ }
1177
1429
  };
1178
1430
  es.addEventListener('dev', async (m) => {
1179
1431
  const ev = JSON.parse(m.data);