@epoch-agent/protocol 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +2037 -1645
  2. package/dist/index.js +46 -6
  3. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -2026,6 +2026,18 @@ interface EpochConfig {
2026
2026
  */
2027
2027
  maxTurns: number;
2028
2028
  contextLength: number;
2029
+ /**
2030
+ * 单次模型请求的输出上限,原样递给 provider 的 `maxOutputTokens`(2026-09-16)。
2031
+ *
2032
+ * **缺省 = 整格不发**,那时出站请求里没有 `max_tokens`、各家服务端按自己的
2033
+ * 默认值办(DeepSeek 是 4K,而它的思维链和正文共用这个预算)。判据全文
2034
+ * ——包括「为什么不默认套用能力表里那个数」——在 core 的
2035
+ * `config/schema.ts` 同名字段上。
2036
+ *
2037
+ * ⚠️ 别和 {@link EpochConfig.budget} 里的 `maxTokens` 弄混:那个是**整个会话**
2038
+ * 的成本闸门,这个是**单次请求**能生成多长。两者量纲不同。
2039
+ */
2040
+ maxOutputTokens?: number;
2029
2041
  homeDir: string;
2030
2042
  dbPath: string;
2031
2043
  /**
@@ -3375,1987 +3387,2085 @@ declare const METRIC: {
3375
3387
  declare const NOOP_TELEMETRY: Telemetry;
3376
3388
 
3377
3389
  /**
3378
- * 结构化提问契约(方案 34)—— 模型遇到分叉时能问一句,而不是瞎猜。
3379
- *
3380
- * ## 它和审批是两件事
3390
+ * 会话消息契约。
3381
3391
  *
3382
- * 审批问的是「**允不允许**我做这件事」,答案是四个固定出口,且**默认往严的方向落**
3383
- * (走开 = 拒绝)。提问问的是「**你想要哪一种**」,答案是内容,而且
3384
- * **没有安全的默认值** —— 替用户挑一个和替他点「允许」一样糟,只是失败的方式不同:
3385
- * 前者让模型基于一个假答案干半小时活。
3392
+ * 这是我们**自己的**消息格式,不是 Vercel AI SDK 的 `ModelMessage`。
3386
3393
  *
3387
- * 所以这里刻意**不提供**任何「宿主答不上来时的兜底答案」。答不上来只有一种表达:
3388
- * `skipped: true`(见 {@link QuestionAnswer})。
3394
+ * 为什么要有这么一层:改造前 AgentLoop、message-codec、以及 AgentProvider
3395
+ * 这个「依赖倒置接口」全都直接用 `ModelMessage`。后果有三个:
3396
+ * 1. 依赖倒置是假的 —— 想塞一个脚本化的假 provider 做测试,得先 import 'ai'
3397
+ * 2. AI SDK 的消息形状是**联合类型 + 部件数组**,`content` 可能是 string
3398
+ * 也可能是 `Array<{type:'text'|'tool-call'|...}>`,于是 loop 里到处
3399
+ * `as Array<Record<string, unknown>>` 地摸字段
3400
+ * 3. SDK 换大版本(v5→v6→v7 每次都动过消息形状)要改的是整个 agent 层,
3401
+ * 而不是一个转换函数
3389
3402
  *
3390
- * ## 为什么不过权限层
3403
+ * 形状故意和 SQLite 里持久化的 `Message`(core/session/types.ts)对齐 ——
3404
+ * 一份扁平 content + 可选的工具部件。两边不一致的话,存盘和回放各自要一套转换。
3391
3405
  *
3392
- * 提问不碰文件、不起进程、不联网。方案 34 §三把 `core/src/permission/**`
3393
- * 列进禁止触碰,就是这个意思 —— 给它加一道权限判定,等于让 `deny` 规则能把
3394
- * 「问一句」拦掉,而拦掉的表现是模型继续瞎猜。
3406
+ * 转成 SDK 格式只发生在一个地方:core/provider/sdk-adapter.ts。
3395
3407
  */
3396
- /** 一个选项。**没有「其他」** —— 自由输入由宿主自动提供,不占模型的选项额度 */
3397
- interface QuestionOption {
3398
- /** 选项正文,用户看到的就是它,模型收到的答案也是它 */
3399
- label: string;
3400
- /** 这个选项意味着什么 / 选了会发生什么 */
3401
- description: string;
3408
+
3409
+ /** 模型请求执行的一次工具调用 */
3410
+ interface EpochToolCall {
3411
+ toolCallId: string;
3412
+ toolName: string;
3413
+ /** 模型给出的入参 */
3414
+ input: Record<string, unknown>;
3402
3415
  }
3403
- /** 一个问题 */
3404
- interface QuestionItem {
3405
- /** 完整问句,带问号 */
3406
- question: string;
3407
- /** 极短标签,UI 上当 chip 用(≤ {@link MAX_HEADER_CHARS} 字) */
3408
- header: string;
3409
- /** {@link MIN_OPTIONS}–{@link MAX_OPTIONS} 个选项 */
3410
- options: QuestionOption[];
3411
- /** 多选(答案是数组)还是单选(答案是一个字符串) */
3412
- multiSelect: boolean;
3416
+ /** 一次工具执行的结果 */
3417
+ interface EpochToolResult {
3418
+ toolCallId: string;
3419
+ toolName: string;
3420
+ /** 输出的文本形式。工具的返回值统一收成字符串,不往下传结构 */
3421
+ output: string;
3422
+ /**
3423
+ * 二进制产物。**只有过完四道闸门、带 `data` 的才会真的进模型上下文**
3424
+ * (见 core/src/context/artifact-gate.ts);否则这里只有 summary + path,
3425
+ * `output` 里已经含了那句 summary。
3426
+ */
3427
+ artifacts?: ToolArtifact[];
3428
+ }
3429
+ /** 文本部件 */
3430
+ interface EpochTextPart {
3431
+ type: 'text';
3432
+ text: string;
3413
3433
  }
3414
3434
  /**
3415
- * 一次结构化提问。
3435
+ * 图片部件。
3416
3436
  *
3417
- * **刻意没有 `requestId`**(方案初稿里有)。跨进程时的凭据由
3418
- * `runtime/src/serialize.ts` 的 `toSerializable()` 生成,和 `approval-request`
3419
- * 逐字同构 —— 请求内容里再带一个 id 的话,进程内的宿主(TUI)手上会有一个
3420
- * 它永远不用的字段,而跨进程那侧有两个候选 id,对不齐时没人说得清该信哪个。
3437
+ * `image` 三种形态:裸 base64、`data:` URL、`http(s)` URL。
3438
+ * 字段名是 `mediaType` 不是 `mimeType` —— 与 `@ai-sdk/provider-utils` 的
3439
+ * `ImagePart` 对齐,省掉边界上的一次改名。
3421
3440
  */
3422
- interface QuestionRequest {
3423
- /** 一次最多 {@link MAX_QUESTIONS} 个问题(超过就该拆成两轮对话了) */
3424
- questions: QuestionItem[];
3441
+ interface EpochImagePart {
3442
+ type: 'image';
3443
+ image: string;
3444
+ mediaType?: string;
3425
3445
  }
3446
+ /** 附件为什么只有路径没有内容 */
3447
+ type FileOmitReason =
3448
+ /** 探测到 NUL 字节,是二进制 */
3449
+ 'binary'
3450
+ /** 权限判定没放行(与 `file_read` 同一条判定) */
3451
+ | 'denied'
3452
+ /** 读不到(不存在 / 没权限 / 是目录) */
3453
+ | 'unreadable'
3454
+ /** 这一条消息附的文件个数或总量超了上限 */
3455
+ | 'over-limit'
3426
3456
  /**
3427
- * 宿主的答复。
3457
+ * 会话是 resume 出来的,附件内容**不重读**。
3428
3458
  *
3429
- * `answers` 的键是 {@link QuestionItem.question} 原文 —— 不是下标。模型写出问句
3430
- * 的时候就知道自己在问什么,拿问句当键它读答案不用再对一次序;而下标一旦和
3431
- * 宿主的呈现顺序错开(多问题逐个走完再一起回),错位是静默的。
3459
+ * 内容当初是过了权限判定才附上的,而那次判定发生在另一个上下文里
3460
+ * (当时的权限级别、当时的信任状态)。恢复时无声地再读一遍磁盘等于让一次
3461
+ * 旧的授权在新的上下文里继续生效。模型仍然看得见路径,要内容就自己调
3462
+ * `file_read` —— 那条路有完整的审批。
3463
+ */
3464
+ | 'not-restored';
3465
+ /**
3466
+ * 文件附件部件 —— 用户用 `@路径` 提到的文件(方案 25)。
3432
3467
  *
3433
- * `skipped` 是方案 §2.4 那条「用户 Esc → 模型自己决定,整轮不失败」在类型上的
3434
- * 落点。它和「`answers` 是空对象」**不等价**:用户答完前两问才按 Esc 时,
3435
- * 前两个答案照样交出去,同时 `skipped: true` 告诉模型「剩下的没答上,别当成
3436
- * 用户选了默认」。
3468
+ * ## 为什么是一种部件,而不是把内容拼进文本里
3469
+ *
3470
+ * 拼字符串看起来省事,代价有四个,每一个都要在很久以后才暴露:压缩器分不清
3471
+ * 「用户打的字」和「附的文件」于是压错东西、token 统计把文件算成用户输入、
3472
+ * artifact 化那条路完全绕过、web 宿主收到的 wire 载荷里两者无法区分。
3473
+ * 单独一种部件之后,这四件事各自都有地方判。
3474
+ *
3475
+ * ## `text` 缺席是常态,不是异常
3476
+ *
3477
+ * 二进制、超限、权限没放行时**只留路径**。这时候模型看到的是「用户提到了这个
3478
+ * 文件,但内容没给你,原因是 X」—— 它可以自己去调 `file_read`(那条路有完整的
3479
+ * 审批流程)。比起静默附上或者静默丢掉,这是唯一不会让人误判的形态。
3437
3480
  */
3438
- interface QuestionAnswer {
3439
- /** question 原文 → 选中的 label(多选是数组)。用户自由输入时是他打的原文 */
3440
- answers: Record<string, string | string[]>;
3441
- /** 用户中途放弃(Esc / 关掉弹窗)。已答的那几个仍在 `answers` 里 */
3442
- skipped?: boolean;
3481
+ interface EpochFilePart {
3482
+ type: 'file';
3483
+ /** 用户敲的那个路径(相对工作区,`/` 分隔),原样保留用于显示 */
3484
+ path: string;
3485
+ /** 文件内容。**缺席时看 `omitted`** */
3486
+ text?: string;
3487
+ /** 没给内容的原因;给了 `text` 时不出现 */
3488
+ omitted?: FileOmitReason;
3489
+ /** 内容被截断到多少字节。给了就是截断过 —— 截断**永远不静默** */
3490
+ truncatedTo?: number;
3491
+ /** 文件原始字节数,用于展示 */
3492
+ bytes?: number;
3443
3493
  }
3444
3494
  /**
3445
- * 一次最多几个问题。
3495
+ * 引用的会话为什么只有标题没有内容(方案 53 §4.2)。
3446
3496
  *
3447
- * 超过就该拆成两轮对话 —— 四个连续弹窗已经是用户能忍的上限,而模型一次问八个
3448
- * 通常意味着它没读上下文。工具会**拒绝**并把这个上限说给模型(验收 5)。
3497
+ * 前五个是**稳定的错误码**,给宿主路由用(headless / 嵌入宿主要能按码分流,
3498
+ * TUI 和 Web 要各自渲染)。清单本体是 {@link SESSION_REFERENCE_ERROR_CODES},
3499
+ * 那才是同构位置的唯一真源。
3449
3500
  */
3450
- declare const MAX_QUESTIONS = 4;
3451
- /** 每个问题的选项数下界。一个选项不是问题,是通知 */
3452
- declare const MIN_OPTIONS = 2;
3453
- /** 每个问题的选项数上界。超过四个用户就开始扫而不是读了 */
3454
- declare const MAX_OPTIONS = 4;
3455
- /** `header` 的长度上界。它是 UI 上的 chip,长了会把弹窗撑破 */
3456
- declare const MAX_HEADER_CHARS = 12;
3501
+ type SessionOmitReason =
3502
+ /** `@:xxx` 匹配不到**唯一**的一段会话(零个,或好几个) */
3503
+ 'invalid-reference'
3504
+ /** 引用的是当前这段会话自己 */
3505
+ | 'self-reference'
3506
+ /** 超过每条消息能引用的会话个数 */
3507
+ | 'too-many'
3508
+ /** 会话可读性判定不放行(方案 48 的那一处判定,见 core/session/authorization.ts) */
3509
+ | 'not-authorized'
3510
+ /** 这一条消息的附件总量已经用完 */
3511
+ | 'budget-exceeded'
3457
3512
  /**
3458
- * 收窄**外部来的**答复载荷。
3513
+ * 会话是 resume 出来的,引用的内容**不重读**。
3459
3514
  *
3460
- * 需要它的是跨进程的宿主(headless 的 `question-response`、`epoch web` 的 REST):
3461
- * 那条路上答复是一段 JSON,不过校验直接喂给引擎等于让宿主随手拼个
3462
- * `{"answers": 3}` 就让模型读到一个数字。进程内的宿主由类型保证,用不到它。
3515
+ * 判据逐字同 {@link FileOmitReason} 的 `not-restored`:内容当初是过了可读性
3516
+ * 判定才附上的,而那次判定发生在另一个上下文里。恢复时无声地再读一遍等于让
3517
+ * 一次旧的授权在新的上下文里继续生效。模型仍然看得见「用户引用过这段会话」,
3518
+ * 要内容就自己调 `session_search`(方案 48 那条路,有工具审批)。
3463
3519
  *
3464
- * 和 `isApprovalOutcome` 同一条理由住在 protocol:判据只有一处,
3465
- * 两个出口(stdin / REST)各写一遍就会有一边漏掉数组那一种形态。
3520
+ * **它不是错误码**,所以不在 {@link SESSION_REFERENCE_ERROR_CODES} 里。
3466
3521
  */
3467
- declare function isQuestionAnswerMap(value: unknown): value is Record<string, string | string[]>;
3468
-
3522
+ | 'not-restored';
3469
3523
  /**
3470
- * Agent 事件契约 —— 引擎 → 宿主(TUI / CLI / 未来的 server)的唯一协议。
3524
+ * 五个稳定的错误码(方案 53 §4.2)—— **同构位置的唯一真源**。
3471
3525
  *
3472
- * 为什么是判别联合而不是「一个 type 字段 + 一堆可选字段」:
3473
- * 旧的 StreamChunk / StreamEvent 是后者,所有载荷字段都是 optional,于是
3474
- * - 三个包各写一份、字段名还不一样(input/args、success/isError),
3475
- * 靠 cli 里一段手写适配缝合;漏一个字段编译器不报错,只是界面上少显示东西。
3476
- * - 协议里没有 callId,TUI 只能靠「toolName + 调用顺序 FIFO」猜工具结果
3477
- * 属于哪次调用,并行工具就会串台。
3478
- * 判别联合让每个事件的必填字段由编译器保证,callId 成为一等字段。
3526
+ * 为什么要稳定的码而不是一句话:headless / 嵌入宿主要能路由这些失败
3527
+ * (`docs/HEADLESS.md` 的退出码那套已经是这个思路),而且 TUI 和 Web 要各自渲染。
3528
+ *
3529
+ * `not-restored` 刻意不在里面 —— 它是「会话恢复了」这个事实,不是一次失败。
3479
3530
  */
3480
-
3531
+ declare const SESSION_REFERENCE_ERROR_CODES: readonly SessionOmitReason[];
3481
3532
  /**
3482
- * Token 用量。
3533
+ * 引用另一段会话的部件 —— 用户用 `@:xxx` 提到的那段会话(方案 53)。
3483
3534
  *
3484
- * 之前 protocol 侧和 core/context/types.ts 各有一个 TokenUsage,
3485
- * 后者多两个字段——同名不同义,收成一个超集。
3535
+ * ## 塞进来的是那段会话的「当前面」,不是原始全量(§二)
3486
3536
  *
3487
- * ## 五个 token 字段的口径(方案 42 PR-0)
3537
+ * 一个跑了两小时、压缩过三次的会话,`messages` 表里有 800 条,但模型当时能看到
3538
+ * 的只有 60 条(其余 `active = 0`)。这里带的是**那 60 条**。三条理由:800 条
3539
+ * 任何预算都塞不下;那 60 条就是这段会话的结论(压缩器已经把前面浓缩进去了);
3540
+ * 原始全量里有已经被推翻的内容(「先试了 A 不行改用 B」,压缩之后留下的是 B)。
3488
3541
  *
3489
- * **两种关系并存,别一视同仁地相加**:
3542
+ * ## 它是**快照**,不是活引用
3490
3543
  *
3491
- * | 字段 | 与谁的关系 |
3492
- * | ------------------------------- | --------------------------- |
3493
- * | `cacheHitTokens` `cacheWriteTokens` | 与 `inputTokens` **不重叠** |
3494
- * | `reasoningTokens` | **包含在** `outputTokens` 里 |
3495
- * | `totalTokens` | 输入总量 + 输出总量 |
3544
+ * 引用是发消息那一刻的快照,之后源会话被归档、删除、继续聊都不影响这一条。
3545
+ * **别去做「引用失效」的检测** —— 那等于让一条已经发出去的消息随时间变形。
3496
3546
  *
3497
- * 所以「一共多少 token」= `inputTokens + outputTokens + cacheHit + cacheWrite`,
3498
- * **不加** `reasoningTokens`。计价同理,见 `core/src/cost/pricing.ts` 文件头。
3547
+ * ## `text` 缺席是常态
3499
3548
  *
3500
- * 这个口径不是我们凭空定的,是 AI SDK v7 那边就这么分的:
3501
- * `inputTokenDetails.noCacheTokens + cacheReadTokens + cacheWriteTokens
3502
- * === inputTokens`,而 `outputTokenDetails.textTokens = outputTokens - reasoning`。
3549
+ * 匹配不到、引用自己、超上限、判定不放行时**只留 `ref` 和原因**。这时候模型
3550
+ * 看到的是「用户引用了某段会话,但内容没给你,原因是 X」—— 要内容它可以自己调
3551
+ * `session_search`(方案 48)。这保证了 `@:` 永远不会成为一条比 `session_search`
3552
+ * **更宽松**的读取通道,和 `@文件` 对 `file_read` 那条判据逐字对应。
3503
3553
  */
3504
- interface TokenUsage {
3505
- /**
3506
- * **非缓存**的输入 token,即 SDK 的 `inputTokenDetails.noCacheTokens`。
3507
- *
3508
- * ⚠️ 不是 SDK 的 `usage.inputTokens`(那个是含缓存的输入总量)。填总量会让
3509
- * 缓存命中被按原价算第二遍 —— 缓存读通常只要原价的 10%,这两个数差一个量级。
3510
- */
3511
- inputTokens: number;
3512
- outputTokens: number;
3513
- /** 缓存命中读到的输入 token,与 `inputTokens` 不重叠,单独计价 */
3514
- cacheHitTokens?: number;
3515
- /** 写入缓存的输入 token,与 `inputTokens` 不重叠,单独计价 */
3516
- cacheWriteTokens?: number;
3517
- /**
3518
- * 推理模型的思考 token(SDK 的 `outputTokenDetails.reasoningTokens`)。
3519
- *
3520
- * ⚠️ 它**包含在 `outputTokens` 里**,和上面两个缓存字段的口径正好相反。
3521
- * 只用来展示「这一轮想了多少」,**不许再加进总数、也不单独计价**,
3522
- * 否则输出侧被算两遍。provider 不是推理模型 / 不上报时为 undefined。
3523
- */
3524
- reasoningTokens?: number;
3525
- /**
3526
- * SDK 报的总数:输入总量(含缓存两项)+ 输出总量。
3527
- *
3528
- * 要显示「一共多少 token」优先用这个,自己四项相加的只当兜底 ——
3529
- * 前者是 provider 报的两个总量直接相加,后者要先经过我们的拆分算术
3530
- * (`inputTokens` 取的是 noCache),多一层就多一个出错的地方。
3531
- * provider 没上报用量时为 undefined。
3532
- */
3533
- totalTokens?: number;
3534
- /**
3535
- * 本次用量折算的美元金额。模型没有定价数据时为 undefined,**不要填 0**。
3536
- *
3537
- * 0 意味着「免费」,undefined 意味着「不知道」。预算闸门对这两种情况的处理
3538
- * 必须不同——未知定价不能当免费放行。
3539
- */
3540
- costUsd?: number;
3554
+ interface EpochSessionPart {
3555
+ type: 'session';
3556
+ /** 用户敲的那个引用串(`@:` 后面那一截),原样保留用于显示 */
3557
+ ref: string;
3558
+ /** 匹配到的会话 id。**没匹配上时缺席** */
3559
+ sessionId?: string;
3560
+ /** 那段会话的标题(空标题时是 sessionId,见方案 53 §5.3) */
3561
+ title?: string;
3562
+ /** 那段会话的工作目录,用于让人 / 模型看出「这是哪个项目的会话」 */
3563
+ cwd?: string;
3564
+ /** 当前面的正文。**缺席时看 `omitted`** */
3565
+ text?: string;
3566
+ /** 没给内容的原因;给了 `text` 时不出现 */
3567
+ omitted?: SessionOmitReason;
3568
+ /** 内容被截断到多少字节。给了就是截断过 —— 截断**永远不静默** */
3569
+ truncatedTo?: number;
3570
+ /** 当前面有多少条消息(`active = 1` 的那些) */
3571
+ messageCount?: number;
3572
+ /** 那段会话一共多少条(含被压掉的)。两个数不等就说明它压缩过 */
3573
+ totalMessages?: number;
3541
3574
  }
3575
+ type EpochContentPart = EpochTextPart | EpochImagePart | EpochFilePart | EpochSessionPart;
3576
+ /** 用户消息内容 —— 纯文本或多模态部件数组 */
3577
+ type EpochUserContent = string | EpochContentPart[];
3542
3578
  /**
3543
- * 预算触顶的原因。
3544
- *
3545
- * 住在 protocol 而不是 `core/src/cost/types.ts`:它出现在 `finish` 事件上,
3546
- * 于是**跨进程的宿主也要认它** —— headless 的 `result.reason` 要靠它区分
3547
- * 「这个月的额度用完了」和「这一次调用的 `--max-budget-usd` 到了」。
3548
- * core 那边 re-export 这一份,不另立一个同名类型。
3549
- */
3550
- type BudgetBreach =
3551
- /** 金额超过 `budget.maxCostUsd`(跨会话累计) */
3552
- 'cost'
3553
- /** token 数超过 `budget.maxTokens`(跨会话累计) */
3554
- | 'tokens'
3555
- /** 模型无定价数据,且 `budget.onUnknownPricing` 为 `'block'` */
3556
- | 'unknown-pricing'
3557
- /**
3558
- * **本次调用**的金额上限到了(`RunInput.maxCallCostUsd` / `--max-budget-usd`)。
3579
+ * 附件在**给模型的**纯文本投影里长什么样。**不吐文件内容** —— 内容走 `parts` 那一路。
3559
3580
  *
3560
- * 和 `'cost'` 分开是因为处理方式不同:`'cost'` 意味着用户配的那份长期预算见底了,
3561
- * 要么等下个周期要么调高上限;`'call-cost'` 只是这一次跑得比预期贵,
3562
- * 换个说法再问一次就好。CI 的退出码也据此分叉。
3581
+ * 输出**逐字冻住**:门禁在 `packages/protocol/__tests__/message-projection.test.ts`,
3582
+ * 它把五种 `omitted` 原因加截断、不截断两支全钉死了。改一个字那份会当场红 ——
3583
+ * 那不是「用例过时了」,是你正在改模型的输入契约(判据见 {@link OMIT_LABEL})。
3563
3584
  */
3564
- | 'call-cost';
3565
- /** 一次运行的收尾原因 */
3566
- type FinishReason =
3567
- /** 模型正常收工 */
3568
- 'stop'
3569
- /** 用尽轮次预算 */
3570
- | 'max-turns'
3571
- /** 死循环检测触发 —— 连续多轮重复同一个工具调用 */
3572
- | 'stalled'
3585
+ declare function filePartSummary(part: EpochFilePart): string;
3573
3586
  /**
3574
- * 模型在**一次回复内**开始复读,被我们掐掉了(core 的 `repetition-detector.ts`)。
3575
- *
3576
- * ⚠️ **和 `'stalled'` 刻意分开。** 那一档是「连续多轮调同一个工具」,
3577
- * 用户能做的是换个说法或者自己接手;这一档整轮工具调用为零,是解码退化,
3578
- * 处置是重发一次(大概率就好了)。混成一个值之后,界面上只能给出一句
3579
- * 同时对两种情况都不准的话。
3587
+ * 历史里的图被**摘掉**之后留在原位的那一行(2026-09-12)。
3580
3588
  *
3581
- * ⚠️ **也不是 `'length'`。** 那一档是 provider 按 max output tokens 切的、
3582
- * 那半截正文是模型真想说的;这一档是**我们**在它撞上上限之前主动停的,
3583
- * 已经产出的正文留着但没有价值。两者在账上差一整个输出预算 ——
3584
- * 现场那一份是 31.6 KB 里只有 12 种句子。
3585
- */
3586
- | 'looping'
3587
- /** 被宿主中止 */
3588
- | 'aborted'
3589
- /** 模型安全策略拒答 —— 是 **provider** 拒答,不是我们拦的 */
3590
- | 'content-filter'
3591
- /**
3592
- * 我们自己的内容合规闸门拦下了(core 的 `compliance/`)。
3589
+ * ## 它补的是一个静默的口子,不是「又一个占位符」
3593
3590
  *
3594
- * ⚠️ **刻意不复用 `content-filter`。** 那个值的含义是「provider 拒答了」,
3595
- * 处置和说法都不一样:那一档用户能做的是换个说法再问,这一档是内容触了
3596
- * 部署方配的合规红线(词表 + 类别档位),换说法不一定有用,而且该看的是
3597
- * 「哪个类别」。混成一个值之后,界面上只能给出一句同时对两种情况都不准的话。
3591
+ * 喂模型的历史里图片是 base64 部件(`session-store.loadHistory()` 把
3592
+ * `inlineImages` 写死成 true)。而「当前模型认不认图」那道闸门在
3593
+ * `core/agent/loop.ts` 上,读的是**这一轮用户新发的图**
3594
+ * (`imageParts(input.userMessage)`)—— 也就是说换到一个不认图的模型之后只要
3595
+ * 不再发新图,历史里那些图会原样发出去:provider 那侧要么 400、要么**一声不吭
3596
+ * 地吞掉**(后者实测存在,判据全文在 `core/provider/capability-probe.ts` 的
3597
+ * 文件头)。摘掉它们的是 `AgentLoop`,留下的那一行是这一个。
3598
3598
  *
3599
- * 输入侧被拦下时也是这个值 —— 那一次**请求根本没发出去**(`usage` 全零),
3600
- * 与输出侧被拦下的区别在文本里说清,不在这个枚举里分叉。
3601
- */
3602
- | 'compliance'
3603
- /**
3604
- * provider 把这一轮的回复**按 max output tokens 截断了**(2026-09-04)。
3599
+ * ## 为什么不复用 `contentToText` 那个 `[图片 image/jpeg]`
3605
3600
  *
3606
- * ## 为什么必须和 `'stop'` 分开
3601
+ * 那一个是**另一个消费者**的投影(`imagesCarriedAsParts: false`,喂压缩器 /
3602
+ * 审计 / `messages.content` 那一列),而且它有前科:图片真的送达之后,它成了
3603
+ * 「唯一还在撒谎的地方」(判据在 {@link ContentToTextOptions.imagesCarriedAsParts})。
3604
+ * 这一行要说的是另一件事 —— 图**确实没给它**,以及为什么。
3607
3605
  *
3608
- * 分不开的时候,「模型自己收的」和「话没说完就被切了」在事后**无法区分** ——
3609
- * 两者在界面上长得一模一样(都是「这一轮没有工具调用」)。而这是所有
3610
- * 「模型半路停下」类问题的**第一个分诊问题**:一句
3611
- * 「我先确认苏菲店的信息,再拉昨日数据。」+ 0 个工具调用,到底是模型宣布了
3612
- * 下一步就收工(该由 `verify:stop` 那道闸救),还是它正要发工具调用时
3613
- * 被截断(该调高 max output tokens)—— 处置完全相反。
3606
+ * 措辞里带上「不支持」这半句,判据同 `agent/prompt.ts` 里那条
3607
+ * 「不支持那一档要说清后果,别笼统一句不支持」:只写「图片未附上」的话,
3608
+ * 模型会去猜是不是传坏了,然后建议用户重发一次 —— 而重发多少次都一样。
3614
3609
  *
3615
- * 实测过一次:`sessions.db` 里 687 条消息的 `finish_reason` 列**全是 NULL**,
3616
- * 而 `AgentLoop` 当时只判了 `'content-filter'`,没有任何一处判 `'length'`。
3617
- * 那一轮到底是哪一种,事后答不出来。
3610
+ * ⚠️ **不给「你可以自己去读」这类下一步**,和 `OMIT_LABEL` 的 `binary` 那档
3611
+ * 不同:那一档有 `file_read` 真的能拿到内容,这一档模型没有任何办法看见像素。
3612
+ * 给一个不存在的出口,比不给更糟。
3618
3613
  *
3619
- * ⚠️ **这一档不是「出错了」。** 请求成功、已经计费、那半截正文是真的,
3620
- * 只是它不完整。宿主该说的话是「回复被截断了」而不是「失败了」。
3614
+ * 逐字冻住,理由同 {@link OMIT_LABEL}(进模型 prompt、进 eval 指纹、不进 catalog)。
3621
3615
  */
3622
- | 'length'
3623
- /** 触达成本或 token 预算上限 */
3624
- | 'budget-exceeded'
3625
- /** 异常终止(详情见前一个 error 事件) */
3626
- | 'error';
3627
- type AgentEvent =
3616
+ declare function imagePartOmittedSummary(part: EpochImagePart): string;
3628
3617
  /**
3629
- * 这一轮真的要发出去了 —— **决定 8 的第一个时刻**(方案 42 §七,2026-08-15)。
3618
+ * 引用的会话在纯文本投影里长什么样。**不吐那段会话的正文** —— 同 `filePartSummary`。
3630
3619
  *
3631
- * ## 发它的是谁
3620
+ * ## 这一行刻意是英文 + 码,不是中文散文
3632
3621
  *
3633
- * core 的 `agent/loop.ts`,在**第一次模型请求之前**、且**在预算闸门之后**。
3634
- * 位置是有判据的:闸门拦下的那一次请求根本没发出去,给它发一条「这一轮开跑了」
3635
- * 是在宣布一件没发生的事(events.ts 上一条 ⚠️ 骂的正是「声明了却没有发射方」,
3636
- * 这一条反过来 —— 别发一件没发生的)。
3622
+ * 它的读者有三个,**没有一个是界面**:模型(经 sdk-adapter 进上下文)、
3623
+ * `messages.content` 那一列(存盘 / 压缩 / 审计 / FTS)、以及宿主的日志。
3624
+ * 界面要展示这一部件时读的是结构化字段(`omitted` / `title` / `messageCount`),
3625
+ * 自己组措辞 —— TUI 那一侧的中文在宿主用 `t()` 拼,见 locales 的 `mention.*`。
3637
3626
  *
3638
- * 一次 `run()` 最多一条:它是「这一轮」的头,不是「这一步」的。
3639
- * 收尾那条 `finish` 是它的另一半,两者一头一尾夹住同一段。
3627
+ * > ⚠️ 「没有一个是界面」这句话 2026-08-24 之前**有个洞**:`contentToText()`
3628
+ * > 会把这一行塞进 tui / web 的气泡里,于是它事实上也是界面的。洞补在
3629
+ * > §34.4 —— 界面改走 `contentToDisplayText()`,那一份把这一行换成
3630
+ * > catalog 的 `message_part.session*`。所以上面那句话现在是真的了。
3640
3631
  *
3641
- * ## `estimatedTokens` 是**估算**,而且永远是
3632
+ * 所以这里直接把**那五个稳定错误码**原样写进去(§4.2):宿主能 grep,模型能读,
3633
+ * 而且它不必跟着界面语言变 —— 一条落库的记录跟着 `EPOCH_LANG` 变语言,
3634
+ * 等于把同一段会话的历史劈成两半(`goal/wording.ts` 那条豁免记的是同一个坑)。
3642
3635
  *
3643
- * 它是那一刻手里那份完整待发送载荷(system prompt + 工具定义 + 全部消息)
3644
- * 过一遍 `estimateTokens()` 的结果。2026-08-15 换了真分词器(o200k_base)之后
3645
- * 这个数**说得出真话**了 —— 在那之前 `chars/4` 在中文场景系统性低估 2–2.7 倍。
3636
+ * 显示名用标题而不是 sessionId:后者是一串随机字符,对人和模型都没有信息量。
3637
+ * 标题为空的会话在解析那一侧已经用 sessionId 兜过底了(方案 53 §5.3)。
3638
+ */
3639
+ declare function sessionPartSummary(part: EpochSessionPart): string;
3640
+ /**
3641
+ * 取部件数组的纯文本投影:图片折成一句占位,绝不吐 base64;
3642
+ * 文件和引用的会话同样只折成一行占位,**不吐内容**。
3646
3643
  *
3647
- * ⚠️ **换了真分词器不等于它变成了事实**,别把这两句话弄反:
3644
+ * 投影是**存盘、压缩、审计**看的那一份。把几十 KB 的文件内容塞进来,
3645
+ * `sessions.db` 会跟着一起胖,而回放时那份内容本来就能从 `parts` 里拿。
3648
3646
  *
3649
- * 1. 分词器是 o200k_base,而模型可能是 Claude / DeepSeek,各家 BPE 不同 ——
3650
- * 这是 `provider/tokenizer.ts` 文件头明写的「有意的近似」;
3651
- * 2. 更硬的一条:这个数是**发之前**算的,而真值只有 provider 回的 usage 说了算,
3652
- * 中途还会插进压缩、任务通知、工具结果。
3647
+ * ## ⚠️ 这一份**只给模型和落库**,界面不许用它(RECORD-40-i18n §34.4)
3653
3648
  *
3654
- * 所以界面上那枚「估算」徽标**不许摘**,判据同 `design/web-ui/thread.css`
3655
- * 里 `.est` 那条:token 的三个时刻里只有运行前这一个是猜的。
3649
+ * 改造前它一个人喂三类消费者,于是把界面切成 English 的人会在**自己那条消息的
3650
+ * 气泡里**看到 `[图片 image/png]` / `[文件 …:二进制文件,未附内容]`。
3651
+ * 这笔债在 §34.4 记了、§34.4 还掉,处置是**分家**:
3656
3652
  *
3657
- * ## 为什么不复用 `usage` 事件
3653
+ * | 谁要 | 用哪一份 | 为什么 |
3654
+ * | ---- | -------- | ------ |
3655
+ * | 模型(`sdk-adapter.ts` / `agent/loop.ts` / `runtime/agent-session.ts`) | **这一个** | 模型输入契约 + eval 指纹,跟着 locale 变就漂(§33.2) |
3656
+ * | ↳ 其中 `agent/loop.ts` 拼**给模型的文本部件**那一次 | 这一个 + `{ imagesCarriedAsParts: true }` | 图片另有真部件带着,占位符会变成一句假话(判据见 {@link ContentToTextOptions}) |
3657
+ * | `messages.content` 那一列(存盘 / 压缩 / 审计 / FTS) | **这一个** | 一条落库的记录跟着 `EPOCH_LANGUAGE` 变语言,等于把同一段会话的历史劈成两半 |
3658
+ * | 界面(tui 的历史项、web 的气泡) | `contentToDisplayText(content, t)` | 那一份走 catalog 的 `message_part.*`,住在两个宿主里 |
3658
3659
  *
3659
- * `usage` 上两个数都来自 provider 的实测回报(`cumulative` / `run`),
3660
- * 往里塞一个猜出来的数,就得让每个消费方去判「这个字段是真的还是估的」——
3661
- * 而那正是徽标要替用户回答的问题。两笔账两条事件,别合。
3660
+ * 界面那一份**落在宿主层**而不是这儿,判据同 §34.6 给 `view` 那 1 处的做法
3661
+ * (兜底文案挪给渲染端):这个包的依赖白名单是空的,`t()` 只能由有它的那一层调。
3662
3662
  */
3663
- {
3664
- type: 'run-start';
3663
+ interface ContentToTextOptions {
3665
3664
  /**
3666
- * 这一轮由哪个角色在跑。**顶层会话没指定角色时整个键缺席**,
3667
- * 不是给一个 `general` —— 「没选专家」在决定 20 ② 里是一个有形状的值
3668
- * (底栏没有 chip),编一个默认名等于把那个值抹掉。
3665
+ * 这一条消息的图片**是不是另有真部件带着**(`EpochMessage.parts` 里那份
3666
+ * base64,最终变成 provider 的原生图片部件)。
3667
+ *
3668
+ * 默认 `false` —— 也就是改造前的行为,落库那一列要的正是它:那一行
3669
+ * `[图片 image/png]` 是**这一轮带过一张图**的唯一记录(`parts` 列另存,
3670
+ * 但 FTS / 预览 / 标题只看 content)。
3671
+ *
3672
+ * `true` 时图片那一支整个**不折占位符**。判据是那句占位符的含义 ——
3673
+ * 「这里本来有张图,但你看不到」。图真的在旁边时它就成了一句假话,而且是
3674
+ * 会被照着信的那种:2026-09-01 实测,`@ai-sdk/deepseek@3.0.20` 把图片
3675
+ * 静默丢了之后,模型读到的就是这一行,于是它逐字回答「我只看到
3676
+ * 「[图片 image/jpeg]」这个占位符,图片没有传到我这边」。**升级 SDK 让图片
3677
+ * 真的送达之后,这一行反而成了唯一还在撒谎的地方。**
3678
+ *
3679
+ * ⚠️ 只关图片那一支。`file` / `session` 的占位符**照旧**:它们说的是
3680
+ * 「内容没附上」(`omitted` / `not-restored`),那句话在任何情况下都是真的,
3681
+ * 而且是模型唯一能知道「有个东西没给我」的途径。
3669
3682
  */
3670
- role?: ActiveAgentRole;
3671
- /** 运行前估算。见上面那两条为什么它永远带「估算」二字 */
3672
- estimatedTokens: number;
3673
- }
3674
- /** 回复正文增量 */
3675
- | {
3676
- type: 'text-delta';
3677
- text: string;
3678
- }
3679
- /** 推理模型的思考过程增量 */
3680
- | {
3681
- type: 'reasoning-delta';
3682
- text: string;
3683
- }
3684
- /** 模型请求调用工具(执行前) */
3685
- | {
3686
- type: 'tool-call';
3687
- callId: string;
3688
- toolName: string;
3689
- input: Record<string, unknown>;
3683
+ imagesCarriedAsParts?: boolean;
3690
3684
  }
3685
+ declare function contentToText(content: EpochUserContent, opts?: ContentToTextOptions): string;
3691
3686
  /**
3692
- * 工具**执行过程中**的输出增量。callId 与对应的 tool-call 一致。
3687
+ * 「上一轮实际动过手」在**给模型的**历史里长什么样(2026-08-28)。
3693
3688
  *
3694
- * 这是一条**只给人看**的旁路通道:模型看到的永远是最终 `tool-result.output`
3695
- * (那份已经截断、已经包过隔离标签)。加它是因为 `tool-call` 和 `tool-result`
3696
- * 之间可以隔着一次三分钟的 `pnpm build` —— 在那三分钟里宿主一个事件都收不到,
3697
- * 屏幕上是一行不动的 `⏳ terminal`,用户分不清是在跑还是挂了。
3689
+ * ## 它修的是什么
3698
3690
  *
3699
- * 发这个事件的工具不必逐 chunk 发:引擎侧统一做合并窗口、剥 ANSI、`\r` 折叠
3700
- * 和总量封顶(见 core 的 tool-output-pump.ts),工具只管把原始 chunk 交出来。
3691
+ * 喂模型的那份历史只有 user / assistant 两种角色的**文本**
3692
+ * (`runtime/src/agent-session.ts` 的累积、`session-store.ts` 的 `loadHistory()`),
3693
+ * 工具调用和结果一条都不在里面 —— 这是刻意的(孤立的 tool 消息会被 provider 拒掉,
3694
+ * 全量 transcript 是另一个体量的事)。代价此前没人算:一段五条消息的会话,
3695
+ * 模型看到的是**五条又长又具体的指令,配四句十来个字的「办完了」**。
3696
+ * 早期那几条指令的字数远大于「它已经做完了」的证据量,于是模型把它们当成还在
3697
+ * 生效的待办,回头去做 —— 而用户刚发的那一条被摊薄。实测在
3698
+ * `packages/runtime/__tests__/context-recency.test.ts`。
3701
3699
  *
3702
- * 宿主可以完全忽略它 —— 忽略的结果就是改动前的行为。
3703
- */
3704
- | {
3705
- type: 'tool-output-delta';
3706
- callId: string;
3707
- toolName: string;
3708
- text: string;
3709
- }
3710
- /**
3711
- * 工具执行完毕。callId 与对应的 tool-call 一致。
3700
+ * 这一行就是补那份证据:不复原过程,只留一句「这一轮真的调了这些工具」。
3712
3701
  *
3713
- * `artifacts` 给宿主渲染缩略图 / 打开文件用。它和 `output` 是**两个通道**:
3714
- * `output` 是喂给模型的纯文本(已含每个 artifact 的 summary),
3715
- * `artifacts` 是给人看的那一份 —— 宿主不必解析 output 去猜有没有图。
3716
- */
3717
- | {
3718
- type: 'tool-result';
3719
- callId: string;
3720
- toolName: string;
3721
- output: string;
3722
- ok: boolean;
3723
- artifacts?: ToolArtifact[];
3724
- }
3725
- /** 一轮 ReAct 结束(轮次从 1 开始) */
3726
- | {
3727
- type: 'turn-complete';
3728
- turn: number;
3729
- }
3730
- /**
3731
- * 需要用户确认。宿主**必须**调用 respond(),否则引擎会一直阻塞在这里。
3732
- * 引擎侧对未答复的请求有兜底(流结束时按 deny 处理),但那意味着操作被拒。
3702
+ * ## 判据用**结果**而不是调用
3733
3703
  *
3734
- * `note` 只有计划审批的 `plan-revise` 用得上 —— 它装的是用户的修改意见,
3735
- * 其余出口一律忽略。**没有它这个出口就是个死循环**:模型不知道要改什么,
3736
- * 只会把同一份计划原样再交一次。
3737
- */
3738
- | {
3739
- type: 'approval-request';
3740
- request: ApprovalRequest;
3741
- respond: (outcome: ApprovalOutcome, note?: string) => void;
3742
- }
3743
- /**
3744
- * 模型问了一个结构化问题,等用户选(方案 34)。宿主**必须**调用 `respond()`,
3745
- * 否则引擎会一直阻塞在这里。
3704
+ * `EpochToolCall` 上有工具名,但一次跑到一半被中止的轮次里那些调用**从没执行过**
3705
+ * (判据逐字同 `runtime/src/session-file-changes.ts` 的边界 1)。而一条
3706
+ * `EpochToolResult` 存在就意味着它跑完了 —— 拿结果当输入,「执行过没有」这件事
3707
+ * 不需要另外判一次。
3746
3708
  *
3747
- * 结构与 `approval-request` 同构(请求 + 一个答复闭包),于是中继、wire 编码、
3748
- * 宿主回调那几层一个都不用新造。但**语义不同,别把两者的兜底混用**:
3749
- * 审批答不上来时有一个安全侧(`deny`),提问没有 —— 替用户挑一个选项和替他
3750
- * 点「允许」一样糟。答不上来只有一种表达:`respond({ answers: {}, skipped: true })`,
3751
- * 模型收到之后**自己决定**(通常是选默认继续),这一轮不算失败。
3709
+ * ⚠️ **它分不出成功和失败**:落盘的结果上没有 `success`(同上那份的边界 2)。
3710
+ * 所以措辞是「调用过」而不是「做成了」—— 一次被权限拒掉的 `file_write`
3711
+ * 在这里照样留一笔,而那句话仍然是真的。
3752
3712
  *
3753
- * 引擎侧对未答复的请求有兜底(流结束时按「跳过」处理)—— 那不是伪造答案,
3754
- * 是如实说「没人答」。
3755
- */
3756
- | {
3757
- type: 'question';
3758
- request: QuestionRequest;
3759
- respond: (answer: QuestionAnswer) => void;
3760
- }
3761
- /**
3762
- * 补丁预览:这次工具调用真的改了哪个文件、改成什么样。
3713
+ * ## 为什么住在 protocol、且是冻住的中文
3763
3714
  *
3764
- * 发它的是 core 的 `agent/file-diff.ts`(`ToolExecutor` 在写类工具动手前后
3765
- * 各读一遍盘),排在同一次调用的 `tool-result` **之前**。
3766
- * `oldContent` 缺 = 这个文件是新建的;`newContent` 缺 = 被删了;两者都在 = 改了。
3715
+ * 它的读者是**模型**,和 {@link contentToText} / {@link filePartSummary} 同一类,
3716
+ * 所以走同一条规矩:住在这个零依赖的包里、逐字冻住、**不跟着界面语言变**
3717
+ * (门禁在 `packages/protocol/__tests__/message-projection.test.ts`)。一条进了
3718
+ * 上下文的历史跟着 `EPOCH_LANGUAGE` 变语言,等于把同一段会话劈成两半。
3767
3719
  *
3768
- * ⚠️ 这个类型 2026-08-13 之前**声明在这里、消费在 view、渲染在两个宿主,
3769
- * 中间一个发射方都没有** —— 三边契约测试全绿,因为站在引擎位置上的是
3770
- * fixture 喂出来的假 agent。判据见 `.agents/plans/43-web-ui-shell-plan.md` §12.5。
3771
- * **往这个 union 里加新类型时,先想清楚谁发。**
3720
+ * 只有工具名,不带参数 / 路径:带路径要 `affectedPaths()` + 工作目录,而那两样
3721
+ * 住在 core 和装配层,拿不到的地方(`loadHistory()`)就会退化成另一种措辞 ——
3722
+ * 两条路产出的历史一旦不一样,症状就是「刷新之后模型看到的和刚才不是同一段」。
3723
+ *
3724
+ * ⚠️ **调用方要的多半是 {@link assistantTurnText},不是这一个** —— 那一份连
3725
+ * 「正文和这一行之间怎么接」一起定了,而那正是两条路会走散的地方。
3726
+ *
3727
+ * @param results 这**一轮**(不是一步)执行过的全部工具结果,按发生序
3728
+ * @returns 空数组时是空串 —— 这一轮没动手,那就没有这句话可说
3772
3729
  */
3773
- | {
3774
- type: 'diff';
3775
- filePath: string;
3776
- oldContent?: string;
3777
- newContent?: string;
3778
- }
3779
- /** 累计用量变化(每轮结束时发一次,宿主用来更新状态栏) */
3780
- | {
3781
- type: 'usage';
3782
- cumulative: TokenUsage;
3783
- /**
3784
- * **到此刻为止、这一次 run** 的用量(方案 42 PR-2)。
3785
- *
3786
- * 和 `finish.runUsage` 同一笔账,只是这一份是**过程中**的快照 ——
3787
- * 界面上「运行中:已消耗 N token」要的就是它。
3788
- *
3789
- * ⚠️ 为什么不让宿主拿 `cumulative` 相减:相减要有一个起算基线,而
3790
- * 「run 开始那一刻的累计」在事件流上根本没有 —— 第一帧 `usage` 到达时,
3791
- * 它的 `cumulative` 既可能是本轮用量、也可能是 `BudgetStore` 恢复的历史
3792
- * 加上本轮,从流本身分辨不了。基线猜错的代价是屏幕上那个数直接差一个量级。
3793
- */
3794
- run?: TokenUsage;
3795
- budgetRemaining?: {
3796
- costUsd?: number;
3797
- tokens?: number;
3798
- };
3799
- }
3730
+ declare function turnDigest(results: readonly Pick<EpochToolResult, 'toolName'>[]): string;
3800
3731
  /**
3801
- * 运行结束。
3732
+ * 一轮的 assistant 消息在**给模型的历史**里长什么样:正文 + {@link turnDigest}。
3802
3733
  *
3803
- * `breach` 只在 `reason === 'budget-exceeded'` 时有值,说明是哪条上限触顶的。
3804
- * 没有它的话「触顶」在事件流上只是一句中文提示(`describeBreach()` 的产物),
3805
- * 而机器宿主要分支就只能去正则匹配那句中文 —— 我们改一个字它就碎。
3734
+ * ## 为什么这条胶水也得住在这儿
3806
3735
  *
3807
- * ## `usage` 和 `runUsage` 是两笔账,别当同义词(方案 42 PR-2)
3736
+ * 因为它有**两个调用方,走两条路,产出必须逐字节相同**:
3808
3737
  *
3809
- * `usage` 是 `BudgetGuard.snapshot()` —— **累计**,口径由 `EpochRuntime.usageScope`
3810
- * 决定(配了 `BudgetStore` 就是跨会话累计,否则是本进程从零算)。宿主拿它画
3811
- * 「这个会话一共花了多少」。
3738
+ * - 直播:`runtime/src/agent-session.ts` 用 `TurnStepCollector` 收的那一轮;
3739
+ * - 恢复:`runtime/src/session-store.ts` 的 `loadHistory()` 从 DB 行折回来的那一轮。
3812
3740
  *
3813
- * `runUsage` 是**这一次 run** 的用量,由本次循环里每一轮的 `result.usage`
3814
- * 累加而成。加它的理由很具体:界面上每一轮结束时要写「共 14,270 token」,
3815
- * 而那个数**减不出来** —— 累计值可能被 `BudgetStore` 从上次会话恢复,
3816
- * 「第一个累计值」既可能是本轮用量、也可能是历史 + 本轮,
3817
- * 从事件流本身分辨不了(`AgentLoopConfig.onTurnUsage` 那段注释骂的是同一件事)。
3741
+ * 两边各写一次 `text + '\n\n' + digest` 的下场是:某天一边多一个换行,
3742
+ * 而症状是**刷新页面之后模型看到的历史和刷新前不是同一段** —— `reloadHistory()`
3743
+ * 的 JSDoc 里那句「它们一旦分叉,症状就是屏幕上和模型看到的对不上」骂的正是这个。
3744
+ * 门禁是 `packages/runtime/__tests__/context-recency.test.ts` 里那条逐字节比对。
3818
3745
  *
3819
- * **两个都是可选的,那不是偷懒**:一次 run 可以一次模型调用都没发生就结束
3820
- * (装配抛异常、预算在发送前拦下)。那时候「这一轮用了多少 / 用的哪个模型」
3821
- * 没有答案,而给一个 0 和给配置里那个模型名都是撒谎 —— 与
3822
- * `TokenUsage.costUsd` 的「0 是免费,undefined 是不知道」同一条约定。
3746
+ * 正文为空、只有工具痕迹时**不留前导空行**:那一轮模型只调了工具没说话
3747
+ * (`loop.ts` 允许),这时这条消息的全部内容就是那一段。
3748
+ *
3749
+ * ⚠️ **断在半路那一轮走的是另一份投影**,判据全文在 {@link unfinishedTurnText} 上:
3750
+ * 收了尾 = 结论在正文里,工具只留一行名字;断在半路 = 工具结果是唯一的记录,
3751
+ * 得把正文一起带回去。
3752
+ *
3753
+ * @param finishReason 这一轮的收尾原因(取值域是 `FinishReason`)。
3754
+ * **缺省 = 没有记录**(老会话的 `finish_reason` 列是 NULL,嵌入宿主也可能
3755
+ * 不传)→ 一律按「收了尾」处理。往宽了判的代价是把工具正文焊进历史,
3756
+ * 而那是每一轮都要重发的东西。
3757
+ *
3758
+ * ⚠️ **收 `string` 而不是 `FinishReason`,这是刻意的。** 恢复那一条路是从
3759
+ * `messages.finish_reason` 读回来的,而它在库里就是一列 `TEXT`
3760
+ * (`core/src/session/db.ts`),读出来是 `string`。把这里收窄成联合类型,
3761
+ * 唯一的效果是逼 `loadHistory()` 写一次**没有依据的** cast ——
3762
+ * 那一列可以装着任何历史版本写进去的字符串,断言它属于今天这个联合
3763
+ * 是一句我们证明不了的话。宽收窄判:不认识的值一律走「收了尾」那一支。
3764
+ * @returns 空串 = 这一轮既没正文也没动手,调用方**别往历史里塞这一条**
3823
3765
  */
3824
- | {
3825
- type: 'finish';
3826
- reason: FinishReason;
3827
- usage: TokenUsage;
3828
- /** **这一次 run** 的用量。provider 一次都没上报就没有这个键 */
3829
- runUsage?: TokenUsage;
3766
+ declare function assistantTurnText(text: string, results: readonly Pick<EpochToolResult, 'toolName' | 'output'>[], finishReason?: string): string;
3767
+ /**
3768
+ * 部件类型的穷尽性守卫 —— **这是「加了一种部件却漏了一处分支」的那道会红的门禁**。
3769
+ *
3770
+ * 消费 `EpochContentPart` 的地方有五处(这里、`sdk-adapter` 的 `toSdkPart`、
3771
+ * `session/payload` 的 `persistParts` / `restoreParts`、以及展示层),改造前它们
3772
+ * 全都写成「if text / if file / 剩下的当图片」。那个形状下加一种部件**不会有
3773
+ * 任何东西变红**:新部件会被静默当成图片,而症状是模型收到一个 `image:` 里
3774
+ * 塞着会话正文的请求,provider 报 400。
3775
+ */
3776
+ declare function assertNeverPart(part: never): never;
3777
+ type EpochMessageRole = 'system' | 'user' | 'assistant' | 'tool';
3778
+ /**
3779
+ * 一条会话消息。
3780
+ *
3781
+ * `content` 永远是字符串(tool 消息为空串,内容在 `toolResults` 里),
3782
+ * 这样「取这条消息的文本」永远不需要判类型。
3783
+ *
3784
+ * 多模态**不是**把 `content` 放宽成联合类型 —— 那正是本文件开头骂过的病,
3785
+ * 会让 loop / codec / 压缩器每处都得判一次类型。改成加一个 `parts` 旁路:
3786
+ * `content` 仍然是权威的**文本投影**(压缩器、审计、持久化都只看它),
3787
+ * `parts` 只在 sdk-adapter 那一处被读走。两者的关系由 `contentToText` 保证。
3788
+ */
3789
+ interface EpochMessage {
3790
+ role: EpochMessageRole;
3791
+ content: string;
3830
3792
  /**
3831
- * 最后一次模型调用**实际回来**的模型(`GenerateOutput.model`)。
3832
- *
3833
- * ⚠️ 不是配置里选的那个。降级和 `switch_model` 之后两者不一样,而
3834
- * 「这一轮到底是谁答的」正是那时候用户最需要知道的一件事。
3793
+ * 多模态部件。给了就以它为准喂模型,`content` 退化成它的文本投影。
3794
+ * **不落 SQLite** —— 图片走 artifact 目录,DB 里只留 content 的占位文本。
3835
3795
  */
3836
- model?: string;
3796
+ parts?: EpochContentPart[];
3797
+ /** assistant 要求的工具调用;与 content 并存(模型可以边说话边调工具) */
3798
+ toolCalls?: EpochToolCall[];
3799
+ /** role 为 'tool' 时携带的执行结果 */
3800
+ toolResults?: EpochToolResult[];
3837
3801
  /**
3838
- * 最后一次模型调用**要求**的那个模型(`GenerateOutput.requestedModel`)。
3802
+ * 这一步模型的思考原文(推理模型的 `reasoning-delta` 累加)。2026-08-18。
3839
3803
  *
3840
- * 和上面的 {@link model} 是一对,宿主要的那个事实是**两者是否相等** ——
3841
- * 不等 = 引擎降级了,相等 = 用户/命令自己挑的。
3804
+ * **只给人看,不回灌模型**:`sdk-adapter.ts` 一个字都不读它。加它是因为
3805
+ * 刷新页面之前屏幕上有「深度思考」那一段,刷新之后整段消失 —— 而那不是
3806
+ * 「这一轮没想」,是我们没记。
3842
3807
  *
3843
- * ⚠️ **读侧不许拿 `model` 去和「当前配置/当前选择」比来代替这个判断。**
3844
- * 那是一个按**此刻**的值,而这条断言是按**轮**的 —— 它会被拿去比对所有
3845
- * 历史轮,于是用户在顶栏换一次模型,之前那些跑得好好的轮子集体变成
3846
- * 「故障转移」。web 脚注那枚徽标栽过这一次,判据全文在
3847
- * `core/src/provider/types.ts` 的 `GenerateOutput.requestedModel` 上。
3808
+ * ⚠️ **它属于某一步,不属于整轮。** 一轮里模型可以想一次、调个工具、再想一次,
3809
+ * 每次的思考挂在**它自己那条 assistant 行**上,位置就是它真实的时序位置。
3810
+ * 拿它当「整轮的思考」拼成一根,回放时那一段会被钉到最前面,讲的却可能是
3811
+ * 二十步之后的事(web 的 `thoughtParts` 治过同一个病,判据在
3812
+ * `web/src/state/reducer.ts` 的 `trackThought()`)。
3813
+ *
3814
+ * 落盘走 `messages.reasoning` 列 —— 那一列**从会话库 v1 就在**,只是一直没人
3815
+ * 写过,所以这一档不需要迁移。
3848
3816
  */
3849
- requestedModel?: string;
3850
- breach?: BudgetBreach;
3817
+ reasoning?: string;
3851
3818
  }
3819
+
3852
3820
  /**
3853
- * 引擎有一句话要对用户说,而**它不是回复正文**(2026-08-26)。
3854
- *
3855
- * ## 发它的是谁,以及为什么非要一条新帧
3856
- *
3857
- * 今天唯一的发射方是 core 的 `AgentLoop`:技能学习器在上一轮收尾之后
3858
- * fire-and-forget 学到了一条技能,而那件事没有任何出口(判据全文在
3859
- * `agent/loop.ts` 的 `maybeLearn()`)。三条现成的路各自被什么挡掉:
3821
+ * 结构化提问契约(方案 34)—— 模型遇到分叉时能问一句,而不是瞎猜。
3860
3822
  *
3861
- * 1. **`text-delta`** —— 它会被折进 assistant 那一段正文,于是这句话进历史、
3862
- * 进落盘、下一轮再进模型。一句「你学会了 X」被当成模型自己说过的话,
3863
- * 等于往上下文里塞一条它没说过的发言;
3864
- * 2. **往 system prompt 里塞** —— 那是 prompt cache 的前缀,每轮加一句会让整段
3865
- * 对话的缓存逐轮失效。同一条判据 `loop.ts` 里为后台任务通知讲过一遍;
3866
- * 3. **一条 `[系统]` 进历史**(`systemNote()` + `recordNote()`,样板是
3867
- * `runtime` 的 `markRoleHandoff()`)—— 它是真出口,但那句话**只在刷新 /
3868
- * 回放时看得见**,而且今天只有 web 认那个前缀,TUI 会把 `[系统] ` 原样露出来。
3823
+ * ## 它和审批是两件事
3869
3824
  *
3870
- * ## 为什么是「码 + 已经翻好的话」两个字段
3825
+ * 审批问的是「**允不允许**我做这件事」,答案是四个固定出口,且**默认往严的方向落**
3826
+ * (走开 = 拒绝)。提问问的是「**你想要哪一种**」,答案是内容,而且
3827
+ * **没有安全的默认值** —— 替用户挑一个和替他点「允许」一样糟,只是失败的方式不同:
3828
+ * 前者让模型基于一个假答案干半小时活。
3871
3829
  *
3872
- * `text` 由**发射方**(core,它有 `t()`)翻好:`@epoch-agent/view` 只依赖
3873
- * protocol、够不着 catalog,在那儿兜底就只能是中文 —— 同 `error` 那条空 message
3874
- * 的兜底为什么落在两个宿主身上,是同一条判据。
3830
+ * 所以这里刻意**不提供**任何「宿主答不上来时的兜底答案」。答不上来只有一种表达:
3831
+ * `skipped: true`(见 {@link QuestionAnswer})。
3875
3832
  *
3876
- * `code` 是给**要分支的宿主**的:判「这是哪一类通知」不许去 `includes()` 那句
3877
- * 随时会改的话(RECORD-40-i18n §32.1 那条 `reason === '已取消'` 的教训)。
3833
+ * ## 为什么不过权限层
3878
3834
  *
3879
- * ⚠️ **要拿技能名去做别的事**(跳到能力页、打开那份 SKILL.md),就再加一个
3880
- * 字段,别去正则 `text` —— 那句话是会随文案改的。
3835
+ * 提问不碰文件、不起进程、不联网。方案 34 §三把 `core/src/permission/**`
3836
+ * 列进禁止触碰,就是这个意思 —— 给它加一道权限判定,等于让 `deny` 规则能把
3837
+ * 「问一句」拦掉,而拦掉的表现是模型继续瞎猜。
3881
3838
  */
3882
- | {
3883
- type: 'notice';
3884
- code: NoticeCode;
3885
- text: string;
3839
+ /** 一个选项。**没有「其他」** —— 自由输入由宿主自动提供,不占模型的选项额度 */
3840
+ interface QuestionOption {
3841
+ /** 选项正文,用户看到的就是它,模型收到的答案也是它 */
3842
+ label: string;
3843
+ /** 这个选项意味着什么 / 选了会发生什么 */
3844
+ description: string;
3845
+ }
3846
+ /** 一个问题 */
3847
+ interface QuestionItem {
3848
+ /** 完整问句,带问号 */
3849
+ question: string;
3850
+ /** 极短标签,UI 上当 chip 用(≤ {@link MAX_HEADER_CHARS} 字) */
3851
+ header: string;
3852
+ /** {@link MIN_OPTIONS}–{@link MAX_OPTIONS} 个选项 */
3853
+ options: QuestionOption[];
3854
+ /** 多选(答案是数组)还是单选(答案是一个字符串) */
3855
+ multiSelect: boolean;
3886
3856
  }
3887
- /** 运行中出错(随后会有一个 finish: 'error') */
3888
- | {
3889
- type: 'error';
3890
- message: string;
3891
- };
3892
3857
  /**
3893
- * 一条 `notice` 是哪一类。
3894
- *
3895
- * 写成联合而不是 `string`,是为了让**加一档时消费方的 switch 会红** ——
3896
- * 判据同 wire.ts 的 `WireResetReason`。
3858
+ * 一次结构化提问。
3897
3859
  *
3898
- * ⚠️ 这里原来写着「今天只有一种」—— 那句话在写下之后就一直在过期
3899
- * (`images-dropped` / `compliance-masked` / `response-truncated` 陆续进来)。
3900
- * 有几种去数下面这个联合,别在散文里再抄一个会骗人的数字。
3860
+ * **刻意没有 `requestId`**(方案初稿里有)。跨进程时的凭据由
3861
+ * `runtime/src/serialize.ts` 的 `toSerializable()` 生成,和 `approval-request`
3862
+ * 逐字同构 —— 请求内容里再带一个 id 的话,进程内的宿主(TUI)手上会有一个
3863
+ * 它永远不用的字段,而跨进程那侧有两个候选 id,对不齐时没人说得清该信哪个。
3901
3864
  */
3902
- type NoticeCode =
3865
+ interface QuestionRequest {
3866
+ /** 一次最多 {@link MAX_QUESTIONS} 个问题(超过就该拆成两轮对话了) */
3867
+ questions: QuestionItem[];
3868
+ }
3903
3869
  /**
3904
- * 技能学习器在上一轮收尾之后学到了一条技能。
3870
+ * 宿主的答复。
3905
3871
  *
3906
- * ⚠️ 这一帧发在**下一轮的头上**,不是学成的那一刻 —— 学成时那条流早关了
3907
- * (`maybeLearn()` 紧接着就是 `yield finish('stop'); return;`)。发在这里还有
3908
- * 第二层意思:那一轮正是这条技能**真的进了 system prompt** 的那一轮。
3872
+ * `answers` 的键是 {@link QuestionItem.question} 原文 —— 不是下标。模型写出问句
3873
+ * 的时候就知道自己在问什么,拿问句当键它读答案不用再对一次序;而下标一旦和
3874
+ * 宿主的呈现顺序错开(多问题逐个走完再一起回),错位是静默的。
3875
+ *
3876
+ * `skipped` 是方案 §2.4 那条「用户 Esc → 模型自己决定,整轮不失败」在类型上的
3877
+ * 落点。它和「`answers` 是空对象」**不等价**:用户答完前两问才按 Esc 时,
3878
+ * 前两个答案照样交出去,同时 `skipped: true` 告诉模型「剩下的没答上,别当成
3879
+ * 用户选了默认」。
3909
3880
  */
3910
- 'skill-learned'
3881
+ interface QuestionAnswer {
3882
+ /** question 原文 → 选中的 label(多选是数组)。用户自由输入时是他打的原文 */
3883
+ answers: Record<string, string | string[]>;
3884
+ /** 用户中途放弃(Esc / 关掉弹窗)。已答的那几个仍在 `answers` 里 */
3885
+ skipped?: boolean;
3886
+ }
3911
3887
  /**
3912
- * provider **把用户发的图片丢了**,而请求已经发出去、已经计费(2026-09-01)。
3888
+ * 一次最多几个问题。
3913
3889
  *
3914
- * 和 `agent_run.images_unsupported` 那条错误分得很清,两者别混:
3890
+ * 超过就该拆成两轮对话 —— 四个连续弹窗已经是用户能忍的上限,而模型一次问八个
3891
+ * 通常意味着它没读上下文。工具会**拒绝**并把这个上限说给模型(验收 5)。
3892
+ */
3893
+ declare const MAX_QUESTIONS = 4;
3894
+ /** 每个问题的选项数下界。一个选项不是问题,是通知 */
3895
+ declare const MIN_OPTIONS = 2;
3896
+ /** 每个问题的选项数上界。超过四个用户就开始扫而不是读了 */
3897
+ declare const MAX_OPTIONS = 4;
3898
+ /** `header` 的长度上界。它是 UI 上的 chip,长了会把弹窗撑破 */
3899
+ declare const MAX_HEADER_CHARS = 12;
3900
+ /**
3901
+ * 收窄**外部来的**答复载荷。
3915
3902
  *
3916
- * | | 谁判的 | 什么时候 | 钱 |
3917
- * | --- | --- | --- | --- |
3918
- * | `images_unsupported`(`error` 帧) | **我们**(闸门 1 的输入侧) | 发请求**之前** | 没花 |
3919
- * | 这一条(`notice` 帧) | **provider 自己报的 warning** | 请求回来之后 | **已经花了** |
3903
+ * 需要它的是跨进程的宿主(headless 的 `question-response`、`epoch web` 的 REST):
3904
+ * 那条路上答复是一段 JSON,不过校验直接喂给引擎等于让宿主随手拼个
3905
+ * `{"answers": 3}` 就让模型读到一个数字。进程内的宿主由类型保证,用不到它。
3920
3906
  *
3921
- * 也就是说走到这一帧意味着闸门 1 判「这个模型认图」,而 provider 那侧不认。
3922
- * 2026-09-01 那次事故就是这一格:`@ai-sdk/deepseek@3.0.20` 静默丢弃图片,
3923
- * 模型对着一张不存在的图回答「我只看到一个占位符」,而屏幕上一个字都没有。
3907
+ * 和 `isApprovalOutcome` 同一条理由住在 protocol:判据只有一处,
3908
+ * 两个出口(stdin / REST)各写一遍就会有一边漏掉数组那一种形态。
3909
+ */
3910
+ declare function isQuestionAnswerMap(value: unknown): value is Record<string, string | string[]>;
3911
+
3912
+ /**
3913
+ * Agent 事件契约 —— 引擎 → 宿主(TUI / CLI / 未来的 server)的唯一协议。
3924
3914
  *
3925
- * ⚠️ **一次 run 只发一帧**,不是每轮一帧:带图的那条用户消息在这一次 run 的
3926
- * 每一轮都会重发,逐轮报的话一次多工具调用的对话会刷出十几行同样的话。
3915
+ * 为什么是判别联合而不是「一个 type 字段 + 一堆可选字段」:
3916
+ * 旧的 StreamChunk / StreamEvent 是后者,所有载荷字段都是 optional,于是
3917
+ * - 三个包各写一份、字段名还不一样(input/args、success/isError),
3918
+ * 靠 cli 里一段手写适配缝合;漏一个字段编译器不报错,只是界面上少显示东西。
3919
+ * - 协议里没有 callId,TUI 只能靠「toolName + 调用顺序 FIFO」猜工具结果
3920
+ * 属于哪次调用,并行工具就会串台。
3921
+ * 判别联合让每个事件的必填字段由编译器保证,callId 成为一等字段。
3927
3922
  */
3928
- | 'images-dropped'
3923
+
3929
3924
  /**
3930
- * 内容合规的输入闸门**改写了用户刚发的那句话**(`mask` 档,2026-09-01)。
3925
+ * Token 用量。
3931
3926
  *
3932
- * ## 没有这一帧的话,这件事是**静默**的
3927
+ * 之前 protocol 侧和 core/context/types.ts 各有一个 TokenUsage,
3928
+ * 后者多两个字段——同名不同义,收成一个超集。
3933
3929
  *
3934
- * `mask` 档不拒绝、也不报错:它把命中处换成 `*` 之后照常发出去。而屏幕上那条
3935
- * 用户气泡显示的是**用户自己敲的原文**(宿主回显的是输入框里那份),
3936
- * 模型收到的是打过码的那份 —— 两边不一样,且没有任何东西说过这件事。
3930
+ * ## 五个 token 字段的口径(方案 42 PR-0)
3937
3931
  *
3938
- * 实测过:`帮我翻译一下黑鬼这个词` 发出去变成 `帮我翻译一下**这个词`。
3939
- * 用户看着自己那句完整的话,收到一个答非所问的回复,**没有任何线索**。
3940
- * 这一帧就是那个线索。
3932
+ * **两种关系并存,别一视同仁地相加**:
3941
3933
  *
3942
- * ⚠️ 和 `block` 那一档分得很清:那一档请求根本没发,走的是
3943
- * `finish: 'compliance'` + 一句正文,用户当场知道发生了什么。
3944
- * 这一档请求照发,所以只欠一句旁白。
3934
+ * | 字段 | 与谁的关系 |
3935
+ * | ------------------------------- | --------------------------- |
3936
+ * | `cacheHitTokens` `cacheWriteTokens` | 与 `inputTokens` **不重叠** |
3937
+ * | `reasoningTokens` | **包含在** `outputTokens` 里 |
3938
+ * | `totalTokens` | 输入总量 + 输出总量 |
3939
+ *
3940
+ * 所以「一共多少 token」= `inputTokens + outputTokens + cacheHit + cacheWrite`,
3941
+ * **不加** `reasoningTokens`。计价同理,见 `core/src/cost/pricing.ts` 文件头。
3942
+ *
3943
+ * 这个口径不是我们凭空定的,是 AI SDK v7 那边就这么分的:
3944
+ * `inputTokenDetails.noCacheTokens + cacheReadTokens + cacheWriteTokens
3945
+ * === inputTokens`,而 `outputTokenDetails.textTokens = outputTokens - reasoning`。
3945
3946
  */
3946
- | 'compliance-masked'
3947
+ interface TokenUsage {
3948
+ /**
3949
+ * **非缓存**的输入 token,即 SDK 的 `inputTokenDetails.noCacheTokens`。
3950
+ *
3951
+ * ⚠️ 不是 SDK 的 `usage.inputTokens`(那个是含缓存的输入总量)。填总量会让
3952
+ * 缓存命中被按原价算第二遍 —— 缓存读通常只要原价的 10%,这两个数差一个量级。
3953
+ */
3954
+ inputTokens: number;
3955
+ outputTokens: number;
3956
+ /** 缓存命中读到的输入 token,与 `inputTokens` 不重叠,单独计价 */
3957
+ cacheHitTokens?: number;
3958
+ /** 写入缓存的输入 token,与 `inputTokens` 不重叠,单独计价 */
3959
+ cacheWriteTokens?: number;
3960
+ /**
3961
+ * 推理模型的思考 token(SDK 的 `outputTokenDetails.reasoningTokens`)。
3962
+ *
3963
+ * ⚠️ 它**包含在 `outputTokens` 里**,和上面两个缓存字段的口径正好相反。
3964
+ * 只用来展示「这一轮想了多少」,**不许再加进总数、也不单独计价**,
3965
+ * 否则输出侧被算两遍。provider 不是推理模型 / 不上报时为 undefined。
3966
+ */
3967
+ reasoningTokens?: number;
3968
+ /**
3969
+ * SDK 报的总数:输入总量(含缓存两项)+ 输出总量。
3970
+ *
3971
+ * 要显示「一共多少 token」优先用这个,自己四项相加的只当兜底 ——
3972
+ * 前者是 provider 报的两个总量直接相加,后者要先经过我们的拆分算术
3973
+ * (`inputTokens` 取的是 noCache),多一层就多一个出错的地方。
3974
+ * provider 没上报用量时为 undefined。
3975
+ */
3976
+ totalTokens?: number;
3977
+ /**
3978
+ * 本次用量折算的美元金额。模型没有定价数据时为 undefined,**不要填 0**。
3979
+ *
3980
+ * 0 意味着「免费」,undefined 意味着「不知道」。预算闸门对这两种情况的处理
3981
+ * 必须不同——未知定价不能当免费放行。
3982
+ */
3983
+ costUsd?: number;
3984
+ }
3947
3985
  /**
3948
- * provider 按 max output tokens **把这一轮的回复切了**(2026-09-04)。
3986
+ * 预算触顶的原因。
3949
3987
  *
3950
- * 和 `FinishReason` 的 `'length'` 是同一件事的两个出口,两个都要:
3951
- * 收尾原因是给**机器**分支的(headless 的 `result.reason`、落盘的
3952
- * `finish_reason` 列、web 那枚记号),这一帧是给**人**看的那句话 ——
3953
- * 屏幕上那半截正文自己不会说「我是半截的」。
3988
+ * 住在 protocol 而不是 `core/src/cost/types.ts`:它出现在 `finish` 事件上,
3989
+ * 于是**跨进程的宿主也要认它** —— headless 的 `result.reason` 要靠它区分
3990
+ * 「这个月的额度用完了」和「这一次调用的 `--max-budget-usd` 到了」。
3991
+ * core 那边 re-export 这一份,不另立一个同名类型。
3992
+ */
3993
+ type BudgetBreach =
3994
+ /** 金额超过 `budget.maxCostUsd`(跨会话累计) */
3995
+ 'cost'
3996
+ /** token 数超过 `budget.maxTokens`(跨会话累计) */
3997
+ | 'tokens'
3998
+ /** 模型无定价数据,且 `budget.onUnknownPricing` 为 `'block'` */
3999
+ | 'unknown-pricing'
4000
+ /**
4001
+ * **本次调用**的金额上限到了(`RunInput.maxCallCostUsd` / `--max-budget-usd`)。
3954
4002
  *
3955
- * ⚠️ 走 `notice` 而不是 `text-delta`,判据同上面 `skill-learned` 那三条:
3956
- * 它不是模型说的话,塞进正文就会进历史、进落盘、下一轮再进模型。
4003
+ * 和 `'cost'` 分开是因为处理方式不同:`'cost'` 意味着用户配的那份长期预算见底了,
4004
+ * 要么等下个周期要么调高上限;`'call-cost'` 只是这一次跑得比预期贵,
4005
+ * 换个说法再问一次就好。CI 的退出码也据此分叉。
3957
4006
  */
3958
- | 'response-truncated'
4007
+ | 'call-cost';
4008
+ /** 一次运行的收尾原因 */
4009
+ type FinishReason =
4010
+ /** 模型正常收工 */
4011
+ 'stop'
4012
+ /** 用尽轮次预算 */
4013
+ | 'max-turns'
4014
+ /** 死循环检测触发 —— 连续多轮重复同一个工具调用 */
4015
+ | 'stalled'
3959
4016
  /**
3960
- * 这一条消息发出去之前,**上下文被自动压了一次**(2026-09-10)。
4017
+ * 模型在**一次回复内**开始复读,被我们掐掉了(core 的 `repetition-detector.ts`)。
3961
4018
  *
3962
- * ## 没有这一帧的话,用户看到的是「它忘了我们刚才聊的」
4019
+ * ⚠️ **和 `'stalled'` 刻意分开。** 那一档是「连续多轮调同一个工具」,
4020
+ * 用户能做的是换个说法或者自己接手;这一档整轮工具调用为零,是解码退化,
4021
+ * 处置是重发一次(大概率就好了)。混成一个值之后,界面上只能给出一句
4022
+ * 同时对两种情况都不准的话。
3963
4023
  *
3964
- * 自动压缩是有损的:摘要替掉了早期几十轮的原文。屏幕上那段对话还在
3965
- * (宿主回显的是自己那份记录),而模型手上只剩摘要 —— 于是它接下来那句
3966
- * 「你之前说的那个方案」答得对不上,而**没有任何线索**说过发生了什么。
3967
- * 这一帧就是那个线索,也是「为什么这一轮多花了一笔」的唯一出处。
4024
+ * ⚠️ **也不是 `'length'`。** 那一档是 provider 按 max output tokens 切的、
4025
+ * 那半截正文是模型真想说的;这一档是**我们**在它撞上上限之前主动停的,
4026
+ * 已经产出的正文留着但没有价值。两者在账上差一整个输出预算 ——
4027
+ * 现场那一份是 31.6 KB 里只有 12 种句子。
4028
+ */
4029
+ | 'looping'
4030
+ /** 被宿主中止 */
4031
+ | 'aborted'
4032
+ /** 模型安全策略拒答 —— 是 **provider** 拒答,不是我们拦的 */
4033
+ | 'content-filter'
4034
+ /**
4035
+ * 我们自己的内容合规闸门拦下了(core 的 `compliance/`)。
3968
4036
  *
3969
- * ⚠️ 走 `notice` 而不是 `text-delta`,判据同 `skill-learned`:它不是模型说的话,
3970
- * 塞进正文就会进历史、进落盘、下一轮再进模型。
4037
+ * ⚠️ **刻意不复用 `content-filter`。** 那个值的含义是「provider 拒答了」,
4038
+ * 处置和说法都不一样:那一档用户能做的是换个说法再问,这一档是内容触了
4039
+ * 部署方配的合规红线(词表 + 类别档位),换说法不一定有用,而且该看的是
4040
+ * 「哪个类别」。混成一个值之后,界面上只能给出一句同时对两种情况都不准的话。
3971
4041
  *
3972
- * ⚠️ **两个位置都发这一帧,语义相同**(都是「这一发之前压了」):
3973
- * `AgentSession` 跑前那道(软线,压完写回会话历史)和 `AgentLoop` 送出前那道
3974
- * (硬线,见 protocol 的 `isPromptOversize`)。不给它们分两档码是因为
3975
- * 用户要知道的是同一件事,而「哪道闸判的」是我们的实现细节。
4042
+ * 输入侧被拦下时也是这个值 —— 那一次**请求根本没发出去**(`usage` 全零),
4043
+ * 与输出侧被拦下的区别在文本里说清,不在这个枚举里分叉。
3976
4044
  */
3977
- | 'context-compacted'
4045
+ | 'compliance'
3978
4046
  /**
3979
- * 这个模型认不认图,**我们查不到,正在现问它本人**(2026-09-11)。
3980
- *
3981
- * ## 为什么这一次探测必须说出来
4047
+ * provider 把这一轮的回复**按 max output tokens 截断了**(2026-09-04)。
3982
4048
  *
3983
- * 它是**用户没要求过的一次额外请求**:多一趟往返(约两秒)、多一笔约 225 token
3984
- * 的账。屏幕上那段静默否则解释不了 —— 用户发了张图,等了两秒,什么都没有。
4049
+ * ## 为什么必须和 `'stop'` 分开
3985
4050
  *
3986
- * 判据同上面几条:多花的钱和多等的时间,只要不是用户自己点的,就欠一句旁白。
4051
+ * 分不开的时候,「模型自己收的」和「话没说完就被切了」在事后**无法区分** ——
4052
+ * 两者在界面上长得一模一样(都是「这一轮没有工具调用」)。而这是所有
4053
+ * 「模型半路停下」类问题的**第一个分诊问题**:一句
4054
+ * 「我先确认苏菲店的信息,再拉昨日数据。」+ 0 个工具调用,到底是模型宣布了
4055
+ * 下一步就收工(该由 `verify:stop` 那道闸救),还是它正要发工具调用时
4056
+ * 被截断(该调高 max output tokens)—— 处置完全相反。
3987
4057
  *
3988
- * ⚠️ **一次探测最多两帧**(「正在问」+ 只在**认图**时补一句「确认认图」):
3989
- * 不认图那一路后面紧跟着 `error` 帧,那句话已经把结论说全了,再发一帧确认
3990
- * 就是同一件事说两遍。
4058
+ * 实测过一次:`sessions.db` 里 687 条消息的 `finish_reason` 列**全是 NULL**,
4059
+ * 而 `AgentLoop` 当时只判了 `'content-filter'`,没有任何一处判 `'length'`。
4060
+ * 那一轮到底是哪一种,事后答不出来。
3991
4061
  *
3992
- * ⚠️ 每个型号**一辈子只探一次**(结论进 `~/.epoch/cache/model_probe.json`,
3993
- * 认图存 30 天、不认图存 24 小时),所以这一帧天然罕见。要是它开始每轮都出现,
3994
- * 说明缓存写不进去(只读的 home),那是个 bug,不是这一帧的问题。
4062
+ * ⚠️ **这一档不是「出错了」。** 请求成功、已经计费、那半截正文是真的,
4063
+ * 只是它不完整。宿主该说的话是「回复被截断了」而不是「失败了」。
3995
4064
  */
3996
- | 'image-probe'
4065
+ | 'length'
3997
4066
  /**
3998
- * **历史里**那几张图被摘掉了,因为此刻这个模型不认图(2026-09-12)。
4067
+ * 这一轮**干了活却什么都没说**:零工具调用收尾,而正文是空的(或只有几个字),
4068
+ * 而且踢回去重答一次之后还是这样(2026-09-16)。
3999
4069
  *
4000
- * ## 和上面 `images-dropped`、和 `agent_run.images_unsupported` 三者的分工
4070
+ * ## 为什么必须和 `'stop'` 分开
4001
4071
  *
4002
- * | | 图从哪来 | 谁判的 | 请求 | 钱 |
4003
- * | --- | --- | --- | --- | --- |
4004
- * | `images_unsupported`(`error` 帧) | **这一轮新发的** | 我们(闸门 1) | 不发 | 没花 |
4005
- * | 这一条(`notice` 帧) | **历史里的** | 我们(装 history 时) | 照发 | 花了,但图不占 token |
4006
- * | `images-dropped`(`notice` 帧) | 这一轮新发的 | **provider 的 warning** | 已发 | 花了 |
4072
+ * 分不开的时候,它和「模型正常收工」在事后**和当场都**无法区分 —— 两边都是
4073
+ * 「这一轮没有工具调用」+ 一个 `finish_reason` 说 `stop`。现场是
4074
+ * `deepseek-flash` 调了 8 次工具、烧掉 289.2k token,最后正文只有「阿飞」
4075
+ * 两个字;同一个库里第二例正文是**空串**,reasoning 只有一个 `The`。
4076
+ * 两条都被记成了成功收工:`hooks.fire('stop')` 照发、`maybeLearn()` 照跑、
4077
+ * headless 的 `ok` 照给。
4078
+ *
4079
+ * ⚠️ **这一档和 `'length'` 不是一回事。** 那一档 provider 亲口说了它切了
4080
+ * (`finishReason === 'length'`),处置是调高 max output tokens;这一档
4081
+ * provider 说的是 `stop` —— 它认为自己正常说完了,而说出来的是空气。
4082
+ * 实测那两例的账:`outputTokens − reasoningTokens` 减掉工具调用 JSON 之后
4083
+ * 残差是 −61 / −58 token(估计噪声 ±484),也就是**正文真的没生成**,
4084
+ * 不是生成了没落库。
4085
+ *
4086
+ * ⚠️ **这一档不是「出错了」。** 请求成功、已经计费。宿主该说的是
4087
+ * 「这一轮没有产出回复」,而不是「失败了」—— 但它**也不是成功**,
4088
+ * 所以 headless 的 `ok` 和调度的 `ok` 都不该给(判据在
4089
+ * `runtime/src/schedule/executor.ts` 的 `FINISH_STATUS` 上)。
4090
+ */
4091
+ | 'blank'
4092
+ /** 触达成本或 token 预算上限 */
4093
+ | 'budget-exceeded'
4094
+ /** 异常终止(详情见前一个 error 事件) */
4095
+ | 'error';
4096
+ type AgentEvent =
4097
+ /**
4098
+ * 这一轮真的要发出去了 —— **决定 8 的第一个时刻**(方案 42 §七,2026-08-15)。
4007
4099
  *
4008
- * 第一行是「你现在做的这件事做不了」,用户当场能改(换模型 / 去掉图);
4009
- * 这一条是「你**以前**做的那件事,从现在起看不见了」—— 他此刻什么都没做,
4010
- * 所以更需要一句旁白。
4100
+ * ## 发它的是谁
4011
4101
  *
4012
- * ## 没有这一帧的话,这件事是彻底静默的
4102
+ * core 的 `agent/loop.ts`,在**第一次模型请求之前**、且**在预算闸门之后**。
4103
+ * 位置是有判据的:闸门拦下的那一次请求根本没发出去,给它发一条「这一轮开跑了」
4104
+ * 是在宣布一件没发生的事(events.ts 上一条 ⚠️ 骂的正是「声明了却没有发射方」,
4105
+ * 这一条反过来 —— 别发一件没发生的)。
4013
4106
  *
4014
- * 触发面是两个:`epoch --resume` 装一段贴过图的会话(启动路径上**没有任何
4015
- * 能力检查**),以及 `/model` 切到一个能力**查不到**的型号
4016
- * (`provider/selection.ts` 那条硬拒绝只挡查得到不认图的)。两条路上用户都
4017
- * 没有再发图,于是屏幕上不会有任何东西提到图 —— 而模型接下来那句
4018
- * 「你发的那张截图我看不到」会像是它在犯病。
4107
+ * 一次 `run()` 最多一条:它是「这一轮」的头,不是「这一步」的。
4108
+ * 收尾那条 `finish` 是它的另一半,两者一头一尾夹住同一段。
4019
4109
  *
4020
- * ⚠️ **一次 run 只发一帧**,判据逐字同 `images-dropped`:那几张历史图在这次
4021
- * run 的每一轮都要重新摘一次,逐轮报的话一次多工具调用的对话会刷出十几行。
4022
- */
4023
- | 'history-images-stripped';
4024
- type AgentEventType = AgentEvent['type'];
4025
- /** 取出某个具体事件的类型,例如 AgentEventOf<'tool-call'> */
4026
- type AgentEventOf<T extends AgentEventType> = Extract<AgentEvent, {
4027
- type: T;
4028
- }>;
4029
-
4030
- /**
4031
- * 插件契约。
4110
+ * ## `estimatedTokens` 是**估算**,而且永远是
4032
4111
  *
4033
- * 插件是 epoch-agent 的扩展单元:一个独立 npm 包,实现 EpochPlugin 接口来提供
4034
- * 工具、技能和生命周期钩子。插件只需依赖本包(@epoch-agent/protocol),
4035
- * 不依赖引擎实现。
4112
+ * 它是那一刻手里那份完整待发送载荷(system prompt + 工具定义 + 全部消息)
4113
+ * 过一遍 `estimateTokens()` 的结果。2026-08-15 换了真分词器(o200k_base)之后
4114
+ * 这个数**说得出真话**了 —— 在那之前 `chars/4` 在中文场景系统性低估 2–2.7 倍。
4036
4115
  *
4037
- * 设计参考:
4038
- * - MCP (Model Context Protocol) 的 tool 定义格式
4039
- * - Vercel AI SDK 的 tool 接口
4116
+ * ⚠️ **换了真分词器不等于它变成了事实**,别把这两句话弄反:
4117
+ *
4118
+ * 1. 分词器是 o200k_base,而模型可能是 Claude / DeepSeek,各家 BPE 不同 ——
4119
+ * 这是 `provider/tokenizer.ts` 文件头明写的「有意的近似」;
4120
+ * 2. 更硬的一条:这个数是**发之前**算的,而真值只有 provider 回的 usage 说了算,
4121
+ * 中途还会插进压缩、任务通知、工具结果。
4122
+ *
4123
+ * 所以界面上那枚「估算」徽标**不许摘**,判据同 `design/web-ui/thread.css`
4124
+ * 里 `.est` 那条:token 的三个时刻里只有运行前这一个是猜的。
4125
+ *
4126
+ * ## 为什么不复用 `usage` 事件
4127
+ *
4128
+ * `usage` 上两个数都来自 provider 的实测回报(`cumulative` / `run`),
4129
+ * 往里塞一个猜出来的数,就得让每个消费方去判「这个字段是真的还是估的」——
4130
+ * 而那正是徽标要替用户回答的问题。两笔账两条事件,别合。
4040
4131
  */
4041
-
4042
- /** 技能定义 */
4043
- interface EpochSkill {
4044
- /** 技能名称 */
4045
- name: string;
4046
- /** 触发条件(自然语言描述 LLM 何时应加载此技能) */
4047
- trigger: string;
4048
- /** 技能内容(Markdown 格式的流程指南) */
4049
- content: string;
4132
+ {
4133
+ type: 'run-start';
4134
+ /**
4135
+ * 这一轮由哪个角色在跑。**顶层会话没指定角色时整个键缺席**,
4136
+ * 不是给一个 `general` —— 「没选专家」在决定 20 ② 里是一个有形状的值
4137
+ * (底栏没有 chip),编一个默认名等于把那个值抹掉。
4138
+ */
4139
+ role?: ActiveAgentRole;
4140
+ /** 运行前估算。见上面那两条为什么它永远带「估算」二字 */
4141
+ estimatedTokens: number;
4050
4142
  }
4051
- /** 生命周期钩子 */
4052
- interface EpochHook {
4053
- /** 钩子类型 */
4054
- type: HookType;
4055
- /** 执行函数 */
4056
- handler: (event: HookEvent) => Promise<void>;
4143
+ /** 回复正文增量 */
4144
+ | {
4145
+ type: 'text-delta';
4146
+ text: string;
4147
+ }
4148
+ /** 推理模型的思考过程增量 */
4149
+ | {
4150
+ type: 'reasoning-delta';
4151
+ text: string;
4152
+ }
4153
+ /** 模型请求调用工具(执行前) */
4154
+ | {
4155
+ type: 'tool-call';
4156
+ callId: string;
4157
+ toolName: string;
4158
+ input: Record<string, unknown>;
4057
4159
  }
4058
4160
  /**
4059
- * 日志出口 —— 库代码唯一允许的「说点什么」的通道。
4161
+ * 工具**执行过程中**的输出增量。callId 与对应的 tool-call 一致。
4060
4162
  *
4061
- * 库不许直接写 stderr:嵌进 Electron 主进程或服务端之后,`console.warn` 既进不了
4062
- * 宿主的日志系统,也没法在 UI 上呈现,更关不掉。所以凡是原来想 console 的地方,
4063
- * 一律收成这个签名,由装配层从宿主那里接一个进来;宿主不给就静默 —— 不是退回 stderr。
4163
+ * 这是一条**只给人看**的旁路通道:模型看到的永远是最终 `tool-result.output`
4164
+ * (那份已经截断、已经包过隔离标签)。加它是因为 `tool-call` 和 `tool-result`
4165
+ * 之间可以隔着一次三分钟的 `pnpm build` —— 在那三分钟里宿主一个事件都收不到,
4166
+ * 屏幕上是一行不动的 `⏳ terminal`,用户分不清是在跑还是挂了。
4064
4167
  *
4065
- * 这本来就是 `PluginContext.log` 的签名,只是以前只有插件能用。
4168
+ * 发这个事件的工具不必逐 chunk 发:引擎侧统一做合并窗口、剥 ANSI、`\r` 折叠
4169
+ * 和总量封顶(见 core 的 tool-output-pump.ts),工具只管把原始 chunk 交出来。
4066
4170
  *
4067
- * **`message` 必须自带完整信息**:`detail` 是给结构化日志系统的补充,
4068
- * 一个只看 `message` 的朴素宿主实现不能因此丢掉错误原因。
4171
+ * 宿主可以完全忽略它 —— 忽略的结果就是改动前的行为。
4172
+ */
4173
+ | {
4174
+ type: 'tool-output-delta';
4175
+ callId: string;
4176
+ toolName: string;
4177
+ text: string;
4178
+ }
4179
+ /**
4180
+ * 工具执行完毕。callId 与对应的 tool-call 一致。
4069
4181
  *
4070
- * 级别刻意写成内联联合、不另开一个 `LogLevel` 导出:infra 的 logger 里已经有一个
4071
- * 结构完全相同的 `LogLevel`,而 protocol 和 infra 都是零内部依赖的包,谁也 import
4072
- * 不了谁。与其在两个包里各挂一个同名类型让人猜该用哪个,不如这里不占这个名字 ——
4073
- * 要单独引用级别时写 `Parameters<LogFn>[0]`。
4182
+ * `artifacts` 给宿主渲染缩略图 / 打开文件用。它和 `output` 是**两个通道**:
4183
+ * `output` 是喂给模型的纯文本(已含每个 artifact 的 summary),
4184
+ * `artifacts` 是给人看的那一份 —— 宿主不必解析 output 去猜有没有图。
4074
4185
  */
4075
- type LogFn = (level: 'debug' | 'info' | 'warn' | 'error', message: string, detail?: object) => void;
4076
- /** 插件上下文(初始化时注入) */
4077
- interface PluginContext {
4078
- /** 工作目录 */
4079
- workDir: string;
4080
- /** 配置数据 */
4081
- config: Record<string, unknown>;
4082
- /** 日志函数 */
4083
- log: LogFn;
4186
+ | {
4187
+ type: 'tool-result';
4188
+ callId: string;
4189
+ toolName: string;
4190
+ output: string;
4191
+ ok: boolean;
4192
+ artifacts?: ToolArtifact[];
4084
4193
  }
4085
- /** 插件定义 */
4086
- interface EpochPlugin {
4087
- /** 插件唯一标识(对应 npm 包名) */
4088
- name: string;
4089
- /** 语义化版本 */
4090
- version: string;
4091
- /** 插件描述 */
4092
- description: string;
4093
- /** 提供的工具列表 */
4094
- tools?: EpochTool[];
4095
- /** 提供的技能(可复用的 prompt 模板) */
4096
- skills?: EpochSkill[];
4097
- /** 注册的生命周期钩子 */
4098
- hooks?: EpochHook[];
4099
- /** 初始化回调 */
4100
- onInit?: (ctx: PluginContext) => Promise<void>;
4101
- /** 清理回调 */
4102
- onDestroy?: () => Promise<void>;
4194
+ /** 一轮 ReAct 结束(轮次从 1 开始) */
4195
+ | {
4196
+ type: 'turn-complete';
4197
+ turn: number;
4103
4198
  }
4104
-
4105
4199
  /**
4106
- * Hook 契约。
4200
+ * 需要用户确认。宿主**必须**调用 respond(),否则引擎会一直阻塞在这里。
4201
+ * 引擎侧对未答复的请求有兜底(流结束时按 deny 处理),但那意味着操作被拒。
4107
4202
  *
4108
- * 参考 Hermes hermes_cli/plugins.py → VALID_HOOKS + invoke_hook
4203
+ * `note` 只有计划审批的 `plan-revise` 用得上 —— 它装的是用户的修改意见,
4204
+ * 其余出口一律忽略。**没有它这个出口就是个死循环**:模型不知道要改什么,
4205
+ * 只会把同一份计划原样再交一次。
4206
+ */
4207
+ | {
4208
+ type: 'approval-request';
4209
+ request: ApprovalRequest;
4210
+ respond: (outcome: ApprovalOutcome, note?: string) => void;
4211
+ }
4212
+ /**
4213
+ * 模型问了一个结构化问题,等用户选(方案 34)。宿主**必须**调用 `respond()`,
4214
+ * 否则引擎会一直阻塞在这里。
4109
4215
  *
4110
- * 注意:HookType 之前在 core/src/plugin.ts 和 core/src/hook/types.ts 各有一份,
4111
- * 且**内容不同**(plugin.ts 那份只有 7 个事件,缺 compress:after / transform:* /
4112
- * verify:stop)。插件作者按 EpochHook 写代码时拿到的是窄的那份,注册进
4113
- * HookManager 却是宽的那份。这里合并成唯一一份。
4216
+ * 结构与 `approval-request` 同构(请求 + 一个答复闭包),于是中继、wire 编码、
4217
+ * 宿主回调那几层一个都不用新造。但**语义不同,别把两者的兜底混用**:
4218
+ * 审批答不上来时有一个安全侧(`deny`),提问没有 —— 替用户挑一个选项和替他
4219
+ * 点「允许」一样糟。答不上来只有一种表达:`respond({ answers: {}, skipped: true })`,
4220
+ * 模型收到之后**自己决定**(通常是选默认继续),这一轮不算失败。
4221
+ *
4222
+ * 引擎侧对未答复的请求有兜底(流结束时按「跳过」处理)—— 那不是伪造答案,
4223
+ * 是如实说「没人答」。
4114
4224
  */
4115
-
4116
- type HookType = 'session:start' | 'session:end' | 'turn:before' | 'turn:after' | 'tool:before' | 'tool:after' | 'stop' | 'compress:after' | 'transform:tool:output' | 'transform:response' | 'transform:user:input' | 'verify:stop';
4117
- interface HookEvent {
4118
- type: HookType;
4119
- sessionId: string;
4120
- timestamp: number;
4121
- turnIndex?: number;
4122
- userMessage?: string;
4123
- response?: string;
4225
+ | {
4226
+ type: 'question';
4227
+ request: QuestionRequest;
4228
+ respond: (answer: QuestionAnswer) => void;
4229
+ }
4230
+ /**
4231
+ * 补丁预览:这次工具调用真的改了哪个文件、改成什么样。
4232
+ *
4233
+ * 发它的是 core 的 `agent/file-diff.ts`(`ToolExecutor` 在写类工具动手前后
4234
+ * 各读一遍盘),排在同一次调用的 `tool-result` **之前**。
4235
+ * `oldContent` 缺 = 这个文件是新建的;`newContent` 缺 = 被删了;两者都在 = 改了。
4236
+ *
4237
+ * ⚠️ 这个类型 2026-08-13 之前**声明在这里、消费在 view、渲染在两个宿主,
4238
+ * 中间一个发射方都没有** —— 三边契约测试全绿,因为站在引擎位置上的是
4239
+ * fixture 喂出来的假 agent。判据见 `.agents/plans/43-web-ui-shell-plan.md` §12.5。
4240
+ * **往这个 union 里加新类型时,先想清楚谁发。**
4241
+ */
4242
+ | {
4243
+ type: 'diff';
4244
+ filePath: string;
4245
+ oldContent?: string;
4246
+ newContent?: string;
4247
+ }
4248
+ /** 累计用量变化(每轮结束时发一次,宿主用来更新状态栏) */
4249
+ | {
4250
+ type: 'usage';
4251
+ cumulative: TokenUsage;
4124
4252
  /**
4125
- * 这一轮的用量。**provider 没上报时整个字段缺席**,不是一个填了 0 的对象 ——
4126
- * 插件据此能区分「这轮没花钱」和「这轮花了多少不知道」(方案 42 PR-0)。
4253
+ * **到此刻为止、这一次 run** 的用量(方案 42 PR-2)。
4254
+ *
4255
+ * 和 `finish.runUsage` 同一笔账,只是这一份是**过程中**的快照 ——
4256
+ * 界面上「运行中:已消耗 N token」要的就是它。
4257
+ *
4258
+ * ⚠️ 为什么不让宿主拿 `cumulative` 相减:相减要有一个起算基线,而
4259
+ * 「run 开始那一刻的累计」在事件流上根本没有 —— 第一帧 `usage` 到达时,
4260
+ * 它的 `cumulative` 既可能是本轮用量、也可能是 `BudgetStore` 恢复的历史
4261
+ * 加上本轮,从流本身分辨不了。基线猜错的代价是屏幕上那个数直接差一个量级。
4127
4262
  */
4128
- usage?: TokenUsage;
4129
- toolName?: string;
4130
- toolArgs?: Record<string, unknown>;
4131
- toolResult?: {
4132
- success: boolean;
4133
- output: string;
4134
- error?: {
4135
- code: number;
4136
- message: string;
4137
- };
4263
+ run?: TokenUsage;
4264
+ budgetRemaining?: {
4265
+ costUsd?: number;
4266
+ tokens?: number;
4138
4267
  };
4139
- rawOutput?: string;
4140
- rawInput?: string;
4141
- reason?: string;
4142
- messageCount?: number;
4143
- totalTokens?: number;
4144
- prunedCount?: number;
4145
- summaryTokens?: number;
4146
- turns?: Array<{
4147
- index: number;
4148
- llmResponse: string | null;
4149
- }>;
4150
- data?: Record<string, unknown>;
4151
- }
4152
- interface HookContext {
4153
- workDir: string;
4154
- log: (level: 'debug' | 'info' | 'warn' | 'error', message: string) => void;
4155
- }
4156
- type HookHandler<T = void> = (event: HookEvent, ctx: HookContext) => Promise<T>;
4157
- interface HookRegistration {
4158
- type: HookType;
4159
- handler: HookHandler<unknown>;
4160
- priority: number;
4161
- plugin: string;
4162
- }
4163
- interface VerifyResult {
4164
- action: 'stop' | 'continue';
4165
- message?: string;
4166
4268
  }
4167
- /** Hook 管理器接口(实现在 core) */
4168
- interface HookManager {
4269
+ /**
4270
+ * 运行结束。
4271
+ *
4272
+ * `breach` 只在 `reason === 'budget-exceeded'` 时有值,说明是哪条上限触顶的。
4273
+ * 没有它的话「触顶」在事件流上只是一句中文提示(`describeBreach()` 的产物),
4274
+ * 而机器宿主要分支就只能去正则匹配那句中文 —— 我们改一个字它就碎。
4275
+ *
4276
+ * ## `usage` 和 `runUsage` 是两笔账,别当同义词(方案 42 PR-2)
4277
+ *
4278
+ * `usage` 是 `BudgetGuard.snapshot()` —— **累计**,口径由 `EpochRuntime.usageScope`
4279
+ * 决定(配了 `BudgetStore` 就是跨会话累计,否则是本进程从零算)。宿主拿它画
4280
+ * 「这个会话一共花了多少」。
4281
+ *
4282
+ * `runUsage` 是**这一次 run** 的用量,由本次循环里每一轮的 `result.usage`
4283
+ * 累加而成。加它的理由很具体:界面上每一轮结束时要写「共 14,270 token」,
4284
+ * 而那个数**减不出来** —— 累计值可能被 `BudgetStore` 从上次会话恢复,
4285
+ * 「第一个累计值」既可能是本轮用量、也可能是历史 + 本轮,
4286
+ * 从事件流本身分辨不了(`AgentLoopConfig.onTurnUsage` 那段注释骂的是同一件事)。
4287
+ *
4288
+ * **两个都是可选的,那不是偷懒**:一次 run 可以一次模型调用都没发生就结束
4289
+ * (装配抛异常、预算在发送前拦下)。那时候「这一轮用了多少 / 用的哪个模型」
4290
+ * 没有答案,而给一个 0 和给配置里那个模型名都是撒谎 —— 与
4291
+ * `TokenUsage.costUsd` 的「0 是免费,undefined 是不知道」同一条约定。
4292
+ */
4293
+ | {
4294
+ type: 'finish';
4295
+ reason: FinishReason;
4296
+ usage: TokenUsage;
4297
+ /** **这一次 run** 的用量。provider 一次都没上报就没有这个键 */
4298
+ runUsage?: TokenUsage;
4169
4299
  /**
4170
- * 宿主注入的诊断出口,同时也是 `HookContext.log` 的来源。
4300
+ * 最后一次模型调用**实际回来**的模型(`GenerateOutput.model`)。
4171
4301
  *
4172
- * 挂在管理器上而不是让 `AgentLoop` 另外收一个 logger:那样宿主要接两次,
4173
- * 接漏一处就是「hook 系统自己的告警有人收、插件在 hook 里说的话没人收」。
4174
- * 可选是给别的实现留的余地,缺省时 `HookContext.log` 退化成静默。
4302
+ * ⚠️ 不是配置里选的那个。降级和 `switch_model` 之后两者不一样,而
4303
+ * 「这一轮到底是谁答的」正是那时候用户最需要知道的一件事。
4175
4304
  */
4176
- readonly log?: LogFn;
4177
- register(reg: HookRegistration): void;
4178
- trigger(type: HookType, event: HookEvent, ctx: HookContext): Promise<void>;
4179
- triggerTransform<T>(type: HookType, event: HookEvent, ctx: HookContext): Promise<T | null>;
4180
- triggerVerify(type: HookType, event: HookEvent, ctx: HookContext): Promise<VerifyResult[]>;
4181
- /** 从 EpochConfig.hooks 加载 command hooks */
4182
- loadFromConfig(rawHooks: Record<string, unknown> | undefined): void;
4183
- /** 触发 command hooks(用于 tool:before 等可阻止的事件) */
4184
- triggerCommandHooks(type: HookType, event: HookEvent, workDir: string): Promise<{
4185
- blocked: boolean;
4186
- reason?: string;
4187
- } | null>;
4188
- hasHook(type: HookType): boolean;
4189
- unregister(type: HookType, handler: HookHandler<unknown>): void;
4190
- count(type: HookType): number;
4305
+ model?: string;
4306
+ /**
4307
+ * 最后一次模型调用**要求**的那个模型(`GenerateOutput.requestedModel`)。
4308
+ *
4309
+ * 和上面的 {@link model} 是一对,宿主要的那个事实是**两者是否相等** ——
4310
+ * 不等 = 引擎降级了,相等 = 用户/命令自己挑的。
4311
+ *
4312
+ * ⚠️ **读侧不许拿 `model` 去和「当前配置/当前选择」比来代替这个判断。**
4313
+ * 那是一个按**此刻**的值,而这条断言是按**轮**的 —— 它会被拿去比对所有
4314
+ * 历史轮,于是用户在顶栏换一次模型,之前那些跑得好好的轮子集体变成
4315
+ * 「故障转移」。web 脚注那枚徽标栽过这一次,判据全文在
4316
+ * `core/src/provider/types.ts` 的 `GenerateOutput.requestedModel` 上。
4317
+ */
4318
+ requestedModel?: string;
4319
+ breach?: BudgetBreach;
4191
4320
  }
4192
-
4193
4321
  /**
4194
- * 定时任务契约(方案 45)。
4322
+ * 引擎有一句话要对用户说,而**它不是回复正文**(2026-08-26)。
4195
4323
  *
4196
- * 这一份只装**形状**:触发器怎么表达、一条任务有哪些字段、一次运行留下什么。
4197
- * 注册进操作系统、抢锁、跑一次,全在 `core/src/schedule/` 和 `runtime` 里。
4324
+ * ## 发它的是谁,以及为什么非要一条新帧
4198
4325
  *
4199
- * ## 为什么触发器是判别联合,而存储是一段 JSON
4326
+ * 今天唯一的发射方是 core 的 `AgentLoop`:技能学习器在上一轮收尾之后
4327
+ * fire-and-forget 学到了一条技能,而那件事没有任何出口(判据全文在
4328
+ * `agent/loop.ts` 的 `maybeLearn()`)。三条现成的路各自被什么挡掉:
4200
4329
  *
4201
- * 三档的字段完全不同(每周要 `weekdays`、按间隔要 `everyMinutes`、单次还要个
4202
- * 日期),拆成列会得到一张一半是 NULL 的表。所以库里 `trigger_spec` 是一段
4203
- * JSON —— 但**类型是这个联合**,JSON 只是它的存储形式。反过来(先有列再拼
4204
- * 类型)的代价是「每周三个字段里只填了两个」这种状态在类型上说得出口。
4330
+ * 1. **`text-delta`** —— 它会被折进 assistant 那一段正文,于是这句话进历史、
4331
+ * 进落盘、下一轮再进模型。一句「你学会了 X」被当成模型自己说过的话,
4332
+ * 等于往上下文里塞一条它没说过的发言;
4333
+ * 2. **往 system prompt 里塞** —— 那是 prompt cache 的前缀,每轮加一句会让整段
4334
+ * 对话的缓存逐轮失效。同一条判据 `loop.ts` 里为后台任务通知讲过一遍;
4335
+ * 3. **一条 `[系统]` 进历史**(`systemNote()` + `recordNote()`,样板是
4336
+ * `runtime` 的 `markRoleHandoff()`)—— 它是真出口,但那句话**只在刷新 /
4337
+ * 回放时看得见**,而且今天只有 web 认那个前缀,TUI 会把 `[系统] ` 原样露出来。
4205
4338
  *
4206
- * ## 为什么没有完整 cron 表达式
4339
+ * ## 为什么是「码 + 已经翻好的话」两个字段
4207
4340
  *
4208
- * 这三档正好是 Windows 任务计划(`schtasks`)与 macOS launchd 的**公共**能力,
4209
- * 而 5 字段 cron 不是。写全 cron 等于把「时区 + 夏令时 + 闰日」搬回我们自己的
4210
- * 代码里算下一次触发时刻 —— 而把触发交给操作系统的全部意义就是不碰它们。
4211
- * 判据一句话:**一个语义在两个平台上不能逐字一致,就不做这个语义。**
4212
- */
4213
-
4214
- /** 三档频率。和 WorkBuddy 的分档逐字一致,理由见文件头 */
4215
- type ScheduleTriggerKind = 'cron' | 'interval' | 'once';
4216
- /** 星期。`0` = 周日 … `6` = 周六,与 `Date.prototype.getDay()` 同口径 */
4217
- type ScheduleWeekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
4218
- /**
4219
- * 「周期」档的三种周期。
4341
+ * `text` 由**发射方**(core,它有 `t()`)翻好:`@epoch-agent/view` 只依赖
4342
+ * protocol、够不着 catalog,在那儿兜底就只能是中文 —— 同 `error` 那条空 message
4343
+ * 的兜底为什么落在两个宿主身上,是同一条判据。
4220
4344
  *
4221
- * 和 `ScheduleTriggerKind` 不是一回事:那个分的是三档,这个只细分 `cron` 那一档。
4345
+ * `code` 是给**要分支的宿主**的:判「这是哪一类通知」不许去 `includes()` 那句
4346
+ * 随时会改的话(RECORD-40-i18n §32.1 那条 `reason === '已取消'` 的教训)。
4347
+ *
4348
+ * ⚠️ **要拿技能名去做别的事**(跳到能力页、打开那份 SKILL.md),就再加一个
4349
+ * 字段,别去正则 `text` —— 那句话是会随文案改的。
4222
4350
  */
4223
- type ScheduleCycle = 'daily' | 'weekly' | 'monthly';
4351
+ | {
4352
+ type: 'notice';
4353
+ code: NoticeCode;
4354
+ text: string;
4355
+ }
4224
4356
  /**
4225
- * 触发器。
4357
+ * 用户在这一轮**跑到一半时**插进来的一句话,已经被引擎取走并拌进上下文
4358
+ * (2026-09-16)。
4226
4359
  *
4227
- * 所有带时刻的档共用 `at: 'HH:mm'`(24 小时制,**本机时区**)—— 统一成一个字段名
4228
- * 是为了让「几点跑」在三档之间读起来是同一件事;单次那一档另加 `date`。
4360
+ * ## 它是「已经发生」的回执,不是请求
4361
+ *
4362
+ * 发它的是 core 的 `agent/loop.ts`,**在那句话真的被 push 进 `messages`
4363
+ * 的同一处**。宿主收到它就意味着「模型下一次请求会看见这句话」——
4364
+ * 在这之前那条消息只是躺在宿主自己的队列里,随时可能因为
4365
+ * 到了 `maxTurns`、或者这一轮被中止而**根本没进去**。
4366
+ *
4367
+ * 两件事因此只能由这一帧回答,别在宿主侧猜:
4368
+ *
4369
+ * 1. **落盘位置**。`runtime` 收到它才写那条 user 行,写下去的位置正是它在
4370
+ * 事件流里的位置 —— 也就是模型看到的位置。宿主自己按「发送时刻」插的话,
4371
+ * 会插到那一刻还在流式输出的 assistant 行**前面**去
4372
+ * (hermes 的 #73793 就是这个形状:mid-turn user bubble 被拼在
4373
+ * active assistant stream row 之前)。
4374
+ * 2. **界面上说哪句话**。「插进这一轮了」和「排在这一轮后面」是两件事,
4375
+ * 而 `POST /messages` 那一刻答不出是哪一件(见 `WireSendMessageResponse`)。
4376
+ *
4377
+ * ## 为什么不复用 `text-delta` 或者 `notice`
4378
+ *
4379
+ * 前者是**模型**说的话,宿主拿它拼 assistant 正文;这一条是**用户**说的,
4380
+ * 拼进去等于把用户的话记成模型的回答。后者是「给人看一眼就过去」的旁路,
4381
+ * 不落盘(判据在 `TurnStepCollector` 那个 `default` 上),而这一条必须落盘。
4382
+ *
4383
+ * ## `text` 与 `parts`
4384
+ *
4385
+ * 同 `EpochMessage`:`text` 是文本投影(落 `content` 列、进 FTS),
4386
+ * `parts` 只在有图片 / 文件时出现。**`parts` 里的 base64 不落 `text`** ——
4387
+ * 判据同 `AgentSession.run()` 里那句「绝不能把 base64 塞进这里」。
4229
4388
  */
4230
- type ScheduleTrigger = {
4231
- kind: 'cron';
4232
- cycle: 'daily';
4233
- at: string;
4234
- } | {
4235
- kind: 'cron';
4236
- cycle: 'weekly';
4237
- at: string;
4238
- weekdays: readonly ScheduleWeekday[];
4239
- }
4240
- /** `days` 是几号(1–31)。31 号在小月不触发,两个平台的原生行为一致 */
4241
4389
  | {
4242
- kind: 'cron';
4243
- cycle: 'monthly';
4244
- at: string;
4245
- days: readonly number[];
4246
- } | {
4247
- kind: 'interval';
4248
- everyMinutes: number;
4390
+ type: 'steer';
4391
+ text: string;
4392
+ parts?: EpochContentPart[];
4249
4393
  }
4250
- /** `date` 是 `YYYY-MM-DD` */
4394
+ /** 运行中出错(随后会有一个 finish: 'error') */
4251
4395
  | {
4252
- kind: 'once';
4253
- date: string;
4254
- at: string;
4396
+ type: 'error';
4397
+ message: string;
4255
4398
  };
4256
4399
  /**
4257
- * 「按间隔」档的**全部**合法取值(分钟)。
4400
+ * 一条 `notice` 是哪一类。
4258
4401
  *
4259
- * 收紧成「60 的约数」和「整小时数」不是洁癖,是 launchd 的硬约束:那一侧只能用
4260
- * `StartCalendarInterval`(列举时刻),不能用 `StartInterval`(睡眠时那一次会被
4261
- * 丢掉,10.11 起文档明写)。60 除不尽 7 时,「每 7 分钟」列举出来的那 9 个时刻
4262
- * 在每小时边界上会漂 —— 于是 Windows 上准、macOS 上漂,而那种平台差异是 bug 的温床。
4402
+ * 写成联合而不是 `string`,是为了让**加一档时消费方的 switch 会红** ——
4403
+ * 判据同 wire.ts 的 `WireResetReason`。
4263
4404
  *
4264
- * 界面上因此是个下拉而不是数字输入框:下拉能表达「只有这些值」,
4265
- * 输入框会让人填 7 然后得到一个漂的任务。
4405
+ * ⚠️ 这里原来写着「今天只有一种」—— 那句话在写下之后就一直在过期
4406
+ * (`images-dropped` / `compliance-masked` / `response-truncated` 陆续进来)。
4407
+ * 有几种去数下面这个联合,别在散文里再抄一个会骗人的数字。
4266
4408
  */
4267
- declare const SCHEDULE_INTERVAL_MINUTES: readonly number[];
4409
+ type NoticeCode =
4268
4410
  /**
4269
- * 一次运行的结局。
4411
+ * 技能学习器在上一轮收尾之后学到了一条技能。
4270
4412
  *
4271
- * 后三个是「没跑」而不是「跑失败了」,它们必须和 `failed` 分开 ——
4272
- * 界面上把「上一次因为上一次还在跑所以跳过了」显示成红叉,用户会去查一个不存在的故障。
4413
+ * ⚠️ 这一帧发在**下一轮的头上**,不是学成的那一刻 —— 学成时那条流早关了
4414
+ * (`maybeLearn()` 紧接着就是 `yield finish('stop'); return;`)。发在这里还有
4415
+ * 第二层意思:那一轮正是这条技能**真的进了 system prompt** 的那一轮。
4273
4416
  */
4274
- type ScheduleRunStatus = 'ok' | 'failed' | 'denied' | 'timeout' | 'budget' | 'skipped-overlap' | 'skipped-window' | 'missed';
4275
- /** 合法结局全集。存进库之前用它收窄,别让一个拼错的状态混进 `last_status` */
4276
- declare const SCHEDULE_RUN_STATUSES: readonly ScheduleRunStatus[];
4417
+ 'skill-learned'
4277
4418
  /**
4278
- * 任务注册在哪儿。
4419
+ * provider **把用户发的图片丢了**,而请求已经发出去、已经计费(2026-09-01)。
4279
4420
  *
4280
- * - `schtasks` / `launchd` —— 真的注册进了操作系统的调度器,**epoch 不用开着**
4281
- * - `host` —— **一行都没注册进 OS**(方案 45 §2.6 的路 B)。到点了唤起它的只能是
4282
- * 宿主自己(`setInterval` 调 `fire()`)—— 而宿主有没有真的这么做,**这一格答不出来**,
4283
- * 它唯一承诺的事实就是「没进 OS」。所以界面上不许把它写成「已启用」,
4284
- * 也**不许许诺「开着就会跑」**(判据全文在 locales 的 `notice_host` 上)
4421
+ * 和 `agent_run.images_unsupported` 那条错误分得很清,两者别混:
4422
+ *
4423
+ * | | 谁判的 | 什么时候 | 钱 |
4424
+ * | --- | --- | --- | --- |
4425
+ * | `images_unsupported`(`error` 帧) | **我们**(闸门 1 的输入侧) | 发请求**之前** | 没花 |
4426
+ * | 这一条(`notice` 帧) | **provider 自己报的 warning** | 请求回来之后 | **已经花了** |
4427
+ *
4428
+ * 也就是说走到这一帧意味着闸门 1 判「这个模型认图」,而 provider 那侧不认。
4429
+ * 2026-09-01 那次事故就是这一格:`@ai-sdk/deepseek@3.0.20` 静默丢弃图片,
4430
+ * 模型对着一张不存在的图回答「我只看到一个占位符」,而屏幕上一个字都没有。
4431
+ *
4432
+ * ⚠️ **一次 run 只发一帧**,不是每轮一帧:带图的那条用户消息在这一次 run 的
4433
+ * 每一轮都会重发,逐轮报的话一次多工具调用的对话会刷出十几行同样的话。
4285
4434
  */
4286
- type ScheduleBackendKind = 'schtasks' | 'launchd' | 'host';
4435
+ | 'images-dropped'
4287
4436
  /**
4288
- * 无人值守清单 —— 「要确认,但没人在,怎么办」。
4437
+ * 内容合规的输入闸门**改写了用户刚发的那句话**(`mask` 档,2026-09-01)。
4289
4438
  *
4290
- * 和权限档(`ScheduleDefinition.permission`)**各管一条轴**:档位回答「这个操作
4291
- * 要不要人确认」,这份清单回答「要确认但没人在时,哪几条可以不问」。
4292
- * 它不是「更宽的一档」,是把「要问人」逐条变成「这一条可以不问」。
4439
+ * ## 没有这一帧的话,这件事是**静默**的
4293
4440
  *
4294
- * 三个字段由粗到细,同时命中任一条即放行:
4295
- * `allowTools` 整个工具 / `allowOperations` 五类操作 / `allowRules` 规则字符串
4296
- * (`terminal(git status)`、`mcp__github__*`、`file_write(./reports/**)`)。
4297
- */
4298
- interface ScheduleAllowlist {
4299
- allowTools: readonly string[];
4300
- allowOperations: readonly OperationType[];
4301
- allowRules: readonly string[];
4302
- }
4303
- /**
4304
- * 一条欠条 —— 「本该问人,但没人可问」。
4441
+ * `mask` 档不拒绝、也不报错:它把命中处换成 `*` 之后照常发出去。而屏幕上那条
4442
+ * 用户气泡显示的是**用户自己敲的原文**(宿主回显的是输入框里那份),
4443
+ * 模型收到的是打过码的那份 —— 两边不一样,且没有任何东西说过这件事。
4305
4444
  *
4306
- * ⚠️ **只收这一类**。权限档自己判出的拒绝(选了「只读」档然后去写文件)
4307
- * **不进欠条** —— 那是档位的判定结果,不是缺授权;把它记成欠条等于建议用户
4308
- * 去加一条永远不会被查到的规则(第 2 层判 deny 时压根走不到第 3 层)。
4309
- */
4310
- interface SchedulePendingApproval {
4311
- toolName: string;
4312
- type: OperationType;
4313
- target: string;
4314
- /** 权限层给的原因,原样带着 —— 人回来时最想看的就是这句 */
4315
- reason: string;
4316
- /**
4317
- * 该往清单里加哪条规则(`rule-suggest` 算的精确规则)。
4318
- *
4319
- * 算不出来时**整个不带这个键**:硬编一条 `terminal()` 出来只会让用户
4320
- * 加进去一个死规则。
4321
- */
4322
- suggestedRule?: string;
4323
- at: number;
4324
- }
4325
- /**
4326
- * 一条定时任务。
4445
+ * 实测过:`帮我翻译一下黑鬼这个词` 发出去变成 `帮我翻译一下**这个词`。
4446
+ * 用户看着自己那句完整的话,收到一个答非所问的回复,**没有任何线索**。
4447
+ * 这一帧就是那个线索。
4327
4448
  *
4328
- * 时间一律是 **epoch 毫秒**而不是 `Date`:这个形状要过 JSON(存库、将来上 SSE),
4329
- * `Date` 一过 JSON 就变成字符串,于是同一个字段在两处是两种类型。
4449
+ * ⚠️ 和 `block` 那一档分得很清:那一档请求根本没发,走的是
4450
+ * `finish: 'compliance'` + 一句正文,用户当场知道发生了什么。
4451
+ * 这一档请求照发,所以只欠一句旁白。
4330
4452
  */
4331
- interface ScheduleDefinition {
4332
- id: string;
4333
- name: string;
4334
- /**
4335
- * 停用不等于删除。停用的任务在系统的任务计划里**仍然看得见,只是停着** ——
4336
- * 删掉再建的话,用户在任务计划程序里看到条目消失会以为我们把他的任务弄丢了。
4337
- */
4338
- enabled: boolean;
4339
- createdAt: number;
4340
- updatedAt: number;
4341
- prompt: string;
4342
- /**
4343
- * 工作目录。**执行器不许继承 cwd** —— OS 调度器起进程时的 cwd 不由我们决定
4344
- * (Windows 上是 `%SystemRoot%\System32`)。用户没填时落在
4345
- * `~/.epoch/automation/<id>/`,那是一个确定的目录,不是「随便哪儿」。
4346
- */
4347
- workDir: string;
4348
- model?: string;
4349
- /**
4350
- * 「召唤专家」。**今天永远不带 —— 但已经不是被别人挡着了。**
4351
- *
4352
- * 立案时写的是「等方案 42 PR-1 把顶层会话的角色接线」,而那一笔
4353
- * **2026-08-15 就结清了**(`SessionFactory.create({ role })` +
4354
- * `WireCreateSessionRequest.role`,见 [agent-role.ts](./agent-role.ts) 文件头)。
4355
- * 挡着这一格的只剩「没人做」:`add` / 表单 / 端点三条写入路都不写它。
4356
- * ⚠️ **别把「被别人挡着」和「没人做」读成一件事** —— 逐条状态在
4357
- * [验收记录 9.2](../../../docs/verify/VERIFY_RECORD-45-automation.md)。
4358
- */
4359
- role?: string;
4360
- /** 同 {@link role}:方案 44 的清单契约早就到齐了,缺的是写入点。今天永远不带 */
4361
- skills?: readonly string[];
4362
- /**
4363
- * 同 {@link role},缺的是写入点。
4364
- * 连接器的免确认另有一条现成的路:`allowRules` 里的 `mcp__<server>__*`。
4365
- */
4366
- mcpServers?: readonly string[];
4367
- /**
4368
- * 权限档 —— **和对话里那枚胶囊同一套取值、同一套词**。
4369
- *
4370
- * 界面只暴露四档(`plan` / `default` / `acceptEdits` / `bypass`)。
4371
- * 不为定时任务发明新词:用户在对话里已经学过这四个词一次,
4372
- * 再教一套等于让他学两遍同一件事的两种说法。
4373
- */
4374
- permission: PermissionLevel;
4375
- allowTools: readonly string[];
4376
- allowOperations: readonly OperationType[];
4377
- allowRules: readonly string[];
4378
- /** 无人看着的一轮不该比有人看着的更长(交互缺省是 50) */
4379
- maxTurns: number;
4380
- /**
4381
- * **必填,没有默认值。** 这是整个方案唯一一处刻意增加摩擦的地方:
4382
- * 一个假默认会让「一觉醒来 40 美元」变成我们的错。
4383
- */
4384
- maxBudgetUsd: number;
4385
- /**
4386
- * 墙钟超时。前两把尺子拦不住「一条 `terminal` 命令挂住了」——
4387
- * 那种情况轮次和花费都不增长,这把尺子是唯一能拦它的。
4388
- */
4389
- timeoutMs: number;
4390
- trigger: ScheduleTrigger;
4391
- /**
4392
- * 生效区间(`YYYY-MM-DD`,含首尾),留空表示始终生效。
4393
- *
4394
- * ⚠️ **两个平台在这个字段上语义不同**:Windows 有原生的 `/SD` `/ED`,过了截止日
4395
- * 系统压根不会调我们;launchd 没有这个概念,所以 macOS 上我们会被调起来、
4396
- * 然后由执行器自己判出区间外并退 0。
4397
- */
4398
- startDate?: string;
4399
- endDate?: string;
4400
- osBackend: ScheduleBackendKind;
4401
- /** 注册进 OS 时用的标识(Windows 的 `epoch\<id>` / launchd 的 `Label`) */
4402
- osTaskId: string;
4403
- /** 没注册进 OS 时不带(`osBackend: 'host'`,或者注册失败) */
4404
- osRegisteredAt?: number;
4405
- /**
4406
- * **真正注册进去的那条 argv**,逐字。`doctor` 靠它比对 OS 侧有没有漂。
4407
- *
4408
- * 存下来而不是每次现算:现算的结果依赖**此刻**的 `process.execPath` 和入口路径,
4409
- * 而漂移检查要问的恰恰是「当初写进去的是什么」。
4410
- */
4411
- osCommand: readonly string[];
4412
- lastRunAt?: number;
4413
- lastStatus?: ScheduleRunStatus;
4414
- }
4415
- /** 一次运行的记录 */
4416
- interface ScheduleRun {
4417
- id: string;
4418
- scheduleId: string;
4419
- /** 真的起了会话才有;三种 `skipped-*` / `missed` 都没有 */
4420
- sessionId?: string;
4421
- startedAt: number;
4422
- endedAt?: number;
4423
- status: ScheduleRunStatus;
4424
- exitCode?: number;
4425
- reason?: string;
4426
- costUsd: number;
4427
- tokens?: TokenUsage;
4428
- turns: number;
4429
- /** 欠条。空数组 = 这一轮没撞上需要确认的操作 */
4430
- pendingApprovals: readonly SchedulePendingApproval[];
4431
- /** 录像(`<runId>.ndjson`)的绝对路径;没起会话时不带 */
4432
- logPath?: string;
4433
- /**
4434
- * `epoch schedule run` 手动跑的那一次。
4435
- *
4436
- * 标出来是因为它**不该影响「上一次到点跑成没有」的判断** ——
4437
- * 用户手点一次成功,不等于明天 9 点那次会成功。
4438
- */
4439
- manual: boolean;
4440
- }
4453
+ | 'compliance-masked'
4441
4454
  /**
4442
- * 宿主注入的「怎么以无界面方式唤起我自己」(方案 45 §2.6)。
4455
+ * provider 按 max output tokens **把这一轮的回复切了**(2026-09-04)。
4443
4456
  *
4444
- * 把引擎 `import` 进 Electron / VS Code 扩展的宿主**没有 `epoch` 可执行文件**,
4445
- * 而**库自己分辨不出宿主打算怎么被唤起** —— 所以这件事必须由宿主说。
4457
+ * 和 `FinishReason` 的 `'length'` 是同一件事的两个出口,两个都要:
4458
+ * 收尾原因是给**机器**分支的(headless 的 `result.reason`、落盘的
4459
+ * `finish_reason` 列、web 那枚记号),这一帧是给**人**看的那句话 ——
4460
+ * 屏幕上那半截正文自己不会说「我是半截的」。
4446
4461
  *
4447
- * ⚠️ **绝对不许拿 `process.execPath` + `argv[1]` 去猜。** 在 Electron 里那两个值
4448
- * 是「Electron 的 exe」+「宿主的 main.js」,注册进去的后果是**到点了给用户弹出
4449
- * 整个应用的主窗口**(甚至一天弹 24 次)。那不是「不够好」,是把定时任务变成骚扰。
4462
+ * ⚠️ 走 `notice` 而不是 `text-delta`,判据同上面 `skill-learned` 那三条:
4463
+ * 它不是模型说的话,塞进正文就会进历史、进落盘、下一轮再进模型。
4450
4464
  */
4451
- interface ScheduleRunnerSpec {
4452
- /**
4453
- * 无界面入口的 argv 前缀,绝对路径。我们在后面追加
4454
- * `schedule fire <id> --home <dir>`。
4455
- *
4456
- * 那个入口必须**不建窗口、跑完就退**(Electron 里就是在 `whenReady` 之前
4457
- * 分叉出去)。
4458
- */
4459
- argv: readonly string[];
4460
- /** 注册进 OS 时给人看的名字前缀。不给就用 `epoch` */
4461
- label?: string;
4462
- }
4463
-
4465
+ | 'response-truncated'
4464
4466
  /**
4465
- * 内置模板六张([方案 45](../../../docs/verify/VERIFY_RECORD-45-automation.md) §7.3)。
4466
- *
4467
- * 判据是「**这件事必须每天/每周做一次,而且做完是一份能读的东西**」——
4468
- * 所以 WorkBuddy 那 12 张消费级的(儿童睡前故事 / 父母联系提醒 / 萌宠壁纸)
4469
- * 一张都没抄,换成开发者场景。
4470
- *
4471
- * ## ⚠️ 三条硬约束都摊在类型上,不靠注释
4472
- *
4473
- * | §7.3 那句话 | 落在类型的哪一格 |
4474
- * | ------------------------------- | -------------------------------------------------------------------- |
4475
- * | 模板**不联网** | 这是一份 `const`,没有取数入口 —— 在线模板市场 42 已定不做 |
4476
- * | 一律不预设「跳过全部检查」 | {@link ScheduleTemplate.permission} 的取值里**没有** `bypass` |
4477
- * | 填进表单后**必须让用户过一遍** | 整份形状里**没有** `maxBudgetUsd`,而它是必填 —— 表单因此保存不了 |
4467
+ * 这一轮零工具调用收尾,而正文是空的(或只有几个字)—— **已经踢回去让它重答**
4468
+ * (2026-09-16)。
4478
4469
  *
4479
- * 最后一条是三条里最要紧的,而它是**结构性**的而不是靠一句提示:预算必填、
4480
- * 没有默认值(§3.5),于是一张模板卡填进表单之后那个保存按钮**天然是灰的**,
4481
- * 用户不看完那一屏就出不去。写成「模板里预填 0.50 + 一句『请检查』」的话,
4482
- * 「必须过一遍」就退化成一句没人读的话。
4470
+ * 和 `FinishReason` 的 `'blank'` 是同一件事的两个出口,但**两者不同时出现**,
4471
+ * 这一点和 `response-truncated` / `'length'` 那一对相反:
4483
4472
  *
4484
- * ## 为什么住在 protocol,而不是「core 一份 + 这里抄一份」
4473
+ * - 这一帧发在**第一次**判空的那一刻,后面紧跟一次重答。重答成功了的话
4474
+ * 那一轮的收尾原因是 `'stop'`(它确实正常收工了),而屏幕上留着这一句话,
4475
+ * 解释「刚才为什么多花了一轮」;
4476
+ * - `'blank'` 只在重答**之后还是空**时才落下去。
4485
4477
  *
4486
- * 任务书给的两条路是「从 server 端点下发」或者「抄一份到 protocol + 一条逐项相等
4487
- * 的用例」(照 `WireScheduleIssueCode` 的先例)。**两条都不用,因为这一份和那个
4488
- * 先例不是同一种东西**:
4478
+ * 所以少了这一帧,用户看到的是一次莫名其妙多出来的等待;少了 `'blank'`,
4479
+ * 两次都空的那一轮会被记成成功。两个都要。
4489
4480
  *
4490
- * - `WireScheduleIssueCode` 的真源在 core**必须**在 core —— 那张码表是
4491
- * `validateSchedule()` 的出口集合,它和判定逻辑长在一起,搬不动;
4492
- * - 而这一份是**纯数据、零判定**:六个触发器、六档权限、六份最小清单。
4493
- * 它谁都不依赖。而 [AGENTS.md](../../../AGENTS.md) 的分层表里 protocol 那一行
4494
- * 写着「跨包共享的东西**只能**放这里」。
4495
- *
4496
- * 放这儿的直接收益是**不存在第二份**:`packages/web` 够得着它(只依赖
4497
- * protocol + view)、cli 够得着、server 够得着、假服务端也够得着。抄一份到
4498
- * protocol 再钉一条「逐项相等」的用例,是在为一个可以不存在的分叉修围栏;
4499
- * 走一条 server 端点则要为一份**从不改变的常量**加一条 HTTP 往返和一个失败态
4500
- * (「模板拉不下来」——而它明明就在包里)。
4501
- *
4502
- * ## ⚠️ 这里一个中文字都没有,措辞在 web 那份 catalog 里
4503
- *
4504
- * 名字 / 说明 / **提示词**三样都是文案:提示词要跟着用户的界面语言走
4505
- * (一个英文界面的用户不该收到一份中文报告),而 protocol 里没有 `t()`、
4506
- * 也不该有。所以这一份只给 `id`,措辞表在
4507
- * `web/src/automation/labels.ts` 的 `TEMPLATE_TEXT` 上 —— 那张表
4508
- * `satisfies Record<ScheduleTemplateId, …>`,**加一张模板而不给它文案会当场编译不过**。
4481
+ * ⚠️ 走 `notice` 而不是 `text-delta`,判据同上面 `skill-learned` 那三条:
4482
+ * 它不是模型说的话,塞进正文就会进历史、进落盘、下一轮再进模型。
4509
4483
  */
4510
-
4484
+ | 'blank-response'
4511
4485
  /**
4512
- * 六张模板的 id。
4486
+ * 这一条消息发出去之前,**上下文被自动压了一次**(2026-09-10)。
4513
4487
  *
4514
- * 它同时是**文案表的键**(web 那侧 `satisfies Record<ScheduleTemplateId, …>`),
4515
- * 所以这个联合就是「有几张模板」的唯一真源 —— 加一张要改的地方由编译器点出来。
4516
- */
4517
- type ScheduleTemplateId = 'repo-sweep' | 'weekly-digest' | 'dep-checkup' | 'stale-docs' | 'test-flake' | 'todo-digest';
4518
- /**
4519
- * 一张模板 = 一条任务除了「名字 / 提示词 / 预算」之外的那些格子。
4488
+ * ## 没有这一帧的话,用户看到的是「它忘了我们刚才聊的」
4520
4489
  *
4521
- * 三样刻意缺席,各有各的理由:
4490
+ * 自动压缩是有损的:摘要替掉了早期几十轮的原文。屏幕上那段对话还在
4491
+ * (宿主回显的是自己那份记录),而模型手上只剩摘要 —— 于是它接下来那句
4492
+ * 「你之前说的那个方案」答得对不上,而**没有任何线索**说过发生了什么。
4493
+ * 这一帧就是那个线索,也是「为什么这一轮多花了一笔」的唯一出处。
4522
4494
  *
4523
- * - `name` / `prompt` —— 是**文案**,见文件头最后一节;
4524
- * - `maxBudgetUsd` —— 必填且没有默认值(§3.5),见文件头那张表第三行;
4525
- * - `workDir` —— 模板不知道用户的仓库在哪儿。留空的语义是
4526
- * `~/.epoch/automation/<id>/`(`ScheduleDefinition.workDir` 上的判据),
4527
- * 而**清单里那条 `file_write(./reports/**)` 是相对的**,所以它跟着工作区走 ——
4528
- * 不管用户填不填,报告都落在那条任务自己的目录下面。
4529
- */
4530
- interface ScheduleTemplate {
4531
- id: ScheduleTemplateId;
4532
- trigger: ScheduleTrigger;
4533
- /**
4534
- * 权限档。**取值里没有 `bypass`**(§7.3:模板一律不预设「跳过全部检查」),
4535
- * 也没有 `auto`(界面压根不暴露它,§3.2)。
4536
- *
4537
- * 两档各有各的用法,而分界线是 by-level 那张表:
4538
- *
4539
- * - `plan`(只读)—— 「读一遍然后写一份报告」的那几张。它天然零授权面,
4540
- * 清单里只要一条 `file_write`(那条走**第 2 层**规则匹配,所以在只读档下
4541
- * 照样放行 —— §3.2 那句「只读 + `file_write(<任务目录>/**)`」说的就是它);
4542
- * - `default`(按需确认)—— 要跑命令的那几张。**除了 `bypass`,写命令在每一档
4543
- * 下都要确认**,所以它们只能靠清单逐条放行,而这正是清单存在的理由。
4544
- */
4545
- permission: 'plan' | 'default' | 'acceptEdits';
4546
- /** 整个工具免确认(粗)。多数模板不用它 —— 逐条列出来的清单才审得动 */
4547
- allowTools?: readonly string[];
4548
- allowOperations?: readonly OperationType[];
4549
- /** 规则(细)。⚠️ **路径一律写成相对的**,理由见 {@link ScheduleTemplate} */
4550
- allowRules?: readonly string[];
4551
- /**
4552
- * 墙钟超时(毫秒)。不给就用引擎缺省的 15 分钟。
4553
- *
4554
- * 只有「测试 flake 复盘」给了 —— 它真的要跑一遍测试,而缺省那把尺子会在
4555
- * 半路把它砍掉。**这一格存在的意义就是让那一张不至于生下来就超时**。
4556
- */
4557
- timeoutMs?: number;
4558
- }
4559
- /**
4560
- * 六张。顺序就是界面上卡片的顺序:**两张每天的排在前面** ——
4561
- * 「每天一次」比「每周一次」更容易让人第一次就试出效果,而这一屏的空状态
4562
- * 要答的问题是「这东西能替我做什么」。
4495
+ * ⚠️ 走 `notice` 而不是 `text-delta`,判据同 `skill-learned`:它不是模型说的话,
4496
+ * 塞进正文就会进历史、进落盘、下一轮再进模型。
4497
+ *
4498
+ * ⚠️ **两个位置都发这一帧,语义相同**(都是「这一发之前压了」):
4499
+ * `AgentSession` 跑前那道(软线,压完写回会话历史)和 `AgentLoop` 送出前那道
4500
+ * (硬线,见 protocol 的 `isPromptOversize`)。不给它们分两档码是因为
4501
+ * 用户要知道的是同一件事,而「哪道闸判的」是我们的实现细节。
4563
4502
  */
4564
- declare const SCHEDULE_TEMPLATES: readonly ScheduleTemplate[];
4565
-
4503
+ | 'context-compacted'
4566
4504
  /**
4567
- * 「还欠着的那几张欠条」——
4568
- * [方案 45](../../../docs/verify/VERIFY_RECORD-45-automation.md) §八 ① / ③ 的素材。
4505
+ * 这个模型认不认图,**我们查不到,正在现问它本人**(2026-09-11)。
4569
4506
  *
4570
- * 三个界面读的是同一个数:dock 上那一条「自动化待确认」、侧栏「自动化」入口上
4571
- * 那个点、TUI 启动时那一行。**它们必须是同一个数** —— 三处各数一遍的下场是
4572
- * 侧栏有点、点进去 dock 上什么都没有。
4507
+ * ## 为什么这一次探测必须说出来
4573
4508
  *
4574
- * ## 一、为什么这份投影住在 protocol
4509
+ * 它是**用户没要求过的一次额外请求**:多一趟往返(约两秒)、多一笔约 225 token
4510
+ * 的账。屏幕上那段静默否则解释不了 —— 用户发了张图,等了两秒,什么都没有。
4511
+ *
4512
+ * 判据同上面几条:多花的钱和多等的时间,只要不是用户自己点的,就欠一句旁白。
4575
4513
  *
4576
- * 它是**纯集合运算**:手上有哪几条任务、每条最近跑过什么、清单里已经有哪几条规则。
4577
- * 没有日期算术、不读盘、不查权限,一个字的 core 知识都不需要。
4578
- * 而它的消费者有四个:`server`(那条端点)、`cli`(`--fix` 和 TUI 那行开机提示)、
4579
- * `packages/web/dev` 那台假服务端、以及将来任何一个嵌入宿主。
4580
- * [AGENTS.md](../../../AGENTS.md) 分层表里 protocol 那一行写着
4581
- * 「跨包共享的东西**只能**放这里」,这就是那一行说的情况。
4514
+ * ⚠️ **一次探测最多两帧**(「正在问」+ 只在**认图**时补一句「确认认图」):
4515
+ * 不认图那一路后面紧跟着 `error` 帧,那句话已经把结论说全了,再发一帧确认
4516
+ * 就是同一件事说两遍。
4582
4517
  *
4583
- * ⚠️ **这一份落地之前,那个集合运算在仓库里有三份**(`server` 的 `fixSchedule`、
4584
- * `cli` 的 `applyFix`、假服务端的 `fakeScheduleFix`),逐字一样的六行。
4585
- * 三份都是「往授权清单里加规则」这条路上的判定 —— 也就是**提权路径上的重复实现**。
4586
- * 收成一份是这个文件存在的第二个理由,比第一个理由更硬。
4518
+ * ⚠️ 每个型号**一辈子只探一次**(结论进 `~/.epoch/cache/model_probe.json`,
4519
+ * 认图存 30 天、不认图存 24 小时),所以这一帧天然罕见。要是它开始每轮都出现,
4520
+ * 说明缓存写不进去(只读的 home),那是个 bug,不是这一帧的问题。
4521
+ */
4522
+ | 'image-probe'
4523
+ /**
4524
+ * **历史里**那几张图被摘掉了,因为此刻这个模型不认图(2026-09-12)。
4587
4525
  *
4588
- * ## 二、「还欠着」= 算得出规则、而且规则还不在清单里
4526
+ * ## 和上面 `images-dropped`、和 `agent_run.images_unsupported` 三者的分工
4589
4527
  *
4590
- * 两道筛,**缺一道这个数就会自己卡住**:
4528
+ * | | 图从哪来 | 谁判的 | 请求 | 钱 |
4529
+ * | --- | --- | --- | --- | --- |
4530
+ * | `images_unsupported`(`error` 帧) | **这一轮新发的** | 我们(闸门 1) | 不发 | 没花 |
4531
+ * | 这一条(`notice` 帧) | **历史里的** | 我们(装 history 时) | 照发 | 花了,但图不占 token |
4532
+ * | `images-dropped`(`notice` 帧) | 这一轮新发的 | **provider 的 warning** | 已发 | 花了 |
4591
4533
  *
4592
- * 1. **`suggestedRule` 必须有值。** 算不出精确规则的那几条(目标为空的操作)
4593
- * 进不了这份清单 —— dock 上唯一的动作是「加进授权清单」,而对它们那一下
4594
- * 什么都加不了。一条点了没用的通知比没有通知坏(设计稿决定 20 ①),
4595
- * 而且它**永远清不掉**:没有任何动作能让它消失。
4596
- * ⚠️ 它们并没有被藏起来:「运行记录」那一屏上那一轮照旧列着它们、
4597
- * 照旧写着「这一条反推不出精确规则」。这里筛掉的是**通知**,不是事实。
4598
- * 2. **规则还不在 `allowRules` 里。** 用户按过那一下之后规则就进清单了,
4599
- * 于是这个数**自己减一**。这条让整件事不需要「读标记」那套东西 ——
4600
- * 没有迁移、没有「哪个界面算看过」这个产品判断、也没有一个只能靠
4601
- * 「我看过了」来清的角标。
4534
+ * 第一行是「你现在做的这件事做不了」,用户当场能改(换模型 / 去掉图);
4535
+ * 这一条是「你**以前**做的那件事,从现在起看不见了」—— 他此刻什么都没做,
4536
+ * 所以更需要一句旁白。
4602
4537
  *
4603
- * ## ⚠️ 三、这个数**不含**「上一次没跑成」的那几条,而那是一条判断不是遗漏
4538
+ * ## 没有这一帧的话,这件事是彻底静默的
4604
4539
  *
4605
- * `failed` / `timeout` / `budget` 三档同样是「你该知道的事」,但它们**没有下一步
4606
- * 动作**:屏幕上给不出一个按钮,用户只能去看运行记录。把它们算进来的话,这个点
4607
- * 会一直亮着,直到那条任务下一次跑成 —— 而在那之前它已经不再指向任何可做的事,
4608
- * 也就退化成了一个「有东西发生过」的常亮灯。
4540
+ * 触发面是两个:`epoch --resume` 装一段贴过图的会话(启动路径上**没有任何
4541
+ * 能力检查**),以及 `/model` 切到一个能力**查不到**的型号
4542
+ * (`provider/selection.ts` 那条硬拒绝只挡查得到不认图的)。两条路上用户都
4543
+ * 没有再发图,于是屏幕上不会有任何东西提到图 —— 而模型接下来那句
4544
+ * 「你发的那张截图我看不到」会像是它在犯病。
4609
4545
  *
4610
- * 三档失败在「运行记录」那一屏上有自己的三态色(✅ ⚠️ ❌),一条都没藏。
4611
- * 📮 真要给它们一个计数,前置是一份**读标记**(`schedule` 那个 namespace 加一次
4612
- * 迁移 + 一条写端点 + 「web 打开那个 tab 算不算看过、TUI 启动算不算」这个产品
4613
- * 判断)。那是独立的一次改动,不该顺手塞进这一轮。
4546
+ * ⚠️ **一次 run 只发一帧**,判据逐字同 `images-dropped`:那几张历史图在这次
4547
+ * run 的每一轮都要重新摘一次,逐轮报的话一次多工具调用的对话会刷出十几行。
4614
4548
  */
4549
+ | 'history-images-stripped';
4550
+ type AgentEventType = AgentEvent['type'];
4551
+ /** 取出某个具体事件的类型,例如 AgentEventOf<'tool-call'> */
4552
+ type AgentEventOf<T extends AgentEventType> = Extract<AgentEvent, {
4553
+ type: T;
4554
+ }>;
4615
4555
 
4616
4556
  /**
4617
- * 一条**算得出规则**的欠条。
4557
+ * 插件契约。
4618
4558
  *
4619
- * `suggestedRule` 从可选收成必填 —— 这不是换个写法:它让「dock 上那个按钮
4620
- * 一定加得进东西」变成一件类型上成立的事,而不是一句要人记住的注释。
4621
- */
4622
- type FixableApproval = SchedulePendingApproval & {
4623
- suggestedRule: string;
4624
- };
4625
- /**
4626
- * dock / 侧栏 / TUI 三处共用的一条。
4559
+ * 插件是 epoch-agent 的扩展单元:一个独立 npm 包,实现 EpochPlugin 接口来提供
4560
+ * 工具、技能和生命周期钩子。插件只需依赖本包(@epoch-agent/protocol),
4561
+ * 不依赖引擎实现。
4627
4562
  *
4628
- * 带 `scheduleName` 而不是只带 id:这条通知的措辞必须**有宾语**
4629
- * (「《每日仓库巡检》差一条授权」,不是「有 1 条待确认」)——
4630
- * 判据和跨会话待办条那一条同源(`web/src/dock/crossbar.tsx` 文件头
4631
- * 「措辞必须带宾语」那一节):一个不带宾语的计数器逼用户挨个点开去看。
4563
+ * 设计参考:
4564
+ * - MCP (Model Context Protocol) 的 tool 定义格式
4565
+ * - Vercel AI SDK 的 tool 接口
4632
4566
  */
4633
- interface SchedulePendingItem {
4634
- scheduleId: string;
4635
- scheduleName: string;
4636
- /** 欠条挂在哪一次运行上。`POST /api/schedules/:id/fix` 收的就是它 */
4637
- runId: string;
4638
- /** 那一次运行的开始时刻(epoch 毫秒)。界面上那句「已欠了多久」 */
4639
- at: number;
4640
- /** 还没进清单的那几条,**至少一条**(空的那几轮压根不进这份清单) */
4641
- approvals: readonly FixableApproval[];
4567
+
4568
+ /** 技能定义 */
4569
+ interface EpochSkill {
4570
+ /** 技能名称 */
4571
+ name: string;
4572
+ /** 触发条件(自然语言描述 LLM 何时应加载此技能) */
4573
+ trigger: string;
4574
+ /** 技能内容(Markdown 格式的流程指南) */
4575
+ content: string;
4576
+ }
4577
+ /** 生命周期钩子 */
4578
+ interface EpochHook {
4579
+ /** 钩子类型 */
4580
+ type: HookType;
4581
+ /** 执行函数 */
4582
+ handler: (event: HookEvent) => Promise<void>;
4642
4583
  }
4643
4584
  /**
4644
- * 这一轮的欠条里,有哪几条规则还不在清单里。**去重,顺序照欠条的顺序。**
4645
- *
4646
- * 三个调用点:这个文件自己(算那个计数)、`POST /api/schedules/:id/fix`
4647
- * (真往清单里写的那一下)、`epoch schedule run --fix`。
4648
- * ⚠️ **三处必须是同一个函数**:它决定的是「往授权清单里加哪几条规则」,
4649
- * 而三份实现走散的方向可能是提权(一边加了另一边没加)。
4650
- */
4651
- declare function unfixedRules(allowRules: readonly string[], approvals: readonly SchedulePendingApproval[]): string[];
4652
- /**
4653
- * 手上那几条任务 + 最近那些运行 → 该通知的清单。
4585
+ * 日志出口 —— 库代码唯一允许的「说点什么」的通道。
4654
4586
  *
4655
- * ## ⚠️ 同一条任务只报**最近**那一次,不是每一次
4587
+ * 库不许直接写 stderr:嵌进 Electron 主进程或服务端之后,`console.warn` 既进不了
4588
+ * 宿主的日志系统,也没法在 UI 上呈现,更关不掉。所以凡是原来想 console 的地方,
4589
+ * 一律收成这个签名,由装配层从宿主那里接一个进来;宿主不给就静默 —— 不是退回 stderr。
4656
4590
  *
4657
- * 一条「每天 9 点」的任务每天记同一条欠条,一周就是七条一模一样的通知。
4658
- * 所以按任务收拢、取最近那一次。这不会漏掉东西:修最近那一次加进去的规则
4659
- * 通常把老的那几次也一起覆盖了;万一老的那次欠的是**另一条**规则,
4660
- * 它在下一次取数时自然浮上来成为「最近的那条未解决的」。
4591
+ * 这本来就是 `PluginContext.log` 的签名,只是以前只有插件能用。
4661
4592
  *
4662
- * ⚠️ 判的是「最近的**还欠着的**那一次」,不是「最近的那一次」:一条任务昨天
4663
- * 卡了、今天跑成了,昨天那条欠条**仍然欠着**(清单里那条规则还是没加),
4664
- * 下一次到点还会再卡一遍。拿「最近一次的状态」当判据会把它静默吞掉。
4593
+ * **`message` 必须自带完整信息**:`detail` 是给结构化日志系统的补充,
4594
+ * 一个只看 `message` 的朴素宿主实现不能因此丢掉错误原因。
4665
4595
  *
4666
- * @param defs 全部任务(`ScheduleControl.list()`)
4667
- * @param runs 最近那些运行,**最新的在前**(`ScheduleControl.recentRuns()` 的顺序)
4596
+ * 级别刻意写成内联联合、不另开一个 `LogLevel` 导出:infra 的 logger 里已经有一个
4597
+ * 结构完全相同的 `LogLevel`,而 protocol 和 infra 都是零内部依赖的包,谁也 import
4598
+ * 不了谁。与其在两个包里各挂一个同名类型让人猜该用哪个,不如这里不占这个名字 ——
4599
+ * 要单独引用级别时写 `Parameters<LogFn>[0]`。
4668
4600
  */
4669
- declare function collectPendingApprovals(defs: readonly ScheduleDefinition[], runs: readonly ScheduleRun[]): SchedulePendingItem[];
4601
+ type LogFn = (level: 'debug' | 'info' | 'warn' | 'error', message: string, detail?: object) => void;
4602
+ /** 插件上下文(初始化时注入) */
4603
+ interface PluginContext {
4604
+ /** 工作目录 */
4605
+ workDir: string;
4606
+ /** 配置数据 */
4607
+ config: Record<string, unknown>;
4608
+ /** 日志函数 */
4609
+ log: LogFn;
4610
+ }
4611
+ /** 插件定义 */
4612
+ interface EpochPlugin {
4613
+ /** 插件唯一标识(对应 npm 包名) */
4614
+ name: string;
4615
+ /** 语义化版本 */
4616
+ version: string;
4617
+ /** 插件描述 */
4618
+ description: string;
4619
+ /** 提供的工具列表 */
4620
+ tools?: EpochTool[];
4621
+ /** 提供的技能(可复用的 prompt 模板) */
4622
+ skills?: EpochSkill[];
4623
+ /** 注册的生命周期钩子 */
4624
+ hooks?: EpochHook[];
4625
+ /** 初始化回调 */
4626
+ onInit?: (ctx: PluginContext) => Promise<void>;
4627
+ /** 清理回调 */
4628
+ onDestroy?: () => Promise<void>;
4629
+ }
4670
4630
 
4671
4631
  /**
4672
- * 模型在对话里交上来的一份定时任务**草稿**。
4673
- *
4674
- * `epoch schedule add` 和自动化面板那张表单是两条**人操作**的写入路。这一份是
4675
- * 第三条来路,而它**不是写入路** —— 草稿谁都落不了库,它只是一份摆到人眼前的
4676
- * 提案。真正落库的仍然是那两条:人点「审阅并保存」之后走的是同一个
4677
- * `POST /api/schedules`,人粘贴命令行走的是同一个 `epoch schedule add`。
4632
+ * Hook 契约。
4678
4633
  *
4679
- * ## ⚠️ 三条硬约束都摊在类型上,不靠注释(照 [schedule-templates.ts](./schedule-templates.js) 那三条办)
4634
+ * 参考 Hermes hermes_cli/plugins.py → VALID_HOOKS + invoke_hook
4680
4635
  *
4681
- * | 要守住的 | 落在类型的哪一格 |
4682
- * | ------------------------------ | -------------------------------------------------------------------- |
4683
- * | 模型不许把任务放到「跳过全部检查」 | {@link ScheduleDraftPermission} 的取值里**没有** `bypass` |
4684
- * | 填进表单后**必须让用户过一遍** | 整份形状里**没有** `maxBudgetUsd`,而它是必填 —— 表单因此保存不了 |
4685
- * | 草稿不是写入路 | 这里只有数据,没有 id 生成、没有 `enabled`、没有 `createdAt` |
4636
+ * 注意:HookType 之前在 core/src/plugin.ts 和 core/src/hook/types.ts 各有一份,
4637
+ * 且**内容不同**(plugin.ts 那份只有 7 个事件,缺 compress:after / transform:* /
4638
+ * verify:stop)。插件作者按 EpochHook 写代码时拿到的是窄的那份,注册进
4639
+ * HookManager 却是宽的那份。这里合并成唯一一份。
4640
+ */
4641
+
4642
+ type HookType = 'session:start' | 'session:end' | 'turn:before' | 'turn:after' | 'tool:before' | 'tool:after' | 'stop' | 'compress:after' | 'transform:tool:output' | 'transform:response' | 'transform:user:input' | 'verify:stop';
4643
+ interface HookEvent {
4644
+ type: HookType;
4645
+ sessionId: string;
4646
+ timestamp: number;
4647
+ turnIndex?: number;
4648
+ userMessage?: string;
4649
+ response?: string;
4650
+ /**
4651
+ * 这一轮的用量。**provider 没上报时整个字段缺席**,不是一个填了 0 的对象 ——
4652
+ * 插件据此能区分「这轮没花钱」和「这轮花了多少不知道」(方案 42 PR-0)。
4653
+ */
4654
+ usage?: TokenUsage;
4655
+ toolName?: string;
4656
+ toolArgs?: Record<string, unknown>;
4657
+ toolResult?: {
4658
+ success: boolean;
4659
+ output: string;
4660
+ error?: {
4661
+ code: number;
4662
+ message: string;
4663
+ };
4664
+ };
4665
+ rawOutput?: string;
4666
+ rawInput?: string;
4667
+ reason?: string;
4668
+ messageCount?: number;
4669
+ totalTokens?: number;
4670
+ prunedCount?: number;
4671
+ summaryTokens?: number;
4672
+ turns?: Array<{
4673
+ index: number;
4674
+ llmResponse: string | null;
4675
+ }>;
4676
+ data?: Record<string, unknown>;
4677
+ }
4678
+ interface HookContext {
4679
+ workDir: string;
4680
+ log: (level: 'debug' | 'info' | 'warn' | 'error', message: string) => void;
4681
+ }
4682
+ type HookHandler<T = void> = (event: HookEvent, ctx: HookContext) => Promise<T>;
4683
+ interface HookRegistration {
4684
+ type: HookType;
4685
+ handler: HookHandler<unknown>;
4686
+ priority: number;
4687
+ plugin: string;
4688
+ }
4689
+ interface VerifyResult {
4690
+ action: 'stop' | 'continue';
4691
+ message?: string;
4692
+ }
4693
+ /** Hook 管理器接口(实现在 core) */
4694
+ interface HookManager {
4695
+ /**
4696
+ * 宿主注入的诊断出口,同时也是 `HookContext.log` 的来源。
4697
+ *
4698
+ * 挂在管理器上而不是让 `AgentLoop` 另外收一个 logger:那样宿主要接两次,
4699
+ * 接漏一处就是「hook 系统自己的告警有人收、插件在 hook 里说的话没人收」。
4700
+ * 可选是给别的实现留的余地,缺省时 `HookContext.log` 退化成静默。
4701
+ */
4702
+ readonly log?: LogFn;
4703
+ register(reg: HookRegistration): void;
4704
+ trigger(type: HookType, event: HookEvent, ctx: HookContext): Promise<void>;
4705
+ triggerTransform<T>(type: HookType, event: HookEvent, ctx: HookContext): Promise<T | null>;
4706
+ triggerVerify(type: HookType, event: HookEvent, ctx: HookContext): Promise<VerifyResult[]>;
4707
+ /** 从 EpochConfig.hooks 加载 command hooks */
4708
+ loadFromConfig(rawHooks: Record<string, unknown> | undefined): void;
4709
+ /** 触发 command hooks(用于 tool:before 等可阻止的事件) */
4710
+ triggerCommandHooks(type: HookType, event: HookEvent, workDir: string): Promise<{
4711
+ blocked: boolean;
4712
+ reason?: string;
4713
+ } | null>;
4714
+ hasHook(type: HookType): boolean;
4715
+ unregister(type: HookType, handler: HookHandler<unknown>): void;
4716
+ count(type: HookType): number;
4717
+ }
4718
+
4719
+ /**
4720
+ * 定时任务契约(方案 45)。
4686
4721
  *
4687
- * 第二条是三条里最要紧的,而它和模板那一条**是同一条**:预算必填、没有默认值
4688
- * (方案 45 §3.5),于是一份草稿填进表单之后那个保存按钮**天然是灰的**,
4689
- * 用户不看完那一屏就出不去。写成「草稿里预填 0.50 + 一句『请检查』」的话,
4690
- * 「必须过一遍」就退化成一句没人读的话 —— 而这一格错了的后果很具体:
4691
- * 一觉醒来 40 美元。
4722
+ * 这一份只装**形状**:触发器怎么表达、一条任务有哪些字段、一次运行留下什么。
4723
+ * 注册进操作系统、抢锁、跑一次,全在 `core/src/schedule/` 和 `runtime` 里。
4692
4724
  *
4693
- * ## 为什么住在 protocol
4725
+ * ## 为什么触发器是判别联合,而存储是一段 JSON
4694
4726
  *
4695
- * 和 `SCHEDULE_TEMPLATES` 同一个理由,而且更硬一档:这一份的**两端都要读它** ——
4696
- * core 那侧要解析模型发来的入参(`tools/builtin/schedule-draft.ts`),
4697
- * `packages/web` 那侧要从那次工具调用的入参里把它读回来渲染成一张卡片
4698
- * (`thread/rows.ts`,照 `todo` 的先例)。web 只依赖 protocol + view,够不着 core。
4727
+ * 三档的字段完全不同(每周要 `weekdays`、按间隔要 `everyMinutes`、单次还要个
4728
+ * 日期),拆成列会得到一张一半是 NULL 的表。所以库里 `trigger_spec` 是一段
4729
+ * JSON —— 但**类型是这个联合**,JSON 只是它的存储形式。反过来(先有列再拼
4730
+ * 类型)的代价是「每周三个字段里只填了两个」这种状态在类型上说得出口。
4699
4731
  *
4700
- * ## ⚠️ 这里一个中文字都没有
4732
+ * ## 为什么没有完整 cron 表达式
4701
4733
  *
4702
- * 判据同 schedule-templates.ts 最后一节:措辞在 `locales/*.yaml` 的
4703
- * `web.ui.thread.draft_*` 底下(卡片)和 core 的工具输出里(给模型看的那半,
4704
- * 按 `locales/zh.yaml` 的范围说明不进 catalog)。
4734
+ * 这三档正好是 Windows 任务计划(`schtasks`)与 macOS launchd 的**公共**能力,
4735
+ * 而 5 字段 cron 不是。写全 cron 等于把「时区 + 夏令时 + 闰日」搬回我们自己的
4736
+ * 代码里算下一次触发时刻 —— 而把触发交给操作系统的全部意义就是不碰它们。
4737
+ * 判据一句话:**一个语义在两个平台上不能逐字一致,就不做这个语义。**
4705
4738
  */
4706
4739
 
4740
+ /** 三档频率。和 WorkBuddy 的分档逐字一致,理由见文件头 */
4741
+ type ScheduleTriggerKind = 'cron' | 'interval' | 'once';
4742
+ /** 星期。`0` = 周日 … `6` = 周六,与 `Date.prototype.getDay()` 同口径 */
4743
+ type ScheduleWeekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
4707
4744
  /**
4708
- * 那个工具的名字。**真源在这儿而不是 core**,因为读它的有两个包:
4709
- * core 注册工具时用它,web 从工具流里认出那次调用时用它
4710
- * (`thread/rows.ts`)。
4745
+ * 「周期」档的三种周期。
4711
4746
  *
4712
- * ⚠️ 这一点上它和隔壁 `TODO_TOOL` 的做法不一样:那个名字今天在
4713
- * `web/src/thread/rows.ts` 里另写了一份字面量(那时 protocol 里没有落脚点)。
4714
- * 别照那个抄 —— 两份字面量漂了的表现是「卡片再也不渲染了」,而 typecheck
4715
- * 一声不响。
4747
+ * 和 `ScheduleTriggerKind` 不是一回事:那个分的是三档,这个只细分 `cron` 那一档。
4716
4748
  */
4717
- declare const SCHEDULE_DRAFT_TOOL = "schedule_draft";
4749
+ type ScheduleCycle = 'daily' | 'weekly' | 'monthly';
4718
4750
  /**
4719
- * 草稿能落在哪几档权限上 —— **`bypass` 不在里面**。
4720
- *
4721
- * 不是「运行期会拒绝」,是那一档在类型上根本不存在(判据同
4722
- * `ScheduleTemplate.permission`)。理由比模板那一侧更直接:`bypass` 要一次
4723
- * 二次确认(`bypass-not-confirmed`),而那次确认的**对象是人**;让模型提议
4724
- * 一个「等着人去勾确认框」的档位,等于把那道确认变成一道例行手续。
4751
+ * 触发器。
4725
4752
  *
4726
- * 用户自己想要那一档时照旧走表单 —— 那条路一个字都没改。
4753
+ * 所有带时刻的档共用 `at: 'HH:mm'`(24 小时制,**本机时区**)—— 统一成一个字段名
4754
+ * 是为了让「几点跑」在三档之间读起来是同一件事;单次那一档另加 `date`。
4727
4755
  */
4728
- type ScheduleDraftPermission = Exclude<PermissionLevel, 'bypass'>;
4729
- /** 草稿里那份清单。三个字段各自**可选**:没提到的一格 = 「这一格我没意见」 */
4730
- interface ScheduleDraftAllowlist {
4731
- allowTools?: readonly string[];
4732
- allowOperations?: readonly OperationType[];
4733
- allowRules?: readonly string[];
4756
+ type ScheduleTrigger = {
4757
+ kind: 'cron';
4758
+ cycle: 'daily';
4759
+ at: string;
4760
+ } | {
4761
+ kind: 'cron';
4762
+ cycle: 'weekly';
4763
+ at: string;
4764
+ weekdays: readonly ScheduleWeekday[];
4734
4765
  }
4735
- /** 起草一条新任务 */
4736
- interface ScheduleDraftCreate extends ScheduleDraftAllowlist {
4737
- action: 'create';
4738
- name: string;
4739
- prompt: string;
4740
- trigger: ScheduleTrigger;
4741
- /** 工作目录。不给就是引擎那份缺省(`~/.epoch/automation/<id>/`) */
4742
- workDir?: string;
4743
- permission: ScheduleDraftPermission;
4744
- /**
4745
- * 为什么建议这一条,一句话。**给人看的**,会印在卡片上。
4746
- *
4747
- * 必填,而这是刻意的:一条凭空出现在对话里的定时任务,用户第一个问题
4748
- * 一定是「你为什么觉得我需要它」。答不上来的提案不该提。
4749
- */
4750
- note: string;
4766
+ /** `days` 是几号(1–31)。31 号在小月不触发,两个平台的原生行为一致 */
4767
+ | {
4768
+ kind: 'cron';
4769
+ cycle: 'monthly';
4770
+ at: string;
4771
+ days: readonly number[];
4772
+ } | {
4773
+ kind: 'interval';
4774
+ everyMinutes: number;
4751
4775
  }
4776
+ /** `date` 是 `YYYY-MM-DD` */
4777
+ | {
4778
+ kind: 'once';
4779
+ date: string;
4780
+ at: string;
4781
+ };
4752
4782
  /**
4753
- * 提议改一条**已有**的任务。
4754
- *
4755
- * 每一格都可选(`undefined` = 这一格不动),但至少要动一格 —— 一份什么都不改的
4756
- * 提案是在浪费用户的一次点击。这条判定在 core 那侧
4757
- * (`schedule/draft.ts` 的 `draft-update-empty`)。
4783
+ * 「按间隔」档的**全部**合法取值(分钟)。
4758
4784
  *
4759
- * ## ⚠️ 「放宽」和别的改动在类型上长得一样,所以判定必须在别处
4785
+ * 收紧成「60 的约数」和「整小时数」不是洁癖,是 launchd 的硬约束:那一侧只能用
4786
+ * `StartCalendarInterval`(列举时刻),不能用 `StartInterval`(睡眠时那一次会被
4787
+ * 丢掉,10.11 起文档明写)。60 除不尽 7 时,「每 7 分钟」列举出来的那 9 个时刻
4788
+ * 在每小时边界上会漂 —— 于是 Windows 上准、macOS 上漂,而那种平台差异是 bug 的温床。
4760
4789
  *
4761
- * 把超时从 10 分钟调到 30 分钟,和往清单里加一条 `terminal(rm:*)`,在这个形状里
4762
- * 是同一种东西:几个可选字段。而后者是**提权**。所以两个宿主都必须自己把
4763
- * 「这次提案放宽了什么」单独标出来(web 的卡片、core 的工具输出),
4764
- * 不许把它混在一列改动里 —— 用户扫一眼就点保存的那一下,正是这条要防的。
4790
+ * 界面上因此是个下拉而不是数字输入框:下拉能表达「只有这些值」,
4791
+ * 输入框会让人填 7 然后得到一个漂的任务。
4765
4792
  */
4766
- interface ScheduleDraftUpdate extends ScheduleDraftAllowlist {
4767
- action: 'update';
4768
- /** 改哪一条。模型得先用 `action: 'list'` 看见 id,界面上那串它猜不出来 */
4769
- id: string;
4770
- name?: string;
4771
- prompt?: string;
4772
- trigger?: ScheduleTrigger;
4773
- workDir?: string;
4774
- permission?: ScheduleDraftPermission;
4775
- enabled?: boolean;
4776
- maxTurns?: number;
4777
- timeoutMs?: number;
4778
- /** 同 {@link ScheduleDraftCreate.note},必填 */
4779
- note: string;
4780
- }
4781
- type ScheduleDraft = ScheduleDraftCreate | ScheduleDraftUpdate;
4793
+ declare const SCHEDULE_INTERVAL_MINUTES: readonly number[];
4782
4794
  /**
4783
- * 一次提案里「放宽了什么」的分类。
4784
- *
4785
- * 只有三类,而三类都是**授权面变大**的方向:档位往松走、清单多了条目、
4786
- * 从停用变成启用。别往里加「超时变长」这种 —— 它花的是钱和时间,不是权限,
4787
- * 而把两种东西混进同一枚警示标,那枚标就不再有信息量。
4795
+ * 一次运行的结局。
4788
4796
  *
4789
- * 判定在 core(`schedule/draft.ts` 的 `draftWidening`),渲染在 web
4790
- * (`thread/schedule-draft.tsx`)—— 两侧各算一遍是刻意的,判据同
4791
- * `web/src/thread/rows.ts` 文件头那条:web 读的是**模型发出的原始入参**,
4792
- * 而不是 core 归一化之后的结论。
4797
+ * 后三个是「没跑」而不是「跑失败了」,它们必须和 `failed` 分开 ——
4798
+ * 界面上把「上一次因为上一次还在跑所以跳过了」显示成红叉,用户会去查一个不存在的故障。
4793
4799
  */
4794
- type ScheduleWideningKind = 'permission' | 'allowlist' | 'enabled';
4795
-
4800
+ type ScheduleRunStatus = 'ok' | 'failed' | 'denied' | 'timeout' | 'budget' | 'skipped-overlap' | 'skipped-window' | 'missed';
4801
+ /** 合法结局全集。存进库之前用它收窄,别让一个拼错的状态混进 `last_status` */
4802
+ declare const SCHEDULE_RUN_STATUSES: readonly ScheduleRunStatus[];
4796
4803
  /**
4797
- * 工作区信任契约。
4798
- *
4799
- * 为什么需要:clone 一个仓库就等于让它的 `AGENTS.md` 进 system prompt、
4800
- * 它的 hook 配置被执行、它的 policy 覆盖本地权限判定。这是漏洞,不是缺功能。
4801
- * 信任判定把「加载项目级配置」这件事收到一次显式用户决策后面。
4802
- *
4803
- * 竞品:Codex `codex-rs/tui/src/onboarding/trust_directory.rs`;
4804
- * Gemini CLI `packages/cli/src/config/trustedFolders.ts` + `packages/core/src/utils/trust.ts`。
4805
- * 两家都只是读过的参照物:契约形状跟它们都不一样(上游是 TRUST_FOLDER / TRUST_PARENT /
4806
- * DO_NOT_TRUST 三枚举 + 靠 TRUST_PARENT 取 dirname 来编码作用域,这里是三态字面量 +
4807
- * 独立的 scope 字段 + decidedAt)。本文件是独立实现,不带 SPDX 头。
4808
- * 判定证据见 docs/verify/VERIFY_RECORD-D-license-attribution.md
4804
+ * 任务注册在哪儿。
4809
4805
  *
4810
- * 这里只有类型,实现在 core —— 注意实现那边(`core/src/trust/manager.ts`)**是**
4811
- * gemini 的衍生作品(搬了「归一成生效目录再取最长匹配」那条算法),带头、在 NOTICE 里。
4812
- * 契约与实现的授权状态不同是对的,不是漏登记。
4806
+ * - `schtasks` / `launchd` —— 真的注册进了操作系统的调度器,**epoch 不用开着**
4807
+ * - `host` —— **一行都没注册进 OS**(方案 45 §2.6 的路 B)。到点了唤起它的只能是
4808
+ * 宿主自己(`setInterval` 调 `fire()`)—— 而宿主有没有真的这么做,**这一格答不出来**,
4809
+ * 它唯一承诺的事实就是「没进 OS」。所以界面上不许把它写成「已启用」,
4810
+ * 也**不许许诺「开着就会跑」**(判据全文在 locales 的 `notice_host` 上)
4813
4811
  */
4814
- /** 一个目录的信任状态 */
4815
- type TrustLevel =
4816
- /** 用户明确信任:加载项目指令 / hook / policy */
4817
- 'trusted'
4818
- /** 用户明确拒绝:只当普通工作目录,不加载任何项目级配置 */
4819
- | 'untrusted'
4820
- /** 还没问过 */
4821
- | 'unknown';
4822
- /** 用户已经做过决策的信任状态(排除「还没问过」) */
4823
- type DecidedTrustLevel = Exclude<TrustLevel, 'unknown'>;
4824
- /** 信任决策的作用域 */
4825
- type TrustScope =
4826
- /** 只这个目录 */
4827
- 'directory'
4828
- /** 这个目录及其所有子目录 */
4829
- | 'directory-tree';
4812
+ type ScheduleBackendKind = 'schtasks' | 'launchd' | 'host';
4830
4813
  /**
4831
- * 一次授信授到哪一档(2026-09-11)。**两档是相加的:`exec` 蕴含 `read`。**
4832
- *
4833
- * ## 为什么拆
4814
+ * 无人值守清单 —— 「要确认,但没人在,怎么办」。
4834
4815
  *
4835
- * 在它之前 `trusted` 是一个布尔,而那一个布尔后面挂着七样东西 —— 其中
4836
- * `.epoch/hooks.json` 和 `.claude/settings.json` 的每一项都是一条要 spawn 子进程的
4837
- * 命令(判据在 [core/hook/sources.ts](../../core/src/hook/sources.ts) 的文件头)。
4838
- * 而用户被问到的那句话逐字是「本目录的项目指令会加载」(`trust_prompt.granted`),
4839
- * **从来没提过 hook**。也就是说「往上下文里注入一段文本」和「在这台机器上执行
4840
- * 任意命令」共用了同一次点击,而问话只描述了前者。这不是 UX 问题,是知情同意错位。
4816
+ * 和权限档(`ScheduleDefinition.permission`)**各管一条轴**:档位回答「这个操作
4817
+ * 要不要人确认」,这份清单回答「要确认但没人在时,哪几条可以不问」。
4818
+ * 它不是「更宽的一档」,是把「要问人」逐条变成「这一条可以不问」。
4841
4819
  *
4842
- * | 档 | 放开什么 | 风险 |
4843
- * | --- | --------------------------------------------------------------- | ---------- |
4844
- * | `read` | 指令文件 / 技能 / 角色 / 命令 | 注入上下文 |
4845
- * | `exec` | `hooks.json` / `.claude/settings*.json` / `policies/` / settings 的 allow 那一半 | 执行、放宽判定 |
4820
+ * 三个字段由粗到细,同时命中任一条即放行:
4821
+ * `allowTools` 整个工具 / `allowOperations` 五类操作 / `allowRules` 规则字符串
4822
+ * (`terminal(git status)`、`mcp__github__*`、`file_write(./reports/**)`)。
4823
+ */
4824
+ interface ScheduleAllowlist {
4825
+ allowTools: readonly string[];
4826
+ allowOperations: readonly OperationType[];
4827
+ allowRules: readonly string[];
4828
+ }
4829
+ /**
4830
+ * 一条欠条 —— 「本该问人,但没人可问」。
4846
4831
  *
4847
- * ## ⚠️ 两条边界,别把它当别的东西
4832
+ * ⚠️ **只收这一类**。权限档自己判出的拒绝(选了「只读」档然后去写文件)
4833
+ * **不进欠条** —— 那是档位的判定结果,不是缺授权;把它记成欠条等于建议用户
4834
+ * 去加一条永远不会被查到的规则(第 2 层判 deny 时压根走不到第 3 层)。
4835
+ */
4836
+ interface SchedulePendingApproval {
4837
+ toolName: string;
4838
+ type: OperationType;
4839
+ target: string;
4840
+ /** 权限层给的原因,原样带着 —— 人回来时最想看的就是这句 */
4841
+ reason: string;
4842
+ /**
4843
+ * 该往清单里加哪条规则(`rule-suggest` 算的精确规则)。
4844
+ *
4845
+ * 算不出来时**整个不带这个键**:硬编一条 `terminal()` 出来只会让用户
4846
+ * 加进去一个死规则。
4847
+ */
4848
+ suggestedRule?: string;
4849
+ at: number;
4850
+ }
4851
+ /**
4852
+ * 一条定时任务。
4848
4853
  *
4849
- * 1. **`exec` 不是权限档。** 它放开的是「这个仓库自带的 hook / policy 算不算数」,
4850
- * 不是「这次操作要不要问用户」——[两套机制照旧正交](../../core/src/trust/gate.ts)。
4851
- * 未授 `exec` 的目录里 agent 照样能跑 bash,只是仓库里那份 `hooks.json` 不生效;
4852
- * 2. **`read` 不是「安全档」。** 一份 `AGENTS.md` 能指挥 agent 去做任何它有权限做的
4853
- * 事,包括执行命令。拆开不是因为 `read` 无害,是因为**两者的问话不该合并成一次**。
4854
+ * 时间一律是 **epoch 毫秒**而不是 `Date`:这个形状要过 JSON(存库、将来上 SSE),
4855
+ * `Date` 一过 JSON 就变成字符串,于是同一个字段在两处是两种类型。
4854
4856
  */
4855
- type TrustTier = 'read' | 'exec';
4856
- interface TrustRecord {
4857
- /** 绝对路径(已 resolve) */
4858
- path: string;
4859
- level: DecidedTrustLevel;
4857
+ interface ScheduleDefinition {
4858
+ id: string;
4859
+ name: string;
4860
4860
  /**
4861
- * 这条决策管到哪儿。
4862
- *
4863
- * `check()` 会把它折叠进 `TrustLevel`,所以判定方用不上;但**列出记录**的一方
4864
- * (`epoch trust` 要显示「仅此目录 / 含子目录」)必须能看见 —— 少了它,
4865
- * `list()` 返回的就不是一条完整的决策,而是一条读不懂的决策。
4861
+ * 停用不等于删除。停用的任务在系统的任务计划里**仍然看得见,只是停着** ——
4862
+ * 删掉再建的话,用户在任务计划程序里看到条目消失会以为我们把他的任务弄丢了。
4866
4863
  */
4867
- scope: TrustScope;
4868
- /** 记录时间戳(ms) */
4869
- decidedAt: number;
4864
+ enabled: boolean;
4865
+ createdAt: number;
4866
+ updatedAt: number;
4867
+ prompt: string;
4870
4868
  /**
4871
- * 执行类也授了吗({@link TrustTier})。`level: 'untrusted'` 时无意义。
4872
- *
4873
- * ⚠️ **缺省(`undefined`)= 没授**,而这一格正是存量记录的迁移:拆档之前写下的
4874
- * 记录没有这一格,于是升级之后它们**只剩读类** —— 判据就是上面那张表下面那段:
4875
- * 那些记录是在一句只说了「项目指令会加载」的问话下拿到的,把它们追认成执行类
4876
- * 等于替用户补签一份他没看过的同意书。代价(老用户的项目 hook 会停)由
4877
- * `trust_gate.exec_migrated` 那条诊断明说,不静默。
4869
+ * 工作目录。**执行器不许继承 cwd** —— OS 调度器起进程时的 cwd 不由我们决定
4870
+ * (Windows 上是 `%SystemRoot%\System32`)。用户没填时落在
4871
+ * `~/.epoch/automation/<id>/`,那是一个确定的目录,不是「随便哪儿」。
4878
4872
  */
4879
- exec?: boolean;
4873
+ workDir: string;
4874
+ model?: string;
4880
4875
  /**
4881
- * 授 `exec` 那一刻,执行类输入文件的内容指纹(见 core 的 `trust/exec-hash.ts`)。
4882
- *
4883
- * 对不上 = 那些文件在授信之后被改过 → `exec` 退回未授,重新问一次。
4884
- * `exec` 为假时这一格不该存在。
4885
- *
4886
- * ## ⚠️ 这是对 [manager.ts](../../core/src/trust/manager.ts) 文件头「两条明确不做」
4887
- * 第 2 条的一次**窄口推翻**,不是把它作废
4876
+ * 「召唤专家」。**今天永远不带 —— 但已经不是被别人挡着了。**
4888
4877
  *
4889
- * 那一条 2026-08-08 写着:记内容 hash「代价是『改一行 AGENTS.md 就被打断一次』,
4890
- * 这个打断比它挡住的风险更贵」。**那个结论在读类上照旧成立,一个字不改** ——
4891
- * 指令文件天天在动,为它记 hash 会把授信变成一件每天都要重做的事。
4878
+ * 立案时写的是「等方案 42 PR-1 把顶层会话的角色接线」,而那一笔
4879
+ * **2026-08-15 就结清了**(`SessionFactory.create({ role })` +
4880
+ * `WireCreateSessionRequest.role`,见 [agent-role.ts](./agent-role.ts) 文件头)。
4881
+ * 挡着这一格的只剩「没人做」:`add` / 表单 / 端点三条写入路都不写它。
4882
+ * ⚠️ **别把「被别人挡着」和「没人做」读成一件事** —— 逐条状态在
4883
+ * [验收记录 9.2](../../../docs/verify/VERIFY_RECORD-45-automation.md)。
4884
+ */
4885
+ role?: string;
4886
+ /** 同 {@link role}:方案 44 的清单契约早就到齐了,缺的是写入点。今天永远不带 */
4887
+ skills?: readonly string[];
4888
+ /**
4889
+ * 同 {@link role},缺的是写入点。
4890
+ * 连接器的免确认另有一条现成的路:`allowRules` 里的 `mcp__<server>__*`。
4891
+ */
4892
+ mcpServers?: readonly string[];
4893
+ /**
4894
+ * 权限档 —— **和对话里那枚胶囊同一套取值、同一套词**。
4892
4895
  *
4893
- * 翻的只有执行类那一半,因为它的两项输入都变了:
4896
+ * 界面只暴露四档(`plan` / `default` / `acceptEdits` / `bypass`)。
4897
+ * 不为定时任务发明新词:用户在对话里已经学过这四个词一次,
4898
+ * 再教一套等于让他学两遍同一件事的两种说法。
4899
+ */
4900
+ permission: PermissionLevel;
4901
+ allowTools: readonly string[];
4902
+ allowOperations: readonly OperationType[];
4903
+ allowRules: readonly string[];
4904
+ /** 无人看着的一轮不该比有人看着的更长(交互缺省是 50) */
4905
+ maxTurns: number;
4906
+ /**
4907
+ * **必填,没有默认值。** 这是整个方案唯一一处刻意增加摩擦的地方:
4908
+ * 一个假默认会让「一觉醒来 40 美元」变成我们的错。
4909
+ */
4910
+ maxBudgetUsd: number;
4911
+ /**
4912
+ * 墙钟超时。前两把尺子拦不住「一条 `terminal` 命令挂住了」——
4913
+ * 那种情况轮次和花费都不增长,这把尺子是唯一能拦它的。
4914
+ */
4915
+ timeoutMs: number;
4916
+ trigger: ScheduleTrigger;
4917
+ /**
4918
+ * 生效区间(`YYYY-MM-DD`,含首尾),留空表示始终生效。
4894
4919
  *
4895
- * | | 读类(AGENTS.md) | 执行类(hooks.json / policies) |
4896
- * | --- | ---------------------- | -------------------------------- |
4897
- * | 改动频率 | 每天 | 几乎不动 |
4898
- * | 改动后果 | 多一段文本 | **多一条会跑的命令** |
4920
+ * ⚠️ **两个平台在这个字段上语义不同**:Windows 有原生的 `/SD` `/ED`,过了截止日
4921
+ * 系统压根不会调我们;launchd 没有这个概念,所以 macOS 上我们会被调起来、
4922
+ * 然后由执行器自己判出区间外并退 0。
4923
+ */
4924
+ startDate?: string;
4925
+ endDate?: string;
4926
+ osBackend: ScheduleBackendKind;
4927
+ /** 注册进 OS 时用的标识(Windows 的 `epoch\<id>` / launchd 的 `Label`) */
4928
+ osTaskId: string;
4929
+ /** 没注册进 OS 时不带(`osBackend: 'host'`,或者注册失败) */
4930
+ osRegisteredAt?: number;
4931
+ /**
4932
+ * **真正注册进去的那条 argv**,逐字。`doctor` 靠它比对 OS 侧有没有漂。
4899
4933
  *
4900
- * 也就是说,同一个做法在这一侧的打断成本低一个量级、挡住的东西贵一个量级。
4901
- * 参照物:codex 对 hook 记 `trusted_hash`(`codex-rs/config/src/hook_config.rs`),
4902
- * 而它的 `AGENTS.md` 压根不过闸门 —— 同一条判据的两侧,它也是这么分的。
4934
+ * 存下来而不是每次现算:现算的结果依赖**此刻**的 `process.execPath` 和入口路径,
4935
+ * 而漂移检查要问的恰恰是「当初写进去的是什么」。
4903
4936
  */
4904
- execHash?: string;
4937
+ osCommand: readonly string[];
4938
+ lastRunAt?: number;
4939
+ lastStatus?: ScheduleRunStatus;
4905
4940
  }
4906
- /** {@link TrustManager.record} 的可选档位。不给 = 只授读类 */
4907
- interface TrustGrant {
4908
- /** 连执行类一起授。**调用方必须真的为这一档单独问过用户** */
4909
- exec?: boolean;
4910
- /** 授 `exec` 时一并记下的内容指纹 */
4911
- execHash?: string;
4941
+ /** 一次运行的记录 */
4942
+ interface ScheduleRun {
4943
+ id: string;
4944
+ scheduleId: string;
4945
+ /** 真的起了会话才有;三种 `skipped-*` / `missed` 都没有 */
4946
+ sessionId?: string;
4947
+ startedAt: number;
4948
+ endedAt?: number;
4949
+ status: ScheduleRunStatus;
4950
+ exitCode?: number;
4951
+ reason?: string;
4952
+ costUsd: number;
4953
+ tokens?: TokenUsage;
4954
+ turns: number;
4955
+ /** 欠条。空数组 = 这一轮没撞上需要确认的操作 */
4956
+ pendingApprovals: readonly SchedulePendingApproval[];
4957
+ /** 录像(`<runId>.ndjson`)的绝对路径;没起会话时不带 */
4958
+ logPath?: string;
4959
+ /**
4960
+ * `epoch schedule run` 手动跑的那一次。
4961
+ *
4962
+ * 标出来是因为它**不该影响「上一次到点跑成没有」的判断** ——
4963
+ * 用户手点一次成功,不等于明天 9 点那次会成功。
4964
+ */
4965
+ manual: boolean;
4912
4966
  }
4913
4967
  /**
4914
- * {@link TrustManager.checkExec} 的答案 —— **记录里写的,不含新鲜度校验**。
4968
+ * 宿主注入的「怎么以无界面方式唤起我自己」(方案 45 §2.6)。
4915
4969
  *
4916
- * 两格一起交出来是因为校验方(`core/src/trust/gate.ts`)需要拿 {@link hash} 去和
4917
- * 现算的值比;拆成两次调用的话,两次之间可能命中**不同的记录**
4918
- * (`bestRecord()` 是按最长匹配现算的),于是拿 A 记录的授信配上 B 记录的指纹。
4970
+ * 把引擎 `import` 进 Electron / VS Code 扩展的宿主**没有 `epoch` 可执行文件**,
4971
+ * 而**库自己分辨不出宿主打算怎么被唤起** —— 所以这件事必须由宿主说。
4972
+ *
4973
+ * ⚠️ **绝对不许拿 `process.execPath` + `argv[1]` 去猜。** 在 Electron 里那两个值
4974
+ * 是「Electron 的 exe」+「宿主的 main.js」,注册进去的后果是**到点了给用户弹出
4975
+ * 整个应用的主窗口**(甚至一天弹 24 次)。那不是「不够好」,是把定时任务变成骚扰。
4919
4976
  */
4920
- interface TrustExecGrant {
4921
- /** 记录里授过执行类吗 */
4922
- granted: boolean;
4977
+ interface ScheduleRunnerSpec {
4923
4978
  /**
4924
- * 授的时候记下的内容指纹。
4979
+ * 无界面入口的 argv 前缀,绝对路径。我们在后面追加
4980
+ * `schedule fire <id> --home <dir>`。
4925
4981
  *
4926
- * ⚠️ **`undefined` = 校验不了,而这一档照旧放行**(判据在 gate 的 `decide()` 上)。
4927
- * 今天所有写入口都会带上它,所以这一档只可能来自手改 `trust.json`。
4928
- */
4929
- hash?: string;
4930
- }
4931
- interface TrustManager {
4932
- /** 查某个目录的信任状态(含父目录的 directory-tree 继承) */
4933
- check(dir: string): TrustLevel;
4934
- /**
4935
- * 查某个目录的执行类授信(含继承)。**和 {@link check} 是两个问题**:
4936
- * 这一个只在 `check()` 回 `'trusted'` 时才可能授过,反过来不成立。
4982
+ * 那个入口必须**不建窗口、跑完就退**(Electron 里就是在 `whenReady` 之前
4983
+ * 分叉出去)。
4937
4984
  */
4938
- checkExec(dir: string): TrustExecGrant;
4939
- /** 记录一次决策 */
4940
- record(dir: string, level: DecidedTrustLevel, scope: TrustScope, grant?: TrustGrant): void;
4941
- list(): readonly TrustRecord[];
4942
- revoke(dir: string): void;
4985
+ argv: readonly string[];
4986
+ /** 注册进 OS 时给人看的名字前缀。不给就用 `epoch` */
4987
+ label?: string;
4943
4988
  }
4944
4989
 
4945
4990
  /**
4946
- * 会话消息契约。
4991
+ * 内置模板六张([方案 45](../../../docs/verify/VERIFY_RECORD-45-automation.md) §7.3)。
4947
4992
  *
4948
- * 这是我们**自己的**消息格式,不是 Vercel AI SDK 的 `ModelMessage`。
4993
+ * 判据是「**这件事必须每天/每周做一次,而且做完是一份能读的东西**」——
4994
+ * 所以 WorkBuddy 那 12 张消费级的(儿童睡前故事 / 父母联系提醒 / 萌宠壁纸)
4995
+ * 一张都没抄,换成开发者场景。
4949
4996
  *
4950
- * 为什么要有这么一层:改造前 AgentLoop、message-codec、以及 AgentProvider
4951
- * 这个「依赖倒置接口」全都直接用 `ModelMessage`。后果有三个:
4952
- * 1. 依赖倒置是假的 —— 想塞一个脚本化的假 provider 做测试,得先 import 'ai'
4953
- * 2. AI SDK 的消息形状是**联合类型 + 部件数组**,`content` 可能是 string
4954
- * 也可能是 `Array<{type:'text'|'tool-call'|...}>`,于是 loop 里到处
4955
- * `as Array<Record<string, unknown>>` 地摸字段
4956
- * 3. SDK 换大版本(v5→v6→v7 每次都动过消息形状)要改的是整个 agent 层,
4957
- * 而不是一个转换函数
4997
+ * ## ⚠️ 三条硬约束都摊在类型上,不靠注释
4958
4998
  *
4959
- * 形状故意和 SQLite 里持久化的 `Message`(core/session/types.ts)对齐 ——
4960
- * 一份扁平 content + 可选的工具部件。两边不一致的话,存盘和回放各自要一套转换。
4999
+ * | §7.3 那句话 | 落在类型的哪一格 |
5000
+ * | ------------------------------- | -------------------------------------------------------------------- |
5001
+ * | 模板**不联网** | 这是一份 `const`,没有取数入口 —— 在线模板市场 42 已定不做 |
5002
+ * | 一律不预设「跳过全部检查」 | {@link ScheduleTemplate.permission} 的取值里**没有** `bypass` |
5003
+ * | 填进表单后**必须让用户过一遍** | 整份形状里**没有** `maxBudgetUsd`,而它是必填 —— 表单因此保存不了 |
4961
5004
  *
4962
- * 转成 SDK 格式只发生在一个地方:core/provider/sdk-adapter.ts。
4963
- */
4964
-
4965
- /** 模型请求执行的一次工具调用 */
4966
- interface EpochToolCall {
4967
- toolCallId: string;
4968
- toolName: string;
4969
- /** 模型给出的入参 */
4970
- input: Record<string, unknown>;
4971
- }
4972
- /** 一次工具执行的结果 */
4973
- interface EpochToolResult {
4974
- toolCallId: string;
4975
- toolName: string;
4976
- /** 输出的文本形式。工具的返回值统一收成字符串,不往下传结构 */
4977
- output: string;
4978
- /**
4979
- * 二进制产物。**只有过完四道闸门、带 `data` 的才会真的进模型上下文**
4980
- * (见 core/src/context/artifact-gate.ts);否则这里只有 summary + path,
4981
- * `output` 里已经含了那句 summary。
4982
- */
4983
- artifacts?: ToolArtifact[];
4984
- }
4985
- /** 文本部件 */
4986
- interface EpochTextPart {
4987
- type: 'text';
4988
- text: string;
4989
- }
4990
- /**
4991
- * 图片部件。
5005
+ * 最后一条是三条里最要紧的,而它是**结构性**的而不是靠一句提示:预算必填、
5006
+ * 没有默认值(§3.5),于是一张模板卡填进表单之后那个保存按钮**天然是灰的**,
5007
+ * 用户不看完那一屏就出不去。写成「模板里预填 0.50 + 一句『请检查』」的话,
5008
+ * 「必须过一遍」就退化成一句没人读的话。
5009
+ *
5010
+ * ## 为什么住在 protocol,而不是「core 一份 + 这里抄一份」
5011
+ *
5012
+ * 任务书给的两条路是「从 server 端点下发」或者「抄一份到 protocol + 一条逐项相等
5013
+ * 的用例」(照 `WireScheduleIssueCode` 的先例)。**两条都不用,因为这一份和那个
5014
+ * 先例不是同一种东西**:
5015
+ *
5016
+ * - `WireScheduleIssueCode` 的真源在 core**必须**在 core —— 那张码表是
5017
+ * `validateSchedule()` 的出口集合,它和判定逻辑长在一起,搬不动;
5018
+ * - 而这一份是**纯数据、零判定**:六个触发器、六档权限、六份最小清单。
5019
+ * 它谁都不依赖。而 [AGENTS.md](../../../AGENTS.md) 的分层表里 protocol 那一行
5020
+ * 写着「跨包共享的东西**只能**放这里」。
5021
+ *
5022
+ * 放这儿的直接收益是**不存在第二份**:`packages/web` 够得着它(只依赖
5023
+ * protocol + view)、cli 够得着、server 够得着、假服务端也够得着。抄一份到
5024
+ * protocol 再钉一条「逐项相等」的用例,是在为一个可以不存在的分叉修围栏;
5025
+ * 走一条 server 端点则要为一份**从不改变的常量**加一条 HTTP 往返和一个失败态
5026
+ * (「模板拉不下来」——而它明明就在包里)。
4992
5027
  *
4993
- * `image` 三种形态:裸 base64、`data:` URL、`http(s)` URL。
4994
- * 字段名是 `mediaType` 不是 `mimeType` —— 与 `@ai-sdk/provider-utils` 的
4995
- * `ImagePart` 对齐,省掉边界上的一次改名。
5028
+ * ## ⚠️ 这里一个中文字都没有,措辞在 web 那份 catalog 里
5029
+ *
5030
+ * 名字 / 说明 / **提示词**三样都是文案:提示词要跟着用户的界面语言走
5031
+ * (一个英文界面的用户不该收到一份中文报告),而 protocol 里没有 `t()`、
5032
+ * 也不该有。所以这一份只给 `id`,措辞表在
5033
+ * `web/src/automation/labels.ts` 的 `TEMPLATE_TEXT` 上 —— 那张表
5034
+ * `satisfies Record<ScheduleTemplateId, …>`,**加一张模板而不给它文案会当场编译不过**。
4996
5035
  */
4997
- interface EpochImagePart {
4998
- type: 'image';
4999
- image: string;
5000
- mediaType?: string;
5001
- }
5002
- /** 附件为什么只有路径没有内容 */
5003
- type FileOmitReason =
5004
- /** 探测到 NUL 字节,是二进制 */
5005
- 'binary'
5006
- /** 权限判定没放行(与 `file_read` 同一条判定) */
5007
- | 'denied'
5008
- /** 读不到(不存在 / 没权限 / 是目录) */
5009
- | 'unreadable'
5010
- /** 这一条消息附的文件个数或总量超了上限 */
5011
- | 'over-limit'
5036
+
5012
5037
  /**
5013
- * 会话是 resume 出来的,附件内容**不重读**。
5038
+ * 六张模板的 id。
5014
5039
  *
5015
- * 内容当初是过了权限判定才附上的,而那次判定发生在另一个上下文里
5016
- * (当时的权限级别、当时的信任状态)。恢复时无声地再读一遍磁盘等于让一次
5017
- * 旧的授权在新的上下文里继续生效。模型仍然看得见路径,要内容就自己调
5018
- * `file_read` —— 那条路有完整的审批。
5040
+ * 它同时是**文案表的键**(web 那侧 `satisfies Record<ScheduleTemplateId, …>`),
5041
+ * 所以这个联合就是「有几张模板」的唯一真源 —— 加一张要改的地方由编译器点出来。
5019
5042
  */
5020
- | 'not-restored';
5043
+ type ScheduleTemplateId = 'repo-sweep' | 'weekly-digest' | 'dep-checkup' | 'stale-docs' | 'test-flake' | 'todo-digest';
5021
5044
  /**
5022
- * 文件附件部件 —— 用户用 `@路径` 提到的文件(方案 25)。
5023
- *
5024
- * ## 为什么是一种部件,而不是把内容拼进文本里
5025
- *
5026
- * 拼字符串看起来省事,代价有四个,每一个都要在很久以后才暴露:压缩器分不清
5027
- * 「用户打的字」和「附的文件」于是压错东西、token 统计把文件算成用户输入、
5028
- * artifact 化那条路完全绕过、web 宿主收到的 wire 载荷里两者无法区分。
5029
- * 单独一种部件之后,这四件事各自都有地方判。
5045
+ * 一张模板 = 一条任务除了「名字 / 提示词 / 预算」之外的那些格子。
5030
5046
  *
5031
- * ## `text` 缺席是常态,不是异常
5047
+ * 三样刻意缺席,各有各的理由:
5032
5048
  *
5033
- * 二进制、超限、权限没放行时**只留路径**。这时候模型看到的是「用户提到了这个
5034
- * 文件,但内容没给你,原因是 X」—— 它可以自己去调 `file_read`(那条路有完整的
5035
- * 审批流程)。比起静默附上或者静默丢掉,这是唯一不会让人误判的形态。
5049
+ * - `name` / `prompt` —— 是**文案**,见文件头最后一节;
5050
+ * - `maxBudgetUsd` —— 必填且没有默认值(§3.5),见文件头那张表第三行;
5051
+ * - `workDir` —— 模板不知道用户的仓库在哪儿。留空的语义是
5052
+ * `~/.epoch/automation/<id>/`(`ScheduleDefinition.workDir` 上的判据),
5053
+ * 而**清单里那条 `file_write(./reports/**)` 是相对的**,所以它跟着工作区走 ——
5054
+ * 不管用户填不填,报告都落在那条任务自己的目录下面。
5036
5055
  */
5037
- interface EpochFilePart {
5038
- type: 'file';
5039
- /** 用户敲的那个路径(相对工作区,`/` 分隔),原样保留用于显示 */
5040
- path: string;
5041
- /** 文件内容。**缺席时看 `omitted`** */
5042
- text?: string;
5043
- /** 没给内容的原因;给了 `text` 时不出现 */
5044
- omitted?: FileOmitReason;
5045
- /** 内容被截断到多少字节。给了就是截断过 —— 截断**永远不静默** */
5046
- truncatedTo?: number;
5047
- /** 文件原始字节数,用于展示 */
5048
- bytes?: number;
5056
+ interface ScheduleTemplate {
5057
+ id: ScheduleTemplateId;
5058
+ trigger: ScheduleTrigger;
5059
+ /**
5060
+ * 权限档。**取值里没有 `bypass`**(§7.3:模板一律不预设「跳过全部检查」),
5061
+ * 也没有 `auto`(界面压根不暴露它,§3.2)。
5062
+ *
5063
+ * 两档各有各的用法,而分界线是 by-level 那张表:
5064
+ *
5065
+ * - `plan`(只读)—— 「读一遍然后写一份报告」的那几张。它天然零授权面,
5066
+ * 清单里只要一条 `file_write`(那条走**第 2 层**规则匹配,所以在只读档下
5067
+ * 照样放行 —— §3.2 那句「只读 + `file_write(<任务目录>/**)`」说的就是它);
5068
+ * - `default`(按需确认)—— 要跑命令的那几张。**除了 `bypass`,写命令在每一档
5069
+ * 下都要确认**,所以它们只能靠清单逐条放行,而这正是清单存在的理由。
5070
+ */
5071
+ permission: 'plan' | 'default' | 'acceptEdits';
5072
+ /** 整个工具免确认(粗)。多数模板不用它 —— 逐条列出来的清单才审得动 */
5073
+ allowTools?: readonly string[];
5074
+ allowOperations?: readonly OperationType[];
5075
+ /** 规则(细)。⚠️ **路径一律写成相对的**,理由见 {@link ScheduleTemplate} */
5076
+ allowRules?: readonly string[];
5077
+ /**
5078
+ * 墙钟超时(毫秒)。不给就用引擎缺省的 15 分钟。
5079
+ *
5080
+ * 只有「测试 flake 复盘」给了 —— 它真的要跑一遍测试,而缺省那把尺子会在
5081
+ * 半路把它砍掉。**这一格存在的意义就是让那一张不至于生下来就超时**。
5082
+ */
5083
+ timeoutMs?: number;
5049
5084
  }
5050
5085
  /**
5051
- * 引用的会话为什么只有标题没有内容(方案 53 §4.2)。
5052
- *
5053
- * 前五个是**稳定的错误码**,给宿主路由用(headless / 嵌入宿主要能按码分流,
5054
- * TUI 和 Web 要各自渲染)。清单本体是 {@link SESSION_REFERENCE_ERROR_CODES},
5055
- * 那才是同构位置的唯一真源。
5086
+ * 六张。顺序就是界面上卡片的顺序:**两张每天的排在前面** ——
5087
+ * 「每天一次」比「每周一次」更容易让人第一次就试出效果,而这一屏的空状态
5088
+ * 要答的问题是「这东西能替我做什么」。
5056
5089
  */
5057
- type SessionOmitReason =
5058
- /** `@:xxx` 匹配不到**唯一**的一段会话(零个,或好几个) */
5059
- 'invalid-reference'
5060
- /** 引用的是当前这段会话自己 */
5061
- | 'self-reference'
5062
- /** 超过每条消息能引用的会话个数 */
5063
- | 'too-many'
5064
- /** 会话可读性判定不放行(方案 48 的那一处判定,见 core/session/authorization.ts) */
5065
- | 'not-authorized'
5066
- /** 这一条消息的附件总量已经用完 */
5067
- | 'budget-exceeded'
5090
+ declare const SCHEDULE_TEMPLATES: readonly ScheduleTemplate[];
5091
+
5068
5092
  /**
5069
- * 会话是 resume 出来的,引用的内容**不重读**。
5093
+ * 「还欠着的那几张欠条」——
5094
+ * [方案 45](../../../docs/verify/VERIFY_RECORD-45-automation.md) §八 ① / ③ 的素材。
5070
5095
  *
5071
- * 判据逐字同 {@link FileOmitReason} 的 `not-restored`:内容当初是过了可读性
5072
- * 判定才附上的,而那次判定发生在另一个上下文里。恢复时无声地再读一遍等于让
5073
- * 一次旧的授权在新的上下文里继续生效。模型仍然看得见「用户引用过这段会话」,
5074
- * 要内容就自己调 `session_search`(方案 48 那条路,有工具审批)。
5096
+ * 三个界面读的是同一个数:dock 上那一条「自动化待确认」、侧栏「自动化」入口上
5097
+ * 那个点、TUI 启动时那一行。**它们必须是同一个数** —— 三处各数一遍的下场是
5098
+ * 侧栏有点、点进去 dock 上什么都没有。
5075
5099
  *
5076
- * **它不是错误码**,所以不在 {@link SESSION_REFERENCE_ERROR_CODES} 里。
5077
- */
5078
- | 'not-restored';
5079
- /**
5080
- * 五个稳定的错误码(方案 53 §4.2)—— **同构位置的唯一真源**。
5100
+ * ## 一、为什么这份投影住在 protocol
5081
5101
  *
5082
- * 为什么要稳定的码而不是一句话:headless / 嵌入宿主要能路由这些失败
5083
- * (`docs/HEADLESS.md` 的退出码那套已经是这个思路),而且 TUI 和 Web 要各自渲染。
5102
+ * 它是**纯集合运算**:手上有哪几条任务、每条最近跑过什么、清单里已经有哪几条规则。
5103
+ * 没有日期算术、不读盘、不查权限,一个字的 core 知识都不需要。
5104
+ * 而它的消费者有四个:`server`(那条端点)、`cli`(`--fix` 和 TUI 那行开机提示)、
5105
+ * `packages/web/dev` 那台假服务端、以及将来任何一个嵌入宿主。
5106
+ * [AGENTS.md](../../../AGENTS.md) 分层表里 protocol 那一行写着
5107
+ * 「跨包共享的东西**只能**放这里」,这就是那一行说的情况。
5084
5108
  *
5085
- * `not-restored` 刻意不在里面 —— 它是「会话恢复了」这个事实,不是一次失败。
5086
- */
5087
- declare const SESSION_REFERENCE_ERROR_CODES: readonly SessionOmitReason[];
5088
- /**
5089
- * 引用另一段会话的部件 —— 用户用 `@:xxx` 提到的那段会话(方案 53)。
5109
+ * ⚠️ **这一份落地之前,那个集合运算在仓库里有三份**(`server` 的 `fixSchedule`、
5110
+ * `cli` 的 `applyFix`、假服务端的 `fakeScheduleFix`),逐字一样的六行。
5111
+ * 三份都是「往授权清单里加规则」这条路上的判定 —— 也就是**提权路径上的重复实现**。
5112
+ * 收成一份是这个文件存在的第二个理由,比第一个理由更硬。
5090
5113
  *
5091
- * ## 塞进来的是那段会话的「当前面」,不是原始全量(§二)
5114
+ * ## 二、「还欠着」= 算得出规则、而且规则还不在清单里
5092
5115
  *
5093
- * 一个跑了两小时、压缩过三次的会话,`messages` 表里有 800 条,但模型当时能看到
5094
- * 的只有 60 条(其余 `active = 0`)。这里带的是**那 60 条**。三条理由:800 条
5095
- * 任何预算都塞不下;那 60 条就是这段会话的结论(压缩器已经把前面浓缩进去了);
5096
- * 原始全量里有已经被推翻的内容(「先试了 A 不行改用 B」,压缩之后留下的是 B)。
5116
+ * 两道筛,**缺一道这个数就会自己卡住**:
5097
5117
  *
5098
- * ## 它是**快照**,不是活引用
5118
+ * 1. **`suggestedRule` 必须有值。** 算不出精确规则的那几条(目标为空的操作)
5119
+ * 进不了这份清单 —— dock 上唯一的动作是「加进授权清单」,而对它们那一下
5120
+ * 什么都加不了。一条点了没用的通知比没有通知坏(设计稿决定 20 ①),
5121
+ * 而且它**永远清不掉**:没有任何动作能让它消失。
5122
+ * ⚠️ 它们并没有被藏起来:「运行记录」那一屏上那一轮照旧列着它们、
5123
+ * 照旧写着「这一条反推不出精确规则」。这里筛掉的是**通知**,不是事实。
5124
+ * 2. **规则还不在 `allowRules` 里。** 用户按过那一下之后规则就进清单了,
5125
+ * 于是这个数**自己减一**。这条让整件事不需要「读标记」那套东西 ——
5126
+ * 没有迁移、没有「哪个界面算看过」这个产品判断、也没有一个只能靠
5127
+ * 「我看过了」来清的角标。
5099
5128
  *
5100
- * 引用是发消息那一刻的快照,之后源会话被归档、删除、继续聊都不影响这一条。
5101
- * **别去做「引用失效」的检测** —— 那等于让一条已经发出去的消息随时间变形。
5129
+ * ## ⚠️ 三、这个数**不含**「上一次没跑成」的那几条,而那是一条判断不是遗漏
5102
5130
  *
5103
- * ## `text` 缺席是常态
5131
+ * `failed` / `timeout` / `budget` 三档同样是「你该知道的事」,但它们**没有下一步
5132
+ * 动作**:屏幕上给不出一个按钮,用户只能去看运行记录。把它们算进来的话,这个点
5133
+ * 会一直亮着,直到那条任务下一次跑成 —— 而在那之前它已经不再指向任何可做的事,
5134
+ * 也就退化成了一个「有东西发生过」的常亮灯。
5104
5135
  *
5105
- * 匹配不到、引用自己、超上限、判定不放行时**只留 `ref` 和原因**。这时候模型
5106
- * 看到的是「用户引用了某段会话,但内容没给你,原因是 X」—— 要内容它可以自己调
5107
- * `session_search`(方案 48)。这保证了 `@:` 永远不会成为一条比 `session_search`
5108
- * **更宽松**的读取通道,和 `@文件` 对 `file_read` 那条判据逐字对应。
5136
+ * 三档失败在「运行记录」那一屏上有自己的三态色(✅ ⚠️ ❌),一条都没藏。
5137
+ * 📮 真要给它们一个计数,前置是一份**读标记**(`schedule` 那个 namespace 加一次
5138
+ * 迁移 + 一条写端点 + 「web 打开那个 tab 算不算看过、TUI 启动算不算」这个产品
5139
+ * 判断)。那是独立的一次改动,不该顺手塞进这一轮。
5109
5140
  */
5110
- interface EpochSessionPart {
5111
- type: 'session';
5112
- /** 用户敲的那个引用串(`@:` 后面那一截),原样保留用于显示 */
5113
- ref: string;
5114
- /** 匹配到的会话 id。**没匹配上时缺席** */
5115
- sessionId?: string;
5116
- /** 那段会话的标题(空标题时是 sessionId,见方案 53 §5.3) */
5117
- title?: string;
5118
- /** 那段会话的工作目录,用于让人 / 模型看出「这是哪个项目的会话」 */
5119
- cwd?: string;
5120
- /** 当前面的正文。**缺席时看 `omitted`** */
5121
- text?: string;
5122
- /** 没给内容的原因;给了 `text` 时不出现 */
5123
- omitted?: SessionOmitReason;
5124
- /** 内容被截断到多少字节。给了就是截断过 —— 截断**永远不静默** */
5125
- truncatedTo?: number;
5126
- /** 当前面有多少条消息(`active = 1` 的那些) */
5127
- messageCount?: number;
5128
- /** 那段会话一共多少条(含被压掉的)。两个数不等就说明它压缩过 */
5129
- totalMessages?: number;
5130
- }
5131
- type EpochContentPart = EpochTextPart | EpochImagePart | EpochFilePart | EpochSessionPart;
5132
- /** 用户消息内容 —— 纯文本或多模态部件数组 */
5133
- type EpochUserContent = string | EpochContentPart[];
5141
+
5134
5142
  /**
5135
- * 附件在**给模型的**纯文本投影里长什么样。**不吐文件内容** —— 内容走 `parts` 那一路。
5143
+ * 一条**算得出规则**的欠条。
5136
5144
  *
5137
- * 输出**逐字冻住**:门禁在 `packages/protocol/__tests__/message-projection.test.ts`,
5138
- * 它把五种 `omitted` 原因加截断、不截断两支全钉死了。改一个字那份会当场红 ——
5139
- * 那不是「用例过时了」,是你正在改模型的输入契约(判据见 {@link OMIT_LABEL})。
5145
+ * `suggestedRule` 从可选收成必填 —— 这不是换个写法:它让「dock 上那个按钮
5146
+ * 一定加得进东西」变成一件类型上成立的事,而不是一句要人记住的注释。
5140
5147
  */
5141
- declare function filePartSummary(part: EpochFilePart): string;
5148
+ type FixableApproval = SchedulePendingApproval & {
5149
+ suggestedRule: string;
5150
+ };
5142
5151
  /**
5143
- * 历史里的图被**摘掉**之后留在原位的那一行(2026-09-12)。
5144
- *
5145
- * ## 它补的是一个静默的口子,不是「又一个占位符」
5152
+ * dock / 侧栏 / TUI 三处共用的一条。
5146
5153
  *
5147
- * 喂模型的历史里图片是 base64 部件(`session-store.loadHistory()` 把
5148
- * `inlineImages` 写死成 true)。而「当前模型认不认图」那道闸门在
5149
- * `core/agent/loop.ts` 上,读的是**这一轮用户新发的图**
5150
- * (`imageParts(input.userMessage)`)—— 也就是说换到一个不认图的模型之后只要
5151
- * 不再发新图,历史里那些图会原样发出去:provider 那侧要么 400、要么**一声不吭
5152
- * 地吞掉**(后者实测存在,判据全文在 `core/provider/capability-probe.ts` 的
5153
- * 文件头)。摘掉它们的是 `AgentLoop`,留下的那一行是这一个。
5154
+ * 带 `scheduleName` 而不是只带 id:这条通知的措辞必须**有宾语**
5155
+ * (「《每日仓库巡检》差一条授权」,不是「有 1 条待确认」)——
5156
+ * 判据和跨会话待办条那一条同源(`web/src/dock/crossbar.tsx` 文件头
5157
+ * 「措辞必须带宾语」那一节):一个不带宾语的计数器逼用户挨个点开去看。
5158
+ */
5159
+ interface SchedulePendingItem {
5160
+ scheduleId: string;
5161
+ scheduleName: string;
5162
+ /** 欠条挂在哪一次运行上。`POST /api/schedules/:id/fix` 收的就是它 */
5163
+ runId: string;
5164
+ /** 那一次运行的开始时刻(epoch 毫秒)。界面上那句「已欠了多久」 */
5165
+ at: number;
5166
+ /** 还没进清单的那几条,**至少一条**(空的那几轮压根不进这份清单) */
5167
+ approvals: readonly FixableApproval[];
5168
+ }
5169
+ /**
5170
+ * 这一轮的欠条里,有哪几条规则还不在清单里。**去重,顺序照欠条的顺序。**
5154
5171
  *
5155
- * ## 为什么不复用 `contentToText` 那个 `[图片 image/jpeg]`
5172
+ * 三个调用点:这个文件自己(算那个计数)、`POST /api/schedules/:id/fix`
5173
+ * (真往清单里写的那一下)、`epoch schedule run --fix`。
5174
+ * ⚠️ **三处必须是同一个函数**:它决定的是「往授权清单里加哪几条规则」,
5175
+ * 而三份实现走散的方向可能是提权(一边加了另一边没加)。
5176
+ */
5177
+ declare function unfixedRules(allowRules: readonly string[], approvals: readonly SchedulePendingApproval[]): string[];
5178
+ /**
5179
+ * 手上那几条任务 + 最近那些运行 → 该通知的清单。
5156
5180
  *
5157
- * 那一个是**另一个消费者**的投影(`imagesCarriedAsParts: false`,喂压缩器 /
5158
- * 审计 / `messages.content` 那一列),而且它有前科:图片真的送达之后,它成了
5159
- * 「唯一还在撒谎的地方」(判据在 {@link ContentToTextOptions.imagesCarriedAsParts})。
5160
- * 这一行要说的是另一件事 —— 图**确实没给它**,以及为什么。
5181
+ * ## ⚠️ 同一条任务只报**最近**那一次,不是每一次
5161
5182
  *
5162
- * 措辞里带上「不支持」这半句,判据同 `agent/prompt.ts` 里那条
5163
- * 「不支持那一档要说清后果,别笼统一句不支持」:只写「图片未附上」的话,
5164
- * 模型会去猜是不是传坏了,然后建议用户重发一次 —— 而重发多少次都一样。
5183
+ * 一条「每天 9 点」的任务每天记同一条欠条,一周就是七条一模一样的通知。
5184
+ * 所以按任务收拢、取最近那一次。这不会漏掉东西:修最近那一次加进去的规则
5185
+ * 通常把老的那几次也一起覆盖了;万一老的那次欠的是**另一条**规则,
5186
+ * 它在下一次取数时自然浮上来成为「最近的那条未解决的」。
5165
5187
  *
5166
- * ⚠️ **不给「你可以自己去读」这类下一步**,和 `OMIT_LABEL` 的 `binary` 那档
5167
- * 不同:那一档有 `file_read` 真的能拿到内容,这一档模型没有任何办法看见像素。
5168
- * 给一个不存在的出口,比不给更糟。
5188
+ * ⚠️ 判的是「最近的**还欠着的**那一次」,不是「最近的那一次」:一条任务昨天
5189
+ * 卡了、今天跑成了,昨天那条欠条**仍然欠着**(清单里那条规则还是没加),
5190
+ * 下一次到点还会再卡一遍。拿「最近一次的状态」当判据会把它静默吞掉。
5169
5191
  *
5170
- * 逐字冻住,理由同 {@link OMIT_LABEL}(进模型 prompt、进 eval 指纹、不进 catalog)。
5192
+ * @param defs 全部任务(`ScheduleControl.list()`)
5193
+ * @param runs 最近那些运行,**最新的在前**(`ScheduleControl.recentRuns()` 的顺序)
5171
5194
  */
5172
- declare function imagePartOmittedSummary(part: EpochImagePart): string;
5195
+ declare function collectPendingApprovals(defs: readonly ScheduleDefinition[], runs: readonly ScheduleRun[]): SchedulePendingItem[];
5196
+
5173
5197
  /**
5174
- * 引用的会话在纯文本投影里长什么样。**不吐那段会话的正文** —— 同 `filePartSummary`。
5198
+ * 模型在对话里交上来的一份定时任务**草稿**。
5175
5199
  *
5176
- * ## 这一行刻意是英文 + 码,不是中文散文
5200
+ * `epoch schedule add` 和自动化面板那张表单是两条**人操作**的写入路。这一份是
5201
+ * 第三条来路,而它**不是写入路** —— 草稿谁都落不了库,它只是一份摆到人眼前的
5202
+ * 提案。真正落库的仍然是那两条:人点「审阅并保存」之后走的是同一个
5203
+ * `POST /api/schedules`,人粘贴命令行走的是同一个 `epoch schedule add`。
5177
5204
  *
5178
- * 它的读者有三个,**没有一个是界面**:模型(经 sdk-adapter 进上下文)、
5179
- * `messages.content` 那一列(存盘 / 压缩 / 审计 / FTS)、以及宿主的日志。
5180
- * 界面要展示这一部件时读的是结构化字段(`omitted` / `title` / `messageCount`),
5181
- * 自己组措辞 —— TUI 那一侧的中文在宿主用 `t()` 拼,见 locales 的 `mention.*`。
5205
+ * ## ⚠️ 三条硬约束都摊在类型上,不靠注释(照 [schedule-templates.ts](./schedule-templates.js) 那三条办)
5182
5206
  *
5183
- * > ⚠️ 「没有一个是界面」这句话 2026-08-24 之前**有个洞**:`contentToText()`
5184
- * > 会把这一行塞进 tui / web 的气泡里,于是它事实上也是界面的。洞补在
5185
- * > §34.4 —— 界面改走 `contentToDisplayText()`,那一份把这一行换成
5186
- * > catalog 的 `message_part.session*`。所以上面那句话现在是真的了。
5207
+ * | 要守住的 | 落在类型的哪一格 |
5208
+ * | ------------------------------ | -------------------------------------------------------------------- |
5209
+ * | 模型不许把任务放到「跳过全部检查」 | {@link ScheduleDraftPermission} 的取值里**没有** `bypass` |
5210
+ * | 填进表单后**必须让用户过一遍** | 整份形状里**没有** `maxBudgetUsd`,而它是必填 —— 表单因此保存不了 |
5211
+ * | 草稿不是写入路 | 这里只有数据,没有 id 生成、没有 `enabled`、没有 `createdAt` |
5187
5212
  *
5188
- * 所以这里直接把**那五个稳定错误码**原样写进去(§4.2):宿主能 grep,模型能读,
5189
- * 而且它不必跟着界面语言变 —— 一条落库的记录跟着 `EPOCH_LANG` 变语言,
5190
- * 等于把同一段会话的历史劈成两半(`goal/wording.ts` 那条豁免记的是同一个坑)。
5213
+ * 第二条是三条里最要紧的,而它和模板那一条**是同一条**:预算必填、没有默认值
5214
+ * (方案 45 §3.5),于是一份草稿填进表单之后那个保存按钮**天然是灰的**,
5215
+ * 用户不看完那一屏就出不去。写成「草稿里预填 0.50 + 一句『请检查』」的话,
5216
+ * 「必须过一遍」就退化成一句没人读的话 —— 而这一格错了的后果很具体:
5217
+ * 一觉醒来 40 美元。
5191
5218
  *
5192
- * 显示名用标题而不是 sessionId:后者是一串随机字符,对人和模型都没有信息量。
5193
- * 标题为空的会话在解析那一侧已经用 sessionId 兜过底了(方案 53 §5.3)。
5194
- */
5195
- declare function sessionPartSummary(part: EpochSessionPart): string;
5196
- /**
5197
- * 取部件数组的纯文本投影:图片折成一句占位,绝不吐 base64;
5198
- * 文件和引用的会话同样只折成一行占位,**不吐内容**。
5219
+ * ## 为什么住在 protocol
5199
5220
  *
5200
- * 投影是**存盘、压缩、审计**看的那一份。把几十 KB 的文件内容塞进来,
5201
- * `sessions.db` 会跟着一起胖,而回放时那份内容本来就能从 `parts` 里拿。
5221
+ * 和 `SCHEDULE_TEMPLATES` 同一个理由,而且更硬一档:这一份的**两端都要读它** ——
5222
+ * core 那侧要解析模型发来的入参(`tools/builtin/schedule-draft.ts`),
5223
+ * `packages/web` 那侧要从那次工具调用的入参里把它读回来渲染成一张卡片
5224
+ * (`thread/rows.ts`,照 `todo` 的先例)。web 只依赖 protocol + view,够不着 core。
5202
5225
  *
5203
- * ## ⚠️ 这一份**只给模型和落库**,界面不许用它(RECORD-40-i18n §34.4)
5226
+ * ## ⚠️ 这里一个中文字都没有
5204
5227
  *
5205
- * 改造前它一个人喂三类消费者,于是把界面切成 English 的人会在**自己那条消息的
5206
- * 气泡里**看到 `[图片 image/png]` / `[文件 …:二进制文件,未附内容]`。
5207
- * 这笔债在 §34.4 记了、§34.4 还掉,处置是**分家**:
5228
+ * 判据同 schedule-templates.ts 最后一节:措辞在 `locales/*.yaml` 的
5229
+ * `web.ui.thread.draft_*` 底下(卡片)和 core 的工具输出里(给模型看的那半,
5230
+ * 按 `locales/zh.yaml` 的范围说明不进 catalog)。
5231
+ */
5232
+
5233
+ /**
5234
+ * 那个工具的名字。**真源在这儿而不是 core**,因为读它的有两个包:
5235
+ * core 注册工具时用它,web 从工具流里认出那次调用时用它
5236
+ * (`thread/rows.ts`)。
5208
5237
  *
5209
- * | 谁要 | 用哪一份 | 为什么 |
5210
- * | ---- | -------- | ------ |
5211
- * | 模型(`sdk-adapter.ts` / `agent/loop.ts` / `runtime/agent-session.ts`) | **这一个** | 模型输入契约 + eval 指纹,跟着 locale 变就漂(§33.2) |
5212
- * | ↳ 其中 `agent/loop.ts` 拼**给模型的文本部件**那一次 | 这一个 + `{ imagesCarriedAsParts: true }` | 图片另有真部件带着,占位符会变成一句假话(判据见 {@link ContentToTextOptions}) |
5213
- * | `messages.content` 那一列(存盘 / 压缩 / 审计 / FTS) | **这一个** | 一条落库的记录跟着 `EPOCH_LANGUAGE` 变语言,等于把同一段会话的历史劈成两半 |
5214
- * | 界面(tui 的历史项、web 的气泡) | `contentToDisplayText(content, t)` | 那一份走 catalog 的 `message_part.*`,住在两个宿主里 |
5238
+ * ⚠️ 这一点上它和隔壁 `TODO_TOOL` 的做法不一样:那个名字今天在
5239
+ * `web/src/thread/rows.ts` 里另写了一份字面量(那时 protocol 里没有落脚点)。
5240
+ * 别照那个抄 —— 两份字面量漂了的表现是「卡片再也不渲染了」,而 typecheck
5241
+ * 一声不响。
5242
+ */
5243
+ declare const SCHEDULE_DRAFT_TOOL = "schedule_draft";
5244
+ /**
5245
+ * 草稿能落在哪几档权限上 —— **`bypass` 不在里面**。
5215
5246
  *
5216
- * 界面那一份**落在宿主层**而不是这儿,判据同 §34.6 给 `view` 那 1 处的做法
5217
- * (兜底文案挪给渲染端):这个包的依赖白名单是空的,`t()` 只能由有它的那一层调。
5247
+ * 不是「运行期会拒绝」,是那一档在类型上根本不存在(判据同
5248
+ * `ScheduleTemplate.permission`)。理由比模板那一侧更直接:`bypass` 要一次
5249
+ * 二次确认(`bypass-not-confirmed`),而那次确认的**对象是人**;让模型提议
5250
+ * 一个「等着人去勾确认框」的档位,等于把那道确认变成一道例行手续。
5251
+ *
5252
+ * 用户自己想要那一档时照旧走表单 —— 那条路一个字都没改。
5218
5253
  */
5219
- interface ContentToTextOptions {
5254
+ type ScheduleDraftPermission = Exclude<PermissionLevel, 'bypass'>;
5255
+ /** 草稿里那份清单。三个字段各自**可选**:没提到的一格 = 「这一格我没意见」 */
5256
+ interface ScheduleDraftAllowlist {
5257
+ allowTools?: readonly string[];
5258
+ allowOperations?: readonly OperationType[];
5259
+ allowRules?: readonly string[];
5260
+ }
5261
+ /** 起草一条新任务 */
5262
+ interface ScheduleDraftCreate extends ScheduleDraftAllowlist {
5263
+ action: 'create';
5264
+ name: string;
5265
+ prompt: string;
5266
+ trigger: ScheduleTrigger;
5267
+ /** 工作目录。不给就是引擎那份缺省(`~/.epoch/automation/<id>/`) */
5268
+ workDir?: string;
5269
+ permission: ScheduleDraftPermission;
5220
5270
  /**
5221
- * 这一条消息的图片**是不是另有真部件带着**(`EpochMessage.parts` 里那份
5222
- * base64,最终变成 provider 的原生图片部件)。
5223
- *
5224
- * 默认 `false` —— 也就是改造前的行为,落库那一列要的正是它:那一行
5225
- * `[图片 image/png]` 是**这一轮带过一张图**的唯一记录(`parts` 列另存,
5226
- * 但 FTS / 预览 / 标题只看 content)。
5227
- *
5228
- * `true` 时图片那一支整个**不折占位符**。判据是那句占位符的含义 ——
5229
- * 「这里本来有张图,但你看不到」。图真的在旁边时它就成了一句假话,而且是
5230
- * 会被照着信的那种:2026-09-01 实测,`@ai-sdk/deepseek@3.0.20` 把图片
5231
- * 静默丢了之后,模型读到的就是这一行,于是它逐字回答「我只看到
5232
- * 「[图片 image/jpeg]」这个占位符,图片没有传到我这边」。**升级 SDK 让图片
5233
- * 真的送达之后,这一行反而成了唯一还在撒谎的地方。**
5271
+ * 为什么建议这一条,一句话。**给人看的**,会印在卡片上。
5234
5272
  *
5235
- * ⚠️ 只关图片那一支。`file` / `session` 的占位符**照旧**:它们说的是
5236
- * 「内容没附上」(`omitted` / `not-restored`),那句话在任何情况下都是真的,
5237
- * 而且是模型唯一能知道「有个东西没给我」的途径。
5273
+ * 必填,而这是刻意的:一条凭空出现在对话里的定时任务,用户第一个问题
5274
+ * 一定是「你为什么觉得我需要它」。答不上来的提案不该提。
5238
5275
  */
5239
- imagesCarriedAsParts?: boolean;
5276
+ note: string;
5240
5277
  }
5241
- declare function contentToText(content: EpochUserContent, opts?: ContentToTextOptions): string;
5242
5278
  /**
5243
- * 「上一轮实际动过手」在**给模型的**历史里长什么样(2026-08-28)。
5244
- *
5245
- * ## 它修的是什么
5246
- *
5247
- * 喂模型的那份历史只有 user / assistant 两种角色的**文本**
5248
- * (`runtime/src/agent-session.ts` 的累积、`session-store.ts` 的 `loadHistory()`),
5249
- * 工具调用和结果一条都不在里面 —— 这是刻意的(孤立的 tool 消息会被 provider 拒掉,
5250
- * 全量 transcript 是另一个体量的事)。代价此前没人算:一段五条消息的会话,
5251
- * 模型看到的是**五条又长又具体的指令,配四句十来个字的「办完了」**。
5252
- * 早期那几条指令的字数远大于「它已经做完了」的证据量,于是模型把它们当成还在
5253
- * 生效的待办,回头去做 —— 而用户刚发的那一条被摊薄。实测在
5254
- * `packages/runtime/__tests__/context-recency.test.ts`。
5255
- *
5256
- * 这一行就是补那份证据:不复原过程,只留一句「这一轮真的调了这些工具」。
5257
- *
5258
- * ## 判据用**结果**而不是调用
5259
- *
5260
- * `EpochToolCall` 上有工具名,但一次跑到一半被中止的轮次里那些调用**从没执行过**
5261
- * (判据逐字同 `runtime/src/session-file-changes.ts` 的边界 1)。而一条
5262
- * `EpochToolResult` 存在就意味着它跑完了 —— 拿结果当输入,「执行过没有」这件事
5263
- * 不需要另外判一次。
5264
- *
5265
- * ⚠️ **它分不出成功和失败**:落盘的结果上没有 `success`(同上那份的边界 2)。
5266
- * 所以措辞是「调用过」而不是「做成了」—— 一次被权限拒掉的 `file_write`
5267
- * 在这里照样留一笔,而那句话仍然是真的。
5279
+ * 提议改一条**已有**的任务。
5268
5280
  *
5269
- * ## 为什么住在 protocol、且是冻住的中文
5281
+ * 每一格都可选(`undefined` = 这一格不动),但至少要动一格 —— 一份什么都不改的
5282
+ * 提案是在浪费用户的一次点击。这条判定在 core 那侧
5283
+ * (`schedule/draft.ts` 的 `draft-update-empty`)。
5270
5284
  *
5271
- * 它的读者是**模型**,和 {@link contentToText} / {@link filePartSummary} 同一类,
5272
- * 所以走同一条规矩:住在这个零依赖的包里、逐字冻住、**不跟着界面语言变**
5273
- * (门禁在 `packages/protocol/__tests__/message-projection.test.ts`)。一条进了
5274
- * 上下文的历史跟着 `EPOCH_LANGUAGE` 变语言,等于把同一段会话劈成两半。
5285
+ * ## ⚠️ 「放宽」和别的改动在类型上长得一样,所以判定必须在别处
5275
5286
  *
5276
- * 只有工具名,不带参数 / 路径:带路径要 `affectedPaths()` + 工作目录,而那两样
5277
- * 住在 core 和装配层,拿不到的地方(`loadHistory()`)就会退化成另一种措辞 ——
5278
- * 两条路产出的历史一旦不一样,症状就是「刷新之后模型看到的和刚才不是同一段」。
5287
+ * 把超时从 10 分钟调到 30 分钟,和往清单里加一条 `terminal(rm:*)`,在这个形状里
5288
+ * 是同一种东西:几个可选字段。而后者是**提权**。所以两个宿主都必须自己把
5289
+ * 「这次提案放宽了什么」单独标出来(web 的卡片、core 的工具输出),
5290
+ * 不许把它混在一列改动里 —— 用户扫一眼就点保存的那一下,正是这条要防的。
5291
+ */
5292
+ interface ScheduleDraftUpdate extends ScheduleDraftAllowlist {
5293
+ action: 'update';
5294
+ /** 改哪一条。模型得先用 `action: 'list'` 看见 id,界面上那串它猜不出来 */
5295
+ id: string;
5296
+ name?: string;
5297
+ prompt?: string;
5298
+ trigger?: ScheduleTrigger;
5299
+ workDir?: string;
5300
+ permission?: ScheduleDraftPermission;
5301
+ enabled?: boolean;
5302
+ maxTurns?: number;
5303
+ timeoutMs?: number;
5304
+ /** 同 {@link ScheduleDraftCreate.note},必填 */
5305
+ note: string;
5306
+ }
5307
+ type ScheduleDraft = ScheduleDraftCreate | ScheduleDraftUpdate;
5308
+ /**
5309
+ * 一次提案里「放宽了什么」的分类。
5279
5310
  *
5280
- * ⚠️ **调用方要的多半是 {@link assistantTurnText},不是这一个** —— 那一份连
5281
- * 「正文和这一行之间怎么接」一起定了,而那正是两条路会走散的地方。
5311
+ * 只有三类,而三类都是**授权面变大**的方向:档位往松走、清单多了条目、
5312
+ * 从停用变成启用。别往里加「超时变长」这种 —— 它花的是钱和时间,不是权限,
5313
+ * 而把两种东西混进同一枚警示标,那枚标就不再有信息量。
5282
5314
  *
5283
- * @param results 这**一轮**(不是一步)执行过的全部工具结果,按发生序
5284
- * @returns 空数组时是空串 —— 这一轮没动手,那就没有这句话可说
5315
+ * 判定在 core(`schedule/draft.ts` 的 `draftWidening`),渲染在 web
5316
+ * (`thread/schedule-draft.tsx`)—— 两侧各算一遍是刻意的,判据同
5317
+ * `web/src/thread/rows.ts` 文件头那条:web 读的是**模型发出的原始入参**,
5318
+ * 而不是 core 归一化之后的结论。
5285
5319
  */
5286
- declare function turnDigest(results: readonly Pick<EpochToolResult, 'toolName'>[]): string;
5320
+ type ScheduleWideningKind = 'permission' | 'allowlist' | 'enabled';
5321
+
5287
5322
  /**
5288
- * 一轮的 assistant 消息在**给模型的历史**里长什么样:正文 + {@link turnDigest}。
5289
- *
5290
- * ## 为什么这条胶水也得住在这儿
5323
+ * 工作区信任契约。
5291
5324
  *
5292
- * 因为它有**两个调用方,走两条路,产出必须逐字节相同**:
5325
+ * 为什么需要:clone 一个仓库就等于让它的 `AGENTS.md` 进 system prompt、
5326
+ * 它的 hook 配置被执行、它的 policy 覆盖本地权限判定。这是漏洞,不是缺功能。
5327
+ * 信任判定把「加载项目级配置」这件事收到一次显式用户决策后面。
5293
5328
  *
5294
- * - 直播:`runtime/src/agent-session.ts` 用 `TurnStepCollector` 收的那一轮;
5295
- * - 恢复:`runtime/src/session-store.ts` 的 `loadHistory()` 从 DB 行折回来的那一轮。
5329
+ * 竞品:Codex `codex-rs/tui/src/onboarding/trust_directory.rs`;
5330
+ * Gemini CLI `packages/cli/src/config/trustedFolders.ts` + `packages/core/src/utils/trust.ts`。
5331
+ * 两家都只是读过的参照物:契约形状跟它们都不一样(上游是 TRUST_FOLDER / TRUST_PARENT /
5332
+ * DO_NOT_TRUST 三枚举 + 靠 TRUST_PARENT 取 dirname 来编码作用域,这里是三态字面量 +
5333
+ * 独立的 scope 字段 + decidedAt)。本文件是独立实现,不带 SPDX 头。
5334
+ * 判定证据见 docs/verify/VERIFY_RECORD-D-license-attribution.md
5296
5335
  *
5297
- * 两边各写一次 `text + '\n\n' + digest` 的下场是:某天一边多一个换行,
5298
- * 而症状是**刷新页面之后模型看到的历史和刷新前不是同一段** —— `reloadHistory()`
5299
- * 的 JSDoc 里那句「它们一旦分叉,症状就是屏幕上和模型看到的对不上」骂的正是这个。
5300
- * 门禁是 `packages/runtime/__tests__/context-recency.test.ts` 里那条逐字节比对。
5336
+ * 这里只有类型,实现在 core —— 注意实现那边(`core/src/trust/manager.ts`)**是**
5337
+ * gemini 的衍生作品(搬了「归一成生效目录再取最长匹配」那条算法),带头、在 NOTICE 里。
5338
+ * 契约与实现的授权状态不同是对的,不是漏登记。
5339
+ */
5340
+ /** 一个目录的信任状态 */
5341
+ type TrustLevel =
5342
+ /** 用户明确信任:加载项目指令 / hook / policy */
5343
+ 'trusted'
5344
+ /** 用户明确拒绝:只当普通工作目录,不加载任何项目级配置 */
5345
+ | 'untrusted'
5346
+ /** 还没问过 */
5347
+ | 'unknown';
5348
+ /** 用户已经做过决策的信任状态(排除「还没问过」) */
5349
+ type DecidedTrustLevel = Exclude<TrustLevel, 'unknown'>;
5350
+ /** 信任决策的作用域 */
5351
+ type TrustScope =
5352
+ /** 只这个目录 */
5353
+ 'directory'
5354
+ /** 这个目录及其所有子目录 */
5355
+ | 'directory-tree';
5356
+ /**
5357
+ * 一次授信授到哪一档(2026-09-11)。**两档是相加的:`exec` 蕴含 `read`。**
5301
5358
  *
5302
- * 正文为空、只有 digest 时**不留前导空行**:那一轮模型只调了工具没说话
5303
- * (`loop.ts` 允许),这时这条消息的全部内容就是那一行。
5359
+ * ## 为什么拆
5304
5360
  *
5305
- * @returns 空串 = 这一轮既没正文也没动手,调用方**别往历史里塞这一条**
5306
- */
5307
- declare function assistantTurnText(text: string, results: readonly Pick<EpochToolResult, 'toolName'>[]): string;
5308
- /**
5309
- * 部件类型的穷尽性守卫 —— **这是「加了一种部件却漏了一处分支」的那道会红的门禁**。
5361
+ * 在它之前 `trusted` 是一个布尔,而那一个布尔后面挂着七样东西 —— 其中
5362
+ * `.epoch/hooks.json` 和 `.claude/settings.json` 的每一项都是一条要 spawn 子进程的
5363
+ * 命令(判据在 [core/hook/sources.ts](../../core/src/hook/sources.ts) 的文件头)。
5364
+ * 而用户被问到的那句话逐字是「本目录的项目指令会加载」(`trust_prompt.granted`),
5365
+ * **从来没提过 hook**。也就是说「往上下文里注入一段文本」和「在这台机器上执行
5366
+ * 任意命令」共用了同一次点击,而问话只描述了前者。这不是 UX 问题,是知情同意错位。
5310
5367
  *
5311
- * 消费 `EpochContentPart` 的地方有五处(这里、`sdk-adapter` 的 `toSdkPart`、
5312
- * `session/payload` 的 `persistParts` / `restoreParts`、以及展示层),改造前它们
5313
- * 全都写成「if text / if file / 剩下的当图片」。那个形状下加一种部件**不会有
5314
- * 任何东西变红**:新部件会被静默当成图片,而症状是模型收到一个 `image:` 里
5315
- * 塞着会话正文的请求,provider 报 400。
5316
- */
5317
- declare function assertNeverPart(part: never): never;
5318
- type EpochMessageRole = 'system' | 'user' | 'assistant' | 'tool';
5319
- /**
5320
- * 一条会话消息。
5368
+ * | 档 | 放开什么 | 风险 |
5369
+ * | --- | --------------------------------------------------------------- | ---------- |
5370
+ * | `read` | 指令文件 / 技能 / 角色 / 命令 | 注入上下文 |
5371
+ * | `exec` | `hooks.json` / `.claude/settings*.json` / `policies/` / settings 的 allow 那一半 | 执行、放宽判定 |
5321
5372
  *
5322
- * `content` 永远是字符串(tool 消息为空串,内容在 `toolResults` 里),
5323
- * 这样「取这条消息的文本」永远不需要判类型。
5373
+ * ## ⚠️ 两条边界,别把它当别的东西
5324
5374
  *
5325
- * 多模态**不是**把 `content` 放宽成联合类型 —— 那正是本文件开头骂过的病,
5326
- * 会让 loop / codec / 压缩器每处都得判一次类型。改成加一个 `parts` 旁路:
5327
- * `content` 仍然是权威的**文本投影**(压缩器、审计、持久化都只看它),
5328
- * `parts` 只在 sdk-adapter 那一处被读走。两者的关系由 `contentToText` 保证。
5375
+ * 1. **`exec` 不是权限档。** 它放开的是「这个仓库自带的 hook / policy 算不算数」,
5376
+ * 不是「这次操作要不要问用户」——[两套机制照旧正交](../../core/src/trust/gate.ts)。
5377
+ * 未授 `exec` 的目录里 agent 照样能跑 bash,只是仓库里那份 `hooks.json` 不生效;
5378
+ * 2. **`read` 不是「安全档」。** 一份 `AGENTS.md` 能指挥 agent 去做任何它有权限做的
5379
+ * 事,包括执行命令。拆开不是因为 `read` 无害,是因为**两者的问话不该合并成一次**。
5329
5380
  */
5330
- interface EpochMessage {
5331
- role: EpochMessageRole;
5332
- content: string;
5381
+ type TrustTier = 'read' | 'exec';
5382
+ interface TrustRecord {
5383
+ /** 绝对路径(已 resolve) */
5384
+ path: string;
5385
+ level: DecidedTrustLevel;
5333
5386
  /**
5334
- * 多模态部件。给了就以它为准喂模型,`content` 退化成它的文本投影。
5335
- * **不落 SQLite** —— 图片走 artifact 目录,DB 里只留 content 的占位文本。
5387
+ * 这条决策管到哪儿。
5388
+ *
5389
+ * `check()` 会把它折叠进 `TrustLevel`,所以判定方用不上;但**列出记录**的一方
5390
+ * (`epoch trust` 要显示「仅此目录 / 含子目录」)必须能看见 —— 少了它,
5391
+ * `list()` 返回的就不是一条完整的决策,而是一条读不懂的决策。
5336
5392
  */
5337
- parts?: EpochContentPart[];
5338
- /** assistant 要求的工具调用;与 content 并存(模型可以边说话边调工具) */
5339
- toolCalls?: EpochToolCall[];
5340
- /** role 为 'tool' 时携带的执行结果 */
5341
- toolResults?: EpochToolResult[];
5393
+ scope: TrustScope;
5394
+ /** 记录时间戳(ms) */
5395
+ decidedAt: number;
5342
5396
  /**
5343
- * 这一步模型的思考原文(推理模型的 `reasoning-delta` 累加)。2026-08-18。
5397
+ * 执行类也授了吗({@link TrustTier})。`level: 'untrusted'` 时无意义。
5344
5398
  *
5345
- * **只给人看,不回灌模型**:`sdk-adapter.ts` 一个字都不读它。加它是因为
5346
- * 刷新页面之前屏幕上有「深度思考」那一段,刷新之后整段消失 —— 而那不是
5347
- * 「这一轮没想」,是我们没记。
5399
+ * ⚠️ **缺省(`undefined`)= 没授**,而这一格正是存量记录的迁移:拆档之前写下的
5400
+ * 记录没有这一格,于是升级之后它们**只剩读类** —— 判据就是上面那张表下面那段:
5401
+ * 那些记录是在一句只说了「项目指令会加载」的问话下拿到的,把它们追认成执行类
5402
+ * 等于替用户补签一份他没看过的同意书。代价(老用户的项目 hook 会停)由
5403
+ * `trust_gate.exec_migrated` 那条诊断明说,不静默。
5404
+ */
5405
+ exec?: boolean;
5406
+ /**
5407
+ * 授 `exec` 那一刻,执行类输入文件的内容指纹(见 core 的 `trust/exec-hash.ts`)。
5348
5408
  *
5349
- * ⚠️ **它属于某一步,不属于整轮。** 一轮里模型可以想一次、调个工具、再想一次,
5350
- * 每次的思考挂在**它自己那条 assistant 行**上,位置就是它真实的时序位置。
5351
- * 拿它当「整轮的思考」拼成一根,回放时那一段会被钉到最前面,讲的却可能是
5352
- * 二十步之后的事(web 的 `thoughtParts` 治过同一个病,判据在
5353
- * `web/src/state/reducer.ts` 的 `trackThought()`)。
5409
+ * 对不上 = 那些文件在授信之后被改过 → `exec` 退回未授,重新问一次。
5410
+ * `exec` 为假时这一格不该存在。
5354
5411
  *
5355
- * 落盘走 `messages.reasoning` 列 —— 那一列**从会话库 v1 就在**,只是一直没人
5356
- * 写过,所以这一档不需要迁移。
5412
+ * ## ⚠️ 这是对 [manager.ts](../../core/src/trust/manager.ts) 文件头「两条明确不做」
5413
+ * 第 2 条的一次**窄口推翻**,不是把它作废
5414
+ *
5415
+ * 那一条 2026-08-08 写着:记内容 hash「代价是『改一行 AGENTS.md 就被打断一次』,
5416
+ * 这个打断比它挡住的风险更贵」。**那个结论在读类上照旧成立,一个字不改** ——
5417
+ * 指令文件天天在动,为它记 hash 会把授信变成一件每天都要重做的事。
5418
+ *
5419
+ * 翻的只有执行类那一半,因为它的两项输入都变了:
5420
+ *
5421
+ * | | 读类(AGENTS.md) | 执行类(hooks.json / policies) |
5422
+ * | --- | ---------------------- | -------------------------------- |
5423
+ * | 改动频率 | 每天 | 几乎不动 |
5424
+ * | 改动后果 | 多一段文本 | **多一条会跑的命令** |
5425
+ *
5426
+ * 也就是说,同一个做法在这一侧的打断成本低一个量级、挡住的东西贵一个量级。
5427
+ * 参照物:codex 对 hook 记 `trusted_hash`(`codex-rs/config/src/hook_config.rs`),
5428
+ * 而它的 `AGENTS.md` 压根不过闸门 —— 同一条判据的两侧,它也是这么分的。
5357
5429
  */
5358
- reasoning?: string;
5430
+ execHash?: string;
5431
+ }
5432
+ /** {@link TrustManager.record} 的可选档位。不给 = 只授读类 */
5433
+ interface TrustGrant {
5434
+ /** 连执行类一起授。**调用方必须真的为这一档单独问过用户** */
5435
+ exec?: boolean;
5436
+ /** 授 `exec` 时一并记下的内容指纹 */
5437
+ execHash?: string;
5438
+ }
5439
+ /**
5440
+ * {@link TrustManager.checkExec} 的答案 —— **记录里写的,不含新鲜度校验**。
5441
+ *
5442
+ * 两格一起交出来是因为校验方(`core/src/trust/gate.ts`)需要拿 {@link hash} 去和
5443
+ * 现算的值比;拆成两次调用的话,两次之间可能命中**不同的记录**
5444
+ * (`bestRecord()` 是按最长匹配现算的),于是拿 A 记录的授信配上 B 记录的指纹。
5445
+ */
5446
+ interface TrustExecGrant {
5447
+ /** 记录里授过执行类吗 */
5448
+ granted: boolean;
5449
+ /**
5450
+ * 授的时候记下的内容指纹。
5451
+ *
5452
+ * ⚠️ **`undefined` = 校验不了,而这一档照旧放行**(判据在 gate 的 `decide()` 上)。
5453
+ * 今天所有写入口都会带上它,所以这一档只可能来自手改 `trust.json`。
5454
+ */
5455
+ hash?: string;
5456
+ }
5457
+ interface TrustManager {
5458
+ /** 查某个目录的信任状态(含父目录的 directory-tree 继承) */
5459
+ check(dir: string): TrustLevel;
5460
+ /**
5461
+ * 查某个目录的执行类授信(含继承)。**和 {@link check} 是两个问题**:
5462
+ * 这一个只在 `check()` 回 `'trusted'` 时才可能授过,反过来不成立。
5463
+ */
5464
+ checkExec(dir: string): TrustExecGrant;
5465
+ /** 记录一次决策 */
5466
+ record(dir: string, level: DecidedTrustLevel, scope: TrustScope, grant?: TrustGrant): void;
5467
+ list(): readonly TrustRecord[];
5468
+ revoke(dir: string): void;
5359
5469
  }
5360
5470
 
5361
5471
  /**
@@ -6090,8 +6200,20 @@ type WireAgentEvent = (FunctionKeys<PlainAgentEvent> extends never ? PlainAgentE
6090
6200
  /**
6091
6201
  * 一个会话的回合状态。
6092
6202
  *
6093
- * `running` 时第二个 `POST /messages` 直接 409,不排队也不交织:`AgentSession`
6094
- * 的历史累积发生在 `run()` 末尾,两轮并发跑会让历史顺序变成不确定的。
6203
+ * `running` 时第二个 `POST /messages` **不会再开一轮**:`AgentSession` 的历史累积
6204
+ * 发生在 `run()` 末尾,两轮并发跑会让历史顺序变成不确定的。
6205
+ *
6206
+ * ⚠️ 这句话的**后半截改过两次,别再照着旧版本推断**:
6207
+ *
6208
+ * | 时期 | 第二个 POST 的下场 |
6209
+ * | --- | --- |
6210
+ * | 方案 20 | 直接 409(那时只有一个会话,409 是唯一能给的答复) |
6211
+ * | 方案 30 §2.3 | **排队**,等这一轮跑完再开(`{ok:true, queued:true}`) |
6212
+ * | 2026-09-16 | 默认被**拌进正在跑的那一轮**(`steered: true`),排队只剩斜杠命令那一档 |
6213
+ *
6214
+ * 三版共同的不变量只有一条:**一个会话同一时刻只有一个 `run()`**。
6215
+ * 「409」和「排队」都是那条不变量在某一版的**实现**,不是契约。
6216
+ * 今天的两档分别在 `WireSendMessageResponse.steered` 和 `SessionHub.steerFrom` 上。
6095
6217
  */
6096
6218
  type WireTurnState = 'idle' | 'running' | 'awaiting-approval' | 'awaiting-question';
6097
6219
  /**
@@ -6201,7 +6323,8 @@ type WireHubEvent =
6201
6323
  type: 'session-cooled';
6202
6324
  }
6203
6325
  /**
6204
- * **会话列表变了** —— 多了一段,或者少了一段(2026-09-11)。
6326
+ * **会话列表变了** —— 多了一段、少了一段,或者有一段被收进 / 移出归档
6327
+ * (2026-09-11;归档那一档 2026-09-16 补)。
6205
6328
  *
6206
6329
  * ## 它治的是「不是我建的那一段」
6207
6330
  *
@@ -6230,12 +6353,25 @@ type WireHubEvent =
6230
6353
  * (`reducer.ts` 的 `applyFrame`:别的会话的帧会进 `parked`,而 `parkFrame`
6231
6354
  * 只认 `session-state` / `session-cooled`,别的 Hub 事件在那儿被丢掉)。
6232
6355
  *
6233
- * ## ⚠️ 覆盖不到的那一档:**标题改了不发这一帧**
6356
+ * ## ⚠️ 「多一段 / 少一段」不等于「建了 / 删了」(2026-09-16 补的第三个产地)
6357
+ *
6358
+ * 原文写的是「它的两个产地都在**进出 Hub** 这条路上」,并据此把整条
6359
+ * `PATCH /api/sessions/:id` 划到覆盖范围之外(「压根不经过 Hub,只动 DB」)。
6360
+ * **那句推论是错的**:这一帧的宾语是**那条列表端点答出来的那几行**,不是 Hub 的表。
6361
+ * 而列表默认**不列归档的会话**,于是「收进归档」在列表上就是少一行、「取回」
6362
+ * 就是多一行 —— 和 DELETE / POST 那两个产地一模一样。
6363
+ *
6364
+ * 症状是这一轮的正题(2026-09-16 用户实测):在设置页「会话」那一分区点「取回」,
6365
+ * 左边侧栏那份列表一动不动,**重启应用才看得见**取回来的那一段 —— 和上面那个
6366
+ * 飞书宿主的原话逐字同形。产地在 `server/src/api.ts` 的 `patchSession`,
6367
+ * **只在 `archived` 真的翻了的时候发**。
6368
+ *
6369
+ * ## ⚠️ 覆盖不到的那一档:**标题改了仍然不发这一帧**
6234
6370
  *
6235
- * 它的两个产地都在「进出 Hub」这条路上(建会话、删会话那两条端点)。而改标题
6236
- * (`PATCH /api/sessions/:id`)压根不经过 Hub,只动 DB —— 于是另一个标签页上
6237
- * 那一行的标题仍然会过期到下一次重取。📮 那一档要治得给它自己的产地,
6238
- * 别照着这一帧的名字想当然。
6371
+ * 同一条端点上的另一半:改标题不改变「列哪几段」,只改变某一行上的一格。
6372
+ * 于是另一个标签页上那一行的标题仍然会过期到下一次重取。📮 那一档要治得给它
6373
+ * 自己的产地(一帧带得动「哪一行的哪一格变了」的事件),别顺手挂到这一帧上 ——
6374
+ * 让所有标签页因为别人改了个名字就重取一次整份列表,是拿错误的粒度换方便。
6239
6375
  */
6240
6376
  | {
6241
6377
  type: 'sessions-changed';
@@ -8166,6 +8302,26 @@ interface WireReplayMessage extends EpochMessage {
8166
8302
  * 「这一轮不知道花了多少」处理 —— 界面上那半行字整个不画,而不是画一行 0。
8167
8303
  */
8168
8304
  turnFacts?: EpochTurnFacts;
8305
+ /**
8306
+ * 这一轮**为什么停**。和 {@link WireReplayMessage.turnFacts} 一样,只有一轮里
8307
+ * 最后一条 assistant 行有。
8308
+ *
8309
+ * ## 没有它的时候,刷新一下就再也看不出这一轮出过事(2026-09-16)
8310
+ *
8311
+ * 引擎在**直播**时是说得出话的:截断发一帧 `response-truncated`、跑完工具
8312
+ * 却没说话发一帧 `blank-response`,两者都画成一行 ℹ️。但那些 `notice`
8313
+ * **不落盘**(`TurnStepCollector.observe()` 只收 text / reasoning / tool / finish),
8314
+ * 而回合一结束 web 就拿权威历史整体换掉 pending —— 那一行当场消失。
8315
+ *
8316
+ * 于是用户回头看到的是:一条两个字的回复,底下是一模一样的脚注
8317
+ * 「共 289.2k token · deepseek-flash · 8 次工具 · 42s」,没有任何东西说过
8318
+ * 刚才发生了什么。这一格就是那个记号。
8319
+ *
8320
+ * ⚠️ 类型是 `string` 而不是 {@link FinishReason}:真源是 `messages.finish_reason`
8321
+ * 那一列,它在库里就是 `TEXT`(老库里还可能是 NULL)。读侧要按码分叉的话,
8322
+ * **认不出来的值一律当「不知道」**,别画成任何一档 —— 编一个终局比不画贵。
8323
+ */
8324
+ finishReason?: string;
8169
8325
  }
8170
8326
  interface WireMessageListResponse {
8171
8327
  messages: readonly WireReplayMessage[];
@@ -8196,6 +8352,56 @@ interface WireMessageListResponse {
8196
8352
  * 三种都是「不知道」,界面上一律整格不画 —— 画 0 是一个假事实。
8197
8353
  */
8198
8354
  usage?: TokenUsage;
8355
+ /**
8356
+ * 上面那批 `messages` **含正在跑的那一轮**,而且它的已落盘部分对应到这一帧
8357
+ * 广播序号为止(按步增量落盘,2026-09-16)。缺席 = 这段会话此刻没有在本进程
8358
+ * 跑着的轮次。
8359
+ *
8360
+ * ## 它治的是什么
8361
+ *
8362
+ * 2026-09-16 之前,「回合在跑 ⇒ 这一发答不出本轮那几条」是一条不变量,
8363
+ * 浏览器整个 `replay` 分支建立在它上面(`web/src/state/reducer.ts`)——
8364
+ * 因为一轮问答要等 `run()` 末尾才整批落盘。代价是**刷新 / 关掉重开 / 切走
8365
+ * 再切回来**都看不见正在跑的那一轮,`WebState` 上那两块补丁
8366
+ * (`localTail` / `parked`)就是在浏览器里兜这件事,而它们的注释逐字写着
8367
+ * 「治不到的那一档……真要修得让服务端答出在飞的那一轮」。
8368
+ *
8369
+ * 改成按步落盘之后服务端答得出了,于是冒出**去重**这个新问题:同一步既在
8370
+ * 这批 `messages` 里、又在浏览器自己攒的 pending 里。
8371
+ *
8372
+ * ## 为什么钥匙是 `seq`
8373
+ *
8374
+ * 因为它是两边**已经共用**的那一个:每一帧信封上都有、是 SSE 续传的游标,
8375
+ * 而浏览器早就把它锚进了每一个 pending 项(`HistoryItemBase.seq`)。
8376
+ * 落盘的消息 id 在浏览器那侧不存在,`seq` 是唯一对得上的东西。
8377
+ *
8378
+ * ## ⚠️ 服务端取这个数和截断 `messages` 是**同一瞬**的两半
8379
+ *
8380
+ * 这一格的语义是「`messages` 里的在飞部分**恰好**到这一帧为止」。所以服务端
8381
+ * 记的是一对 `(消息 id, seq)`,两个数在同一次 `publish()` 之后一起取,
8382
+ * 然后按那个 id 截断。分两次取的话会出现「行已经写下去、对应那一帧还没发」
8383
+ * 的窗口,而那个窗口里浏览器会把同一步画两遍。
8384
+ *
8385
+ * 浏览器拿到它之后做两件事:丢掉 `localTail`(用户那句话已经在库里了),
8386
+ * 以及丢掉 `seq <= 这个数` 的 pending 项。
8387
+ */
8388
+ inFlightThroughSeq?: number;
8389
+ /**
8390
+ * 有一轮**没跑完**,它从这条消息 id 开始(按步增量落盘,2026-09-16)。
8391
+ * 缺席 = 没有这种轮次(绝大多数情况)。
8392
+ *
8393
+ * 和上面那一格**互斥**:那一格说「正在跑」,这一格说「跑过但没收尾」。
8394
+ * 两者都由 `sessions.open_turn_from` 那格标记来判,区别只在「这个进程手上
8395
+ * 有没有它」—— 判据全文在 `SessionMeta.openTurnFrom` 上。
8396
+ *
8397
+ * ⚠️ **不能靠 `finishReason` 为空来推断。** 那一列在会话库 v6 之前的行上
8398
+ * 全是 NULL(一台真机上量过 687 条 687 个 NULL),拿它判会把每一段老会话的
8399
+ * 每一轮都标成「没跑完」。
8400
+ *
8401
+ * 界面拿它在那一轮上画一句「这一轮没跑完」。不画的话,用户看到的是一段
8402
+ * 说到一半就断的对话,而分不出「模型自己停了」和「程序被关掉了」。
8403
+ */
8404
+ interruptedTurnFrom?: number;
8199
8405
  }
8200
8406
  declare const WIRE_MESSAGE_LIST_FIELDS: WireFields<WireMessageListResponse>;
8201
8407
  /**
@@ -8290,6 +8496,31 @@ interface WireSendMessageResponse {
8290
8496
  accepted: true;
8291
8497
  /** 这一条被排到了当前这一轮后面,还没开始跑 */
8292
8498
  queued: boolean;
8499
+ /**
8500
+ * `queued: true` 的两种去向里的哪一种(2026-09-16)。
8501
+ *
8502
+ * | 值 | 意思 |
8503
+ * | ------------ | ---------------------------------------------------------- |
8504
+ * | `true` | 正在跑的那一轮会在下一次模型请求前把它拌进去(**插进本轮**)|
8505
+ * | `false` | 排在后面,等这一轮跑完再**开新的一轮** |
8506
+ * | 整个键缺席 | `queued: false`(这条消息立刻开了一轮),问题不成立 |
8507
+ *
8508
+ * ## 为什么必须由服务端说
8509
+ *
8510
+ * 两条判据都在服务端手上:队列里排在它前面的那几条**能不能**折进去,
8511
+ * 以及它自己是不是一条带轮级设置的斜杠命令(`allowed-tools` / `model:`
8512
+ * 那种收窄只对一整轮成立,折进别人的轮里就整个落空)。浏览器两条都看不见。
8513
+ *
8514
+ * ## ⚠️ 它是「会被拌进去」,不是「已经拌进去了」
8515
+ *
8516
+ * 真的进上下文那一刻由事件流上的 `steer` 那一帧宣布。这一轮在那之前撞上轮次
8517
+ * 上限、或者被用户中止时,这条消息会退回队列(开新一轮)或者被
8518
+ * `WireAbortResponse.returned` 交还 —— 那时这一格就说早了。
8519
+ *
8520
+ * 所以界面**据它选措辞**(「插入本轮」/「排队中」)可以,
8521
+ * **据它把消息画成已送达**不行。
8522
+ */
8523
+ steered?: boolean;
8293
8524
  /**
8294
8525
  * 这条输入是一条自定义斜杠命令,服务端把它展开了(方案 23 的 web 那一半)。
8295
8526
  *
@@ -8412,6 +8643,30 @@ declare const WIRE_SEND_MESSAGE_RESPONSE_FIELDS: WireFields<WireSendMessageRespo
8412
8643
  /** `aborted: false` 表示这一轮本来就没在跑,不是失败 */
8413
8644
  interface WireAbortResponse {
8414
8645
  aborted: boolean;
8646
+ /**
8647
+ * 按下停那一刻**还没被引擎取走**的排队消息,按用户发送的先后(2026-09-16)。
8648
+ *
8649
+ * ## 它答的是「我刚打的那段话去哪了」
8650
+ *
8651
+ * 中止一直会把队列一起清掉(判据在 `SessionHub.abort` 上:按下停要的是现在
8652
+ * 别干了,不是停掉这一轮然后立刻开始下一轮)。「跑到一半插一句话」落地之后,
8653
+ * 那条清空变成一个真会咬人的形状 —— 用户盯着屏幕打了一整段修正意见、发出去、
8654
+ * 看见模型还在往错的方向跑,于是顺手按了停,**那段话就没了**。
8655
+ *
8656
+ * 所以原样交出来,宿主把它放回输入框。codex 的同一处叫
8657
+ * `submit_pending_steers_after_interrupt`。
8658
+ *
8659
+ * ## ⚠️ 已经拌进上下文的那些**不在这里**
8660
+ *
8661
+ * 事件流上已经发过 `steer` 的那几条早就是这段对话的一部分(库里有、屏幕上有),
8662
+ * 交还一次就成了重复。这一条不靠服务端判断保证 —— 两条路(引擎取用和这里的
8663
+ * 清空)动的是同一个数组。
8664
+ *
8665
+ * **一条都没有时整个键缺席**,不是空数组:绝大多数中止发生在没有排队消息的
8666
+ * 时候,写一个 `[]` 会让每个读它的人多判一次「空数组是没有,还是这一版
8667
+ * 服务端不支持」。
8668
+ */
8669
+ returned?: EpochUserContent[];
8415
8670
  }
8416
8671
  declare const WIRE_ABORT_FIELDS: WireFields<WireAbortResponse>;
8417
8672
  /**
@@ -10450,6 +10705,83 @@ interface WireRoleAddResponse {
10450
10705
  path: string;
10451
10706
  }
10452
10707
  declare const WIRE_ROLE_ADD_RESPONSE_FIELDS: WireFields<WireRoleAddResponse>;
10708
+ /**
10709
+ * 一次删除为什么没成 —— 五档,**每一档对一个不同的下一步**。
10710
+ *
10711
+ * |   | 意思 | 用户的下一步 |
10712
+ * | ------------- | -------------------------------------- | ------------------------------------- |
10713
+ * | `missing` | 请求里没给名字 | (界面不该发出这一发,见下) |
10714
+ * | `not-found` | 角色表里没有这个名字 | 刷新这一栏再看,多半已经没了 |
10715
+ * | `not-user` | 它来自内置 / 项目 / 插件 / 宿主 | 那句话里点名了是哪一层,各有各的删法 |
10716
+ * | `ambiguous` | 目录里两份以上都叫这个名字 | 去那个目录里留下要的那一份 |
10717
+ * | `failed` | 真的删不动(权限 / 只读盘) | 去那条路径那儿看一眼 |
10718
+ *
10719
+ * ⚠️ `missing` 那一档**照走 200 + `ok:false`**,不是 400 —— 判据逐字同
10720
+ * {@link WireSkillRemoveFailure}:`{error:{code,message}}` 在界面上会走到
10721
+ * 「请求坏了」那一支去。
10722
+ */
10723
+ type WireRoleRemoveFailure = 'missing' | 'not-found' | 'not-user' | 'ambiguous' | 'failed';
10724
+ /**
10725
+ * `POST /api/roles/remove` —— 删掉一个**用户级**身份。
10726
+ *
10727
+ * 请求体只有 `{name}`,所以不给它单开一个类型(同 `WireSkillRemoveResponse`
10728
+ * 那条:一个只有一格的请求体类型,读者要多跳一次才知道它就是那一格)。
10729
+ *
10730
+ * ## ⚠️ 没有「预览 → 确认」那一对,而那不是省事
10731
+ *
10732
+ * 判据逐字借技能那条:导入那个 token 防的是「用户看过的那一份」和「真装上的
10733
+ * 那一份」不是一份,而删除这边**要删掉的东西由名字唯一确定**。
10734
+ * 挡住不可撤销的是另外两样:界面上那一步「再按一次确认」,和回执里的
10735
+ * {@link path} —— 用户得看得见自己刚销毁了盘上的哪一处。
10736
+ *
10737
+ * ⚠️ 而身份这条比技能那条多一格 {@link takenOverBy},理由见那一格。
10738
+ */
10739
+ interface WireRoleRemoveResponse {
10740
+ ok: boolean;
10741
+ /**
10742
+ * 谁真的没了。**没删成时是空串**,不是把请求里那个名字抄回去 ——
10743
+ * 判据逐字同 `WireSkillRemoveResponse.name`:回一个其实还在盘上的名字,
10744
+ * 是这条端点最坏的说谎方式。
10745
+ */
10746
+ name: string;
10747
+ /**
10748
+ * 刚刚被销毁的是盘上哪一份(服务端的绝对路径)。没删成时 `null`。
10749
+ *
10750
+ * **必须发出来**,判据同技能那条(不可撤销的动作,回执得说得出销毁了哪一处)。
10751
+ * ⚠️ 而这条路上它还多一层意思:**文件名可以和身份名不一样**
10752
+ * (frontmatter 的 `name` 优先于文件名,判据在 core 的 `removeRoleFile`
10753
+ * 文件头第一节)—— 所以这条路径是用户**唯一**能核对「我删掉的是不是那一份」
10754
+ * 的东西,浏览器自己拼不出来。
10755
+ */
10756
+ path: string | null;
10757
+ /**
10758
+ * 删完之后,**这个名字还在不在合并表里**(`null` = 真的没了)。
10759
+ *
10760
+ * ## ⚠️ 这一格答的是「那一行会不会消失」,而答案常常是「不会」
10761
+ *
10762
+ * 用户级那一份可能是一个**同名覆盖**(手写一份 `general.md` 就是,
10763
+ * `mergeRoles` 的同名语义是「prompt / description 整份替换、工具取交集」)。
10764
+ * 删掉它之后下层那一份**浮上来**:能力页刷新出来,那一行**还在**,只是挪去了
10765
+ * 「内置」那一组、描述和工具都变了。
10766
+ *
10767
+ * 少了这一格,那一屏对用户说的是「已经删掉 x」而列表里 x 还在 —— 一次成功的
10768
+ * 删除长成了一次失败的删除,而屏幕上没有任何地方解释得了。
10769
+ *
10770
+ * 非 `null` 时它是**接管者那一层**(`builtin` / `project` / `plugin` / `host`),
10771
+ * 界面据此多印一句。⚠️ 它**不可能**是 `'user'`:用户级那一份刚被删掉,
10772
+ * 而同一个目录里还有第二份同名的话,上面那道 `ambiguous` 已经把这一发拦掉了。
10773
+ */
10774
+ takenOverBy: AgentRoleSource | null;
10775
+ /**
10776
+ * 没删成时那一句话(`reason` 各配一句,含「那该去哪儿改」)。删成了是 `null`。
10777
+ *
10778
+ * ⚠️ 它在 **core / runtime** 里现渲染,所以 `lang` 要从服务端一路递下去 ——
10779
+ * 这条病 2026-08-18 在导入那一屏上实测见过一次。
10780
+ */
10781
+ detail: string | null;
10782
+ reason: WireRoleRemoveFailure | null;
10783
+ }
10784
+ declare const WIRE_ROLE_REMOVE_FIELDS: WireFields<WireRoleRemoveResponse>;
10453
10785
 
10454
10786
  /**
10455
10787
  * 插件那一页的契约 —— 十四条端点(方案 59 §六 E2,2026-08-21;本地源与市场管理
@@ -12778,6 +13110,16 @@ declare const WIRE_PERMISSION_SET_RESPONSE_FIELDS: WireFields<WirePermissionSetR
12778
13110
  * 新值,就是这一屏存在的理由本身要消灭的那种撒谎 —— 而这一条对 `new-session`
12779
13111
  * 那一档**同样成立**(正在跑的会话按的仍是旧值),只是它下一次 `GET` 会真的变。
12780
13112
  *
13113
+ * ## ✅ 2026-09-16:值照旧不变,但**盘上是什么现在也发出来了**
13114
+ *
13115
+ * 上面那一条一个字都没改,改的是它**只说了一半**这件事:行上的值不动是对的,
13116
+ * 可那句「已写进 X,重启后生效」当时只活在浏览器的一个组件 state 里 —— 切一次分区、
13117
+ * 刷一次页面就没了,屏幕上回到「旧值 + ① 内置默认」,而那个文件里躺着用户刚写的值。
13118
+ * 用户的原话是「改完了保存了,页面也没有展示新的数字,不知道有没有生效」。
13119
+ *
13120
+ * 所以现在每一行可以多带一格 {@link WireSettingRow.pending}:**盘上那一份**。
13121
+ * 两格并排、各答各的问题,没有哪一格需要撒谎。
13122
+ *
12781
13123
  * ## 会话作用域,不是进程级的 `/api/settings`
12782
13124
  *
12783
13125
  * 项目级那两层(③④)跟着**工作区**走,而工作区按会话绑(决定 18)。
@@ -12956,6 +13298,54 @@ interface WireSettingStep {
12956
13298
  value?: WireSettingValue;
12957
13299
  }
12958
13300
  declare const WIRE_SETTING_STEP_FIELDS: WireFields<WireSettingStep>;
13301
+ /**
13302
+ * **已经写进盘、但这个进程还没在用**的那一次改动(2026-09-16)。
13303
+ *
13304
+ * ## 它补的是上面那条「值不变」留下的另一半
13305
+ *
13306
+ * `restart` 那一档写完,行上的 {@link WireSettingRow.value} 一个字都不变 —— 那是对的
13307
+ * (判据在 {@link WireSettingApply},把它乐观换成新值是这一屏存在的理由本身要消灭的
13308
+ * 那种撒谎)。可**只做到这一步,屏幕上就一条线索都不剩了**:用户按完保存、切一次
13309
+ * 分区再回来,看到的是「旧值 + ① 内置默认」,而 `~/.epoch/config.yaml` 里明明躺着他
13310
+ * 刚写的那个值 —— 不但没人告诉他写成了,那枚来源徽标还在说一句会被文件反驳的话。
13311
+ *
13312
+ * 所以这一格发的是**盘上那一份**,和行上那个「进程正在用的」并排摆着。两件事从来
13313
+ * 就不是一件事,这一轮之前只是没地方说出后者。
13314
+ *
13315
+ * ## ⚠️ 它只认「**这个进程**里有人按过保存」
13316
+ *
13317
+ * 服务端记的是一本进程内的账(runtime 的 `SettingsControl.write()` 每成功一次记一笔),
13318
+ * **不是**每次 `GET` 去重读那几个文件。于是 `epoch config set` 和用户自己拿编辑器改
13319
+ * `config.yaml` 这两条路**不会**在这里出现 —— 那是这个取舍写在脸上的代价,别把它
13320
+ * 读成 bug。重读那条路要重走一遍配置解析,还要分辨 `EPOCH_*` 环境变量造成的
13321
+ * 「文件值和进程快照天然不同」,而那种差别报出来全是假的 pending。
13322
+ *
13323
+ * ## 三件让这一格不至于自己变成假话的事
13324
+ *
13325
+ * 1. **和生效值相等时整个不带这个键。** 用户改完又改回原值,盘上和进程就一致了,
13326
+ * 那时还挂着一句「待重启」是自己造出来的焦虑;
13327
+ * 2. **`value` 是落盘后回读验证过的那个**,不是把请求原样回显(同
13328
+ * {@link WireSettingsWriteResponse.value});
13329
+ * 3. **`futile` / `apply` 是现算的**,不是记账那一刻拍下来的 —— 它们必须和这一行此刻
13330
+ * 那个 {@link WireSettingWrite} 说的是同一句话,否则行上的警告和行下的回执会打架。
13331
+ *
13332
+ * 字段和 {@link WireSettingsWriteResponse} 逐字相同,那不是巧合:「刚刚写完的那一次」
13333
+ * 和「写过了还没生效的那一次」是同一件事的两个时刻。所以那一份**继承这一份**,
13334
+ * 界面也只有一个渲染回执的地方。
13335
+ */
13336
+ interface WireSettingPending {
13337
+ /** 写进了哪一层 */
13338
+ layer: WireSettingWriteLayer;
13339
+ /** 真正写了的那个文件的绝对路径,含义同 {@link WireSettingWrite.path} */
13340
+ path: string;
13341
+ /** **盘上现在**是这个值。行上那个 `value` 是进程正在用的,两者此刻不同 */
13342
+ value: WireSettingValue;
13343
+ /** 写进去了也照样不生效的理由。生效时整个不带,含义同 {@link WireSettingWrite.futile} */
13344
+ futile?: WireSettingFutile;
13345
+ /** 要做什么它才会被用上,见 {@link WireSettingApply} */
13346
+ apply: WireSettingApply;
13347
+ }
13348
+ declare const WIRE_SETTING_PENDING_FIELDS: WireFields<WireSettingPending>;
12959
13349
  /** 设置页上的一行 */
12960
13350
  interface WireSettingRow {
12961
13351
  /**
@@ -13019,6 +13409,18 @@ interface WireSettingRow {
13019
13409
  * 的话,schema 哪天多一档,那一档在文档里存在、在界面上点不出来。
13020
13410
  */
13021
13411
  choices?: readonly string[];
13412
+ /**
13413
+ * 这一行**已经写进盘、而这个进程还没在用**的那个值(2026-09-16)。
13414
+ * **没有这回事时整个不带这个键**,同 `value` / `futile` 那条规矩。
13415
+ *
13416
+ * ⚠️ **它不是 `value` 的候补,两格必须同时读。** `value` 答的是「这个进程此刻按的
13417
+ * 是什么」,这一格答的是「盘上现在是什么」—— 界面拿前者画那一行、拿后者在行上挂一枚
13418
+ * 芯片加一句回执。把后者显示成前者,就退回成了 {@link WireSettingApply} 里那条判据
13419
+ * 明令禁止的乐观更新。
13420
+ *
13421
+ * 整个契约在 {@link WireSettingPending} 上,包括「为什么它照不到 `epoch config set`」。
13422
+ */
13423
+ pending?: WireSettingPending;
13022
13424
  }
13023
13425
  declare const WIRE_SETTING_ROW_FIELDS: WireFields<WireSettingRow>;
13024
13426
  interface WireSettingsResponse {
@@ -13106,29 +13508,19 @@ declare const WIRE_SETTINGS_WRITE_REQUEST_FIELDS: WireFields<WireSettingsWriteRe
13106
13508
  *
13107
13509
  * 值真的变没变由下一次 `GET` 说(`model` 走 `new-session` 那一档时它会跟着动,
13108
13510
  * 判据在 {@link WireSettingApply})。
13511
+ *
13512
+ * ## ⚠️ 它**继承** {@link WireSettingPending},多出来的只有一个 `key`(2026-09-16)
13513
+ *
13514
+ * 那一份是「写过了、还没生效」,这一份是「刚刚写完」—— 同一件事的两个时刻,
13515
+ * 于是字段逐字相同。声明成两个独立的接口的话,哪天有人往其中一边加一格,
13516
+ * 界面上「刚保存完看到的」和「刷新之后看到的」就会分叉,而那种分叉在屏幕上
13517
+ * 看起来只是「刷新一下少了点东西」,没人会报这个 bug。
13518
+ *
13519
+ * `key` 只在这一份上,因为那一份是**挂在行上的**({@link WireSettingRow.pending}),
13520
+ * 键已经由那一行说了 —— 再发一遍就是同一个问题的两个答案。
13109
13521
  */
13110
- interface WireSettingsWriteResponse {
13522
+ interface WireSettingsWriteResponse extends WireSettingPending {
13111
13523
  key: string;
13112
- layer: WireSettingWriteLayer;
13113
- /** 真正写了的那个文件 */
13114
- path: string;
13115
- /**
13116
- * **校验之后**落盘的那个值,不是把请求原样回显。
13117
- *
13118
- * 两者在 zod 修剪过的输入上会不一样,而界面印的必须是文件里现在那个 ——
13119
- * 回显请求等于让屏幕上出现一个文件里没有的东西。
13120
- */
13121
- value: WireSettingValue;
13122
- /**
13123
- * 落盘之后**仍然**不生效的理由,含义同 {@link WireSettingWrite.futile}。
13124
- *
13125
- * 写之前那一份已经说过一次了,这里再算一次不是重复:用户是看着警告按下去的
13126
- * (那是他的权利 —— 把用户级默认值钉住、由项目本地临时压着是件正常事),
13127
- * 所以回执里必须把同一句话再说一遍,否则「保存成功」四个字会盖掉它。
13128
- */
13129
- futile?: WireSettingFutile;
13130
- /** 见 {@link WireSettingApply} */
13131
- apply: WireSettingApply;
13132
13524
  }
13133
13525
  declare const WIRE_SETTINGS_WRITE_FIELDS: WireFields<WireSettingsWriteResponse>;
13134
13526
 
@@ -14907,4 +15299,4 @@ declare const SECRET_SERVICE = "epoch-agent";
14907
15299
  */
14908
15300
  declare const MCP_DATA_KEY_NAME = "mcp-auth-data-key";
14909
15301
 
14910
- export { ACTION_CONTEXTS, AGENT_ROLE_NAME_PATTERN, API_KEY_ENV_VARS, APPROVAL_OUTCOMES, type ActiveAgentRole, type AgentEvent, type AgentEventOf, type AgentEventType, type AgentRole, type AgentRoleScope, type AgentRoleSource, type ApprovalAnswer, type ApprovalOutcome, type ApprovalReply, type ApprovalRequest, type ArtifactKind, type AttributeValue, type Attributes, type BackgroundTaskInfo, type BackgroundTaskKind, type BackgroundTaskStatus, type BlankCard, type BlankGreeting, type BoundKey, type BudgetBreach, COMMAND_NAME_PATTERN, COMPLIANCE_ACTIONS, COMPLIANCE_CATEGORIES, CURRENCIES, type Catalogs, type ComplianceAction, type ComplianceCategory, type ContextBreakdown, type ContextSegment, type Currency, type CurrencyDisplay, type CustomCommandDef, type CustomCommandSource, DEFAULT_AGENT_ROLE, DEFAULT_LANG, type DecidedTrustLevel, type Diagnostic, DiagnosticSink, type DiagnosticStatus, type EpochConfig, type EpochContentPart, type EpochFilePart, type EpochHook, type EpochImagePart, type EpochMessage, type EpochMessageRole, type EpochPlugin, type EpochSessionPart, type EpochSkill, type EpochTextPart, type EpochTool, type EpochToolCall, type EpochToolResult, type EpochTurnFacts, type EpochUserContent, type ExpandedCommand, type FileOmitReason, type FinishReason, type FixableApproval, type FlatCatalog, type FormatMoneyOptions, GEN_AI, HEADLESS_INPUT_EVENT_TYPES, HEADLESS_INPUT_FORMATS, HEADLESS_OUTPUT_FORMATS, HEADLESS_PROTOCOL_VERSION, type HeadlessEnvelope, type HeadlessEvent, type HeadlessInit, type HeadlessInputEvent, type HeadlessInputEventType, type HeadlessInputFormat, type HeadlessLifecycleEvent, type HeadlessOutputFormat, type HeadlessResult, type HeadlessResultReason, type HookContext, type HookEvent, type HookHandler, type HookManager, type HookRegistration, type HookType, type HostText, type HostUser, type JsonSchema, KEY_ACTIONS, type KeyAction, type KeyChord, type KeyContext, type KeySequence, type KeybindingReport, type KeybindingSource, type KeybindingTable, LANGS, type Lang, type LocalizedDetail, type LogFn, MAX_ATTACH_BYTES, MAX_ATTACH_FILES, MAX_ATTACH_TOTAL_BYTES, MAX_HEADER_CHARS, MAX_OPTIONS, MAX_QUESTIONS, MAX_SESSION_ATTACH_BYTES, MAX_SESSION_REFS, MCP_DATA_KEY_NAME, METRIC, MIN_OPTIONS, type MentionSet, type ModelRef, type ModelSelection, type ModelSelectionOrigin, type MoneyDigits, NOOP_TELEMETRY, type NoticeCode, OPERATION_TYPES, OVERSIZE_RATIO, type Operation, type OperationType, PERMISSION_LEVELS, PLAN_OUTCOMES, PROVIDER_INFOS, PROVIDER_TYPES, type PermissionDecision, type PermissionLevel, type PermissionManager, type PermissionRuleLists, type PlanApprovalOutcome, type PlanProposal, type PluginContext, type ProviderInfo, type ProviderType, type QuestionAnswer, type QuestionItem, type QuestionOption, type QuestionRequest, RESERVED_CHORDS, RESERVED_COMMAND_NAMES, type RejectReason, type RejectedKey, type RuleValue, SCHEDULE_DRAFT_TOOL, SCHEDULE_INTERVAL_MINUTES, SCHEDULE_RUN_STATUSES, SCHEDULE_TEMPLATES, SEARCH_API_KEY_ENV, SEARCH_PROVIDER_TYPES, SECRET_SERVICE, SEQUENCE_CONTEXTS, SESSION_MENTION_PREFIX, SESSION_REFERENCE_ERROR_CODES, SHELL_KINDS, SIDEBAR_MENU_MESSAGE_TYPE, SIMULTANEOUS_CONTEXTS, SYSTEM_NOTE_PREFIX, type ScheduleAllowlist, type ScheduleBackendKind, type ScheduleCycle, type ScheduleDefinition, type ScheduleDraft, type ScheduleDraftAllowlist, type ScheduleDraftCreate, type ScheduleDraftPermission, type ScheduleDraftUpdate, type SchedulePendingApproval, type SchedulePendingItem, type ScheduleRun, type ScheduleRunStatus, type ScheduleRunnerSpec, type ScheduleTemplate, type ScheduleTemplateId, type ScheduleTrigger, type ScheduleTriggerKind, type ScheduleWeekday, type ScheduleWideningKind, type SearchProviderType, type SecretBackendId, type SecretStore, type SelectionContext, type SelectionRejection, type SelectionWarning, type SessionAttachKind, type SessionAttachResult, type SessionOmitReason, type SessionSurfaceMessage, type SessionSurfaceView, type SetSelectionResult, type ShellKind, type SidebarMenuItem, type SidebarMenuMessage, type SpanLike, type SubToolCall, type SubToolCallResult, type Telemetry, type TokenUsage, type ToolAnnotations, type ToolArtifact, type ToolArtifactInput, type ToolContext, type ToolDefinition, ToolExposure, type ToolProvider, type ToolResult, type Translate, type TranslateVars, type TrustExecGrant, type TrustGrant, type TrustLevel, type TrustManager, type TrustRecord, type TrustScope, type TrustTier, UNLIMITED_TURNS, type VerifyResult, WIRE_ABORT_FIELDS, WIRE_ABOUT_FIELDS, WIRE_AGENT_ROLE_FIELDS, WIRE_APPROVAL_CACHE_REVOKE_REQUEST_FIELDS, WIRE_APPROVAL_CACHE_REVOKE_RESPONSE_FIELDS, WIRE_APPROVAL_LIST_FIELDS, WIRE_APPROVAL_ROW_FIELDS, WIRE_ARTIFACTS_FIELDS, WIRE_ATTACHMENT_UPLOAD_FIELDS, WIRE_AUDIT_LOG_FIELDS, WIRE_AUDIT_ROW_FIELDS, WIRE_AUTH_COOKIE, WIRE_AUTH_COOKIE_ATTRS, WIRE_AUTH_TOKEN_PARAM, WIRE_BACKGROUND_TASK_FIELDS, WIRE_BLOCKED_GOALS_FIELDS, WIRE_BLOCKED_GOAL_FIELDS, WIRE_BLOCKED_LEVEL_FIELDS, WIRE_BUILTIN_CONNECTOR_FIELDS, WIRE_CACHED_APPROVAL_FIELDS, WIRE_CAPABILITIES_FIELDS, WIRE_CAPABILITY_PROJECT_FIELDS, WIRE_CHECKPOINT_LIST_FIELDS, WIRE_CHECKPOINT_SUMMARY_FIELDS, WIRE_COMMANDS_FIELDS, WIRE_COMMAND_EXPANSION_FIELDS, WIRE_COMPACT_FIELDS, WIRE_COMPRESSION_LINE_FIELDS, WIRE_CONFIG_FIELDS, WIRE_CONTEXT_FIELDS, WIRE_CREATE_SESSION_FIELDS, WIRE_CREATE_WORKSPACE_FIELDS, WIRE_CREATE_WORKSPACE_RESPONSE_FIELDS, WIRE_CUSTOM_COMMAND_FIELDS, WIRE_DELETE_SESSION_FIELDS, WIRE_DIFF_FILE_FIELDS, WIRE_ERROR_FIELDS, WIRE_FILE_CANDIDATES_FIELDS, WIRE_FILE_CANDIDATE_FIELDS, WIRE_FILE_CHANGE_FIELDS, WIRE_FILE_OPEN_RESPONSE_FIELDS, WIRE_FILE_REFERENCE_FIELDS, WIRE_FILE_STAT_MAX, WIRE_FILE_STAT_OK_FIELDS, WIRE_FILE_STAT_REFUSED_FIELDS, WIRE_FILE_STAT_RESPONSE_FIELDS, WIRE_FILE_TEXT_RESPONSE_FIELDS, WIRE_FORGET_WORKSPACE_FIELDS, WIRE_FORGET_WORKSPACE_RESPONSE_FIELDS, WIRE_GOAL_ACTIONS, WIRE_GOAL_BLOCK_FIELDS, WIRE_GOAL_CLEAR_FIELDS, WIRE_GOAL_CREATE_FIELDS, WIRE_GOAL_FIELDS, WIRE_GOAL_LIMITS_FIELDS, WIRE_GOAL_PHASES, WIRE_GOAL_REFUSALS, WIRE_GOAL_RESPONSE_FIELDS, WIRE_GOAL_WRITE_FIELDS, WIRE_HEALTH_FIELDS, WIRE_IMAGE_REFERENCE_FIELDS, WIRE_KNOWN_WORKSPACE_FIELDS, WIRE_LANG_PARAM, WIRE_LAST_SESSION_FIELDS, WIRE_LAST_SESSION_RESPONSE_FIELDS, WIRE_MANAGED_LOCK_FIELDS, WIRE_MCP_ADD_FIELDS, WIRE_MCP_ADD_RESPONSE_FIELDS, WIRE_MCP_APPLY_ENTRY_FIELDS, WIRE_MCP_APPLY_RESPONSE_FIELDS, WIRE_MCP_CONFIG_APPLY_FIELDS, WIRE_MCP_CONFIG_FIELDS, WIRE_MCP_CONFIG_ISSUE_FIELDS, WIRE_MCP_CONFIG_SAVE_FIELDS, WIRE_MCP_CONFIG_SAVE_RESPONSE_FIELDS, WIRE_MCP_CONNECTOR_FIELDS, WIRE_MCP_RECONNECT_FIELDS, WIRE_MESSAGE_LIST_FIELDS, WIRE_MODEL_FIELDS, WIRE_MODEL_SELECTION_FIELDS, WIRE_MODEL_SET_FIELDS, WIRE_MODEL_SUGGESTIONS_FIELDS, WIRE_PATCH_SESSION_FIELDS, WIRE_PATCH_SESSION_RESPONSE_FIELDS, WIRE_PERMISSION_RULE_FIELDS, WIRE_PERMISSION_SET_REQUEST_FIELDS, WIRE_PERMISSION_SET_RESPONSE_FIELDS, WIRE_PERMISSION_SHADOW_FIELDS, WIRE_PERMISSION_STATE_FIELDS, WIRE_PERMISSION_STATUS_FIELDS, WIRE_PLAN_ACTIONS, WIRE_PLAN_FIELDS, WIRE_PLAN_MODE_REQUEST_FIELDS, WIRE_PLAN_MODE_RESPONSE_FIELDS, WIRE_PLAN_MODE_STATE_FIELDS, WIRE_PLUGINS_FIELDS, WIRE_PLUGIN_ACTION_FIELDS, WIRE_PLUGIN_ARCHIVE_SUFFIXES, WIRE_PLUGIN_INSTALL_FIELDS, WIRE_PLUGIN_LOCAL_FIELDS, WIRE_PLUGIN_LOCAL_INSTALL_FIELDS, WIRE_PLUGIN_MARKETPLACE_FIELDS, WIRE_PLUGIN_MARKETPLACE_RESPONSE_FIELDS, WIRE_PLUGIN_NAME_FIELDS, WIRE_PLUGIN_PREVIEW_FIELDS, WIRE_PLUGIN_PREVIEW_RESPONSE_FIELDS, WIRE_PLUGIN_REFRESH_RESPONSE_FIELDS, WIRE_PLUGIN_STAGE_FIELDS, WIRE_PLUGIN_STAGE_RESPONSE_FIELDS, WIRE_PLUGIN_UPDATE_FIELDS, WIRE_PLUGIN_UPLOAD_CONFIRM_FIELDS, WIRE_PLUGIN_UPLOAD_FIELDS, WIRE_PLUGIN_UPLOAD_FORM_FIELDS, WIRE_PLUGIN_UPLOAD_PREVIEW_RESPONSE_FIELDS, WIRE_PLUGIN_UPLOAD_RESPONSE_FIELDS, WIRE_POLICY_DIR_FIELDS, WIRE_POLICY_STATUS_FIELDS, WIRE_PROJECT_INVENTORY_FIELDS, WIRE_PROVIDERS_FIELDS, WIRE_PROVIDER_KEY_FIELDS, WIRE_PROVIDER_MODELS_FIELDS, WIRE_PROVIDER_OPTION_FIELDS, WIRE_QUESTION_LIST_FIELDS, WIRE_QUESTION_ROW_FIELDS, WIRE_RESPOND_APPROVAL_FIELDS, WIRE_RESPOND_APPROVAL_RESPONSE_FIELDS, WIRE_RESPOND_QUESTION_FIELDS, WIRE_RESPOND_QUESTION_RESPONSE_FIELDS, WIRE_REWIND_FILE_PLAN_FIELDS, WIRE_REWIND_OUTCOME_FIELDS, WIRE_REWIND_PREVIEW_FIELDS, WIRE_REWIND_REQUEST_FIELDS, WIRE_REWIND_RESPONSE_FIELDS, WIRE_REWIND_SCOPES, WIRE_ROLE_ADD_FIELDS, WIRE_ROLE_ADD_RESPONSE_FIELDS, WIRE_RUNNING_SCHEDULE_FIELDS, WIRE_SANDBOX_STATUS_FIELDS, WIRE_SCHEDULE_CAPABILITY_FIELDS, WIRE_SCHEDULE_CREATE_FIELDS, WIRE_SCHEDULE_DEFAULTS_FIELDS, WIRE_SCHEDULE_DELETE_FIELDS, WIRE_SCHEDULE_FIELDS, WIRE_SCHEDULE_FIX_FIELDS, WIRE_SCHEDULE_FIX_REQUEST_FIELDS, WIRE_SCHEDULE_ISSUE_CODES, WIRE_SCHEDULE_LIST_FIELDS, WIRE_SCHEDULE_PENDING_FIELDS, WIRE_SCHEDULE_RECORDING_FIELDS, WIRE_SCHEDULE_ROW_FIELDS, WIRE_SCHEDULE_RUNNING_FIELDS, WIRE_SCHEDULE_RUNS_FIELDS, WIRE_SCHEDULE_RUN_FIELDS, WIRE_SCHEDULE_SAVE_FIELDS, WIRE_SECURITY_FIELDS, WIRE_SECURITY_WORKSPACE_FIELDS, WIRE_SEND_MESSAGE_FIELDS, WIRE_SEND_MESSAGE_RESPONSE_FIELDS, WIRE_SESSION_CANDIDATES_FIELDS, WIRE_SESSION_CANDIDATE_FIELDS, WIRE_SESSION_FINISH_FIELDS, WIRE_SESSION_LIST_FIELDS, WIRE_SESSION_REFERENCE_FIELDS, WIRE_SESSION_RESPONSE_FIELDS, WIRE_SESSION_SUMMARY_FIELDS, WIRE_SESSION_WORKSPACE_FIELDS, WIRE_SESSION_WORKSPACE_RESPONSE_FIELDS, WIRE_SETTINGS_FIELDS, WIRE_SETTINGS_WRITE_FIELDS, WIRE_SETTINGS_WRITE_REQUEST_FIELDS, WIRE_SETTING_ROW_FIELDS, WIRE_SETTING_STEP_FIELDS, WIRE_SETTING_WRITE_FIELDS, WIRE_SETTING_WRITE_LAYERS, WIRE_SKILL_BODY_FIELDS, WIRE_SKILL_DROP_SUFFIXES, WIRE_SKILL_FIELDS, WIRE_SKILL_IMPORT_FIELDS, WIRE_SKILL_IMPORT_PREVIEW_FIELDS, WIRE_SKILL_REMOVE_FIELDS, WIRE_SKILL_STAGE_FIELDS, WIRE_TASKS_FIELDS, WIRE_TOOLS_FIELDS, WIRE_TOOL_GATE_FIELDS, WIRE_TOOL_ROW_FIELDS, WIRE_TRUST_REQUEST_FIELDS, WIRE_TRUST_REQUEST_RESPONSE_FIELDS, WIRE_WORKSPACE_DIFF_FIELDS, WIRE_WORKSPACE_DIRS_FIELDS, WIRE_WORKSPACE_DIR_ENTRY_FIELDS, WIRE_WORKSPACE_REF_FIELDS, type WireAbortResponse, type WireAbout, type WireAgentEnvelope, type WireAgentEvent, type WireAgentRoleSummary, type WireApprovalCacheRevokeRequest, type WireApprovalCacheRevokeResponse, type WireApprovalListResponse, type WireApprovalRequest, type WireArtifactsResponse, type WireAttachmentUploadResponse, type WireAuditCode, type WireAuditLog, type WireAuditOutcome, type WireAuditRow, type WireAuthDenyReason, type WireAuthDenyStatus, type WireAuthGuard, type WireAuthRequestView, type WireAuthVerdict, type WireBackgroundTask, type WireBindWorkspaceRequest, type WireBlockedGoal, type WireBlockedGoalsResponse, type WireBlockedLevel, type WireBuiltinConnector, type WireCachedApproval, type WireCachedDecision, type WireCapabilitiesResponse, type WireCapabilityProject, type WireCheckpointListResponse, type WireCheckpointSummary, type WireCommandExpansion, type WireCommandsResponse, type WireCompactRequest, type WireCompactResponse, type WireCompressionLine, type WireConfigResponse, type WireContextResponse, type WireCreateSessionRequest, type WireCreateSessionResponse, type WireCreateWorkspaceRequest, type WireCreateWorkspaceResponse, type WireCustomCommand, type WireDeleteSessionResponse, type WireDiffFile, type WireDiffNote, type WireDiffOmitReason, type WireDiffSource, type WireDiffStatus, type WireEnvelope, type WireErrorResponse, type WireEvent, type WireFields, type WireFileCandidate, type WireFileCandidatesResponse, type WireFileChange, type WireFileKind, type WireFileOpenRefusal, type WireFileOpenResponse, type WireFileReference, type WireFileRefusal, type WireFileStat, type WireFileStatOk, type WireFileStatRefused, type WireFileStatResponse, type WireFileTextResponse, type WireFixture, type WireFixtureCase, type WireForgetWorkspaceRequest, type WireForgetWorkspaceResponse, type WireGoal, type WireGoalBlock, type WireGoalClearResponse, type WireGoalCreateRequest, type WireGoalLimits, type WireGoalPhase, type WireGoalRefusal, type WireGoalResponse, type WireGoalUpdateRequest, type WireGoalWriteResponse, type WireHealthResponse, type WireHubEnvelope, type WireHubEvent, type WireImageReference, type WireKnownWorkspace, type WireKnownWorkspaceRef, type WireLastSessionRequest, type WireLastSessionResponse, type WireManagedLock, type WireMcpAddRequest, type WireMcpAddResponse, type WireMcpApplyAction, type WireMcpApplyEntry, type WireMcpApplyResponse, type WireMcpConfigApplyRequest, type WireMcpConfigIssue, type WireMcpConfigResponse, type WireMcpConfigSaveRequest, type WireMcpConfigSaveResponse, type WireMcpConnector, type WireMcpReconnectResponse, type WireMessageListResponse, type WireModelCaveat, type WireModelProbeStatus, type WireModelRejection, type WireModelResponse, type WireModelSelection, type WireModelSetRequest, type WireModelSetResponse, type WireModelSource, type WireModelSuggestions, type WirePatchSessionRequest, type WirePatchSessionResponse, type WirePermissionRememberFailure, type WirePermissionRuleRow, type WirePermissionSetRequest, type WirePermissionSetResponse, type WirePermissionShadow, type WirePermissionState, type WirePermissionStatus, type WirePickDirectoryResponse, type WirePlanAction, type WirePlanModeRequest, type WirePlanModeResponse, type WirePlanModeState, type WirePlanResponse, type WirePluginActionResponse, type WirePluginCounts, type WirePluginEntry, type WirePluginHit, type WirePluginHookTally, type WirePluginInstallRequest, type WirePluginLocalInstallRequest, type WirePluginLocalRequest, type WirePluginMarketplace, type WirePluginMarketplaceRequest, type WirePluginMarketplaceResponse, type WirePluginNameRequest, type WirePluginPreview, type WirePluginPreviewRequest, type WirePluginPreviewResponse, type WirePluginRefreshResponse, type WirePluginRefuseReason, type WirePluginSourceType, type WirePluginStageRequest, type WirePluginStageResponse, type WirePluginStatus, type WirePluginUpdateRequest, type WirePluginUploadChoice, type WirePluginUploadConfirmRequest, type WirePluginUploadField, type WirePluginUploadFieldName, type WirePluginUploadFields, type WirePluginUploadFile, type WirePluginUploadForm, type WirePluginUploadPlan, type WirePluginUploadPreviewResponse, type WirePluginUploadRequest, type WirePluginUploadResponse, type WirePluginVisibility, type WirePluginsResponse, type WirePolicyDir, type WirePolicyStatus, type WireProjectInventory, type WireProviderKeyRequest, type WireProviderKeyResponse, type WireProviderModelsRequest, type WireProviderModelsResponse, type WireProviderOption, type WireProviderSummary, type WireProvidersResponse, type WireQuestionListResponse, type WireQuestionRequest, type WireReplayMessage, type WireResetReason, type WireRespondApprovalRequest, type WireRespondApprovalResponse, type WireRespondQuestionRequest, type WireRespondQuestionResponse, type WireRewindAction, type WireRewindDrift, type WireRewindFilePlan, type WireRewindOutcome, type WireRewindPreview, type WireRewindRequest, type WireRewindResponse, type WireRewindScope, type WireRoleAddRequest, type WireRoleAddResponse, type WireRunningSchedule, type WireSandboxReason, type WireSandboxStatus, type WireScheduleCapability, type WireScheduleCreateRequest, type WireScheduleDefaults, type WireScheduleDeleteResponse, type WireScheduleFixRequest, type WireScheduleFixResponse, type WireScheduleIssue, type WireScheduleIssueCode, type WireScheduleListResponse, type WireSchedulePendingResponse, type WireScheduleRecordingResponse, type WireScheduleRegisterRefusal, type WireScheduleRegisterWarning, type WireScheduleRegistration, type WireScheduleResponse, type WireScheduleRow, type WireScheduleRunResponse, type WireScheduleRunningResponse, type WireScheduleRunsResponse, type WireScheduleSaveResponse, type WireScheduleUpdateRequest, type WireSecurityResponse, type WireSecurityWorkspace, type WireSendMessageRequest, type WireSendMessageResponse, type WireSessionCandidate, type WireSessionCandidatesResponse, type WireSessionFinish, type WireSessionListResponse, type WireSessionReference, type WireSessionResponse, type WireSessionSummary, type WireSessionWorkspace, type WireSessionWorkspaceResponse, type WireSettingApply, type WireSettingFutile, type WireSettingLayer, type WireSettingRow, type WireSettingStep, type WireSettingValue, type WireSettingValueKind, type WireSettingWrite, type WireSettingWriteLayer, type WireSettingsResponse, type WireSettingsWriteRequest, type WireSettingsWriteResponse, type WireSkillBodyResponse, type WireSkillImportConflict, type WireSkillImportEntry, type WireSkillImportFailure, type WireSkillImportForm, type WireSkillImportIssue, type WireSkillImportPreviewResponse, type WireSkillImportResponse, type WireSkillRemoveFailure, type WireSkillRemoveResponse, type WireSkillStageFailure, type WireSkillStageResponse, type WireSkillSummary, type WireSummaryWorkspace, type WireTaggedEvent, type WireTasksResponse, type WireToolGate, type WireToolGateAxis, type WireToolRow, type WireToolSummary, type WireToolVerdict, type WireToolsResponse, type WireTrustRequestRequest, type WireTrustRequestResponse, type WireTurnState, type WireWorkspaceBinding, type WireWorkspaceDiffResponse, type WireWorkspaceDirEntry, type WireWorkspaceDirsResponse, type WireWorkspaceRef, apiKeyEnvVar, assertNeverPart, assistantTurnText, attachSessionSurface, chordId, collectPendingApprovals, compressionThresholdTokens, conformsToWire, contentToText, diagnosticToLine, extractAllMentions, extractMentions, extractSessionMentions, filePartSummary, flattenCatalog, formatMoney, getProviderInfo, hasFailure, imagePartOmittedSummary, isApprovalOutcome, isContextNearlyFull, isCurrency, isHeadlessInputFormat, isHeadlessLifecycleEvent, isHeadlessOutputFormat, isKeyAction, isLang, isMentionableFilePath, isOperationType, isPermissionLevel, isPlanOutcome, isPromptOversize, isProviderType, isQuestionAnswerMap, isSearchProviderType, isValidAgentRoleName, isValidCommandName, isWireGoalPhase, isWireHubEvent, isWirePlanAction, isWirePluginArchiveName, isWireRewindScope, isWireSettingWriteLayer, isWireSkillDropName, makeTranslate, mcpConnectorState, normalizeApproval, packSessionSurface, parseChord, parseModelRef, parseSequence, perAction, placeholdersOf, promptTokens, resolveHostText, sequenceId, sessionPartSummary, stripSystemNote, systemNote, tableOf, totalUsageTokens, translatorFor, truncateToBudget, turnDigest, unfixedRules, utf8ByteLength, wireFields };
15302
+ export { ACTION_CONTEXTS, AGENT_ROLE_NAME_PATTERN, API_KEY_ENV_VARS, APPROVAL_OUTCOMES, type ActiveAgentRole, type AgentEvent, type AgentEventOf, type AgentEventType, type AgentRole, type AgentRoleScope, type AgentRoleSource, type ApprovalAnswer, type ApprovalOutcome, type ApprovalReply, type ApprovalRequest, type ArtifactKind, type AttributeValue, type Attributes, type BackgroundTaskInfo, type BackgroundTaskKind, type BackgroundTaskStatus, type BlankCard, type BlankGreeting, type BoundKey, type BudgetBreach, COMMAND_NAME_PATTERN, COMPLIANCE_ACTIONS, COMPLIANCE_CATEGORIES, CURRENCIES, type Catalogs, type ComplianceAction, type ComplianceCategory, type ContextBreakdown, type ContextSegment, type Currency, type CurrencyDisplay, type CustomCommandDef, type CustomCommandSource, DEFAULT_AGENT_ROLE, DEFAULT_LANG, type DecidedTrustLevel, type Diagnostic, DiagnosticSink, type DiagnosticStatus, type EpochConfig, type EpochContentPart, type EpochFilePart, type EpochHook, type EpochImagePart, type EpochMessage, type EpochMessageRole, type EpochPlugin, type EpochSessionPart, type EpochSkill, type EpochTextPart, type EpochTool, type EpochToolCall, type EpochToolResult, type EpochTurnFacts, type EpochUserContent, type ExpandedCommand, type FileOmitReason, type FinishReason, type FixableApproval, type FlatCatalog, type FormatMoneyOptions, GEN_AI, HEADLESS_INPUT_EVENT_TYPES, HEADLESS_INPUT_FORMATS, HEADLESS_OUTPUT_FORMATS, HEADLESS_PROTOCOL_VERSION, type HeadlessEnvelope, type HeadlessEvent, type HeadlessInit, type HeadlessInputEvent, type HeadlessInputEventType, type HeadlessInputFormat, type HeadlessLifecycleEvent, type HeadlessOutputFormat, type HeadlessResult, type HeadlessResultReason, type HookContext, type HookEvent, type HookHandler, type HookManager, type HookRegistration, type HookType, type HostText, type HostUser, type JsonSchema, KEY_ACTIONS, type KeyAction, type KeyChord, type KeyContext, type KeySequence, type KeybindingReport, type KeybindingSource, type KeybindingTable, LANGS, type Lang, type LocalizedDetail, type LogFn, MAX_ATTACH_BYTES, MAX_ATTACH_FILES, MAX_ATTACH_TOTAL_BYTES, MAX_HEADER_CHARS, MAX_OPTIONS, MAX_QUESTIONS, MAX_SESSION_ATTACH_BYTES, MAX_SESSION_REFS, MCP_DATA_KEY_NAME, METRIC, MIN_OPTIONS, type MentionSet, type ModelRef, type ModelSelection, type ModelSelectionOrigin, type MoneyDigits, NOOP_TELEMETRY, type NoticeCode, OPERATION_TYPES, OVERSIZE_RATIO, type Operation, type OperationType, PERMISSION_LEVELS, PLAN_OUTCOMES, PROVIDER_INFOS, PROVIDER_TYPES, type PermissionDecision, type PermissionLevel, type PermissionManager, type PermissionRuleLists, type PlanApprovalOutcome, type PlanProposal, type PluginContext, type ProviderInfo, type ProviderType, type QuestionAnswer, type QuestionItem, type QuestionOption, type QuestionRequest, RESERVED_CHORDS, RESERVED_COMMAND_NAMES, type RejectReason, type RejectedKey, type RuleValue, SCHEDULE_DRAFT_TOOL, SCHEDULE_INTERVAL_MINUTES, SCHEDULE_RUN_STATUSES, SCHEDULE_TEMPLATES, SEARCH_API_KEY_ENV, SEARCH_PROVIDER_TYPES, SECRET_SERVICE, SEQUENCE_CONTEXTS, SESSION_MENTION_PREFIX, SESSION_REFERENCE_ERROR_CODES, SHELL_KINDS, SIDEBAR_MENU_MESSAGE_TYPE, SIMULTANEOUS_CONTEXTS, SYSTEM_NOTE_PREFIX, type ScheduleAllowlist, type ScheduleBackendKind, type ScheduleCycle, type ScheduleDefinition, type ScheduleDraft, type ScheduleDraftAllowlist, type ScheduleDraftCreate, type ScheduleDraftPermission, type ScheduleDraftUpdate, type SchedulePendingApproval, type SchedulePendingItem, type ScheduleRun, type ScheduleRunStatus, type ScheduleRunnerSpec, type ScheduleTemplate, type ScheduleTemplateId, type ScheduleTrigger, type ScheduleTriggerKind, type ScheduleWeekday, type ScheduleWideningKind, type SearchProviderType, type SecretBackendId, type SecretStore, type SelectionContext, type SelectionRejection, type SelectionWarning, type SessionAttachKind, type SessionAttachResult, type SessionOmitReason, type SessionSurfaceMessage, type SessionSurfaceView, type SetSelectionResult, type ShellKind, type SidebarMenuItem, type SidebarMenuMessage, type SpanLike, type SubToolCall, type SubToolCallResult, type Telemetry, type TokenUsage, type ToolAnnotations, type ToolArtifact, type ToolArtifactInput, type ToolContext, type ToolDefinition, ToolExposure, type ToolProvider, type ToolResult, type Translate, type TranslateVars, type TrustExecGrant, type TrustGrant, type TrustLevel, type TrustManager, type TrustRecord, type TrustScope, type TrustTier, UNLIMITED_TURNS, type VerifyResult, WIRE_ABORT_FIELDS, WIRE_ABOUT_FIELDS, WIRE_AGENT_ROLE_FIELDS, WIRE_APPROVAL_CACHE_REVOKE_REQUEST_FIELDS, WIRE_APPROVAL_CACHE_REVOKE_RESPONSE_FIELDS, WIRE_APPROVAL_LIST_FIELDS, WIRE_APPROVAL_ROW_FIELDS, WIRE_ARTIFACTS_FIELDS, WIRE_ATTACHMENT_UPLOAD_FIELDS, WIRE_AUDIT_LOG_FIELDS, WIRE_AUDIT_ROW_FIELDS, WIRE_AUTH_COOKIE, WIRE_AUTH_COOKIE_ATTRS, WIRE_AUTH_TOKEN_PARAM, WIRE_BACKGROUND_TASK_FIELDS, WIRE_BLOCKED_GOALS_FIELDS, WIRE_BLOCKED_GOAL_FIELDS, WIRE_BLOCKED_LEVEL_FIELDS, WIRE_BUILTIN_CONNECTOR_FIELDS, WIRE_CACHED_APPROVAL_FIELDS, WIRE_CAPABILITIES_FIELDS, WIRE_CAPABILITY_PROJECT_FIELDS, WIRE_CHECKPOINT_LIST_FIELDS, WIRE_CHECKPOINT_SUMMARY_FIELDS, WIRE_COMMANDS_FIELDS, WIRE_COMMAND_EXPANSION_FIELDS, WIRE_COMPACT_FIELDS, WIRE_COMPRESSION_LINE_FIELDS, WIRE_CONFIG_FIELDS, WIRE_CONTEXT_FIELDS, WIRE_CREATE_SESSION_FIELDS, WIRE_CREATE_WORKSPACE_FIELDS, WIRE_CREATE_WORKSPACE_RESPONSE_FIELDS, WIRE_CUSTOM_COMMAND_FIELDS, WIRE_DELETE_SESSION_FIELDS, WIRE_DIFF_FILE_FIELDS, WIRE_ERROR_FIELDS, WIRE_FILE_CANDIDATES_FIELDS, WIRE_FILE_CANDIDATE_FIELDS, WIRE_FILE_CHANGE_FIELDS, WIRE_FILE_OPEN_RESPONSE_FIELDS, WIRE_FILE_REFERENCE_FIELDS, WIRE_FILE_STAT_MAX, WIRE_FILE_STAT_OK_FIELDS, WIRE_FILE_STAT_REFUSED_FIELDS, WIRE_FILE_STAT_RESPONSE_FIELDS, WIRE_FILE_TEXT_RESPONSE_FIELDS, WIRE_FORGET_WORKSPACE_FIELDS, WIRE_FORGET_WORKSPACE_RESPONSE_FIELDS, WIRE_GOAL_ACTIONS, WIRE_GOAL_BLOCK_FIELDS, WIRE_GOAL_CLEAR_FIELDS, WIRE_GOAL_CREATE_FIELDS, WIRE_GOAL_FIELDS, WIRE_GOAL_LIMITS_FIELDS, WIRE_GOAL_PHASES, WIRE_GOAL_REFUSALS, WIRE_GOAL_RESPONSE_FIELDS, WIRE_GOAL_WRITE_FIELDS, WIRE_HEALTH_FIELDS, WIRE_IMAGE_REFERENCE_FIELDS, WIRE_KNOWN_WORKSPACE_FIELDS, WIRE_LANG_PARAM, WIRE_LAST_SESSION_FIELDS, WIRE_LAST_SESSION_RESPONSE_FIELDS, WIRE_MANAGED_LOCK_FIELDS, WIRE_MCP_ADD_FIELDS, WIRE_MCP_ADD_RESPONSE_FIELDS, WIRE_MCP_APPLY_ENTRY_FIELDS, WIRE_MCP_APPLY_RESPONSE_FIELDS, WIRE_MCP_CONFIG_APPLY_FIELDS, WIRE_MCP_CONFIG_FIELDS, WIRE_MCP_CONFIG_ISSUE_FIELDS, WIRE_MCP_CONFIG_SAVE_FIELDS, WIRE_MCP_CONFIG_SAVE_RESPONSE_FIELDS, WIRE_MCP_CONNECTOR_FIELDS, WIRE_MCP_RECONNECT_FIELDS, WIRE_MESSAGE_LIST_FIELDS, WIRE_MODEL_FIELDS, WIRE_MODEL_SELECTION_FIELDS, WIRE_MODEL_SET_FIELDS, WIRE_MODEL_SUGGESTIONS_FIELDS, WIRE_PATCH_SESSION_FIELDS, WIRE_PATCH_SESSION_RESPONSE_FIELDS, WIRE_PERMISSION_RULE_FIELDS, WIRE_PERMISSION_SET_REQUEST_FIELDS, WIRE_PERMISSION_SET_RESPONSE_FIELDS, WIRE_PERMISSION_SHADOW_FIELDS, WIRE_PERMISSION_STATE_FIELDS, WIRE_PERMISSION_STATUS_FIELDS, WIRE_PLAN_ACTIONS, WIRE_PLAN_FIELDS, WIRE_PLAN_MODE_REQUEST_FIELDS, WIRE_PLAN_MODE_RESPONSE_FIELDS, WIRE_PLAN_MODE_STATE_FIELDS, WIRE_PLUGINS_FIELDS, WIRE_PLUGIN_ACTION_FIELDS, WIRE_PLUGIN_ARCHIVE_SUFFIXES, WIRE_PLUGIN_INSTALL_FIELDS, WIRE_PLUGIN_LOCAL_FIELDS, WIRE_PLUGIN_LOCAL_INSTALL_FIELDS, WIRE_PLUGIN_MARKETPLACE_FIELDS, WIRE_PLUGIN_MARKETPLACE_RESPONSE_FIELDS, WIRE_PLUGIN_NAME_FIELDS, WIRE_PLUGIN_PREVIEW_FIELDS, WIRE_PLUGIN_PREVIEW_RESPONSE_FIELDS, WIRE_PLUGIN_REFRESH_RESPONSE_FIELDS, WIRE_PLUGIN_STAGE_FIELDS, WIRE_PLUGIN_STAGE_RESPONSE_FIELDS, WIRE_PLUGIN_UPDATE_FIELDS, WIRE_PLUGIN_UPLOAD_CONFIRM_FIELDS, WIRE_PLUGIN_UPLOAD_FIELDS, WIRE_PLUGIN_UPLOAD_FORM_FIELDS, WIRE_PLUGIN_UPLOAD_PREVIEW_RESPONSE_FIELDS, WIRE_PLUGIN_UPLOAD_RESPONSE_FIELDS, WIRE_POLICY_DIR_FIELDS, WIRE_POLICY_STATUS_FIELDS, WIRE_PROJECT_INVENTORY_FIELDS, WIRE_PROVIDERS_FIELDS, WIRE_PROVIDER_KEY_FIELDS, WIRE_PROVIDER_MODELS_FIELDS, WIRE_PROVIDER_OPTION_FIELDS, WIRE_QUESTION_LIST_FIELDS, WIRE_QUESTION_ROW_FIELDS, WIRE_RESPOND_APPROVAL_FIELDS, WIRE_RESPOND_APPROVAL_RESPONSE_FIELDS, WIRE_RESPOND_QUESTION_FIELDS, WIRE_RESPOND_QUESTION_RESPONSE_FIELDS, WIRE_REWIND_FILE_PLAN_FIELDS, WIRE_REWIND_OUTCOME_FIELDS, WIRE_REWIND_PREVIEW_FIELDS, WIRE_REWIND_REQUEST_FIELDS, WIRE_REWIND_RESPONSE_FIELDS, WIRE_REWIND_SCOPES, WIRE_ROLE_ADD_FIELDS, WIRE_ROLE_ADD_RESPONSE_FIELDS, WIRE_ROLE_REMOVE_FIELDS, WIRE_RUNNING_SCHEDULE_FIELDS, WIRE_SANDBOX_STATUS_FIELDS, WIRE_SCHEDULE_CAPABILITY_FIELDS, WIRE_SCHEDULE_CREATE_FIELDS, WIRE_SCHEDULE_DEFAULTS_FIELDS, WIRE_SCHEDULE_DELETE_FIELDS, WIRE_SCHEDULE_FIELDS, WIRE_SCHEDULE_FIX_FIELDS, WIRE_SCHEDULE_FIX_REQUEST_FIELDS, WIRE_SCHEDULE_ISSUE_CODES, WIRE_SCHEDULE_LIST_FIELDS, WIRE_SCHEDULE_PENDING_FIELDS, WIRE_SCHEDULE_RECORDING_FIELDS, WIRE_SCHEDULE_ROW_FIELDS, WIRE_SCHEDULE_RUNNING_FIELDS, WIRE_SCHEDULE_RUNS_FIELDS, WIRE_SCHEDULE_RUN_FIELDS, WIRE_SCHEDULE_SAVE_FIELDS, WIRE_SECURITY_FIELDS, WIRE_SECURITY_WORKSPACE_FIELDS, WIRE_SEND_MESSAGE_FIELDS, WIRE_SEND_MESSAGE_RESPONSE_FIELDS, WIRE_SESSION_CANDIDATES_FIELDS, WIRE_SESSION_CANDIDATE_FIELDS, WIRE_SESSION_FINISH_FIELDS, WIRE_SESSION_LIST_FIELDS, WIRE_SESSION_REFERENCE_FIELDS, WIRE_SESSION_RESPONSE_FIELDS, WIRE_SESSION_SUMMARY_FIELDS, WIRE_SESSION_WORKSPACE_FIELDS, WIRE_SESSION_WORKSPACE_RESPONSE_FIELDS, WIRE_SETTINGS_FIELDS, WIRE_SETTINGS_WRITE_FIELDS, WIRE_SETTINGS_WRITE_REQUEST_FIELDS, WIRE_SETTING_PENDING_FIELDS, WIRE_SETTING_ROW_FIELDS, WIRE_SETTING_STEP_FIELDS, WIRE_SETTING_WRITE_FIELDS, WIRE_SETTING_WRITE_LAYERS, WIRE_SKILL_BODY_FIELDS, WIRE_SKILL_DROP_SUFFIXES, WIRE_SKILL_FIELDS, WIRE_SKILL_IMPORT_FIELDS, WIRE_SKILL_IMPORT_PREVIEW_FIELDS, WIRE_SKILL_REMOVE_FIELDS, WIRE_SKILL_STAGE_FIELDS, WIRE_TASKS_FIELDS, WIRE_TOOLS_FIELDS, WIRE_TOOL_GATE_FIELDS, WIRE_TOOL_ROW_FIELDS, WIRE_TRUST_REQUEST_FIELDS, WIRE_TRUST_REQUEST_RESPONSE_FIELDS, WIRE_WORKSPACE_DIFF_FIELDS, WIRE_WORKSPACE_DIRS_FIELDS, WIRE_WORKSPACE_DIR_ENTRY_FIELDS, WIRE_WORKSPACE_REF_FIELDS, type WireAbortResponse, type WireAbout, type WireAgentEnvelope, type WireAgentEvent, type WireAgentRoleSummary, type WireApprovalCacheRevokeRequest, type WireApprovalCacheRevokeResponse, type WireApprovalListResponse, type WireApprovalRequest, type WireArtifactsResponse, type WireAttachmentUploadResponse, type WireAuditCode, type WireAuditLog, type WireAuditOutcome, type WireAuditRow, type WireAuthDenyReason, type WireAuthDenyStatus, type WireAuthGuard, type WireAuthRequestView, type WireAuthVerdict, type WireBackgroundTask, type WireBindWorkspaceRequest, type WireBlockedGoal, type WireBlockedGoalsResponse, type WireBlockedLevel, type WireBuiltinConnector, type WireCachedApproval, type WireCachedDecision, type WireCapabilitiesResponse, type WireCapabilityProject, type WireCheckpointListResponse, type WireCheckpointSummary, type WireCommandExpansion, type WireCommandsResponse, type WireCompactRequest, type WireCompactResponse, type WireCompressionLine, type WireConfigResponse, type WireContextResponse, type WireCreateSessionRequest, type WireCreateSessionResponse, type WireCreateWorkspaceRequest, type WireCreateWorkspaceResponse, type WireCustomCommand, type WireDeleteSessionResponse, type WireDiffFile, type WireDiffNote, type WireDiffOmitReason, type WireDiffSource, type WireDiffStatus, type WireEnvelope, type WireErrorResponse, type WireEvent, type WireFields, type WireFileCandidate, type WireFileCandidatesResponse, type WireFileChange, type WireFileKind, type WireFileOpenRefusal, type WireFileOpenResponse, type WireFileReference, type WireFileRefusal, type WireFileStat, type WireFileStatOk, type WireFileStatRefused, type WireFileStatResponse, type WireFileTextResponse, type WireFixture, type WireFixtureCase, type WireForgetWorkspaceRequest, type WireForgetWorkspaceResponse, type WireGoal, type WireGoalBlock, type WireGoalClearResponse, type WireGoalCreateRequest, type WireGoalLimits, type WireGoalPhase, type WireGoalRefusal, type WireGoalResponse, type WireGoalUpdateRequest, type WireGoalWriteResponse, type WireHealthResponse, type WireHubEnvelope, type WireHubEvent, type WireImageReference, type WireKnownWorkspace, type WireKnownWorkspaceRef, type WireLastSessionRequest, type WireLastSessionResponse, type WireManagedLock, type WireMcpAddRequest, type WireMcpAddResponse, type WireMcpApplyAction, type WireMcpApplyEntry, type WireMcpApplyResponse, type WireMcpConfigApplyRequest, type WireMcpConfigIssue, type WireMcpConfigResponse, type WireMcpConfigSaveRequest, type WireMcpConfigSaveResponse, type WireMcpConnector, type WireMcpReconnectResponse, type WireMessageListResponse, type WireModelCaveat, type WireModelProbeStatus, type WireModelRejection, type WireModelResponse, type WireModelSelection, type WireModelSetRequest, type WireModelSetResponse, type WireModelSource, type WireModelSuggestions, type WirePatchSessionRequest, type WirePatchSessionResponse, type WirePermissionRememberFailure, type WirePermissionRuleRow, type WirePermissionSetRequest, type WirePermissionSetResponse, type WirePermissionShadow, type WirePermissionState, type WirePermissionStatus, type WirePickDirectoryResponse, type WirePlanAction, type WirePlanModeRequest, type WirePlanModeResponse, type WirePlanModeState, type WirePlanResponse, type WirePluginActionResponse, type WirePluginCounts, type WirePluginEntry, type WirePluginHit, type WirePluginHookTally, type WirePluginInstallRequest, type WirePluginLocalInstallRequest, type WirePluginLocalRequest, type WirePluginMarketplace, type WirePluginMarketplaceRequest, type WirePluginMarketplaceResponse, type WirePluginNameRequest, type WirePluginPreview, type WirePluginPreviewRequest, type WirePluginPreviewResponse, type WirePluginRefreshResponse, type WirePluginRefuseReason, type WirePluginSourceType, type WirePluginStageRequest, type WirePluginStageResponse, type WirePluginStatus, type WirePluginUpdateRequest, type WirePluginUploadChoice, type WirePluginUploadConfirmRequest, type WirePluginUploadField, type WirePluginUploadFieldName, type WirePluginUploadFields, type WirePluginUploadFile, type WirePluginUploadForm, type WirePluginUploadPlan, type WirePluginUploadPreviewResponse, type WirePluginUploadRequest, type WirePluginUploadResponse, type WirePluginVisibility, type WirePluginsResponse, type WirePolicyDir, type WirePolicyStatus, type WireProjectInventory, type WireProviderKeyRequest, type WireProviderKeyResponse, type WireProviderModelsRequest, type WireProviderModelsResponse, type WireProviderOption, type WireProviderSummary, type WireProvidersResponse, type WireQuestionListResponse, type WireQuestionRequest, type WireReplayMessage, type WireResetReason, type WireRespondApprovalRequest, type WireRespondApprovalResponse, type WireRespondQuestionRequest, type WireRespondQuestionResponse, type WireRewindAction, type WireRewindDrift, type WireRewindFilePlan, type WireRewindOutcome, type WireRewindPreview, type WireRewindRequest, type WireRewindResponse, type WireRewindScope, type WireRoleAddRequest, type WireRoleAddResponse, type WireRoleRemoveFailure, type WireRoleRemoveResponse, type WireRunningSchedule, type WireSandboxReason, type WireSandboxStatus, type WireScheduleCapability, type WireScheduleCreateRequest, type WireScheduleDefaults, type WireScheduleDeleteResponse, type WireScheduleFixRequest, type WireScheduleFixResponse, type WireScheduleIssue, type WireScheduleIssueCode, type WireScheduleListResponse, type WireSchedulePendingResponse, type WireScheduleRecordingResponse, type WireScheduleRegisterRefusal, type WireScheduleRegisterWarning, type WireScheduleRegistration, type WireScheduleResponse, type WireScheduleRow, type WireScheduleRunResponse, type WireScheduleRunningResponse, type WireScheduleRunsResponse, type WireScheduleSaveResponse, type WireScheduleUpdateRequest, type WireSecurityResponse, type WireSecurityWorkspace, type WireSendMessageRequest, type WireSendMessageResponse, type WireSessionCandidate, type WireSessionCandidatesResponse, type WireSessionFinish, type WireSessionListResponse, type WireSessionReference, type WireSessionResponse, type WireSessionSummary, type WireSessionWorkspace, type WireSessionWorkspaceResponse, type WireSettingApply, type WireSettingFutile, type WireSettingLayer, type WireSettingPending, type WireSettingRow, type WireSettingStep, type WireSettingValue, type WireSettingValueKind, type WireSettingWrite, type WireSettingWriteLayer, type WireSettingsResponse, type WireSettingsWriteRequest, type WireSettingsWriteResponse, type WireSkillBodyResponse, type WireSkillImportConflict, type WireSkillImportEntry, type WireSkillImportFailure, type WireSkillImportForm, type WireSkillImportIssue, type WireSkillImportPreviewResponse, type WireSkillImportResponse, type WireSkillRemoveFailure, type WireSkillRemoveResponse, type WireSkillStageFailure, type WireSkillStageResponse, type WireSkillSummary, type WireSummaryWorkspace, type WireTaggedEvent, type WireTasksResponse, type WireToolGate, type WireToolGateAxis, type WireToolRow, type WireToolSummary, type WireToolVerdict, type WireToolsResponse, type WireTrustRequestRequest, type WireTrustRequestResponse, type WireTurnState, type WireWorkspaceBinding, type WireWorkspaceDiffResponse, type WireWorkspaceDirEntry, type WireWorkspaceDirsResponse, type WireWorkspaceRef, apiKeyEnvVar, assertNeverPart, assistantTurnText, attachSessionSurface, chordId, collectPendingApprovals, compressionThresholdTokens, conformsToWire, contentToText, diagnosticToLine, extractAllMentions, extractMentions, extractSessionMentions, filePartSummary, flattenCatalog, formatMoney, getProviderInfo, hasFailure, imagePartOmittedSummary, isApprovalOutcome, isContextNearlyFull, isCurrency, isHeadlessInputFormat, isHeadlessLifecycleEvent, isHeadlessOutputFormat, isKeyAction, isLang, isMentionableFilePath, isOperationType, isPermissionLevel, isPlanOutcome, isPromptOversize, isProviderType, isQuestionAnswerMap, isSearchProviderType, isValidAgentRoleName, isValidCommandName, isWireGoalPhase, isWireHubEvent, isWirePlanAction, isWirePluginArchiveName, isWireRewindScope, isWireSettingWriteLayer, isWireSkillDropName, makeTranslate, mcpConnectorState, normalizeApproval, packSessionSurface, parseChord, parseModelRef, parseSequence, perAction, placeholdersOf, promptTokens, resolveHostText, sequenceId, sessionPartSummary, stripSystemNote, systemNote, tableOf, totalUsageTokens, translatorFor, truncateToBudget, turnDigest, unfixedRules, utf8ByteLength, wireFields };