@migor/agentia 0.8.3 → 0.9.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/CHANGELOG.md CHANGED
@@ -5,6 +5,254 @@
5
5
  (0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
6
6
  决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.9.1] - 2026-09-22
11
+
12
+ ### 新增 · dev 面板的实时右栏(在飞就看得到调用树)
13
+
14
+ - **右栏从「收尾才有」变成「一个个长出来」**:runner 订阅框架的增量记账出口
15
+ (`onTraceEvent`,0.8.3 已落地)逐笔 `POST /ingest-event` → 父进程**原样**广播
16
+ (SSE 的 `dev` 命名事件多一类 `trace-event`)→ 面板按 `seq` 折回一棵**临时**的树。
17
+ 收尾那份整棵 trace 回来时**覆盖**它(缺的 span 由那份补齐)。纪律与框架同名出口一致:
18
+ **不保证送达**,所以刷新页面之后早先那些 span 不会回来(收尾那份会补)。
19
+ 计划 §D7 的评审补充点名要求的那条(P1b 面板侧工作)这轮落地。
20
+ 折回逻辑是**纯逻辑 + 单测**(`panel-logic.ts` 的 `applyTraceEvent`),e2e-dev 另有一条
21
+ 「帧必须先于 run-done 到达,且折回逐字等于收尾的整棵 trace」的真进程断言。
22
+
23
+ ### 变更 · 工作目录选择器:文件可选、隐藏目录可达、`浏览…` 不再是开关
24
+
25
+ - **文件也能选**:`GET /api/fs` 现在分三类回(`dirs` / `dotDirs` / `files`;文件有上限,
26
+ 超限时回 `filesTruncated`)。点文件名 = 工作目录取它**所在的**目录 + prompt 为空时填文件名
27
+ (你已经写好的 prompt **一个字不动**)。旧形态只列目录 ⇒「让 agent 看某个文件」只能靠自己打名字。
28
+ - **隐藏目录单列**(不再整批过滤):`.git` / `~/x/.y` 这类目录以前**根本点不进去**
29
+ (`..` 只能退到它上面,进不去)。
30
+ - **`浏览…` 不再当开关**:以前面板开着时再点一次只是把它关掉,于是「在输入框敲了路径 → 点浏览」
31
+ 这个最自然的动作是**把面板关掉**。现在点它**总是**按输入框里的路径打开,点面板外面收起。
32
+
33
+ ### 修复
34
+
35
+ - **跑完的回复被面板自己擦掉(端到端实测 17 ms)**:`run-done` 与「这条 trace 的收尾帧」
36
+ 几乎同毫秒到达,而 `open()`(收尾帧触发的自动打开)**无条件**清回复 ⇒ 刚写上去的那句立刻没了,
37
+ 表现是「run 完成了,但面板上没有回复」。现在按「这条 trace 是不是这段回复的主人」判
38
+ (`panel-logic.replyBelongsTo`):打开**别的** run 的 trace 照样清(防张冠李戴),
39
+ 打开**刚跑完那一轮**的不清。
40
+ - **在飞的树被收尾帧骗成「已完成」**:`playTrace` 以前对根 span 没有 `endedAt` 的树也调
41
+ `finish()`(根被标成 `●` + 耗时),而在飞时那是**假事实**。现在根没结束就不收尾(留 `◌`)——
42
+ 该分支只对「在飞 / 截断」的树生效,收尾的整棵 trace 行为逐字未变。
43
+
44
+ ### 文档
45
+
46
+ - `docs/usage-guide.md` §2.2:补「右栏是实时的」(含两条边界)与工作目录的三个点击语义。
47
+ - `docs/guards.md` §1.4:新增五条守卫登记(见该节)。
48
+ - `docs/plans/2026-09-22-dev-debug-loop.md`:§D7 那条评审补充标注 **P1b 已落地**。
49
+ - `docs/spec.md` §10 2026-09-22 ⑦:本轮三个缺陷的现场证据与修法(含「同一条事实两个表面」的第 N 个影子)。
50
+
51
+ ## [0.9.0] - 2026-09-22
52
+
53
+ > 本版主题(窗口 `0.8.3 → 0.9.0`):**dev 调试环落地** —— `agentia dev` 从「`tsx watch` 跑你的
54
+ > `main.ts`」变成「**常驻 runner 子进程 + 本地 inspector 面板**驱动你的 `src/app.ts`」,
55
+ > 四个控件(能力选择 / 工作目录 / 多轮 / prompt)都在面板上;顺带补上「**中止在飞 run**」与
56
+ > 「**清空对话**」两个操作,以及一轮复核修复(9 条,含 5 条严重:窄窗口竞态、收尾路径、
57
+ > 同一事实两个表面口径不一致)。
58
+ >
59
+ > ⚠️ **含破坏性变更,但只在脚手架模板形态上**(下面的迁移小节写了要做什么动作):
60
+ > 模板把「装配」与「启动」拆成两个文件(新增 `src/app.ts` 工厂),老工程不迁移的话
61
+ > `agentia dev` 会**明确报错**(不是静默降级成一个驱动不了任何东西的空壳)。
62
+ > **框架本体的公共 API 无破坏性变更**:没有删签名、没有改默认行为 —— 已有工程照常
63
+ > `npm start`;受影响的是「在工程里敲 `agentia dev`」这条开发期路径。
64
+
65
+ ### 破坏性变更 · 脚手架模板:装配与启动拆开(新增 `src/app.ts`)
66
+
67
+ **要做的动作**:把老工程 `src/main.ts` 里的 `createApp({...})` 整段搬进新的 `src/app.ts`,
68
+ 包成一个**工厂函数**;`main.ts` 只留「读 `.env` → 调工厂 → `app.run` → 处理 `result.error`」。
69
+
70
+ **为什么**:`agentia dev` 的调试环要把「这次调哪个能力 / 工作目录是哪个」喂进 `createApp`,
71
+ 而**只有调用者能设这些选项**。拆出工厂之后 CLI 才是调用者 ⇒ **工程里一个 dev 文件都不需要**
72
+ (只多一个**数据**文件 `src/dev.config.ts`)。不迁移的后果是**明确报错**,不是静默降级:
73
+ `agentia dev` 会指出 `src/app.ts` 缺失并打印迁移说明。
74
+
75
+ **最小迁移**(对着 `agentia create` 新生成的工程看最省事):
76
+
77
+ 1. 新建 `src/app.ts`:把 `main.ts` 里的 `CAPABILITY_DIRS` + `discover` + `createApp({...})` 搬进去,
78
+ 改成 `export async function createAgentApp(opts: CreateAgentAppOptions = {})`。老的那段可以**原样**搬
79
+ —— 第 3 步不做也能跑,只是面板上的「工作目录」旋钮不生效。
80
+ 2. `src/main.ts` 改薄入口:`import { createAgentApp } from './app.js'`(**注意 `.js` 后缀**)→
81
+ `const app = await createAgentApp();`,删掉 `createApp` 的 import 与 `CAPABILITY_DIRS`。
82
+ 3. (要「工作目录」真生效就得做)在 `providers` 里加 `{ provide: 'WORKDIR', useValue: opts.workdir ?? <项目根> }`,
83
+ 并把需要工作目录的能力从 `discover` 挪到显式 `providers` 声明 `deps: ['WORKDIR']`
84
+ —— **`discover` 自动注册的 provider 没有 `deps`**,拿不到注入值。
85
+ 4. (可选)新建 `src/dev.config.ts` 声明多轮(`export default { multiTurn: ['trip-planner'] }`),
86
+ 并给 `.gitignore` 补一行 `.agentia/`(dev 环的对话历史落盘处)。
87
+
88
+ ### 新增
89
+
90
+ - **面板的「中止」按钮:中止在飞的 run(`POST /run/abort`)。** 此前只做了「在飞时拒绝第二次
91
+ `POST /run`(409)」这一半 —— 后果不是小事:**一个卡住的 run 会让面板永久锁死**,
92
+ 此后每次运行都 409,而面板**没有任何办法**解开它。
93
+ 实现走 IPC 让 runner 自己 `abort()`,**不是**父进程杀子进程 —— 理由是 **trace**:
94
+ `RunInvocationOptions.signal` 是契约字段(原样送进引擎),中止后在回合边界以
95
+ `stopReason='aborted'` **正常返回**(`engine/loop-result.ts` 的 `abortedResult()`)
96
+ ⇒ **trace 照常落盘**。杀进程那条路会把这次 run 的 trace 整个丢掉。
97
+ ⚠️ signal 是**协作式**的:工具不读它就没人理(「MCP 在途中止 ⇒ Promise 永不 settle」正是这一类),
98
+ `running` 会永远为 `true` ⇒ 所以补一条兜底:**5 s(`ABORT_GRACE_MS`)后仍没结束就重启进程**,
99
+ 此时这次 run 的 trace 会丢,面板**明说**这一点(不静默)。
100
+ - **面板的「清空对话」按钮(`POST /session/clear`)。** 清空 = **换一个 `sessionId`**,
101
+ **不删** `session.json` —— 后者是 `SessionStore` 的账,面板对它**只读**(写它会造出
102
+ 「面板显示的对话」与「模型真正看到的对话」不一致)。旧对话仍在盘上、run 列表里也还指得到,
103
+ 只是模型不再带着它跑。当前 id 落盘到 `.agentia/dev-session-id`(**不是** `session.json` 的一部分):
104
+ 只放内存的话,重启 `npm run dev` 之后刚清空的对话会**自己回来**;该路径已在监视排除清单里,
105
+ 写它不触发重启。id 由**父进程**决定并经 IPC 下传 —— 它是面板级状态,必须跨 runner 重启稳定。
106
+
107
+ ### 变更
108
+
109
+ - **`agentia dev` 从「看 trace」补上「驱动 run」**:面板上多四组输入 —— prompt 输入框(带 `↑`/`↓` 历史)、
110
+ 能力多选(缺省全选;收窄的是**菜单**,主 agent 仍在环里 —— 别的能力 `tools` 里的显式引用仍能调到
111
+ 被排除的那个)、工作目录选择器、多轮开关(按能力声明,混选取 **OR** 且面板**标出来源**)。
112
+ `npm run dev -- "你的问题"` 仍可直接带上第一句。
113
+ - **文件监视收编进 CLI**:`agentia dev` 不再叠一层 `tsx watch`(旧形态有**两个重启主人**,保存的瞬间
114
+ 恰好点运行会双双 spawn),自己按**允许清单**看文件 —— **`.md` 必须在里面**:文本资产
115
+ (`@Prompt` 拉的 `.md`、子 agent 的 `system.md`)不在 tsx 的 import 图里,旧实现改它们
116
+ **静默无感**(改了没反应、也不提示)。排除 `node_modules` / `dist` / `.git` / `.agentia` / `coverage`
117
+ —— `.agentia` 那条不是洁癖:它是 dev 环自己的对话历史,看它就变成「每次 run 重启一次」的自噬循环。
118
+ - **重启与不重启的边界**:改代码或改**能力选择** ⇒ 重启子进程;改**工作目录** / prompt / 多轮 ⇒ 不重启。
119
+ 在飞 run 期间的重启**延后**到它结束。⚠️ 重启能力选择的理由**不是贵**(进程内重建实测 **2.6 ms**,
120
+ 初稿的「几秒」高估约两个数量级),而是**回收口**:框架没有 `AgentApp.close()`,MCP 连接器由用户代码
121
+ 持有 ⇒ 进程内反复重建会攒孤儿 MCP 子进程。配套硬约定(写进模板注释):**进程外资源一律在模块作用域
122
+ 创建并注入**,不许在 provider 构造函数里建。
123
+ - **dev 环鉴权(两层,零依赖)**:`Origin` 校验(**缺失放行** / `Origin: null` **拒绝** / 跨源拒绝)
124
+ + **每次启动生成的一次性 token**(首帧 `?t=` → `HttpOnly; SameSite=Strict` cookie;脚本走
125
+ `x-agentia-token` 头;`timingSafeEqual` 比较),**每个端点**都校验。token 挡的是**本机其它进程**
126
+ (它们能 `curl`、不受 `Origin` 约束),**别把它当网络边界** —— 面板只绑 `127.0.0.1`。
127
+ - **dev 环缺省预算护栏**:面板上「点一下 = 一次**真** run」⇒ 缺省套
128
+ `{ maxCostUsd: 1, maxTotalTokens: 200_000 }`(可用 `dev.config.ts` 的 `budget` 覆盖)。
129
+ - **模板 `@SubAgent` 的 `system` 改函数形态**(`() => asset(...)`):值形态在**类定义时**求值 ⇒
130
+ `system.md` 要重启进程才生效,而 `.md` 不在 tsx 的 import 图里 ⇒ 静默失效。
131
+ 通则:**模块加载期读 = 冻;调用期读 = 热**。
132
+ - **去掉 `NODE_OPTIONS=--import` 注入**:子进程改跑 CLI 自己的 `dev-runner.js`,
133
+ 连带那条 Node ≥ 20.6 / ≥ 18.19 的版本闸与字符串拼接一起删掉。
134
+ ⚠️ 代价是顺序变成**承重的**:必须在 import 用户 `app.ts` **之前** `await registerTraceSink()`
135
+ —— `createApp` 在构造时就把 `defaultSinks` 快照下来了。
136
+ - **脚手架自带 `read-file` 工具**:读**工作目录**下的文本文件,根由 DI 注入(`WORKDIR`)——
137
+ 它是「面板上那个文件夹选择器真的生效」的接线。越界**响亮报错**,不静默截断。
138
+ - **脚手架自带 `src/session-store.ts`**(`FileSessionStore`,**原子写**:临时文件 + rename)
139
+ 与 `src/dev.config.ts`(**数据**,不是逻辑)。
140
+
141
+ ### 修复(dev 环真跑一次抓出来的 —— 上面那些改动全绿时它们照样在)
142
+
143
+ - **`agentia dev` 起不来:`npx` 吞掉了 IPC 通道。** 子进程原本是 `spawn('npx', ['tsx', runner])`,
144
+ 而 `npx` 是包装器、**不给孙进程转发 fd 3** ⇒ runner 里 `process.send` 是 `undefined`,
145
+ 代码写的是 `process.send?.()`(可选链)⇒ 所有协议消息**静默丢弃**:面板等不到 `ready`、
146
+ `POST /run` 永远不回来,且进程会以 `code=0` **干净退出**(看起来像「用户代码跑完了」)。
147
+ 改成 `node <tsx/cli>` 直起(`resolveTsxCli()`:用户工程优先,退到 CLI 自身;不走
148
+ `node_modules/.bin/tsx` —— Windows 上那是 `.cmd` shim,会绕回 CVE-2024-27980 那个坑)。
149
+ - **`npm run dev` 读不到 `.env`(`npm start` 读得到)。** 上一节把模板拆成 `app.ts` / `main.ts` 时,
150
+ `loadEnvFile()` 留在了 `main.ts`,而 dev 环只 import `app.ts`、**从不执行 `main.ts`**。
151
+ 失败形状是静默的:用户看到「没配 key」,然后去怀疑框架。已挪进 `src/app.ts`。
152
+ ⚠️ 当时**两处**断言都钉着这件事,但都指着 `main.ts` —— 断言存在 ≠ 钉对了位置,现已改成成对断言。
153
+ - **面板能力选择器空着且无解释。** `DevEvent` 有 restart / error / run-start / run-done,
154
+ **唯独没有「就绪」**;而面板加载时只查一次 `/api/dev`,那时 runner 还没装配完
155
+ (拿到的是父进程初值 `[]`)⇒ 菜单要等用户先跑一次才填上。新增 `{ kind: 'runner-ready' }` 广播。
156
+ - **「runner 没起来就退出」广播了一句更没用的原因。** `exit` 处理器先 `fail()`(reject)再同步
157
+ `emit`,而那时 `lastError` 还是 `null` ⇒ 面板收到笼统的「意外退出」,真正的原因只进了终端
158
+ (reject 要到下一个微任务才被 catch 接住)。改成先写 `lastError` 再 reject。
159
+ - **新增 `scripts/e2e-dev.ts`**:`agentia dev` 整条链真跑(此前**零覆盖**),四条行为断言
160
+ (IPC 就绪 / `.env` 生效 / 能力收窄真的收窄了请求体 / 改 `.md` 触发重启且能恢复)。
161
+ 折进 `verify-all.sh` 第 7 步(`npm run e2e`),不加步骤。
162
+ - **中止的 run 被三个地方各自当成「失败」(同一个事实的三个影子)。** 引擎的 `abortedResult()`
163
+ **刻意**给已取消的 run 带上结构化 `error`(取消不是失败,但原因要可查),而 runner 的
164
+ `ok` 定义是 `!result.error` ⇒ **中止时 `ok` 也是 `false`**。于是:
165
+ ① 面板把「我按的中止」显示成「run 失败(aborted):run 已被取消」;
166
+ ② `dev.ts` 把它写进 `lastError` ⇒ 告警条上永远挂一条红字、且不会再消;
167
+ ③ 终端打成「run 失败(stopReason=aborted)」。
168
+ 三处统一为「**先认 `stopReason` 再认 `ok`**」,并把面板那一处的判别抽进 `panel-logic.ts`
169
+ (顺序是**语义**,不是渲染 —— 面板那份没有单测)。
170
+ 判别口径此前只活在注释里,且**写错了**(写成「中止是正常返回、`ok` 两者都是 true」——
171
+ 那是把「不抛异常」误当成「不设 `error`」)。
172
+ - **`watchTree` 的目录跳过判据是「半实现」的**(追一次**瞬时红**追出来的 —— 一次完整门禁里
173
+ CLI 套件红过一次、重跑又绿,没当噪声放过,连跑三次复现后隔离到 watch 用例)。
174
+ `WATCH_SKIP` / 点开头此前**只在「初始递归」那一个调用点**执行,而 watcher 回调里发现新目录时
175
+ 调 `addDir` 走的是**另一条路**、那条路上一次判据都没有 ⇒ 行为是「启动时就存在的 `dist/` 不看、
176
+ **启动后才出现**的 `dist/` 看」。判据已收进 `addDir` **内部**(唯一一处),`root` 由调用方
177
+ 显式豁免 —— 判据只看**目录名**、不看路径段(项目根本身就叫 `dist` / `.foo` 是合法的;
178
+ 用绝对路径段去认 `dist` 会把整个项目判成「不该看」)。
179
+ ⚠️ 真跑时的**第一层**防护是**监视根**:`devServer` 只 `watchTree(<projectRoot>/src)`,
180
+ 而 `.agentia/` 与 `dist/` 在项目根、根本不在范围内。`WATCH_SKIP` 是**第二层**,两层都要有 ——
181
+ 只靠根的话,哪天有人把根改成项目根,`.agentia/session.json` 会立刻变成
182
+ 「每次多轮 run 重启一次子进程」的自噬循环。
183
+ ⚠️ **这一轮最值钱的不是修好,而是照出一条「假守卫」**:我给 e2e 加了一条「重启次数总账 +
184
+ 理由里不许出现 `.agentia` / `dist/`」的断言,反向验证时**单测红了、e2e 照样绿** ——
185
+ 逐条排除后确认不是「修复没生效」也不是「断言写错」,而是这条缺陷在 e2e 里**不可达**
186
+ (监视根是 `src/`)。也就是说那条断言真正守的是「**监视根保持 `src/`**」,它**碰不到**
187
+ `WATCH_SKIP` 那条路。断言是真的、绿的、也是对的,只是它守的是**另一件事** ——
188
+ 所以 dev.ts 与 e2e 两处的注释都已照实改准(说清哪条守哪件、以及它**不守**什么)。
189
+ - **改 `.env` 不会重启(文档承诺了、代码够不着)。** `WATCH_NAMES` 把 `.env` / `.env.local`
190
+ 列进允许清单、`usage-guide` 也把 `.env` 写进「看什么」,但 `watchTree` 的根是
191
+ `<项目根>/src`,而这两份文件在**项目根** ⇒ 这条判据**没有任何一个 watch 够得着**,
192
+ 改 `.env` 静默无感。与上面那条是同一类的两个面:**判据认得它**(`shouldWatch` 有单测、
193
+ 写得也对)≠ **有人够得着它**(根在调用点决定)。
194
+ 新增 `watchRootEnvFiles()` 单开一个 watch:**不递归**、且**只认名字**(不认扩展名 ——
195
+ 项目根的 `package.json` 也不该由它管)。刻意**不**把 `watchTree` 的根抬到项目根:
196
+ 那会把 `README.md` / `docs/` / `examples/` 全收进来,「改代码要重启」就变成「改任何文档也重启」。
197
+ 两处 watch 共用抽出来的去抖器(各写一份会漂 —— 比如只有一处清理 `timer`,停机后仍会回调一次)。
198
+ ⚠️ **这条只有真跑能守**:根是**调用点**决定的,`watchTree` 自己无从知道该看哪儿 ——
199
+ 所以钉它的是 `scripts/e2e-dev.ts` 第 9-bis 步(真改一次项目根的 `.env`)。
200
+
201
+ ### 修复(复核轮:四条窄窗口 / 口径缺陷 + 两条顺手抓出的)
202
+
203
+ 同一天对这轮 dev 环改动做了一次逐条回读代码的复核(决策链见 `docs/spec.md` §10 2026-09-22 ⑦)。
204
+ 下面每条都补了**反向验证**(把修复摘掉 ⇒ 新加的门禁真的会红):
205
+
206
+ - **并发闸没盖住「换能力选择 ⇒ 重启」那条路径**(双击运行会真并发两次)。受理到 run 真正发出去之间
207
+ 有 `await restart(...)`,而 `running` 只在 run 发进通道之后才置位 ⇒ 两个请求双双通过检查,
208
+ runner 里两个 run 并发、`currentAbort` 被覆盖、CLI 侧记账挂到别人的 traceId 上。
209
+ 现在多了个只给闸看的 `launching` 占位(刻意不并进 `running`:「中止」按钮据此仍只在真在飞时出现)。
210
+ - **Ctrl+C 会丢掉 SIGKILL 兜底 ⇒ 不响应 SIGTERM 的子进程树活成孤儿**。旧的 SIGINT 处理器固定
211
+ 50 ms 就 `process.exit(0)`,而 `stopChild()` 给子进程的宽限期是 3 s(SIGTERM 后等满才补 SIGKILL)——
212
+ 父进程先没,那段宽限连同兜底一起消失,`process.on('exit')` 那条也已空转。现在等收尾真的做完再退
213
+ (另有 5 s 硬上限兜「面板连接没关掉」这类卡死,再按一次 Ctrl+C 立即硬退)。
214
+ - **「中止」落在装配窗口里会被静默丢弃**。`runOnce` 是先 `await ensureApp(...)` 再建 AbortController,
215
+ 窗口内到达的中止只能看到「没有在飞的 controller」⇒ run 照跑,而父进程 5 s 后把这次**健康的** run
216
+ 升级成重启兜底(trace 丢掉、原因还写成「工具不响应 signal」)。现在窗口内的中止会被记住并立即补上。
217
+ - **被中止的 run 在面板上被标成「失败」**(通知条已修好,但 run 列表的红点与对话视图的红边走的是
218
+ `ok` —— 中止的 `ok` 同样是 `false`)。判别统一到 `panel-logic.runIsFailure`(先 `stopReason` 后 `ok`),
219
+ 且**中止轮仍留在对话视图里**(框架只在成功路径回写会话,排除它会显示一份少了一轮的对话)。
220
+ - **会话文件损坏时只在 dev 环告警**(`FileSessionStore` 自己会响亮抛错,但框架在 load / append 里
221
+ 刻意吞掉会话侧异常 —— 既定口径「辅助动作不击穿 run」)⇒ 历史被当成「第一轮」且新历史写不进去,
222
+ 全程无声。现在 runner 在**启动期**探测一次,把原因送进既有的 warning 通道(面板告警条)。
223
+ - **`agentia dev -- "你的问题"`**:裸 CLI 形状会把分隔符 `--` 当成 prompt 传给模型
224
+ (`npm run dev -- "…"` 那条因为 npm 吃掉 `--` 而一直是对的,两条形状都文档化了)。现在两者都取到真 prompt。
225
+ - **模板 `read-file` 未归一化工作目录**:工作目录带尾斜杠时(面板输入框 / `dev.config.ts` / 从 Finder
226
+ 粘过来都常见),越界判定 `abs.startsWith(root + '/')` 恒为假 ⇒ **每一次**调用都报「路径越出工作目录」。
227
+ 那不是误报,是工具整个变坏,且错误归因会把模型带偏。
228
+ - **模板 subagent 的 `system.md` 补上「你的回复就是报告」约定**:模板这轮把 `system` 改成了**函数形态**
229
+ (改 `.md` 立刻生效),而框架只对**值形态**追加 `REPORT_HINT` ⇒ 函数形态下子代理不知道自己的最终回复
230
+ 就是交回主 agent 的交付物,措辞差异没有任何测试看得见。约定现在写在模板自己那份 `system.md` 里。
231
+
232
+ ### 文档
233
+
234
+ - `docs/usage-guide.md` §2.1 / §2.2(新):脚手架文件分工 + `agentia dev` 的四组输入 / 监视规则 /
235
+ 重启边界 / 鉴权 / 成本 / 会话历史;§6.4 补「对话历史从哪来」与**两层「历史」**的区别
236
+ (prompt 回显模型看不见;对话历史真的进上下文,且只在开了多轮时出现)。
237
+ §2.2 另补一条「**看哪里**」—— 此前只写了「看什么扩展名」,没写监视根(`<项目根>/src` 整棵树
238
+ + 项目根单独的 `.env` / `.env.local`)⇒ 读者无从知道「改项目根的 `README.md` 不触发重启」。
239
+ - `docs/spec.md` §10 2026-09-22 ②:本轮决策记录,含「**P0–P2 全程零框架改动**」的逐项核对。
240
+ §10 2026-09-22 ③:真跑探针抓出的两个缺陷与三条修复(含反向验证记录)。
241
+ §10 2026-09-22 ④:拿功能文档逐项对照做审计 —— 又抓出两处「文档里有、代码里没有」,
242
+ 以及「同一条事实的第三个影子」(中止的 `ok` 是 `false`)。
243
+ §10 2026-09-22 ⑤:追一次瞬时红 → `WATCH_SKIP` 的半实现 + 那条「守了另一件事」的假守卫。
244
+ §10 2026-09-22 ⑥:拿 ⑤ 的「监视根」去核每条判据 → `.env` 是一条**够不着**的判据
245
+ (含「判据的正确性 vs 可达性」与「探针别用写死序号」两条可复用的教训)。
246
+ - `docs/guards.md` §1.4:新增五条守卫登记(面板纯逻辑 + watch 允许清单 / dev 环鉴权与 HTTP 面 /
247
+ 生成物能力名一致性 / `agentia dev` 整链真跑 / `.env` 接线成对断言),并随 ④ 扩面
248
+ (`nextSessionId` / `runDoneNotice` / `POST /run/abort` / `POST /session/clear` /
249
+ 面板 import 名单反向全覆盖 / e2e 第 10-11 步)、随 ⑤ 再扩面(`watchTree` 的**动态新增目录**
250
+ 用例 + e2e 第 12 步重启总账,并注明 e2e 第 12 步**不守**什么)、随 ⑥ 三度扩面
251
+ (`watchRootEnvFiles` 的名字过滤用例 + e2e 第 9-bis 步改项目根 `.env`)。
252
+ §2 尾注补三条可复用的自查问。
253
+ - `docs/plans/2026-09-11-dev-inspector.md`:非目标清单修订 —— **`run 重放执行` 仍然不做**,
254
+ 面板做的是「发一次**新的** run」,两者相邻但不同。
255
+
8
256
  ## [0.8.3] - 2026-09-21
9
257
 
10
258
  ### 变更
@@ -574,7 +822,6 @@
574
822
  「手写转发列表不得漏字段」形状,历史事故 = `runAgentScoped` 漏 `toolTimeoutMs`)—— 待口径判定:
575
823
  转发,或在 `types.ts` 注明「只作用本层循环」。
576
824
 
577
- ## [Unreleased]
578
825
 
579
826
  ## [0.6.3] - 2026-09-18
580
827
 
@@ -939,7 +1186,9 @@
939
1186
  首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
940
1187
  框架本体单包;CLI 独立成包(workspaces)。
941
1188
 
942
- [Unreleased]: https://github.com/retrychx/agentia/compare/v0.8.3...HEAD
1189
+ [Unreleased]: https://github.com/retrychx/agentia/compare/v0.9.1...HEAD
1190
+ [0.9.1]: https://github.com/retrychx/agentia/releases/tag/v0.9.1
1191
+ [0.9.0]: https://github.com/retrychx/agentia/releases/tag/v0.9.0
943
1192
  [0.8.3]: https://github.com/retrychx/agentia/releases/tag/v0.8.3
944
1193
  [0.8.2]: https://github.com/retrychx/agentia/releases/tag/v0.8.2
945
1194
  [0.8.1]: https://github.com/retrychx/agentia/releases/tag/v0.8.1
package/README.en.md CHANGED
@@ -42,7 +42,7 @@ $EDITOR my-app/.env # put ANTHROPIC_API_KEY here (export works too —
42
42
  agentia create my-app
43
43
  cd my-app && npm install
44
44
  agentia g tool weather # create src/tools/weather/index.ts and register it
45
- agentia dev # tsx watch + local inspector panel
45
+ agentia dev # local inspector panel (drive a run, pick capabilities/workdir)
46
46
  ```
47
47
 
48
48
  Four self-describing directories hold capabilities — the directory name **is** the kind:
@@ -141,7 +141,7 @@ One run == one trace (`traceId === runId`), **built in from turn 0** — not a b
141
141
  | Production observability recipes | [`docs/observability.md`](./docs/observability.md) |
142
142
  | Examples (complete app / minimal deploy / observability) | [`examples/`](./examples/) |
143
143
  | Design spec & roadmap | [`docs/spec.md`](./docs/spec.md) · [`docs/roadmap.md`](./docs/roadmap.md) |
144
- | Website · Playground | [agentia-web.pages.dev](https://agentia-web.pages.dev) · [playground](https://agentia-web.pages.dev/playground.html) |
144
+ | Website · Playground | [agentia-web.pages.dev](https://agentia-web.pages.dev) · [playground](https://agentia-web.pages.dev/playground) |
145
145
 
146
146
  ## Contributing
147
147
 
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  npx @migor/cli create my-app # 脚手架(含 .env / .env.example)
23
23
  cd my-app && npm install # 框架与 CLI 都装进工程
24
24
  $EDITOR .env # 填 ANTHROPIC_API_KEY(也可直接 export,真实环境变量优先)
25
- npm run dev # = agentia dev:tsx watch + 本地 inspector 面板
25
+ npm run dev # = agentia dev:本地 inspector 面板(输入 prompt / 选能力 / 选工作目录)
26
26
  # 可选:ANTHROPIC_BASE_URL(兼容端点)、AGENTIA_MODEL(缺省 claude-opus-5)
27
27
 
28
28
  # 工程内直接用:npx agentia g tool fetch-weather / npx agentia doctor / npx agentia --version
@@ -31,10 +31,12 @@ npm run dev # = agentia dev:tsx watch + 本地 inspec
31
31
  # 想全局装(到处都能敲 agentia):npm i -g @migor/cli
32
32
  ```
33
33
 
34
- > 框架**不自动**读 `.env`:脚手架 `src/main.ts` 首行的 `loadEnvFile()` 负责把它读进 `process.env`。
34
+ > 框架**不自动**读 `.env`:脚手架 `src/app.ts` 的 `loadEnvFile()` 负责把它读进 `process.env`。
35
+ > 放在**装配模块**(app.ts)而不是启动入口(main.ts),是因为 `agentia dev` 只 import app.ts、
36
+ > 从不执行 main.ts —— 写错一侧会让 `npm run dev` 静默读不到 `.env` 而 `npm start` 读得到。
35
37
  > 真实环境变量优先(CI / docker / 命令行永远赢过文件),`loadEnvFile({ override: true })` 才反过来。
36
38
 
37
- > **版本**:`0.8.3`(`@migor/agentia` 与 `@migor/cli` 均已发布到 npm)。`examples/` **刻意**用
39
+ > **版本**:`0.9.1`(`@migor/agentia` 与 `@migor/cli` 均已发布到 npm)。`examples/` **刻意**用
38
40
  > `file:../..` 指向本仓库而不是 npm 版本 —— 跑的是**工作区代码**,理由见
39
41
  > [`examples/README.md`](examples/README.md) 的「依赖说明」。
40
42
 
@@ -56,7 +58,7 @@ agentia g tool weather # 生成 src/tools/weather/index.ts 并
56
58
  agentia g subagent doc-reviewer # 生成 src/subagents/doc-reviewer/{index.ts,system.md}
57
59
  agentia g skill note-writer # 生成 src/skills/note-writer/index.ts
58
60
  agentia g prompt style-guide # 生成 src/prompts/style-guide/{index.ts,asset.md}
59
- agentia dev # tsx watch + 本地 inspector 面板
61
+ agentia dev # 本地 inspector 面板:输入 prompt 驱动一次 run + 看 trace
60
62
  ```
61
63
 
62
64
  **目录约定**:四分类目录,一能力一文件夹 —— `src/tools/` · `src/skills/` · `src/prompts/` · `src/subagents/`。
@@ -379,4 +381,4 @@ npm run e2e # 端到端:CLI 脚手架 → 装配 mock run;再真跑
379
381
  - 生产可观测配方(落库检索 / 日志关联 / 采样 / 脱敏):[`docs/observability.md`](docs/observability.md)
380
382
  - 完整示例(四类能力 + 三种触发 + 全观测栈):[`examples/complete/`](examples/complete/)
381
383
  - 最小部署示例:[`examples/deploy/`](examples/deploy/)
382
- - 官网:[agentia-web.pages.dev](https://agentia-web.pages.dev)([在线 Playground](https://agentia-web.pages.dev/playground.html) · [文档](https://agentia-web.pages.dev/docs.html))
384
+ - 官网:[agentia-web.pages.dev](https://agentia-web.pages.dev)([在线 Playground](https://agentia-web.pages.dev/playground) · [文档](https://agentia-web.pages.dev/docs))
package/dist/AGENTS.md CHANGED
@@ -83,7 +83,7 @@ console.log(result.finalText, result.stopReason, result.trace.totalUsage);
83
83
  npx @migor/cli create my-app # 首次创建:必须带 scope(短名 agentia 在 npm 上是别人的包)
84
84
  cd my-app && npm install # 框架与 CLI 都装进工程
85
85
  $EDITOR .env # 填 ANTHROPIC_API_KEY(脚手架已生成,且已被 .gitignore 挡住)
86
- npm run dev # = agentia dev:tsx watch + 本地 inspector 面板
86
+ npm run dev # = agentia dev:本地 inspector 面板(输入 prompt / 选能力 / 选工作目录)
87
87
  npx agentia g tool fetch-weather # 生成到 src/tools/fetch-weather/(skill/prompt/subagent 同理)
88
88
  npx agentia doctor # 静态体检(未登记/悬空/命名/重复)
89
89
  npx agentia --version # CLI 版本(= -v)
@@ -96,12 +96,103 @@ npx agentia --version # CLI 版本(= -v)
96
96
 
97
97
  四分类目录,一能力一文件夹:`src/tools/` · `src/skills/` · `src/prompts/` · `src/subagents/` —— **目录名就是类型**,不用记别名。每个文件夹的 `index.ts` 是入口,`default export` 支持三种形态:**类**(token = 文件夹名)、**Provider 对象**、**Provider 数组**。显式注册表在 `src/registry.ts`(`agentia g` 自动维护,也可手改)。
98
98
 
99
- > **脚手架 `src/main.ts` 怎么找这些目录**:按**本文件位置**解析(`fileURLToPath(new URL('tools/', import.meta.url))`),
99
+ ### 2.1 脚手架生成的文件(**装配与启动是分开的**)
100
+
101
+ | 文件 | 作用 |
102
+ |---|---|
103
+ | `src/app.ts` | **装配**:导出 `createAgentApp({ toolSources?, workdir? })` 工厂 + `CAPABILITY_DIRS` + `createSessionStore()`,并在模块顶部 `loadEnvFile()` 读 `.env` |
104
+ | `src/main.ts` | **启动**:薄入口 —— `createAgentApp()` → `app.run(...)`,再处理 `result.error` |
105
+ | `src/dev.config.ts` | **数据**(不是逻辑):开发期的声明,如 `multiTurn: ['trip-planner']`;见 §2.2 |
106
+ | `src/session-store.ts` | `FileSessionStore`:把多轮对话落成 `.agentia/session.json`(原子写);见 §6.4「对话历史」 |
107
+ | `src/registry.ts` | 显式注册表(`agentia g` 自动维护) |
108
+ | `src/tools/read-file/` | 示例工具:读**工作目录**下的文本文件(工作目录由 DI 注入,见 §2.2) |
109
+
110
+ **为什么拆**:`agentia dev` 的调试环要**复用同一个工厂**,才能把「这次调哪个能力 / 工作目录是哪个」
111
+ 喂进 `createApp`。所以装配必须在 `app.ts` 里、以**函数**形态存在 —— 别把 `createApp(...)` 搬回
112
+ `main.ts`(搬回去 dev 环就起不来了)。
113
+
114
+ > **脚手架 `src/app.ts` 怎么找这些目录**:按**本文件位置**解析(`fileURLToPath(new URL('tools/', import.meta.url))`),
100
115
  > 所以 dev 解析到 `src/`、`npm run build` 之后解析到 `dist/` —— 从任何目录启动都成立,也不受 cwd 影响。
101
116
  > 别改成 cwd 相对写法(形如 `src/tools` 的字符串):那样 `node dist/main.js` 会去加载 `src/` 下的 `.ts`
102
117
  > 源码,而装饰器不是可擦除的类型语法,Node 直接跑不了。空分类目录(还没有该类型的能力 ⇒ 构建后没有
103
118
  > 对应 `dist/<分类>/`)要过滤掉,否则 `discover` 会因「显式给出的路径不存在」而报错。
104
119
 
120
+ > **进程外资源的所有权**(`app.ts` 里最容易写错的一处):MCP 连接器、数据库句柄、定时器这类
121
+ > **进程外资源一律在模块作用域创建、再以 `useValue` 注入**,不许在 provider 的构造函数里建。
122
+ > 理由见 §2.2 的重启策略:框架没有 `AgentApp.close()`,构造函数里建的东西会随每次重建攒下孤儿进程。
123
+
124
+ ### 2.2 开发期调试环(`agentia dev`)
125
+
126
+ `npm run dev` 起一个**本地 inspector 面板**,四组输入都在面板上(不用改代码重跑)。
127
+ 另有两个操作按钮(都不是「装饰」—— 缺了各自的后果见下面两段):**■ 中止**(只在有 run 在飞时出现)
128
+ 与**清空对话**(只在对话视图出现时显示):
129
+
130
+ | 输入 | 口径 |
131
+ |---|---|
132
+ | **prompt** | 输入框 + `↑`/`↓` 调回历史(像 shell)。面板上「点一下 = 一次**真** run」 |
133
+ | **能力** | 多选,缺省全选。收窄的是**菜单**(主 agent 仍在环里),不是「只可能调它」—— 别的能力 `tools` 里的显式引用仍能调到被排除的那个 |
134
+ | **工作目录** | 目录选择器(`浏览…`)。**它是这个环的主控件**:任务型 agent 的 prompt 几乎不变,变的是目录。三个点击语义:点**目录名**进去 / 点 `..` 上一层 / **点文件名** = 把它**所在的**目录当工作目录(prompt 为空时顺手把文件名填进去,你写好的话一个字不动);`.git` 这类**隐藏目录**单列在选择器末尾(可达但靠后)。`浏览…` **总是**按输入框里的路径打开 —— 敲了路径再点它就是打开那个路径(不是「再点一次关掉」);点面板外面收起 |
135
+ | **多轮** | 跟能力走:`src/dev.config.ts` 里 `multiTurn: [...]` 声明哪些能力该多轮,混选时取 **OR** 且面板**标出来源**(`多轮·trip-planner`) |
136
+
137
+ `npm run dev -- "你的问题"` 可以直接把第一句 prompt 带上(等价于启动后在输入框里敲)。
138
+
139
+ **右栏是实时的(在飞就看得到)**:run 一开跑,右栏就从空树开始,**span 一个个长出来**
140
+ (`◌` = 还没结束,`●` = 已结束),收尾时用**整棵 trace** 覆盖一遍。实现走框架的增量记账出口
141
+ (`onTraceEvent`,0.8.3):runner 订阅 → 逐笔 `POST /ingest-event` → 父进程广播 → 面板按 `seq` 折回。
142
+ 两条边界(与框架同名出口一致,都写在文档里以免被当成缺陷):
143
+
144
+ - **不保证送达**:SSE 断了就断了,没有重放。所以「在飞的那棵树」是**临时的** —— 刷新页面之后
145
+ 早先那些 span 不会回来(收尾的整棵 trace 会补齐它们),**收尾那份永远覆盖在飞的这份**。
146
+ - 它只影响「此刻看到什么」,**不影响 run 本身**,也不影响落盘的 trace(那是另一条缝)。
147
+
148
+ **为什么用 `agentia dev` 而不是 `tsx watch`**:`agentia dev` 自己 import 你的 `src/app.ts` 并驱动它,
149
+ 于是**它才是调用方**,四个输入才可能生效。它也**自己管文件监视**(不再叠一层 `tsx watch`):
150
+
151
+ - **看什么**:`.ts` / `.mts` / `.cts` / `.tsx` / `.js` / `.mjs` / `.cjs` / `.json` / **`.md`** / `.env` / `.env.local`。
152
+ `.md` **必须在**里面 —— 文本资产(`@Prompt` 拉的 `.md`、子 agent 的 `system.md`)不在 tsx 的
153
+ import 图里,交给 `tsx watch` 会**静默无感**:改了没反应、也不提示。
154
+ - **看哪里**:代码与文本资产看 `<项目根>/src/` **整棵树**(含启动后新建的子目录);
155
+ `.env` / `.env.local` 在**项目根**,所以那两份**单独**盯。**只有**这两份 —— 项目根的
156
+ `README.md` / `package.json` 不触发重启(把根抬到项目根会让「改文档也重启」)。
157
+ - **不看什么**:`node_modules` / `dist` / `.git` / `.agentia` / `coverage`(`.agentia` 是 dev 环自己的
158
+ 对话历史,看它就变成「每次 run 重启一次」的自噬循环)。
159
+ - **什么时候重启**:改代码或**改能力选择** ⇒ 重启子进程;改**工作目录** / prompt / 多轮 ⇒ 不重启。
160
+ 在飞 run 期间需要重启时**延后到它结束**,不杀在飞的 run。
161
+ - **面板怎么知道它起来了**:runner 装配完成会广播 `runner-ready`,面板收到就重刷 `/api/dev`。
162
+ 少了这条事件,面板加载时那次查询(早于装配完成)拿到的 `capabilities` 是父进程的初值空数组,
163
+ 能力选择器会**空着且没有任何解释**,要等你先跑一次才填上。
164
+
165
+ **子进程是 `node <tsx/cli>`,不是 `npx tsx`**:`npx` 是包装器,它**不给孙进程转发 fd 3 的 IPC 通道**,
166
+ 于是 runner 里 `process.send` 变成 `undefined` —— 而代码写的是 `process.send?.()`(可选链),
167
+ 所以所有协议消息被**静默**丢弃:面板永远等不到 `ready`、`POST /run` 永远回不来;没有 IPC 通道后
168
+ 事件循环排空,进程还会以 `code=0` 干净退出(看起来像「用户代码自己跑完了」)。这条有 e2e 钉着
169
+ (`scripts/e2e-dev.ts`)。
170
+
171
+ **`.env` 由 `src/app.ts` 读**:runner 只 import `app.ts`、**从不执行 `main.ts`**,所以读 `.env`
172
+ 的那一行必须在装配模块里 —— 放 `main.ts` 会让 `npm run dev` 静默读不到、而 `npm start` 读得到。
173
+
174
+ **安全**:面板只绑 `127.0.0.1`,并做两层校验 —— `Origin` 校验(缺失放行,`Origin: null` 拒绝)
175
+ + **每次启动生成的一次性 token**(首帧 `?t=` 换成 `HttpOnly` cookie,后续请求带着走)。
176
+ token 的作用是挡住**本机其它进程**,别把它当网络边界:这个面板**不要**暴露到 `0.0.0.0`。
177
+
178
+ **成本**:面板缺省给每次 run 套一层预算护栏(`maxCostUsd: 1` / `maxTotalTokens: 200_000`,
179
+ 可用 `dev.config.ts` 的 `budget` 覆盖)。手快点两下就是两次真 run —— 护栏就是为这个存在的。
180
+
181
+ **会话历史**:开了多轮的能力才有对话视图,对话落在 `.agentia/session.json`(已被 `.gitignore` 挡住)。
182
+ 失败的轮次**不在会话文件里**(框架只在成功轮次回写),面板靠 trace 的 `session.id` 把它们 join 回来。
183
+ 对话视图旁边有一个**「清空对话」**按钮:清空 = **换一个 `sessionId`**(`dev` → `dev-2` → …),
184
+ **不删** `session.json` —— 旧对话还在盘上、run 列表里也还指得到,只是模型不再带着它跑。
185
+ 当前用哪个 id 记在 `.agentia/dev-session-id`(**不是** `session.json` 的一部分):只放内存的话,
186
+ 重启 `npm run dev` 之后刚清空的对话会**自己回来**。有 run 在飞时不能清空(会 409)。
187
+
188
+ **中止在飞的 run**:run 跑着的时候输入条上会多一个**「■ 中止」**按钮(没在飞时不显示)。
189
+ 点了之后:
190
+ - run 在**回合边界**以 `stopReason='aborted'` **正常收尾**,**trace 照常落盘**(面板也会说「已中止」,
191
+ 不是「失败」—— 中止的 `ok` 是 `false`,判别看 `stopReason`);
192
+ - 在飞时的第二次 `POST /run` 会被**拒绝**(409,提示先等它结束或先中止);
193
+ - ⚠️ 中断是**协作式**的:工具若不读 `signal`(例如某些 MCP 在途调用)就没人理。所以 5 秒后
194
+ **兜底重启 runner**,此时这次 run 的 trace 会丢 —— 面板会**明说**这一点(不静默)。
195
+
105
196
  > **陷阱**:装饰器注册表是模块级 `WeakMap`。框架必须是**单一模块实例** —— 混用 `src` 与 `dist`、或在一个仓库里装两份 agentia,会让能力收集为空。让 CLI 生成的 `package.json` 里只依赖一份框架即可。
106
197
 
107
198
  ---
@@ -359,11 +450,15 @@ const app = await createApp({ ... });
359
450
 
360
451
  三条语义(都刻意,别当成实现细节):
361
452
 
362
- - **框架不自动读 `.env`** —— 读哪个文件、什么时候读是宿主的启动决策。塞进 `createApp` 里自动做,会让「同一份代码换个目录跑结果不同」变成要花时间排查的悬案;而放 CLI 里只有 `agentia dev` 生效。写在**你的** `main.ts` 里,`node dist/main.js`、docker、别的宿主都一样读得到。
453
+ - **框架不自动读 `.env`** —— 读哪个文件、什么时候读是宿主的启动决策。塞进 `createApp` 里自动做,会让「同一份代码换个目录跑结果不同」变成要花时间排查的悬案;而放 CLI 里只有 `agentia dev` 生效。写在**你的** `app.ts` 里,`npm run dev`、`node dist/main.js`、docker、别的宿主都一样读得到。
363
454
  - **真实环境变量优先**(缺省不覆盖):`process.env` 里已定义(哪怕空串)的键保持不动 —— CI / docker / `FOO=bar npm start` 永远赢过文件。要让文件里的值压过环境变量就 `loadEnvFile({ override: true })`。
364
455
  - **想知道「生效没」看返回值**,别去看文件:被挡下的键不在返回对象里。
365
456
 
366
- `agentia create` 生成的脚手架把 `.env`、`.env.example` 与 `main.ts` 首行的 `loadEnvFile();` 都备好了,并在 `.gitignore` 里挡住 `.env` —— 生成 `.env` 却不 ignore,等于把 key 送进用户的第一个 commit。
457
+ `agentia create` 生成的脚手架把 `.env`、`.env.example` 与 `app.ts` 里的 `loadEnvFile();` 都备好了,并在 `.gitignore` 里挡住 `.env` —— 生成 `.env` 却不 ignore,等于把 key 送进用户的第一个 commit。
458
+
459
+ > ⚠️ **`loadEnvFile()` 必须在 `app.ts`,不能在 `main.ts`**:`agentia dev` 的 runner 只 import
460
+ > `app.ts`、**从不执行 `main.ts`**。放错一侧的失败形状是静默的 —— `npm run dev` 读不到 `.env`
461
+ > 而 `npm start` 读得到,用户只会看到「没配 key」然后去怀疑框架。这条有 e2e 钉着(`scripts/e2e-dev.ts`)。
367
462
 
368
463
  > ⚠️ **本机 export 过 `ANTHROPIC_API_KEY` 的人**(比如同时用 Claude Code):按上面的优先级,脚手架 `.env` 里的 key 会被**静默压住**。改了 `.env` 却「没生效」时,先 `echo $ANTHROPIC_API_KEY` 看看环境里是不是已经有一份。
369
464
 
@@ -822,6 +917,28 @@ async placeOrder(input: { sku: string }) {
822
917
  - 入参折叠态的摘要被砍到 62 字符(4 个键 / 每值 21 字符);**展开拿到的是原文**,不是那份摘要 ——
823
918
  本地面板与官网 Playground 两个宿主都是如此(两个宿主都不该只喂摘要,否则点开什么都没多出来)。
824
919
 
920
+ > 本地面板比 Playground 多一层**输入端**(prompt / 能力 / 工作目录 / 多轮四组控件 + 输入框历史),
921
+ > 见 §2.2。Playground 是只读展示 trace 的。
922
+
923
+ #### 对话历史(多轮)从哪来
924
+
925
+ 开了**多轮**的能力,面板上多一个「对话」页签,内容 = 会话文件里的消息 **∪ 失败的 run**。
926
+
927
+ | 概念 | 落点 | 说明 |
928
+ |---|---|---|
929
+ | 会话存储 | `SessionStore` 接口 | `load()` / `append()`;脚手架给的是 `FileSessionStore`(写 `.agentia/session.json`,**原子写**:临时文件 + rename) |
930
+ | 会话标识 | `RunInvocationOptions.sessionId` | 同 id 的多次 run 共享历史;面板的 dev 环固定用 `'dev'` |
931
+ | 回写时机 | `runtime/run.ts` | ⚠️ **只在成功轮次回写** ⇒ 失败的轮次**在会话文件里不存在** |
932
+ | 面板怎么补上失败轮 | run 根 span 的 `attributes['session.id']` | 用它与 run 列表 join,再按 prompt 文本对上;少了这一步就是「显示一份**少了**一轮的对话」 |
933
+
934
+ **两层「历史」别混**(这是开发期最容易误导人的一处):
935
+
936
+ - **prompt 回显**(输入框 `↑`/`↓`):只是把文字填回输入框,**模型看不见** —— 你还能改,
937
+ 模型收到的仍是你最终回车的那句。与多轮开关无关,**永远有**。
938
+ - **对话历史**(对话页签):前面说过的话**真的进模型上下文**(更长、更贵、可能被裁剪)。
939
+ **只在开了多轮时出现** —— 没开多轮时一次 run = 一句输入 + 一段输出,凭空多一个对话视图
940
+ 会让人以为上个仓库的评审被带进来了(它没有,而且不该有)。
941
+
825
942
  #### 调优报告(**哪个能力慢 / 贵 / 爱失败**)
826
943
 
827
944
  指标回答「整体怎么样」,报告回答「**该拧哪个旋钮**」:
@@ -872,7 +989,7 @@ const app = await createApp({ /* … */ sinks: [jsonl] });
872
989
 
873
990
  每个 run 的**根 span** 都带一组 `config.*` attributes(`config.maxTokens` / `config.maxCostUsd` /
874
991
  `config.retry.maxAttempts` / `config.contextPolicy.budgetTokens` / `config.priceOverrides` /
875
- `config.maxEventChars` …)。
992
+ `config.maxEventChars` / `config.traceLimits.maxEvents` …)。
876
993
  带缺省值的那几项(`maxTokens` / `maxIterations` / `contextPolicy` / `retry`)**缺省值也记**——
877
994
  「没配」与「配了缺省值」因此可区分;可选项(`toolTimeoutMs` / `maxToolConcurrency` /
878
995
  `maxEventChars`)只在设了才记。换参数前后对比、复现线上行为都有据可查。
@@ -1268,7 +1385,7 @@ const callable = {
1268
1385
  | 历史畸形就放弃裁剪 | `trimToolPairs` 遇到非严格交替历史会整体放弃(宁可少裁,也不切出孤立 tool_use 让请求 400) |
1269
1386
  | 同键去重的能力边界 | `idempotencyKey` 的去重分三档:**同步 store** 下 `submit` 当场返回既有记录;**异步 store**(Redis / SQLite 等)下 `submit` 是同步门面、无法 await,去重靠**进程内认领表**,只覆盖「**同进程内并发提交**」(2026-09-21 修复前这里会两次都执行);**跨进程并发**与**终态之后重提同键**仍是 at-least-once —— store 的 idem 索引是 last-wins(重提即新任务,`redisStore` / `fsStore` / `sqliteStore` 头注释同口径)。要严格一次,请让副作用自身幂等(或在 store 层做唯一约束) |
1270
1387
  | 增量出口与 sink 是**两条缝** | `onTraceEvent` 是「运行期逐笔」,`sinks` 是「收尾拿整棵」——消费者与保证都不同:sink 的抛错被吞但仍会**投递**(有兜底语义),`onTraceEvent` **不保证送达**(宿主自己的流断了就断了,没有重试/重放)。要「一条都不能少」用 `sinks`;要「现在就看到」用 `onTraceEvent` |
1271
- | 任务进度流的边界(内存 / 跨进程) | `GET /tasks/:id/stream` 的事件缓冲在**跑任务的进程内存**里:每任务最近 500 条(可用 `AsyncRunner` 的 `streamBufferEvents` 调),超限丢**最旧**并先发一帧 `stream.truncated`;终态流只留最近 16 条。**跨进程**(队列消费者在别的进程)时没有实时流,只能拿到一帧 `stream.unavailable` + 终态 —— 要跨进程实时请用 `onTraceEvent` 把事件转发到宿主自己的总线(Redis Streams / Kafka) |
1388
+ | 任务进度流的边界(内存 / 跨进程) | `GET /tasks/:id/stream` 的事件缓冲在**跑任务的进程内存**里:每任务最近 500 条(可用 `AsyncRunner` 的 `streamBufferEvents` 调,须为正整数,坏值构造期抛错),超限丢**最旧**并先发一帧 `stream.truncated`;终态流只留最近 16 条。**跨进程**(队列消费者在别的进程)时没有实时流:发一帧 `stream.unavailable` 后**立即收口** —— 任务已终态则补 `task.end`(随后关连接);**非终态**则补一帧 `stream.closed`(流级收尾,不是伪造终态)并关连接,客户端此后应转去轮询 `GET /tasks/:id`。要跨进程实时请用 `onTraceEvent` 把事件转发到宿主自己的总线(Redis Streams / Kafka) |
1272
1389
  | `traceLimits` 与 `maxEventChars` 各管一头 | `maxEventChars` 管**单个事件正文多长**(既有),`traceLimits.maxEvents` 管**整条 trace 多少个事件**(本版)。两者正交、都「不设 = 不限」;上限触发时**丢弃量写在 run 根的 `trace.truncated`** 上(不静默)。⚠️ 实测 `maxEventChars: false` + 大出参会让 trace 放大 **13.7×**(`npm run bench:trace` 可复现)—— 先收长度再谈采样,收益顺序比反过来大 |
1273
1390
  | **采样是导出决策,不是记账决策** | 采样在 `sink` 外做(配方见 `docs/observability.md` 2.3):被采样掉的 trace 在框架内**仍然完整记账**,只是没发给下游。所以别拿「有采样」当「可以少记账」;也正因如此,出站 `traceparent` 的 flags 恒 `00`(记录/导出决策发生在收尾之后,运行期不可知——不替下游声明) |
1274
1391
  | 缺省内存 store 不淘汰 | 长跑宿主请设 `InMemoryTaskStore({ maxRecords })` 或换 `FileTaskStore` / `SqliteTaskStore` |
@@ -1298,7 +1415,7 @@ const callable = {
1298
1415
  | MCP 只做 tools | `sampling`(server 反向请求模型)/ `resources` / `prompts` 原语不做;出厂连接器同样只做 `tools/list` + `tools/call` |
1299
1416
  | MCP 的协议层错误框架看不见 | `isError: true` 只有连接器能看见 —— 它必须转成抛错,否则模型收到的是一条「成功」的结果(出厂连接器已代你处理) |
1300
1417
  | MCP 超时同样是「不等了」 | 桥的 `timeoutMs` 取消不了 server 侧执行(拿不到取消句柄);它只是**兜底** —— 引擎设了 `toolTimeoutMs` 时**不参与**判定(一次调用只有一个裁判;**显式 `toolTimeoutMs: 0` 也算设了** —— 那是引擎表态「不限」,桥不会再自作主张判 60s),两条路径**同判定、同账**(`errorKind='timeout'`) |
1301
- | 已中止的 MCP 调用**不发请求** | 信号在**发送前**就已中止 ⇒ 立刻以 `AbortError` 收场,请求不出门(2026-09-21 前是「照样 write、Promise 永不 settle」:副作用真送达、调用方永久挂起)。发送**之后**才中止的,请求已在路上、取消不了 —— 那是「不等了」,与 `toolTimeoutMs` 同口径 |
1418
+ | 已中止的 MCP 调用**不发请求** | 信号在**发送前**就已中止 ⇒ 立刻以 `AbortError` 收场,请求不出门(2026-09-21 前是「照样 write、Promise 永不 settle」:副作用真送达、调用方永久挂起)。发送**之后**才中止的,请求已在路上、取消不了 server 侧的执行,但返回的 Promise 同样立刻以 `AbortError` 收场、簿记同步清掉 —— 直接 await 连接器 API 的宿主不会永久挂起(2026-09-22 前是「只删簿记不 reject」:Promise 永不 settle,与 HTTP 连接器的在途中止行为不一致);取消不了的只是 server 侧执行,这点与 `toolTimeoutMs` 同口径 |
1302
1419
  | MCP 连接器的超时只管装配期 | 连接器自带的 `timeoutMs` 只作用于**握手 + `tools/list`**(那两步**没有任何别的裁判** —— server 卡住会让 `createApp` 永久挂起);`callTool` 仍是引擎 / 桥那一个裁判 |
1303
1420
  | MCP 连接的 `close()` 保证子进程已终止 | stdio 先 `SIGTERM`、`MCP_CLOSE_GRACE_MS`(2000 ms)后 `SIGKILL`,然后**等真正的 `'exit'`** —— **返回即代表进程已被回收**(此前到点即返回,会留孤儿进程而调用方无从知晓);HTTP 尽力 `DELETE` 会话(server 不认也无所谓) |
1304
1421
  | StreamableHTTP 会话过期**自愈** | 带会话 id 收到 `404` = 会话已终止、**该请求未被 server 执行** ⇒ 丢会话 → 重新握手 → 把**这一次**重试一次(**只一次**,不再循环)。自愈本身是静默的 ⇒ 用 `onSessionExpired` 去计数 / 告警,否则它和「静默失效」在监控上看不出区别。`404` **之外**的失败仍按 `classifyError` 分流抛出,不重试 |
@@ -1328,7 +1445,7 @@ const callable = {
1328
1445
  | 同步 `/run` 撞上审批没人可批 | 同步 RPC 会带着 `stopReason: 'awaiting_approval'` 收尾返回 —— 但响应体(`toHttpBody`)**不含** `suspendedMessages`,也没有任务记录可审批(待决清单只能去 trace 的 `approval.requested` 事件里看)。**要审批请走 `POST /tasks` 异步宿主** |
1329
1446
  | Scheduler 调度表不落库 | `every` / `at` 的调度本身只在内存:已 submit 的任务记录能经 `resumePending` 续跑,但「未来某刻再触发」的调度在重启后不存在(远期单发由宿主自己的 cron 驱动)。另:`drain()` 不停 Scheduler —— 停机窗口内到点的 tick 会打一条触发失败日志(无害但吵),介意就 `scheduler.stop()` 先行 |
1330
1447
  | file store 的撕裂写只在启动时自愈 | 写入中途失败(磁盘满等)留下的残行由 `healTail` 在**构造期**修复;同进程内继续 append 会把新记录粘在残行尾部、下次启动时一起丢弃 —— 磁盘满告警后先恢复写入能力再继续依赖它 |
1331
- | 终态落库失败无告警 | AsyncRunner 终态 save 失败被吞(「不击穿主流程」的代价):store 抖动时任务可能永远停在 `running`,重启后 `resumePending` 会重跑一个**实际已成功**(副作用已发生)的任务 —— 耐久 store 的故障告警是宿主的事 |
1448
+ | 终态落库失败 ⇒ 重启会重跑 | AsyncRunner 终态 `save` 失败**不遮罩主流程**(「不击穿业务」的代价):store 抖动时任务可能永远停在 `running`,重启后 `resumePending` 会重跑一个**实际已成功**(副作用已发生)的任务 —— 所以副作用工具必须自身幂等。**但失败本身不再静默**:`new AsyncRunner(app, { onPersistError })` 会收到 `{ record, error, phase }`(`phase: 'initial' \| 'outcome'`,后者就是这条)。⚠️ 框架**修不了**它(写不进去就是写不进去)—— 出口的职责是让你能对账、让「记录无声丢失」不再是默认行为(与 `createOtlpExporter({ onExportError })` 同因同形) |
1332
1449
  | `contextPolicy` 不进子循环 | 应用级 `contextPolicy` / `onText` 只对主循环生效:@SubAgent / @Skill 的子运行不做上下文裁剪(长跑子 agent 撞上下文上限会以 api 错误收尾)。预算护栏(`maxTotalTokens` / `maxCostUsd`)正常透传 |
1333
1450
  | 记忆没有删除语义 | `MemoryStore` 只有 load/save:run 内 `ctx.delete` 掉的键回写时不会从 store 移除(下一轮水合会复活)。要真删请直接操作 store 实现 |
1334
1451
  | 内容护栏不给实现 | 同「配额」:只给缝(入参包 `app.run` / 工具前 `middleware` / 出参包返回值或 `sinks`),策略(正则 / 分类器 / 外部 API)是你的 |
@@ -14,15 +14,27 @@
14
14
  * 不限。所以:
15
15
  *
16
16
  * - 表与实现漂了 ⇒ 集中用例红(不是文档过期,是构建失败);
17
- * - 新增一个旋钮却忘了归类 ⇒ 用例里 `Record<LimitKnob, …>` 少一项,`typecheck:tests` 红。
17
+ * - 表加了旋钮而探针没加 ⇒ 用例里 `Record<LimitKnob, …>` 少一项,`typecheck:tests` 红。
18
+ *
19
+ * ⚠️ 守卫方向的如实口径:`LimitKnob` 类型**来自这张表本身**,所以
20
+ * `Record<LimitKnob, …>` 只守得住「表 ↔ 探针」这一个方向的对账(表加了没探针、
21
+ * 探针加了表里摘了,都会被拦住);它**抓不到**「代码里新增了旋钮却根本没进表」——
22
+ * 那一步只能靠改代码的人主动登记(这张表就是登记处),别把它当成全自动的网。
23
+ * 表与探针的互查另有一条运行期用例兜底(`tests/limits.test.ts` 末尾)——
24
+ * tsx 不跑类型检查,光有 `Record` 的编译期守卫在单跑测试时看不见。
18
25
  *
19
26
  * ⚠️ 本模块**不是公共 API**(不进 `src/index.ts`):它是内部口径的真源,不是给使用者的旋钮。
20
27
  * 面向使用者的口径在 `docs/usage-guide.md` 的「边界」一节。
21
28
  *
22
- * ⚠️ 表里的 `zeroClause` 是**报错文案的一部分**:四处构造期校验(`runTimeoutMs` /
23
- * `approvalTimeoutMs` / `traceLimits.maxEvents` / `maxRetries`)把它插进错误消息里
29
+ * ⚠️ 表里的 `zeroClause` 是**报错文案的一部分**:有构造期校验的旋钮由实现代码直接把它插进
30
+ * 错误消息里(`runTimeoutMs` / `approvalTimeoutMs` / `traceLimits.maxEvents` / `maxRetries` /
31
+ * `streamBufferEvents` / `retainTerminal`)
24
32
  * (`…(0 = 不重试),收到 -1 —— …`)。所以「文案里怎么解释 0」与「代码里怎么实现 0」
25
33
  * 是同一个值,改一处等于同时改另一处 —— 这正是先前缺的那条单源。
34
+ *
35
+ * ⚠️ **别在这里写死「几处」**:计数会随旋钮增减立刻过期 —— 本行曾写「五处」并列举五个,
36
+ * 而漏掉的正是**同一次改动里新增**的 `retainTerminal`(实为六处)。要核对当前全集:
37
+ * `grep -rn 'zeroClauseOf' src/`。清单本身有价值,计数没有。
26
38
  */
27
39
  /**
28
40
  * 一个旋钮在取 `0`(或「非正数」)时的**含义分类**。四选一,没有第五种:
@@ -139,6 +151,14 @@ export declare const LIMIT_SEMANTICS: readonly [{
139
151
  readonly zeroClause: '非正 = 不设超时(原样透传)';
140
152
  readonly badValue: 'none';
141
153
  readonly note: '`toolTimeoutMs` 的底座。⚠️ 漏透传这个值不是「少一层保险」而是**反的**:`withTimeout(p, 0)` 直接返回原 promise = 永不超时,同时 MCP 桥找不到引擎预算又起自己的 60s 兜底 ⇒ 双计时器、双账本(`toolkit/subagent.ts` / `skill.ts` 的透传注释指的就是这条)。';
154
+ }, {
155
+ readonly knob: 'TaskEventStreams.retainTerminal';
156
+ readonly where: 'transport/task-events.ts';
157
+ readonly unit: 'count';
158
+ readonly zero: 'disabled';
159
+ readonly zeroClause: '0 = 不留终态流(终态即忘)';
160
+ readonly badValue: 'throws';
161
+ readonly note: '判定是 `excess <= 0 提前返回` ⇒ NaN 恒假、**全部**无订阅者的终态流被清空(保留机制静默失效,方向与 maxEvents 的「闸失效」相反、同族)。⚠️ 只跳过「还有订阅者」的终态流 —— 那是还没被读完的流,丢了下游会莫名断在半路。';
142
162
  }, {
143
163
  readonly knob: 'interruptibleSleep.ms';
144
164
  readonly where: 'core/timeout.ts';
@@ -163,6 +183,14 @@ export declare const LIMIT_SEMANTICS: readonly [{
163
183
  readonly zeroClause: '必须为正数(0 = 没有 worker)';
164
184
  readonly badValue: 'throws';
165
185
  readonly note: '0 个 worker 的池子会让每个任务永远排在队列里(既不跑也不失败)。';
186
+ }, {
187
+ readonly knob: 'AsyncRunner.streamBufferEvents';
188
+ readonly where: 'transport/task-events.ts';
189
+ readonly unit: 'count';
190
+ readonly zero: 'invalid';
191
+ readonly zeroClause: '必须为正安全整数(0 没有「缓冲几条」的读法)';
192
+ readonly badValue: 'throws';
193
+ readonly note: '0 既不是「关掉流」(live 推送仍在工作)也不是「不限」,三种合法读法都不沾 ⇒ 配置错误;旧实现 `Math.max(1, …)` 把 0 静默抬成 1(替使用者改配置),且 NaN 穿过后 `length > NaN` 恒假 ⇒ 每任务内存闸静默失效(使用者以为设了上限)。';
166
194
  }, {
167
195
  readonly knob: 'Scheduler.every.intervalMs';
168
196
  readonly where: 'transport/scheduler.ts';