@epoch-agent/server 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { Lang, WireSkillImportFailure, WireSkillImportForm, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, PermissionLevel, WireModelSource, WireModelProbeStatus, ScheduleDefinition, ScheduleRun, ScheduleBackendKind, OperationType, ScheduleTrigger, WireScheduleIssue, ScheduleRunStatus, TrustLevel, WireSandboxReason, WireAuditOutcome, WireAuditCode, WireToolVerdict, WireToolGateAxis, WireSecurityResponse, WireRewindScope, WireRewindRequest, WireCheckpointSummary, WireRewindPreview, WireRewindResponse, ActiveAgentRole, WireSettingValue, WireSettingLayer, WireSettingWriteLayer, WireSettingFutile, WireSettingApply, WireSettingsResponse, EpochConfig, WireCompressionLine, WireProviderSummary, WireToolSummary, Diagnostic, WireReplayMessage, WireFileChange, WireAuthGuard, WireWorkspaceDiffResponse, BackgroundTaskInfo, WireTasksResponse, WireBackgroundTask, WireScheduleRow } from '@epoch-agent/protocol';
2
- import { SerializableApprovalRequest, SerializableQuestionRequest, LevelChangeResult } from '@epoch-agent/runtime';
1
+ import { Lang, WireSkillImportFailure, WireSkillImportForm, WireSkillRemoveFailure, WireCapabilitiesResponse, WireSkillBodyResponse, EpochUserContent, AgentEvent, WireEnvelope, CustomCommandDef, ExpandedCommand, ModelRef, SetSelectionResult, WireCommandExpansion, WireCustomCommand, WireGoalPhase, WireGoalRefusal, WireTurnState, WireSessionFinish, ApprovalOutcome, QuestionAnswer, SessionSurfaceView, WireSessionReference, WireFileReference, ProviderType, ModelSelectionOrigin, WireModelCaveat, WireModelRejection, ContextBreakdown, PermissionLevel, WirePermissionRememberFailure, WirePluginSourceType, WirePluginRefuseReason, WirePluginEntry, WireModelSource, WireModelProbeStatus, ScheduleDefinition, ScheduleRun, ScheduleBackendKind, OperationType, ScheduleTrigger, WireScheduleIssue, ScheduleRunStatus, TrustLevel, WireSandboxReason, WireAuditOutcome, WireAuditCode, WireCachedApproval, WireCachedDecision, WireToolVerdict, WireToolGateAxis, WireSecurityResponse, WireRewindScope, WireRewindRequest, WireCheckpointSummary, WireRewindPreview, WireRewindResponse, ActiveAgentRole, WireSettingValue, WireSettingLayer, WireSettingWriteLayer, WireSettingFutile, WireSettingApply, WireSettingValueKind, WireSettingsResponse, EpochConfig, WireCompressionLine, WireProviderSummary, WireToolSummary, Diagnostic, WireReplayMessage, WireFileChange, WireAuthGuard, WireWorkspaceDiffResponse, BackgroundTaskInfo, WireTasksResponse, WireBackgroundTask, WireScheduleRow } from '@epoch-agent/protocol';
2
+ import { SerializableApprovalRequest, SerializableQuestionRequest, ApprovalRevokeResult, LevelChangeResult, WorkspaceFilesView } from '@epoch-agent/runtime';
3
+ import { ServerResponse } from 'node:http';
3
4
 
4
5
  /**
5
6
  * 绑定策略 —— 「监听哪儿、给不给 token、拒不拒绝启动」。
@@ -396,6 +397,12 @@ interface RoleAddView {
396
397
  description: string;
397
398
  prompt?: string;
398
399
  tools?: readonly string[];
400
+ /**
401
+ * 技能索引白名单(2026-08-27)。⚠️ **这一层不校验它**,和 `tools` 刻意不同 ——
402
+ * 判据全文在 protocol 的 `WireRoleAddRequest.skills` 上(技能表会在会话中途变,
403
+ * 把「此刻没装」判成 400 是拒掉一个明天就成立的名字)。
404
+ */
405
+ skills?: readonly string[];
399
406
  maxTurns?: number;
400
407
  }
401
408
  /**
@@ -536,11 +543,18 @@ type ImportResultView<T> = ({
536
543
  issues?: readonly IssueView[];
537
544
  };
538
545
  /**
539
- * `SkillControl`(runtime)在服务端眼里的样子 —— **只有这两个动作**。
540
- *
541
- * 镜像里只有它们,于是这个包**写不出** `SkillSystem` 上那六个写方法、
542
- * 也写不出会 `matchCount++` 的 `view()` 和会自动删技能的 `maintain()`:
543
- * 那不是靠 review 盯住的,是编译期的事(同 `McpControlView` 只有 reconnect)。
546
+ * `SkillControl`(runtime)在服务端眼里的样子 —— **只有导入那两个动作**。
547
+ *
548
+ * 镜像里只有它们,于是这个包**写不出** `SkillSystem` 上那几个写方法、
549
+ * 也写不出会 `matchCount++` 的 `view()`:那不是靠 review 盯住的,
550
+ * 是编译期的事(同 `McpControlView` 只有 reconnect)。
551
+ *
552
+ * ⚠️ **「删一条技能」不在这张镜像里,它自己有一张**
553
+ * ([skill-remove.ts](./skill-remove.js) 的 `SkillRemoveControlView`),
554
+ * 而 `RuntimeCapabilities.skillWrite` 声明成两张的**交集**。镜像跟着 handler
555
+ * 走、而不是跟着 runtime 那一格走,是这一套收窄能起作用的前提:一张把两件事
556
+ * 都装进来的镜像,等于任何一个 handler 都能顺手调另一件事 —— 判据同
557
+ * `capability.ts` 上 `mcp` 那一格。
544
558
  */
545
559
  interface SkillImportControlView {
546
560
  preview(source: string, lang?: Lang): Promise<ImportResultView<{
@@ -549,6 +563,83 @@ interface SkillImportControlView {
549
563
  import(source: string, token: string, lang?: Lang): Promise<ImportResultView<ImportDoneView>>;
550
564
  }
551
565
 
566
+ /**
567
+ * 删掉一条技能的那一条端点(2026-09-01):
568
+ *
569
+ * ```
570
+ * POST /api/skills/remove {name} → 递归删掉 ~/.epoch/skills/<分类>/<名字>
571
+ * ```
572
+ *
573
+ * 起因是一句话:**能加就得能删**。在这之前能力页技能那一栏只有「导入技能」,
574
+ * 拿掉一份得让用户自己去 `~/.epoch/skills/` 里翻目录 —— 而那一栏里的每一行
575
+ * 都是每一轮都在花钱的常驻开销(行尾那个 tokens 数就是它)。
576
+ *
577
+ * ## 一、为什么是 POST,为什么没有 token
578
+ *
579
+ * POST 和导入那两条同一条判据(`auth.ts` 的 Origin 校验只在非安全方法上要求,
580
+ * 而这条会在用户机器上真的删文件)。**但没有「预览 → 确认」那一对**,
581
+ * 而那不是省事:
582
+ *
583
+ * - 导入那个 token 防的是「用户看过的那一份」和「真装上的那一份」不是一份 ——
584
+ * 预览和导入之间目录可能被改过。删除这边没有这条缝:**要删掉的东西由名字
585
+ * 唯一确定**(技能名在 `SkillSystem` 里是全局唯一的 map 键),而不是由一次
586
+ * 扫描的结果确定;
587
+ * - 删除的风险不是「删错了一份」,是**不可撤销**(`rmSync(recursive)`,没有
588
+ * 回收站)。挡它的是另外两样东西:界面上那一步「再按一次确认」,
589
+ * 和回执里那个 `path` —— 用户得看得见自己刚销毁了盘上的哪一处。
590
+ *
591
+ * ## 二、⚠️ 这条**不吃 `refusedByLan`**,而那是一个判断
592
+ *
593
+ * 插件那几个写口和 `POST /api/roles` 都要求回环绑定才给写。这一条不要求,
594
+ * 判据是:**导入那条也不要求**。只给删除加一道闸的结果,是 `--host 0.0.0.0`
595
+ * 那一档下技能变成「只能加不能删」—— 那正是这次要修的病,换个地方原样长回来。
596
+ *
597
+ * 两条一起加闸不行(那是把已经在用的导入功能收回去),所以这一格的口径是
598
+ * 「和导入同档」。真要收,得两条一起收,而那是一次独立的决定 ——
599
+ * 别只改这半边。
600
+ *
601
+ * ## 三、只有**用户级**删得掉,而那道闸不在这一层
602
+ *
603
+ * 项目级 / 插件 / 宿主那三档 `SkillSystem.requireWritable()` 会抛,
604
+ * runtime 的 `SkillControl.remove` 把它翻成 `reason: 'readonly'` 加上 core
605
+ * 那句带路径的原话。这一层**不自己判 scope** —— 判两遍会有一天判得不一样,
606
+ * 而只有 core 那一遍碰得到磁盘。
607
+ *
608
+ * 界面上那颗按钮只画在用户级那一组的行上,但那是**体贴,不是安全性质**:
609
+ * 手搓一条 `POST /api/skills/remove {"name":"项目里那条"}` 会拿到 200 +
610
+ * `ok:false` + `readonly`。
611
+ *
612
+ * ## 四、失败是 200,判据在 `WireSkillRemoveResponse` 上
613
+ *
614
+ * 四个 `reason` 各对一个不同的下一步(那张表在 protocol 那个类型上)。
615
+ * `detail` 那句话在 **core / runtime** 里现渲染,所以 `lang` 要从这一层
616
+ * 一路递下去 —— 这条病 2026-08-18 在导入那一屏上实测见过一次
617
+ * (判据在 [skill-import.ts](./skill-import.js) 文件头倒数第三节)。
618
+ */
619
+
620
+ /** 删成了那一下回的东西(runtime 的 `SkillRemoveDone`) */
621
+ interface RemoveDoneView {
622
+ name: string;
623
+ path: string;
624
+ }
625
+ type RemoveResultView = ({
626
+ ok: true;
627
+ } & RemoveDoneView) | {
628
+ ok: false;
629
+ reason: WireSkillRemoveFailure;
630
+ detail: string;
631
+ };
632
+ /**
633
+ * `SkillControl`(runtime)在服务端眼里的样子 —— **只有 `remove` 一个动作**。
634
+ *
635
+ * 和 [skill-import.ts](./skill-import.js) 那张镜像刻意分开,理由在那张镜像的
636
+ * ⚠️ 上:镜像跟着 handler 走,于是**这个文件里写不出 `import`**,
637
+ * 而 `RuntimeCapabilities.skillWrite` 是两张的交集(同 `mcp` 那一格)。
638
+ */
639
+ interface SkillRemoveControlView {
640
+ remove(name: string, lang?: Lang): Promise<RemoveResultView>;
641
+ }
642
+
552
643
  /**
553
644
  * 能力页的取数与投影 —— `GET /api/sessions/:id/capabilities`(方案 42 PR-1),
554
645
  * 外加技能正文那条下钻(方案 56 §1.2)。
@@ -608,15 +699,32 @@ interface RoleView {
608
699
  name: string;
609
700
  description: string;
610
701
  tools?: readonly string[];
702
+ /**
703
+ * 技能索引白名单(`AgentRole.skills`,2026-08-27)。⚠️ **镜像上漏掉这一格不会
704
+ * 红** —— 真源上它是可选的,少声明照样满足这份镜像,表现只是 `toWireRole()`
705
+ * 一声不吭地读不到它(判据同下面 `McpView.source` 那条 ⚠️)。它是这一屏上
706
+ * 「一个身份自己声明了哪几条技能」的唯一来源。
707
+ */
708
+ skills?: readonly string[];
611
709
  maxTurns?: number;
612
710
  source: 'builtin' | 'user' | 'project' | 'plugin' | 'host';
613
711
  }
614
712
  /**
615
- * `RoleMergeNotice` 的镜像。**只取 `role` `cut`** —— `detail` 是中文文案,
616
- * 浏览器要用自己的 catalog 组句(同诊断的规矩),下发它等于让英文界面照抄中文。
713
+ * `RoleMergeNotice` 的镜像。**只取 `role` / `kind` / `cut`** —— `detail` 是中文
714
+ * 文案,浏览器要用自己的 catalog 组句(同诊断的规矩),下发它等于让英文界面
715
+ * 照抄中文。
716
+ *
717
+ * ⚠️ **`kind` 这一格是必须的,不是「顺手多带一样」**(2026-08-27)。加上它之前
718
+ * 这一族 notice 全是工具收窄,于是 `toWireRole()` 里一句 `find(n => n.role === …)`
719
+ * 就够了;技能收窄一旦也产 notice,那句 `find` 会**抓到技能那一条并把技能名画成
720
+ * 划掉的工具芯片** —— 两边都是 `string[]`,编译器一个字都不会说。
721
+ * 记账全文在
722
+ * [RECORD-skill-index-narrowing](../../../docs/verify/VERIFY_RECORD-skill-index-narrowing.md) §四第 3 条。
617
723
  */
618
724
  interface RoleNoticeView {
619
725
  role: string;
726
+ /** 收窄的是哪一轴。真源是 core 的 `RoleMergeNotice.kind` */
727
+ kind: 'tools' | 'skills';
620
728
  cut: readonly string[];
621
729
  }
622
730
  /** `SkillMeta` 在服务端眼里的样子。`scope` 同上一条 ⚠️ */
@@ -662,12 +770,29 @@ interface RuntimeCapabilities {
662
770
  */
663
771
  skillBody(name: string): string | null;
664
772
  /**
665
- * 从**本机目录**导入技能(方案 42 §六)。**进程级**,同 {@link mcp}。
773
+ * 每条技能此刻进没进索引,名字 三档(2026-08-27)。**这一层不自己算** ——
774
+ * 判据是它算不出来:封顶的丢弃顺序(`DROP_RANK`)和角色白名单的交集语义
775
+ * 都在 core 的 `prompt-index.ts` 里,而这个包不许 import core。按
776
+ * `role.skills.includes()` 在这儿再判一遍就是第二份实现,
777
+ * 而它和真索引分叉之后两边都答得出「我算了」。
778
+ */
779
+ skillIndexResidency(role: string | null): ReadonlyMap<string, 'indexed' | 'capped' | 'role'>;
780
+ /**
781
+ * 技能目录的写口:从**本机目录**导入(方案 42 §六)+ 删掉一条用户级技能
782
+ * (2026-09-01)。**进程级**,同 {@link mcp}。
666
783
  *
667
- * ⚠️ 它收的是**路径**不是字节 —— 那是这条路上的安全性质本体,判据全文在
784
+ * ⚠️ 导入收的是**路径**不是字节 —— 那是那条路上的安全性质本体,判据全文在
668
785
  * [skill-import.ts](./skill-import.js) 的文件头。
786
+ *
787
+ * ⚠️ **交集,理由逐字同下面 {@link mcp} 那一格**:两个动作挂在同一个 runtime
788
+ * 对象上(`runtime.skillWrite`),分成两份声明是为了让镜像跟着 handler 走 ——
789
+ * 于是 `skill-import.ts` 里写不出 `remove`、`skill-remove.ts` 里写不出 `import`。
790
+ *
791
+ * ⚠️ **删除那条刻意不吃 `refusedByLan`**,和导入同档;那是一个判断而不是遗漏,
792
+ * 判据全文在 [skill-remove.ts](./skill-remove.js) 文件头第二节 ——
793
+ * 要收得两条一起收。
669
794
  */
670
- readonly skillImport: SkillImportControlView;
795
+ readonly skillWrite: SkillImportControlView & SkillRemoveControlView;
671
796
  /**
672
797
  * MCP 的写口:重连一台(方案 56 §1.1)+ 加一台(2026-08-18)+ **那份文件的原文
673
798
  * 读写与应用**(2026-08-18)。**全都是进程级**。
@@ -706,6 +831,14 @@ interface RuntimeCapabilities {
706
831
  *
707
832
  * @param bound 这个会话绑的工作区(`workspaces.of(sessionId)`)。没绑就是 null,
708
833
  * 那时「项目」那一层整个不存在 —— 不是「有但是空的」
834
+ * @param role 这个会话的**底座身份名**(`LiveSession.role?.name`),没绑就是 null。
835
+ * 只有技能那一栏读它:一个带 `skills` 白名单的身份会让某几条技能不进索引,
836
+ * 于是那几行行尾那个「常驻开销」是假话(2026-08-27 那笔账)。
837
+ *
838
+ * ⚠️ **它是必传的一格,不是可选参数。** 这个函数今天只有一个调用方,加个
839
+ * 默认 `null` 只会让下一个调用方在**默认不收窄**这条路上静默走对一半 ——
840
+ * 而走错的表现是屏幕上多出一个假数字,没有任何东西会红。判据同
841
+ * `toWireSkill` 的 `residency` 那一格。
709
842
  */
710
843
  declare function collectCapabilities(runtime: RuntimeCapabilities, tools: readonly {
711
844
  name: string;
@@ -717,7 +850,7 @@ declare function collectCapabilities(runtime: RuntimeCapabilities, tools: readon
717
850
  trust: {
718
851
  trusted: boolean;
719
852
  };
720
- } | null): WireCapabilitiesResponse;
853
+ } | null, role: string | null): WireCapabilitiesResponse;
721
854
  /**
722
855
  * 一份 `SKILL.md` 最多发多少字节。形状照
723
856
  * [workspace/diff.ts](./workspace/diff.ts) 的 `MAX_DIFF_BYTES`。
@@ -980,6 +1113,138 @@ interface ExpansionOutcome {
980
1113
  */
981
1114
  declare function expandUserContent(runtime: RuntimeCommands, message: EpochUserContent, model: ModelTurnsView | null, role: TurnRole | null): ExpansionOutcome;
982
1115
 
1116
+ /**
1117
+ * `WebRuntimeView.goalCatalog` —— **server 从 runtime 借的目标那一片**
1118
+ * ([方案 52](../../../../docs/verify/VERIFY_RECORD-52-goal.md) PR-4)。
1119
+ *
1120
+ * ## 为什么是一份结构镜像,而不是 `import type { GoalCatalog }`
1121
+ *
1122
+ * 判据逐字同 [schedule/view.ts](../schedule/view.js),两条,第二条是硬的:
1123
+ *
1124
+ * 1. **`context.ts` 那句话**:这一层只借「用得到的那一小片」。`GoalCatalog` 的
1125
+ * 每个方法都回 core 的 `Goal` / `GoalWrite` / `GoalClear` —— 借整个接口等于
1126
+ * 让 `@epoch-agent/server` 的公开 `.d.ts` 引用一个它没声明依赖的包;
1127
+ * 2. **用例要造得出一个假的**。`server/__tests__/harness.ts` 的假 runtime 是手写
1128
+ * 对象。镜像用 protocol 的类型写完之后,那个假件一行 core 都不用碰。
1129
+ *
1130
+ * ⚠️ **镜像必须和真身逐字段对得上**,否则 `cli/src/commands/web.ts` 那一行
1131
+ * `createWebServer({ runtime })` 当场编译不过 —— 那正是这份镜像的全部强制力。
1132
+ *
1133
+ * ## ⚠️ 借的是 `goalCatalog` 而**不是** `goals`,这一条是这个文件的正题
1134
+ *
1135
+ * `EpochRuntime` 上有两格,答的是两个问题:
1136
+ *
1137
+ * ```
1138
+ * runtime.goals 「**引导会话**那一段的目标」 GoalControl(收窄到一段)
1139
+ * runtime.goalCatalog 「**任意一段**会话的目标」 GoalCatalog(按 id 现查)
1140
+ * ```
1141
+ *
1142
+ * 借前者的下场,这个仓库已经付过一次账了:`WebRuntimeView` 上原来有一个进程级的
1143
+ * `checkpoints`,而三条回退端点都在拿它答任意一个 sessionId ——「编译得过、跑得通、
1144
+ * 只是答错会话」(方案 30 §9.5)。那个字段 2026-08-15 删掉了,`context.ts` 上那段
1145
+ * ⚠️ 写着为什么。这一格从第一天起就借对的那一份。
1146
+ *
1147
+ * ## 为什么不像检查点那样「向工厂要这个会话自己那一份」
1148
+ *
1149
+ * 因为**目标没有内存态**。`GoalService` 每次现读 SQLite(`GoalStore.current`),
1150
+ * 于是「这段会话此刻活不活着」和「它的目标是什么」是两个无关的问题:
1151
+ *
1152
+ * | 那一格 | 真源 | 冷却掉的会话答得出吗 |
1153
+ * | -------------- | -------------------------- | -------------------- |
1154
+ * | 检查点 | 这个进程的 `CheckpointManager` | ❌ 只能回空列表 |
1155
+ * | 计划 | 内存那份优先,回退读盘 | ⚠️ 读盘那份可能旧 |
1156
+ * | **目标** | **只有 SQLite 一份** | ✅ 一样准 |
1157
+ *
1158
+ * 走工厂那条路的话,用户打开一段昨天的会话会看到「这个会话还没有目标」——
1159
+ * 而库里那条目标正躺在那儿,那正是「交代完走开」这份方案要修的病本身。
1160
+ *
1161
+ * ## ⚠️ 这里**没有** `block()`
1162
+ *
1163
+ * runtime 那一侧的 `GoalCatalog` 上也没有(判据在它的 JSDoc 上):`blocked` 是
1164
+ * 模型 / 策略层的态,而那个 `code` 是给程序路由用的键 —— 从 HTTP 这一侧露出一条
1165
+ * 报 `blocked` 的路,等于让浏览器编一个 `needs-approval` 出来,而那个方向是提权
1166
+ * (52 §5.1 结论 3)。网线上那份请求体里同样说不出这个态
1167
+ * (`WireGoalUpdateRequest` 只有三态),**两道各自独立**:这一道是编译期的,
1168
+ * 那一道是契约上的,少任何一道另一道都会变成唯一的防线。
1169
+ */
1170
+
1171
+ /**
1172
+ * core 的 `Goal` 在服务端眼里的样子。
1173
+ *
1174
+ * **没有 `id`** —— 那是 `goals` 表的主键,这一层拿它什么都做不了(一个会话至多
1175
+ * 一个目标,寻址靠 sessionId)。镜像上没有它是**编译期生效**的:投影里写不出
1176
+ * `goal.id`,写了当场不过。判据同 `CheckpointSummaryView` 那处「没有 `cursor`」。
1177
+ *
1178
+ * `sessionId` **留着**,和 `id` 刻意不同:跨会话那条端点要拿它去查标题。
1179
+ */
1180
+ interface GoalView {
1181
+ sessionId: string;
1182
+ objective: string;
1183
+ phase: WireGoalPhase;
1184
+ /** `phase === 'blocked'` 时必有,其余态必无 */
1185
+ block?: {
1186
+ readonly code: string;
1187
+ readonly message: string;
1188
+ };
1189
+ maxRounds: number;
1190
+ roundsUsed: number;
1191
+ /** `phase === 'complete'` 时必有 */
1192
+ completeEvidence?: string;
1193
+ createdAt: number;
1194
+ updatedAt: number;
1195
+ }
1196
+ /**
1197
+ * 一次写的结果 —— core 的 `GoalWrite`。
1198
+ *
1199
+ * **判别联合原样照抄,不摊平成 `{ok, goal?, reason?}`**:摊平之后
1200
+ * 「`ok: true` 却没有 goal」在类型上说得出口,而 handler 那一侧只能补一个
1201
+ * `?? null` —— 那个 `null` 会一路走到网线上,界面读成「写成功了但目标没了」。
1202
+ */
1203
+ type GoalWriteView = {
1204
+ ok: true;
1205
+ goal: GoalView;
1206
+ } | {
1207
+ ok: false;
1208
+ reason: WireGoalRefusal;
1209
+ };
1210
+ /** `clear` 的结果 —— core 的 `GoalClear`。它没有「写完之后那份目标」 */
1211
+ type GoalClearView = {
1212
+ ok: true;
1213
+ cleared: boolean;
1214
+ } | {
1215
+ ok: false;
1216
+ reason: WireGoalRefusal;
1217
+ };
1218
+ /**
1219
+ * 目标那一片能力。`EpochRuntime.goalCatalog` 结构上正好满足它。
1220
+ *
1221
+ * ## ⚠️ 可以为 null,而这和 `schedules` 那一格刻意不同
1222
+ *
1223
+ * 定时任务那一格不可为 null(底下只有一张表和一次平台探测,没有起不来的可能)。
1224
+ * 这一格**会**为 null:目标是**落盘的状态**,会话库起不来时它整个不存在 ——
1225
+ * 判据逐字同 TUI 那一侧的「会话库起不来时 `/goal` 压根不注册」(那时还让它出现在
1226
+ * `/help` 里,等于承诺一个建了就没的东西)。
1227
+ *
1228
+ * 于是那五条端点在这一档回 **503 `no-goal-store`**,而不是一份 `goal: null`:
1229
+ * 后者是「这个会话没有目标」,前者是「问不出来」,两者在界面上是两种画法
1230
+ * (一个是空态加一个「来定一条」的按钮,一个是一句「这台机器上没有会话库」)。
1231
+ * 判据全文在 `WireGoalResponse` 上。
1232
+ */
1233
+ interface GoalCatalogView {
1234
+ current: (sessionId: string) => GoalView | null;
1235
+ /** 建一个。**人专用** —— 判据在 runtime 的 `GoalCatalog.create` 上 */
1236
+ create: (sessionId: string, objective: string, maxRounds?: number) => GoalWriteView;
1237
+ /** 改正文。**不重置轮次、不改 phase** */
1238
+ edit: (sessionId: string, objective: string) => GoalWriteView;
1239
+ /** 改预算。⚠️ 两个方向都会顺手改 phase,判据在 `WireGoalUpdateRequest` 上 */
1240
+ setBudget: (sessionId: string, maxRounds: number) => GoalWriteView;
1241
+ pause: (sessionId: string) => GoalWriteView;
1242
+ resume: (sessionId: string) => GoalWriteView;
1243
+ /** 人替它报完成。依据必填 */
1244
+ complete: (sessionId: string, evidence: string) => GoalWriteView;
1245
+ clear: (sessionId: string) => GoalClearView;
1246
+ }
1247
+
983
1248
  /**
984
1249
  * SessionHub —— 事件总线 + 回合状态机 + 审批生命周期。
985
1250
  *
@@ -1319,6 +1584,214 @@ declare class SessionHub {
1319
1584
  private reap;
1320
1585
  }
1321
1586
 
1587
+ /**
1588
+ * `@` 提及在 Web 那一侧的两半(方案 53 PR-4 的会话 + 方案 63 的文件)——
1589
+ * 补全面板的候选,和发消息那条路上的解析。
1590
+ *
1591
+ * ## 四件事,一个文件
1592
+ *
1593
+ * | 做什么 | 谁调 |
1594
+ * | ----------------------------- | ------------------------------------- |
1595
+ * | `GET .../reference-candidates` | 输入框打 `@:` 那一刻(方案 53 PR-4) |
1596
+ * | `GET .../file-candidates` | 输入框打 `@文件` 那一刻(方案 63) |
1597
+ * | 把解析结果塞进这一条消息 | `api.postMessage`(每条消息都问一次) |
1598
+ *
1599
+ * 不摊进 [api.ts](./api.ts),理由同 [commands.ts](./commands.js):那个文件是
1600
+ * 「一个端点一个函数」,而这里有一份 runtime 能力的**结构镜像**加一段提及解析。
1601
+ *
1602
+ * ## ⚠️ 引擎那一侧一行都不重写 —— 这是这一整份的性质
1603
+ *
1604
+ * 三样东西全部来自 `EpochRuntime.sessionReferences`(判据在它的 JSDoc 上):
1605
+ *
1606
+ * - **候选**(只碰 id / cwd / 标题,一个字的转录文本都不搜,§5.2)
1607
+ * - **解析**(`@:xxx` → 哪一段,或者为什么不是)
1608
+ * - **当前面**(`active = 1` 的那些,§二)
1609
+ *
1610
+ * 而这三样里最要紧的是**可读性判定**:它在 core 里、和[方案 48](../../../docs/verify/VERIFY_RECORD-48-session-query.md)
1611
+ * 共用一处(`core/src/session/authorization.ts` 的 `resolveSessionVisibility`,
1612
+ * 判据全文在那个文件头)。让服务端自己去读 `sessions.db`
1613
+ * 等于给 `@:` 开一条绕过那个判定的读取通道 —— 而 `@:` 存在的前提恰恰是
1614
+ * 「它永远不比 `session_search` 宽松」(方案 53 §4.3)。
1615
+ *
1616
+ * 上限那张表和截断那段算术也不在这儿:它们住在 protocol
1617
+ * (`attachSessionSurface`),和 TUI 那条路**共用同一份**。抄第二遍的形态很具体:
1618
+ * TUI 上一段会话附了 48 条、web 上同一段附了 60 条,两边都不报错,
1619
+ * 而「模型到底看见了什么」从此有两个答案。判据全文在 protocol 的 `mentions.ts`。
1620
+ *
1621
+ * ## `@文件` 那一半(方案 63)的行状
1622
+ *
1623
+ * - **候选**走 `EpochRuntime.workspaceFiles.candidates`(runtime 的收窄面):
1624
+ * 过滤、`@` 表达不出来的去尾、排序、缓存全在那一侧(判据在那个文件的文件头),
1625
+ * 这里只做「按 `:id` 现算工作区根 → 递进去 → 搬上网线」三件事。
1626
+ * - **解析**走同一条路的两步:`resolve` 出界 = `denied`,`readFile` 一趟走完
1627
+ * 边界 / `file_read` 同源判定 / 二进制探测(失败三档码见 runtime 的
1628
+ * `FileReadOutcome`)。**服务端这里没有一个字的文件读取** —— 判据同上面
1629
+ * 「让服务端自己去读 sessions.db」那一条:`@` 存在的前提是它永远不比
1630
+ * `file_read` 宽松,而判定只有权限层那一份。
1631
+ * - **预算**:文件先吃、会话后到,两者共享同一本 200KB 的账 —— 判据逐字同
1632
+ * `core/src/context/mentions.ts` 的 `resolveMentions`(反过来的话两个小文件
1633
+ * 会被一段长会话挤成 `budget-exceeded`,而用户显然更想要那两个文件)。
1634
+ *
1635
+ * ## 这一层自己拿的两个决定
1636
+ *
1637
+ * 1. **`currentSessionId` 一律是 URL 上那个 `:id`,不是 `runtime.sessionId`。**
1638
+ * 后者是**引导会话**。拿它去答别的会话,表现是「引用自己」判不出来
1639
+ * (于是真的把自己塞进自己)、血缘链算成另一段会话的 —— 而它编译得过、
1640
+ * 跑得通、只是答错会话(同 `EpochRuntime.checkpoints` 那段 ⚠️ 骂的那件事)。
1641
+ * 2. **工作区取 `projectContext.rootDir`,不是 `workspace.root`。**
1642
+ * 两者在「在子目录里起的那次会话」上不同(前者是仓库根,后者是启动目录),
1643
+ * 而写入侧往 `sessions.cwd` 里写的是前者(判据在 `core/src/session/manager.ts`
1644
+ * 的 `getLatestByCwd` 和 `authorization.ts` 的 `workspaceCwd` 上)。
1645
+ * 给错的表现是**一条候选都列不出来**,而屏幕上只是一个空面板。
1646
+ */
1647
+
1648
+ /**
1649
+ * 候选清单一次最多回几条。
1650
+ *
1651
+ * 面板上放不下更多,而这个数同时是那条 SQL 的 `LIMIT` —— 用户打得越细,
1652
+ * 回来的越少(过滤在 SQL 里,见文件头)。
1653
+ *
1654
+ * ⚠️ **它不是 TUI 那个 `SESSION_CANDIDATE_POOL`。** 那一个是「挂载时拉一池子回来、
1655
+ * 之后在前端筛」,于是第 41 段会话**永远搜不到而且屏幕上没有提示**(那笔账记在
1656
+ * `docs/verify/VERIFY_RECORD-53-session-reference.md` 第六章)。这一条路每次带着
1657
+ * `?q=` 现问,所以这个数只封「一屏列多少」,不封「能搜到哪几段」。
1658
+ */
1659
+ declare const MAX_CANDIDATES = 20;
1660
+ /**
1661
+ * 一段会话候选在引擎眼里的样子(core 的 `SessionCandidate`)。
1662
+ *
1663
+ * ⚠️ **和网线那一份({@link WireSessionCandidate})字段逐个相同,而它们仍然是两个
1664
+ * 类型**:这一份是镜像(server 不许 import core),那一份是契约。所以
1665
+ * {@link toWireCandidate} 是一次**显式**的搬运而不是 `as` —— 哪天引擎那侧加了一个
1666
+ * 带转录文本的字段(`preview`),它不会顺着 spread 悄悄上网线。
1667
+ */
1668
+ interface SessionCandidateView {
1669
+ sessionId: string;
1670
+ title: string;
1671
+ cwd?: string | undefined;
1672
+ updatedAt: number;
1673
+ messageCount: number;
1674
+ sameWorkspace: boolean;
1675
+ }
1676
+ /** 解析的结果(core 的 `ResolvedReference`)。三个码见方案 53 §4.2 */
1677
+ interface ResolvedReferenceView {
1678
+ sessionId?: string | undefined;
1679
+ reason?: 'invalid-reference' | 'self-reference' | 'not-authorized' | undefined;
1680
+ }
1681
+ /** 一次可读范围的入参。三格逐字同 core 的 `SessionVisibilityInput`(去掉 `db`) */
1682
+ interface VisibilityInput {
1683
+ /** 谁在引用 —— **URL 上那个 `:id`**,见文件头第 1 条 */
1684
+ currentSessionId: string;
1685
+ /** 项目根(`projectContext.rootDir`),见文件头第 2 条 */
1686
+ workspaceCwd?: string | undefined;
1687
+ /** 跨工作区。只能来自已经打开了配置开关的那条路 */
1688
+ allWorkspaces?: boolean;
1689
+ }
1690
+ /**
1691
+ * `EpochRuntime.sessionReferences` 在服务端眼里的样子 —— **三个方法,一个不多**。
1692
+ *
1693
+ * 写镜像而不是 import core,判据同 `commands.ts` / `capability.ts` 的文件头。
1694
+ * 而这一格还多一条:它收窄的东西本身就是一条安全边界 ——
1695
+ * 那份门面刻意「交不出任意 SQL」(`SessionReferences` 的 JSDoc),
1696
+ * 镜像照着它抄,服务端就连绕过判定的**语法**都没有。
1697
+ *
1698
+ * `EpochRuntime.sessionReferences` 结构上正好满足它,
1699
+ * 对不上就是两边长歪了,`cli/src/commands/web.ts` 那一行当场编译不过。
1700
+ */
1701
+ interface SessionReferencesView {
1702
+ /** 补全面板的候选。**只按 id / cwd / 标题过滤**(§5.2) */
1703
+ candidates(input: VisibilityInput & {
1704
+ query: string;
1705
+ limit: number;
1706
+ }): readonly SessionCandidateView[];
1707
+ /** `@:xxx` → 哪一段会话(§1.2) */
1708
+ resolve(input: VisibilityInput & {
1709
+ ref: string;
1710
+ }): ResolvedReferenceView;
1711
+ /** 那一段会话的当前面(§二)。**调用方要先过 `resolve`** */
1712
+ surface(sessionId: string): SessionSurfaceView | undefined;
1713
+ }
1714
+ /**
1715
+ * `GET /api/sessions/:id/reference-candidates` —— 打 `@:` 那一刻面板该列哪几段。
1716
+ *
1717
+ * `:id` 做两件事,同 `tasks.ts` 的那两件:**认门**(不存在的会话 404,不回一份空
1718
+ * 清单冒充「没有可引用的会话」),以及**真的参与判定** —— 自己不在候选里、
1719
+ * 血缘链从它算起、亲和度按它绑的工作区排。
1720
+ *
1721
+ * ## 会话库起不来时 503,不是空清单;而且 503 **排在 404 前面**
1722
+ *
1723
+ * 判据同 `no-goal-store`(`goal/handlers.ts`):「这个宿主没有会话库」和
1724
+ * 「一段可引用的会话都没有」在界面上是两种画法;而顺序上,没有会话库的进程连
1725
+ * 「认不认识这个会话」都答不出来(`ctx.runtime.sessions` 是**同一个库**),
1726
+ * 那时的 404 是一句碰巧为真的话。
1727
+ *
1728
+ * 浏览器那一侧对两者的处置恰好相同(面板不画,见 `mention/use-candidates.ts`)——
1729
+ * 那是浏览器的选择,不是服务端可以把两句话合成一句的理由。
1730
+ */
1731
+ declare function listReferenceCandidates(ctx: ApiContext, res: ServerResponse, sessionId: string,
1732
+ /** `?q=` / `?limit=` 从这儿取 */
1733
+ rawUrl: string | undefined,
1734
+ /** 这一次响应用哪个语言渲染(方案 58)。`undefined` = 这条请求没说,沿用进程语言 */
1735
+ lang?: Lang): void;
1736
+ /**
1737
+ * `GET /api/sessions/:id/file-candidates` —— 打 `@`(后面不是 `:`)那一刻
1738
+ * 面板该列哪几个文件(方案 63)。
1739
+ *
1740
+ * ## 和 `reference-candidates` 同一条路由判据的两处小差异
1741
+ *
1742
+ * 1. **没绑工作区时 409 `no-workspace`**,而不是像会话那条路那样「有库就能答」:
1743
+ * 文件清单压根是**从工作区算出来的**,没有根连「列不出来」这一档都不存在。
1744
+ * 409 而不是 404:这个会话认得,是它的**状态**(还没选地盘)给不出答案 ——
1745
+ * 同 `workspace-locked` 那条的拆码判据。
1746
+ * 2. **`?q=` 过滤在 runtime 那一侧**(`EpochRuntime.workspaceFiles.candidates`),
1747
+ * 判据同会话那条路的「过滤在 SQL 里」:拉一池子回来再在前端筛,
1748
+ * 池子之外的永远搜不到而且屏幕上没有提示。
1749
+ *
1750
+ * 候选**已经是「提交时会被认出来」的那一批**(`@` 表达不出来的路径 runtime 已
1751
+ * 经去尾,只把条数带回来 —— 见 `WireFileCandidatesResponse.unmentionable`)。
1752
+ * 服务端这里一个字都不再筛:再筛一遍就是给同一件事第二个答案。
1753
+ */
1754
+ declare function listFileCandidates(ctx: ApiContext, res: ServerResponse, sessionId: string,
1755
+ /** `?q=` / `?limit=` 从这儿取 */
1756
+ rawUrl: string | undefined,
1757
+ /** 这一次响应用哪个语言渲染(方案 58)。`undefined` = 这条请求没说,沿用进程语言 */
1758
+ lang?: Lang): void;
1759
+ /** {@link attachMentions} 的结果 */
1760
+ interface AttachedMentions {
1761
+ /** 真正发给引擎的那条消息。一个提及都没有时 === 传进来的那条 */
1762
+ message: EpochUserContent;
1763
+ /** 逐段一条会话收据。**一段都没引用时是空数组**,调用方据此决定那个键要不要出现 */
1764
+ references: readonly WireSessionReference[];
1765
+ /** 逐个一条文件收据。**一个都没提时是空数组**,判据同上面那个键 */
1766
+ files: readonly WireFileReference[];
1767
+ }
1768
+ /**
1769
+ * 把这条消息里的 `@` 提及(文件 + 会话)解析成部件,塞在正文**前面**
1770
+ * (方案 53 §二 + 方案 63)。
1771
+ *
1772
+ * ## 三条顺序上的判据
1773
+ *
1774
+ * 1. **抽取读的是用户敲的那一行**,也就是这条消息开头那段文本。数组那一支
1775
+ * 只看第一个部件(同 `commands.ts` 的 `leadingText`)—— 输入框拼数组时文本
1776
+ * 永远在最前面。
1777
+ * 2. **部件排在文本前面**,逐字同 TUI(`app.tsx` 的 `handleSubmit`):
1778
+ * 模型先看见「这是哪个文件 / 哪段会话」再看见问题,和「工具结果先于追问」
1779
+ * 同一个顺序。
1780
+ * 3. **这一步排在斜杠命令展开之后**(见 `api.postMessage`)。反过来的话,
1781
+ * 展开那一侧的 `leadingText` 拿到的第一个部件是附件而不是文本,
1782
+ * 于是**斜杠命令悄悄失效** —— 而屏幕上没有任何区别。
1783
+ *
1784
+ * ## 预算:文件先吃、会话后到,两者共享同一本 200KB 的账(方案 63)
1785
+ *
1786
+ * 顺序逐字同 core 的 `resolveMentions`:会话的当前面动辄几十 KB,而文件提及是
1787
+ * 高频的那一种。反过来的话,一次 `@:某个长会话 @a.ts @b.ts` 会让两个小文件
1788
+ * 双双 `budget-exceeded` —— 而用户显然更想要那两个文件。
1789
+ *
1790
+ * ⚠️ **从 2026-08-26 起这条路上有两个消费方了**:`usedBytes` 不再从 0 起算,
1791
+ * 会话那一半排在文件那一半**之后**,拿到的预算是扣掉文件之后的余额。
1792
+ */
1793
+ declare function attachMentions(ctx: ApiContext, sessionId: string, message: EpochUserContent): AttachedMentions;
1794
+
1322
1795
  /**
1323
1796
  * 一个会话自己那三样(模型 / 权限档 / plan 模式)在**服务端眼里**的样子
1324
1797
  * ([方案 30](../../../../docs/verify/VERIFY_RECORD-30-web-multi-session.md) §六,2026-08-16)。
@@ -1490,7 +1963,16 @@ interface SessionPermissionsView {
1490
1963
  managed(): {
1491
1964
  bypassDisabled: boolean;
1492
1965
  };
1493
- setLevel(level: PermissionLevel): SetLevelView;
1966
+ /**
1967
+ * @param opts.remember 这一发**算不算用户显式挑的**(2026-08-31)。
1968
+ * ⚠️ **照抄真身的必填形状**(runtime 的 `SessionPermissions.setLevel`),
1969
+ * 别在这份镜像上写成可选的:真身那边这个参数存在的理由是「定时任务和用户
1970
+ * 菜单共用同一个方法」,写成可选之后,漏传的那一方会静默拿到默认行为 ——
1971
+ * 而那正是这个参数要防的东西。三行表在 runtime 的 `DefaultLevelSlot` 上。
1972
+ */
1973
+ setLevel(level: PermissionLevel, opts: {
1974
+ remember: boolean;
1975
+ }): SetLevelView;
1494
1976
  }
1495
1977
  /**
1496
1978
  * 一次换档的结果。**只有码,没有句子** —— 措辞归浏览器的两份 catalog
@@ -1498,6 +1980,14 @@ interface SessionPermissionsView {
1498
1980
  */
1499
1981
  type SetLevelView = {
1500
1982
  ok: true;
1983
+ /**
1984
+ * 「换成了,但没记住」(2026-08-31)。**只在真没记住时有值** ——
1985
+ * 它直接转成 `WirePermissionSetResponse.notRemembered`,判据在那儿。
1986
+ */
1987
+ notRemembered?: {
1988
+ reason: WirePermissionRememberFailure;
1989
+ path?: string;
1990
+ };
1501
1991
  } | {
1502
1992
  ok: false;
1503
1993
  reason: 'managed-bypass-disabled';
@@ -1546,6 +2036,51 @@ interface SessionHistoryFacts {
1546
2036
  }[];
1547
2037
  }[];
1548
2038
  }
2039
+ /**
2040
+ * 「这段会话的上下文预算怎么花的」+「现在压一次」—— `/context` 和 `/compact`
2041
+ * 那两条命令在 web 上的底座(2026-08-26)。
2042
+ *
2043
+ * ## 为什么和上面 {@link SessionHistoryFacts} 分开,而不是把它加宽
2044
+ *
2045
+ * 两者都要 `getHistory()`,但**要的是同一串东西的两个不同投影**:那一份问
2046
+ * 「有没有 `type: 'image'` 的 part」,这一份要 `content` 那个文本投影去估 token。
2047
+ * 把 `content` 加进那一份的代价是换模型那条路上凭空多出一个它不看的字段 ——
2048
+ * 而收窄镜像这件事的全部意义就是「只借用得到的那几格」。
2049
+ *
2050
+ * ⚠️ **`compact()` 在这儿而不在别处,因为它换掉的是 `AgentSession.history`**
2051
+ * ——「下一轮送进模型的是什么」。它**不动会话库**,所以 `GET /messages`
2052
+ * 压完之后一行都不会少(判据全文在 `WireCompactResponse` 上)。
2053
+ */
2054
+ interface SessionContextFacts {
2055
+ /**
2056
+ * 历史的**文本投影**。`content` 就是那个投影本身(见 core 的 `EpochMessage`),
2057
+ * 拼起来算 token 比逐条估更接近真实发出去的量。
2058
+ */
2059
+ getHistory(): readonly {
2060
+ content: string;
2061
+ }[];
2062
+ /** 手动压一次。三种结局(没跑 / 跑了没压动 / 压了)全在返回值里,见 `WireCompactResponse` */
2063
+ compact(instruction?: string): Promise<{
2064
+ ran: boolean;
2065
+ before: number;
2066
+ after: number;
2067
+ reason?: string;
2068
+ }>;
2069
+ }
2070
+ /**
2071
+ * **这个会话的循环**在服务端眼里的样子。只借 `contextBreakdown` 一件事。
2072
+ *
2073
+ * ⚠️ **别拿它去 `run()`**,runtime 的 `LiveSession.agent` 上那段警告一字不改地
2074
+ * 适用:开一轮必须经过 `AgentSession`(历史累积 + 持久化 + 审批桥),
2075
+ * 绕过去的表现是「聊完之后重开这个会话,刚才那几轮不见了」。镜像上只有这一个
2076
+ * 方法,正是为了让那条路在这一层**够不着**。
2077
+ */
2078
+ interface SessionLoopView {
2079
+ contextBreakdown(opts: {
2080
+ historyText: string;
2081
+ toolSource?: (name: string) => string | undefined;
2082
+ }): ContextBreakdown;
2083
+ }
1549
2084
 
1550
2085
  /**
1551
2086
  * 这个会话**此刻**用哪个模型 —— `GET` / `POST /api/sessions/:id/model`
@@ -1728,10 +2263,218 @@ interface RuntimeModel {
1728
2263
  }
1729
2264
 
1730
2265
  /**
1731
- * 「我们支持哪几家、这一家有哪些模型」 —— `GET /api/providers` 和
1732
- * `POST /api/providers/:type/models`。
2266
+ * 插件那一页的六条端点(方案 59 §六 E2,2026-08-21):
2267
+ *
2268
+ * ```
2269
+ * GET /api/plugins 装着的 + 市场里有什么 + pendingRestart
2270
+ * POST /api/plugins/install/preview {ref} → 将安装什么,一个字节都不写
2271
+ * POST /api/plugins/install {ref, token} → 真装
2272
+ * POST /api/plugins/update/preview {name} → 更新会带来什么
2273
+ * POST /api/plugins/update {name, token} → 真更新
2274
+ * POST /api/plugins/uninstall {name} → 卸载
2275
+ * ```
2276
+ *
2277
+ * ## ⚠️ 这一层**一条策略判断都没有**
2278
+ *
2279
+ * 谁能装(清单里那些)、装什么(`<市场>/<插件>`)、远程那一档开没开、要不要重启 ——
2280
+ * 四个问题的答案全在 `EpochRuntime.plugins` 那一片里(判据全文在
2281
+ * `runtime/src/plugin-control.ts` 的文件头四节)。这个文件只做三件事:
2282
+ * 把请求体收窄成那一片认得的形状、把结局投影上网线、外加下面那**一道闸**。
2283
+ *
2284
+ * 在这儿重判一次的下场是「同一个问题两个答案」——而那两个答案分叉的那天,
2285
+ * 界面上是一条画成可点、点了却被拒的行(判据同 `PluginSearchHit.installable`
2286
+ * 为什么不从结果里剔掉远程那条)。
2287
+ *
2288
+ * ## ⚠️ 那一道闸:**五条 POST 在回环之外一律 403**,这是这个文件的正题
2289
+ *
2290
+ * 判据全文在 protocol 的 [wire-plugin.ts](../../protocol/src/wire-plugin.ts) 文件头
2291
+ * 第二节,这里记它对这一层的四条直接后果:
2292
+ *
2293
+ * 1. **`ctx.lanExposed` 为真时 403 `plugin-write-lan-exposed`,一个字节都不写。**
2294
+ * 装一包插件会落下 `hooks.json` 和 `mcp.json`,那两样**起子进程** ——
2295
+ * 下一程启动时照着跑。往 `~/.epoch/agents/` 落一份 md 只影响模型看到的字,
2296
+ * 这一条影响这台机器上会跑起什么进程,所以它比 `role-add.ts` 那道**还远一格**;
2297
+ * 2. **`install/preview` 也吃这道闸,虽然它一个字节都不写**:`allowRemote: true`
2298
+ * 的部署上,预览一条远程条目会 spawn `git clone --depth 1` 到 staging。
2299
+ * 按「写不写盘」切一刀会漏掉它;
2300
+ * 3. **那道闸在读请求体之前**,同 `addRole`:被拒的那一发连正文都不该被解析;
2301
+ * 4. **`GET` 刻意不挡。** 那一份里是插件名 / 版本 / 目录路径,和
2302
+ * `GET /api/sessions/:id/capabilities` 早就在发的那些同一档 —— 而挡掉它的
2303
+ * 唯一后果是那一页在 LAN 那一档下一片空白,
2304
+ * 偏偏那时用户最想知道的就是「我装了什么、生效了没有」。
2305
+ * (对比 `GET /api/mcp/config` 为什么连读都挡:那份文件里的 `env` 是明文密钥。)
2306
+ *
2307
+ * ## ⚠️ 这一片可以整个不存在 —— 503 `no-plugin-control`
2308
+ *
2309
+ * `runtime.plugins` 在宿主没给 `hostMarketplaces` 时是 `null`,而那**不是**
2310
+ * 「底下那层起不来了」,是「宿主没打算给用户这条路」(判据在 `build.ts` 那一格的
2311
+ * JSDoc 上)。六条一律 503,判据同 `no-goal-store`:那是一句关于**这个部署**的
2312
+ * 真话,而一份「装了 0 个插件」的空列表会让用户去找一个不存在的安装按钮。
2313
+ *
2314
+ * ⚠️ **默认的 `epoch web` 就落在这一档上**(不给 `--plugins` 就不传
2315
+ * `hostMarketplaces`)。那一格 2026-08-21 答掉了:`epoch web --plugins` 才接上
2316
+ * (传 `{ sources: [] }`),**默认关**,判据在
2317
+ * [验收记录](../../../docs/verify/VERIFY_RECORD-59-plugin-page.md) 第六章。
2318
+ *
2319
+ * ## 为什么单开一个文件
2320
+ *
2321
+ * 同 [role-add.ts](./role-add.ts) 从 `capability.ts` 里搬出来那次,**刀口按题目下
2322
+ * 不按行数**:那个文件答的是「能力页那三栏取什么数」,这一份答的是「怎么往这台
2323
+ * 机器上装一包扩展物」。而且这一份带着一整套结构镜像 + 一道闸门 + 七档拒绝的映射。
2324
+ *
2325
+ * ## 进程级,所以 URL 上没有 `:id`
2326
+ *
2327
+ * 落点是 `~/.epoch/plugins/`(一个进程一份),判据逐字同 `reconnectMcp` /
2328
+ * `importSkills` / `addRole`:挂在 `/sessions/:id/` 下面会得到一条名字是会话级、
2329
+ * 行为是进程级的端点,比不一致更坏。
2330
+ *
2331
+ * ## 文案是**第三种形态**,不是 `sendError`(方案 58 §1.2 那条 📮)
2332
+ *
2333
+ * `detail` 走的是 **200 响应体**,而它在 **runtime / core** 里现渲染
2334
+ * (`plugin_control.*` 那几条 + core 那些安装失败原因),所以 `lang` 要从这一层
2335
+ * 一路穿到 `PluginControlView` 的每一个方法上 —— 漏一处的表现是英文界面上
2336
+ * 装插件失败拿到一句中文。
2337
+ */
2338
+
2339
+ /** 六类扩展物各贡献了几条(core 的 `PluginCounts`,不能直接 import) */
2340
+ interface CountsView {
2341
+ commands: number;
2342
+ roles: number;
2343
+ skills: number;
2344
+ hooks: number;
2345
+ denyRules: number;
2346
+ mcpServers: number;
2347
+ }
2348
+ /** `PluginListEntry`(runtime)的镜像。`marketplace` / `counts` 是**可选**,见投影那两行 */
2349
+ interface PluginEntryView {
2350
+ name: string;
2351
+ version: string;
2352
+ source: string;
2353
+ sourceType: WirePluginSourceType;
2354
+ path: string;
2355
+ linked: boolean;
2356
+ enabled: boolean;
2357
+ installedAt: number;
2358
+ marketplace?: string;
2359
+ active: boolean;
2360
+ counts?: CountsView;
2361
+ }
2362
+ /** `PluginSearchHit`(runtime)的镜像 */
2363
+ interface PluginHitView {
2364
+ marketplace: string;
2365
+ ref: string;
2366
+ entry: {
2367
+ name: string;
2368
+ description?: string;
2369
+ source: string;
2370
+ category?: string;
2371
+ keywords?: readonly string[];
2372
+ };
2373
+ sourceType?: WirePluginSourceType;
2374
+ installable: boolean;
2375
+ }
2376
+ /** `InstallPreview`(core,经 runtime 原样透出)的镜像 —— 「将安装什么」那一屏的内容 */
2377
+ interface PluginPreviewView {
2378
+ manifest: {
2379
+ name: string;
2380
+ version: string;
2381
+ description?: string;
2382
+ author?: {
2383
+ name: string;
2384
+ };
2385
+ homepage?: string;
2386
+ };
2387
+ source: {
2388
+ raw: string;
2389
+ type: WirePluginSourceType;
2390
+ sha256?: string;
2391
+ };
2392
+ inventory: {
2393
+ commands: readonly string[];
2394
+ roles: readonly string[];
2395
+ skills: readonly string[];
2396
+ hooks: readonly {
2397
+ type: string;
2398
+ count: number;
2399
+ }[];
2400
+ denyRules: number;
2401
+ mcpServers: readonly string[];
2402
+ ignoredBuckets: readonly string[];
2403
+ jsTools: boolean;
2404
+ };
2405
+ conflict?: {
2406
+ name: string;
2407
+ version: string;
2408
+ };
2409
+ }
2410
+ /** 七档拒绝的共同形状(runtime 的 `PluginRefusal`) */
2411
+ interface RefusalView {
2412
+ ok: false;
2413
+ reason: WirePluginRefuseReason;
2414
+ detail: string;
2415
+ }
2416
+ type PreviewOutcomeView = {
2417
+ ok: true;
2418
+ preview: PluginPreviewView;
2419
+ token: string;
2420
+ } | RefusalView;
2421
+ /**
2422
+ * 装 / 更新的结局。
2423
+ *
2424
+ * ⚠️ **`record` 上刻意只借了一个 `name`**,尽管 runtime 那边交回来的是一整条
2425
+ * 安装记录:那一条上**没有 `active`**(它是 `list()` 现算的),而这一层要发给
2426
+ * 界面的正是那一格。所以成功之后回头 `list()` 里查一遍才是唯一说得准的做法 ——
2427
+ * 借宽了只会让下一个人顺手拿它拼一个 `active: false`,而更新一个**本来就在生效**
2428
+ * 的插件时那句话是假的。判据在 `WirePluginActionResponse.entry` 上。
2429
+ */
2430
+ type InstallOutcomeView = {
2431
+ ok: true;
2432
+ record: {
2433
+ name: string;
2434
+ };
2435
+ } | RefusalView;
2436
+ type ActionOutcomeView = {
2437
+ ok: true;
2438
+ detail: string;
2439
+ } | RefusalView;
2440
+ /**
2441
+ * `PluginControl`(runtime)在服务端眼里的样子 —— **只有那六个动词加一格布尔**。
2442
+ *
2443
+ * 镜像里只有它们,于是这个包**写不出** core 的 `setPluginEnabled`
2444
+ * (停用一个插件会静默拿掉别人正依赖的一批命令,正当性和「装 / 卸」不是一回事)、
2445
+ * 也写不出「往任意路径装一个插件」——那不是靠 review 盯住的,是编译期的事
2446
+ * (同 `SkillImportControlView` 只有两个动作、`RoleWriteControlView` 只有 `add`)。
2447
+ *
2448
+ * ⚠️ 每个方法都带 `lang?`。**镜像上漏一格不一定会红**(多一个可选参数是宽化,
2449
+ * `EpochRuntime` 照样满足这份镜像),而漏了的表现是这一层递不出去、一声不吭
2450
+ * 地回中文 —— 判据逐字同 `RoleWriteControlView.add` 上那条 ⚠️。
2451
+ */
2452
+ interface PluginControlView {
2453
+ list(): readonly PluginEntryView[];
2454
+ /** 空关键词 = 全部。**这一层永远只传空**,搜索在浏览器里做(判据在 wire 那份文件头) */
2455
+ search(keyword?: string): readonly PluginHitView[];
2456
+ preview(ref: string, lang?: Lang): Promise<PreviewOutcomeView>;
2457
+ install(ref: string, token: string, lang?: Lang): Promise<InstallOutcomeView>;
2458
+ previewUpdate(name: string, lang?: Lang): Promise<PreviewOutcomeView>;
2459
+ update(name: string, token: string, lang?: Lang): Promise<InstallOutcomeView>;
2460
+ uninstall(name: string, lang?: Lang): Promise<ActionOutcomeView>;
2461
+ readonly pendingRestart: boolean;
2462
+ }
2463
+ /**
2464
+ * 装着的一条 → 网线上那一行。
2465
+ *
2466
+ * `marketplace` / `counts` 缺席时下发 **`null` 而不是省掉这个键**,判据同
2467
+ * `WireSkillImportEntry.conflict`:两格在 wire 上写成必填,于是「服务端忘了转
2468
+ * 这一格」会当场编译不过。而 `counts` 漏掉的表现是一个生效中的插件在界面上
2469
+ * 「什么都不带」。
2470
+ */
2471
+ declare function toWirePluginEntry(entry: PluginEntryView): WirePluginEntry;
2472
+
2473
+ /**
2474
+ * 「我们支持哪几家、这一家有哪些模型、给这一家配一把 key」 —— `GET /api/providers`、
2475
+ * `POST /api/providers/:type/models` 和 `POST /api/providers/:type/key`。
1733
2476
  *
1734
- * ## ⚠️ 这两条**进程级,URL 上一个 `:id` 都没有**
2477
+ * ## ⚠️ 这三条**进程级,URL 上一个 `:id` 都没有**
1735
2478
  *
1736
2479
  * 判据同 `POST /api/mcp/:name/reconnect` 和 `GET /api/workspaces/dirs`:
1737
2480
  * provider 清单和模型清单**不属于某一段会话**。挂到 `/sessions/:id/` 底下会得到
@@ -1775,6 +2518,17 @@ interface RuntimeModel {
1775
2518
  * (`cli/src/commands/model.ts` 的 `describeDiscovery()`),照抄过来就能省一份
1776
2519
  * catalog。不许 —— 那一句是给同进程宿主(终端)拼的一次渲染,
1777
2520
  * 而且它只分两档(有没有 key),这一屏要分四档。
2521
+ *
2522
+ * ⚠️ 配 key 那一条上同一个诱惑又来了一遍,形状是 `SecretWriteResult.detail`
2523
+ * (「已写入系统钥匙串」那句现成的话)。**照旧不发**:下发的是
2524
+ * `backend` 这个码,措辞在浏览器那侧。判据逐字同上。
2525
+ *
2526
+ * ## ⚠️ 配 key 那一条是**单向**的:key 上得去,下不来
2527
+ *
2528
+ * 请求体里有裸 key,响应体里只有 `maskApiKey` 过的那份。
2529
+ * [§6.4 的第一条「绝不」](../../../docs/verify/VERIFY_RECORD-20-web.md)
2530
+ * 管的是**下行**,这一条一个字都没松 —— 而这个包在 `check-layers.mjs` 的
2531
+ * `LIBRARY_DIRS` 里(一处 console 都不许有),所以它也没有把 key 打进日志的口子。
1778
2532
  */
1779
2533
 
1780
2534
  /** 一个可挑的 provider 在服务端眼里的样子。runtime 的 `ProviderOption` 满足它 */
@@ -1783,6 +2537,15 @@ interface ProviderOptionView {
1783
2537
  label: string;
1784
2538
  envVar: string | null;
1785
2539
  hasKey: boolean;
2540
+ keyHint: string | null;
2541
+ }
2542
+ /** 一次配 key 的结果。runtime 的 `ProviderKeyWritten` 满足它 */
2543
+ interface ProviderKeyWrittenView {
2544
+ envVar: string;
2545
+ backend: string;
2546
+ encrypted: boolean;
2547
+ envPath: string | null;
2548
+ keyHint: string;
1786
2549
  }
1787
2550
  /** 探一家的结果。runtime 的 `ModelSuggestions` 满足它 */
1788
2551
  interface ModelSuggestionsView {
@@ -1792,17 +2555,22 @@ interface ModelSuggestionsView {
1792
2555
  status: WireModelProbeStatus;
1793
2556
  }
1794
2557
  /**
1795
- * `ModelCatalogControl`(runtime)在服务端眼里的样子 —— **只有两个动词**。
2558
+ * `ModelCatalogControl`(runtime)在服务端眼里的样子 —— **只有三个动词**。
1796
2559
  *
1797
- * 镜像里只有这两个,于是这个包**写不出**「把 homeDir 拿来自己拼一个缓存路径」
2560
+ * 镜像里只有这三个,于是这个包**写不出**「把 homeDir 拿来自己拼一个缓存路径」
1798
2561
  * 或者「读一把裸 key」这种事:那两样在收窄口上压根不存在。
1799
2562
  * 同 `McpControlView` 只有 `reconnect`、五份安全镜像各自漏掉一个中文字段。
2563
+ *
2564
+ * ⚠️ `setKey` 是**单向**的:裸 key 只往里走,回来的
2565
+ * {@link ProviderKeyWrittenView} 上只有脱敏串 —— 这个包因此**读不出**
2566
+ * 任何一把已经存着的 key,连自己刚写的那把都读不回来。
1800
2567
  */
1801
2568
  interface ModelCatalogView {
1802
2569
  providers(): readonly ProviderOptionView[];
1803
2570
  discover(provider: ProviderType, opts?: {
1804
2571
  refresh?: boolean;
1805
2572
  }): Promise<ModelSuggestionsView>;
2573
+ setKey(provider: ProviderType, apiKey: string): Promise<ProviderKeyWrittenView>;
1806
2574
  }
1807
2575
  /**
1808
2576
  * server 从 runtime 借的「provider 目录」那一片。`WebRuntimeView` 继承它。
@@ -1817,7 +2585,7 @@ interface RuntimeModelCatalog {
1817
2585
 
1818
2586
  /**
1819
2587
  * `WebRuntimeView.schedules` —— **server 从 runtime 借的定时任务那一片**
1820
- * ([方案 45](../../../../.agents/plans/45-scheduled-automation-plan.md) PR-3)。
2588
+ * ([方案 45](../../../../docs/verify/VERIFY_RECORD-45-automation.md) PR-3)。
1821
2589
  *
1822
2590
  * ## 为什么是一份结构镜像,而不是 `import type { ScheduleControl }`
1823
2591
  *
@@ -1922,11 +2690,22 @@ interface ScheduleCapabilityView {
1922
2690
  /**
1923
2691
  * 定时任务那一片能力。`EpochRuntime.schedules` 结构上正好满足它。
1924
2692
  *
1925
- * ⚠️ **不可为 null**:底下只有一张 SQLite 表和一次平台探测,没有起不来的可能
1926
- * (`build.ts` 上那个字段的 JSDoc 写着同一句)。本平台没有 OS 后端时
1927
- * `capability()` 如实说 `backend: null` —— 那是一个答案,不是一个缺失的能力。
2693
+ * ⚠️ **这一格不可为 null,但「不为 null」不等于「用得了」**(2026-08-26 改准)。
2694
+ * 原来这里写着「底下只有一张 SQLite 表和一次平台探测,**没有起不来的可能**」
2695
+ * —— 后半句是假的:那张表就是 `sessions.db`,打不开的时候这一片一条都列不出来。
2696
+ * 所以多了 {@link ScheduleControlView.storeAvailable} 那一格,十条端点**先过它**
2697
+ * (`handlers.ts` 的 `schedulesOf()`,503 `no-schedule-store`)。
2698
+ *
2699
+ * 本平台没有 OS 后端时 `capability()` 如实说 `backend: null` —— 那是一个答案,
2700
+ * 不是一个缺失的能力;那一格和这一格答的是两件事,别合并。
1928
2701
  */
1929
2702
  interface ScheduleControlView {
2703
+ /**
2704
+ * 底下那张表打开了没有。**同步的** —— 十条端点要在碰 store 之前判得出来。
2705
+ *
2706
+ * `false` 时下面每个方法都会抛,所以调用方**必须先看这一格**。
2707
+ */
2708
+ readonly storeAvailable: boolean;
1930
2709
  list: () => ScheduleDefinition[];
1931
2710
  get: (id: string) => ScheduleDefinition | undefined;
1932
2711
  /** 某条任务最近几次运行,最新的在前 */
@@ -1983,6 +2762,20 @@ interface BoundWorkspace {
1983
2762
  /** `trusted: false` = 这个目录的指令文件**不进** system prompt */
1984
2763
  trust: TrustView;
1985
2764
  projectContext: {
2765
+ /**
2766
+ * **项目根** —— `resolveRootDir(workDir)` 的结果(仓库根,找不到就是启动目录)。
2767
+ *
2768
+ * ⚠️ **和上面那个 `workspace.root` 不是同一个值**,别互相顶替:后者是
2769
+ * `resolve(workDir)`(用户敲 `epoch` 那一刻所在的目录),两者在
2770
+ * 「在子目录里起的那次会话」上不同。
2771
+ *
2772
+ * 2026-08-21 加进这份镜像的(方案 53 PR-4):`@:` 那条路要按工作区算候选和
2773
+ * 可读范围,而**写入侧往 `sessions.cwd` 里写的是这一个**(判据在 core 的
2774
+ * `SessionManager.getLatestByCwd` 和 `session/authorization.ts` 的 `workspaceCwd`
2775
+ * 上)。给 `workspace.root` 的表现是**一条候选都列不出来**,
2776
+ * 而屏幕上只是一个空面板 —— 没有任何一处会报错。
2777
+ */
2778
+ rootDir: string;
1986
2779
  /** 真加载了的那几份 */
1987
2780
  instructions: ReadonlyArray<{
1988
2781
  path: string;
@@ -2086,7 +2879,9 @@ interface WorkspaceView {
2086
2879
  * 安全中心的取数与投影 —— `GET /api/sessions/:id/security`(方案 42 PR-3)。
2087
2880
  *
2088
2881
  * 不在 [api.ts](./api.ts) 里,同 [capability.ts](./capability.ts) 的理由:那个
2089
- * 文件是「一个端点一个函数」,而这里有五份 runtime 能力的**结构镜像**加六段投影。
2882
+ * 文件是「一个端点一个函数」,而这里是一叠 runtime 能力的**结构镜像**加同样一叠
2883
+ * 投影函数。(这两个数原来写死成「五份 / 六段」,2026-08-27 加审批缓存那一格时
2884
+ * 双双变成假话 —— 改成链到下面的声明本身,同铁律 14 那条「要么写准,要么别写数字」。)
2090
2885
  *
2091
2886
  * ## 结构镜像,不是 import
2092
2887
  *
@@ -2113,7 +2908,9 @@ interface WorkspaceView {
2113
2908
  *
2114
2909
  * ## 「进程有什么」和「这个会话有什么」
2115
2910
  *
2116
- * 权限规则、托管锁、审计流水、策略目录四样今天是**进程级**的。前三样的判据在
2911
+ * 权限规则、托管锁、审计流水、**审批缓存**、策略目录五样今天是**进程级**的。
2912
+ * (审批缓存那一格是 2026-08-27 加的,它和前三样共用同一条判据 —— 见下一句。)
2913
+ * 前四样的判据在
2117
2914
  * core 的 [permission/shared.ts](../../core/src/permission/shared.ts) 文件头:
2118
2915
  * 它们是关于**用户**和**这台机器**的事实,跟着会话走反而会说假话(审批缓存
2119
2916
  * 分了家,「总是允许」在第二个会话上白点;流水分了家,这一屏会漏掉除引导会话
@@ -2174,7 +2971,7 @@ interface WorkspaceView {
2174
2971
  * 这个进程没在跑那段会话(只剩历史 / 被冷却)时退回进程那一份,那仍然是一句
2175
2972
  * 真话,判据逐字同 `permissionState()` 那条兜底。
2176
2973
  *
2177
- * ⚠️ **`rules` / `shadows` / `managed` / `audit` 四样一个都不许跟着搬。**
2974
+ * ⚠️ **`rules` / `shadows` / `managed` / `audit` / `cached` 五样一个都不许跟着搬。**
2178
2975
  * 判据在 core 的 `permission/shared.ts`:它们**没有**跟着会话分家。搬过去的
2179
2976
  * 具体代价是这一屏只剩下这一个会话判过的那几行 ——
2180
2977
  * **一本漏了行的安全流水比没有账更坏,因为它看起来是完整的。**
@@ -2244,6 +3041,29 @@ interface AuditView {
2244
3041
  entries: readonly AuditRowView[];
2245
3042
  dropped: number;
2246
3043
  }
3044
+ /**
3045
+ * core 的 `CachedApproval` 的镜像 —— 审批缓存里的一行(2026-08-27)。
3046
+ *
3047
+ * **没有 `ttlMs`**,而这一条和上面四份「漏掉中文」不是同一个理由:它不是散文,
3048
+ * 是引擎侧的一个实现细节(`allow-session` 的有效期)。发过去的话浏览器只能拿它
3049
+ * 算一个「还剩几分钟」,而那个数每一秒都在变、这一屏又没有刷新它的路 ——
3050
+ * 一个一打开就开始撒谎的倒计时。真源在引擎里:`listApprovals()` 每次都先
3051
+ * `prune()`,**过期的根本不在这张表里**。
3052
+ *
3053
+ * `scope` / `decision` 直接钉成网线上那两个联合而不是 `string`,同
3054
+ * {@link AuditRowView} 的 `code` 那条机制:core 那边给缓存多存一档决定而忘了同步
3055
+ * 契约时,**装配那一行当场编译不过**。写成 `string` 的话,新档会一路发到浏览器,
3056
+ * 在界面上显示成一个查不到文案的空白格。
3057
+ */
3058
+ interface CachedApprovalView {
3059
+ id: string;
3060
+ toolName: string;
3061
+ target: string;
3062
+ scope: WireCachedApproval['scope'];
3063
+ decision: WireCachedDecision;
3064
+ /** 引擎侧那个字段叫 `createdAt`,网线上叫 `at`(同流水那一列)—— 见投影函数 */
3065
+ createdAt: number;
3066
+ }
2247
3067
  /**
2248
3068
  * `PermissionsControl` 的镜像。前五样都是函数,因为它们**每次读都是当前值** ——
2249
3069
  * 审计流水尤其如此,它每判一次就长一条。
@@ -2291,6 +3111,28 @@ interface PermissionsView {
2291
3111
  };
2292
3112
  managed: () => ManagedView;
2293
3113
  audit: () => AuditView;
3114
+ /**
3115
+ * 这个进程记下了哪些弹窗答案(审批缓存,2026-08-27)。
3116
+ *
3117
+ * **和 {@link audit} 一档,跟着进程不跟着会话**(core 的 `permission/shared.ts`)。
3118
+ * 这也是它搭在这一份载荷里、而不是另开一条 `GET` 的理由之一 ——
3119
+ * 判据全文在 `protocol/wire-approval-cache.ts` 的文件头。
3120
+ *
3121
+ * ⚠️ **每次读都是当前值**(同上面五样都是函数):撤掉一条之后这张表就短一行,
3122
+ * 而那正是撤销那条端点要回读它的原因。
3123
+ */
3124
+ approvals: () => readonly CachedApprovalView[];
3125
+ /**
3126
+ * 撤销一条缓存的决定(2026-08-27)。**判定本体一个字都不在 server** ——
3127
+ * 同下面 `setLevel` 那条:`prune()` → 按 id 删 → `allow-always` 重写盘,
3128
+ * 三步都在 core 的 `ApprovalCache.revoke()` 里。
3129
+ *
3130
+ * 返回类型钉成 `ApprovalRevokeResult` 而不是 `boolean`:runtime 那边把
3131
+ * 「没有权限层」和「这条不在表里」分成了两个码,而它们在界面上是两句完全
3132
+ * 不同的话(一句是「这一节不适用」,一句是「刚才那条已经没了」)。
3133
+ * 收成一个布尔的话,后者会被画成前者的样子。
3134
+ */
3135
+ revokeApproval: (id: string) => ApprovalRevokeResult;
2294
3136
  /**
2295
3137
  * 切到某一档。**判定本体一个字都不在 server** —— 托管挡 bypass、
2296
3138
  * `PermissionManager.setLevel()`、`plan.forget()` 三步都在 runtime 的
@@ -2301,8 +3143,13 @@ interface PermissionsView {
2301
3143
  * **当场编译不过**(同 `AuditRowView.code` 用 `WireAuditCode` 的机制)。
2302
3144
  * 写成 `string` 的话,新码会被静默报成 `managed`,而界面照着它说一句
2303
3145
  * 和真实原因无关的话。
3146
+ *
3147
+ * @param opts.remember 这一发算不算用户显式挑的(2026-08-31)。同 `SetLevelView`
3148
+ * 那份镜像:**照抄真身的必填形状,别写成可选的**,判据在那儿。
2304
3149
  */
2305
- setLevel: (level: PermissionLevel) => LevelChangeResult;
3150
+ setLevel: (level: PermissionLevel, opts: {
3151
+ remember: boolean;
3152
+ }) => LevelChangeResult;
2306
3153
  /**
2307
3154
  * ⚠️ 第七样,消费方也不在这个文件里 —— 是 [tools.ts](./tools.js)
2308
3155
  * (设置 › 工具那一节)。摆在这儿的理由同上面 `setLevel` 那一段:
@@ -2362,6 +3209,21 @@ interface RuntimeSecurity {
2362
3209
  isolation: IsolationView | null;
2363
3210
  permissions: PermissionsView | null;
2364
3211
  policy: PolicyView;
3212
+ /**
3213
+ * 装配时那份合并后的配置里,沙箱开关那一格(方案 46 §11.3 第四条的后半格)。
3214
+ *
3215
+ * 只镜像 `sandbox.terminal` 这一个键:它是「这次 terminal 的命令包不包」的
3216
+ * **配置事实**,和 {@link RuntimeSecurity.isolation} 那份实测报告是两个正交的
3217
+ * 轴 —— 所以它在载荷上单独一格(`WireSandboxStatus.terminalEnabled`),
3218
+ * 不并进后端那一行、也不并进 `covers` / `excludes` 那两张常量清单(判据逐字
3219
+ * 同 `epoch doctor` 的「开关」那一行)。`EpochRuntime.config` 结构上正好
3220
+ * 满足它,同其余几份镜像的机制:对不上就是装配那一行编译不过。
3221
+ */
3222
+ config: {
3223
+ sandbox?: {
3224
+ terminal?: boolean;
3225
+ };
3226
+ };
2365
3227
  }
2366
3228
  /**
2367
3229
  * 五块一次取齐。
@@ -2579,8 +3441,24 @@ interface LiveSessionView {
2579
3441
  * 换模型那条路要问「**这段**对话里有没有图片」。写成交集而不是各借一个字段,
2580
3442
  * 判据同它原来在 `WebRuntimeView.session` 上那句:它们说的是**同一个对象**,
2581
3443
  * 拆成两个字段的话装配那边可以只填其中一个。
3444
+ *
3445
+ * ⚠️ **2026-08-26 起又多一个 {@link SessionContextFacts}**(`/compact` 那条路要
3446
+ * `compact()`,`/context` 那条路要 `getHistory()` 的**文本**投影)。同一条判据第三次
3447
+ * 用上:三样说的是同一个对象,拆成三个字段的话装配那边可以只填其中一个。
3448
+ */
3449
+ readonly session: HubSession & SessionHistoryFacts & SessionContextFacts;
3450
+ /**
3451
+ * **这个会话自己那个循环**(2026-08-26)。转出来只为一件事:
3452
+ * `GET /api/sessions/:id/context` 要问「现在的上下文预算怎么构成的」,
3453
+ * 而那是循环才答得上的。
3454
+ *
3455
+ * ⚠️ **不是 `EpochRuntime.contextBreakdown`。** 那个 getter 答的恒是**引导会话**
3456
+ * ——它内部拿的是装配时那个 `agent` 和 `session`。拿它去答任意一个 sessionId,
3457
+ * 就是 2026-08-15 从 `WebRuntimeView` 上删掉 `checkpoints` 的那条 bug 原样重演
3458
+ * (方案 30 §9.5):编译得过、跑得通、只是答错会话,而第二个会话看到的是
3459
+ * 别人的预算构成 —— 且屏幕上没有任何一个字说得出这件事。
2582
3460
  */
2583
- readonly session: HubSession & SessionHistoryFacts;
3461
+ readonly agent: SessionLoopView;
2584
3462
  /**
2585
3463
  * **这个会话此刻用哪个模型**。runtime 那边 provider 起不来时一个会话都建不出来,
2586
3464
  * 所以这一格不为 null。
@@ -2929,6 +3807,15 @@ interface SettingRowView {
2929
3807
  chain: readonly SettingStepView[];
2930
3808
  overridable: boolean;
2931
3809
  writes: readonly SettingWriteView[];
3810
+ /**
3811
+ * 界面画哪种输入控件。**钉成 {@link WireSettingValueKind} 而不是 `string`**,
3812
+ * 判据同 `layer` 那一处(文件头第 2 条):引擎那边多一档控件形态而忘了同步
3813
+ * wire 上这个联合,装配那一行当场编译不过 —— 写成 `string` 的话,那一档会
3814
+ * 一路发到浏览器,然后落进「认不出就当输入框」的兜底分支里,
3815
+ * 而那看起来只是「这个控件有点怪」,查不出来。
3816
+ */
3817
+ valueKind?: WireSettingValueKind;
3818
+ choices?: readonly string[];
2932
3819
  }
2933
3820
  /** runtime 的 `SettingWriteReport` 的镜像。`reason` 是码,不是句子 —— 见文件头第 1 条 */
2934
3821
  type SettingWriteReportView = {
@@ -3204,6 +4091,42 @@ interface WebRuntimeView extends RuntimeCapabilities, RuntimeSecurity, RuntimeSe
3204
4091
  * 而 DELETE / PATCH 回 503 —— 不是假装成功。
3205
4092
  */
3206
4093
  sessions: SessionCatalog | null;
4094
+ /**
4095
+ * `@:` 引用另一段会话那一片(方案 53 PR-4)。SQLite 起不来时为 null。
4096
+ * `EpochRuntime.sessionReferences` 结构上正好满足它。
4097
+ *
4098
+ * ## ⚠️ 借的是**那个收窄面**,不是会话库
4099
+ *
4100
+ * 上面 {@link sessionStore} 是「回放旧会话」的读路,这一格是「把另一段会话塞进
4101
+ * 这一条消息」—— 它只交出候选 / 解析 / 当前面三件事,**交不出任意 SQL**。
4102
+ * 这不是省事:那三件事里最要紧的**可读性判定在 core 里**(和方案 48 共用一处),
4103
+ * 而服务端不许 import core。让服务端自己去 `sessionStore` 上拼一条 SQL 出来,
4104
+ * 等于给 `@:` 开一条绕过那个判定的读取通道 —— 而 `@:` 存在的前提恰恰是
4105
+ * 「它永远不比 `session_search` 宽松」(方案 53 §4.3)。
4106
+ * 判据全文在 `EpochRuntime.sessionReferences` 的 JSDoc 上。
4107
+ *
4108
+ * 为 null 时那条候选端点回 503 `no-session-store`(**不是**一份空清单 ——
4109
+ * 判据同 `no-goal-store`),而发消息那条路上 `@:xxx` 一律得到
4110
+ * `invalid-reference`:说「没找到唯一匹配的会话」比悄悄丢掉那个提及诚实。
4111
+ */
4112
+ sessionReferences: SessionReferencesView | null;
4113
+ /**
4114
+ * `@文件` 补全与解析(方案 63)。`EpochRuntime.workspaceFiles` 结构上正好满足它
4115
+ * —— **对不上就是两边长歪了,装配那一行当场编译不过**。
4116
+ *
4117
+ * ## ⚠️ 不可为 null,判据逐字同 `EpochRuntime.workspaceFiles` 那一格
4118
+ *
4119
+ * 它不依赖任何会起不来的东西:清单 `git ls-files` 失败退 walk,读文件失败有
4120
+ * 各自的码(`FileReadOutcome`)。权限层缺席时它照样在,只是读跳过那道判定。
4121
+ *
4122
+ * ## ⚠️ 它和 {@link sessionReferences} 是同一件事的两半
4123
+ *
4124
+ * 那一格让服务端列会话候选而**交不出任意 SQL**;这一格让服务端列文件候选、
4125
+ * 读正文而**交不出任意 `readFileSync`**。收窄的是同一句话 —— `@` 永远不比
4126
+ * `file_read` 宽松,而判定只有权限层那一份(`toolName: 'file_read'`)。
4127
+ * 服务端这里要读文件就得走 `workspaceFiles.readFile`,没有第二条路。
4128
+ */
4129
+ workspaceFiles: WorkspaceFilesView;
3207
4130
  /**
3208
4131
  * 每会话的工作区绑定([决定 18](../../../design/web-ui/README.md))。
3209
4132
  * `EpochRuntime.workspaces` 结构上满足它。
@@ -3238,6 +4161,54 @@ interface WebRuntimeView extends RuntimeCapabilities, RuntimeSecurity, RuntimeSe
3238
4161
  * 「校验不过就什么都不写」这条不变量会分散到两处去守。
3239
4162
  */
3240
4163
  schedules: ScheduleControlView;
4164
+ /**
4165
+ * 目标(方案 52 PR-4)。`EpochRuntime.goalCatalog` 结构上正好满足它 ——
4166
+ * **对不上就是两边长歪了,`cli/src/commands/web.ts` 那一行当场编译不过**。
4167
+ *
4168
+ * ## ⚠️ 借的是 `goalCatalog` 而**不是** `runtime.goals`
4169
+ *
4170
+ * 后者是**引导会话**那一份(`GoalControl`,收窄到一段会话)。拿它去答 URL 上
4171
+ * 那个 sessionId,就是上面 `sessionFactory` 那段 ⚠️ 讲的那条 bug 的形状 ——
4172
+ * 「编译得过、跑得通、只是答错会话」(方案 30 §9.5)。这一格从第一天起借的
4173
+ * 就是按 id 现查的那一份。
4174
+ *
4175
+ * ## ⚠️ 可为 null,而这和上面 `schedules` 那一格刻意不同
4176
+ *
4177
+ * 目标是**落盘的状态**:会话库起不来时它整个不存在,判据逐字同 TUI 那一侧的
4178
+ * 「会话库起不来时 `/goal` 压根不注册」。为 null 时那四条会话级端点回 503
4179
+ * `no-goal-store`(不是一份 `goal: null` —— 那是「这个会话没有目标」,
4180
+ * 两句话在界面上是两种画法),而 dock 那条角标端点回空数组。
4181
+ * 全文在 [goal/view.ts](./goal/view.js) 的文件头。
4182
+ *
4183
+ * ## ⚠️ 它也**不像检查点那样向工厂要**
4184
+ *
4185
+ * 目标没有内存态 —— `GoalService` 每次现读 SQLite,所以一段冷却掉的会话在这
4186
+ * 一格上和活着的一样准。走工厂那条路的话,用户打开一段昨天的会话会看到
4187
+ * 「这个会话还没有目标」,而库里那条正躺在那儿。那张三格对照表在
4188
+ * `goal/view.ts` 里。
4189
+ */
4190
+ goalCatalog: GoalCatalogView | null;
4191
+ /**
4192
+ * 插件那一页(方案 59 §六 E2,2026-08-21)。`EpochRuntime.plugins` 结构上正好
4193
+ * 满足它 —— **对不上就是两边长歪了,`cli/src/commands/web.ts` 那一行当场编译不过**。
4194
+ *
4195
+ * ## ⚠️ 可为 null,而这一档和 `goalCatalog` / `sessions` 那两格**不同**
4196
+ *
4197
+ * 那两个是「底下那层起不来了」(SQLite 没起来),这一个是**「宿主没打算给用户
4198
+ * 这条路」**:`buildRuntime()` 没收到 `hostMarketplaces` 时它就是 `null`,
4199
+ * 而那是刻意的 —— 它是一个真的新攻击面(往 `~/.epoch/plugins/` 里落东西,
4200
+ * 而落进去的 hook / `mcp.json` 下一程会起子进程),所以是**选进来**的,
4201
+ * 不是白送的(判据在 `build.ts` 那一格的 JSDoc 上)。
4202
+ *
4203
+ * 为 null 时六条端点一律 503 `no-plugin-control`(**不是**一份空列表 ——
4204
+ * 判据同 `no-goal-store`:那两句话在界面上是两种画法)。
4205
+ * ⚠️ **`epoch web` 今天就落在这一档上**,见 [plugin.ts](./plugin.js) 文件头那条 ⚠️。
4206
+ *
4207
+ * 借的是**一整片控制面**而不是几个方法,理由同 `schedules`:那一片本来就是一个
4208
+ * 整体(列 / 搜 / 预览 / 装 / 更新 / 卸 + 一格 `pendingRestart`),而拆成散字段
4209
+ * 之后「装完了、还没生效」这条不变量会分散到两处去守。
4210
+ */
4211
+ plugins: PluginControlView | null;
3241
4212
  dispose(): void;
3242
4213
  }
3243
4214
  interface ApiContext {
@@ -3262,8 +4233,49 @@ interface ApiContext {
3262
4233
  * 今天的两个消费方:那条端点的 403,和 `GET /api/config` 上原样下发的那一格
3263
4234
  * (界面据此在按下去之前把话说出来)。完整判据在 protocol 的
3264
4235
  * `wire-agent-role.ts` 文件头。
4236
+ *
4237
+ * ⚠️ **2026-09-01 起它还是另一格的输入**:{@link nativeDirPicker} 由它算出来
4238
+ * (远程绑定时不许弹原生框)。那一格是**算出来的结论**,这一格是**事实** ——
4239
+ * 别把两者合成一个,判据在它自己那条 JSDoc 上。
3265
4240
  */
3266
4241
  lanExposed: boolean;
4242
+ /**
4243
+ * 这个进程能不能替浏览器弹一个**系统目录选择框**(2026-09-01)。
4244
+ *
4245
+ * 真源是 `workspace/native-pick.ts` 的 `resolveNativeDirPicker()`,装配时算一次
4246
+ * (回环绑定 + 非 SSH + 平台在支持列表里,三件的合取;它继承的那个 `ssh -L`
4247
+ * 盲区也写在那儿)。
4248
+ *
4249
+ * ## ⚠️ 和 {@link lanExposed} 一样**必传**,理由却更硬一档
4250
+ *
4251
+ * 那一格忘了传的后果是「以为自己在本机」;这一格忘了传(默认成 `true`)的后果是
4252
+ * **一个远程用户点一下,框弹在服务器那台没人看着的机器上,而这一发 HTTP 就
4253
+ * 挂在那儿等一个永远不会有的答复**。所以它没有默认值,而且往「不能弹」那一侧落。
4254
+ *
4255
+ * 两个消费方,形状同上:`POST /api/workspaces/pick` 的 403,和
4256
+ * `GET /api/config` 上原样下发的那一格(界面据此决定那一下走原生框还是自绘
4257
+ * 那一屏)。完整判据在 protocol 的 `WirePickDirectoryResponse` 那一节。
4258
+ */
4259
+ nativeDirPicker: boolean;
4260
+ /**
4261
+ * 浏览器多半就在这个服务进程的机器上吗(2026-09-02)。
4262
+ *
4263
+ * 真源是 `workspace/native-pick.ts` 的 `isSameMachine()` —— **和
4264
+ * {@link nativeDirPicker} 是同一个函数、同一批事实**,今天两格的值必然相等。
4265
+ *
4266
+ * ## 那为什么是两格,而不是一格
4267
+ *
4268
+ * 因为它们是**两个结论**,只是今天恰好共用一条判据:一格答「能不能弹目录框」
4269
+ * (要 osascript / PowerShell 那两套 UI 实现),一格答「能不能把文件交给系统
4270
+ * 默认应用」(要的是 `open` / `rundll32`)。合成一格之后,哪天有人给目录框
4271
+ * 补上 Linux 的 `zenity`,「用默认应用打开」会**跟着**在 Linux 上打开 ——
4272
+ * 而那一侧一次都没验过。判据在 `isSameMachine` 那条 📮 上。
4273
+ *
4274
+ * 消费方只有一个:`GET /api/sessions/:id/file-stat` 里 `openable` 那一格
4275
+ * (界面据此决定画不画那个按钮 —— 决定 20 ①)。⚠️ **`POST .../file-open`
4276
+ * 自己也要再查一遍**,别指望浏览器只在按钮亮着的时候才发那一下。
4277
+ */
4278
+ sameMachine: boolean;
3267
4279
  }
3268
4280
 
3269
4281
  /**
@@ -3565,6 +4577,122 @@ interface WorkspaceDiffOptions {
3565
4577
  */
3566
4578
  declare function collectWorkspaceDiff(opts: WorkspaceDiffOptions): Promise<WireWorkspaceDiffResponse>;
3567
4579
 
4580
+ /**
4581
+ * 在**服务进程那台机器**上弹一个系统目录选择框 —— `POST /api/workspaces/pick`
4582
+ * (2026-09-01)。
4583
+ *
4584
+ * ## 它推翻的是 [browse.ts](./browse.js) 文件头那四条里的一条半
4585
+ *
4586
+ * 那份文件头 2026-08-17 写着「为什么不弹原生对话框」四条,末尾那句是
4587
+ * 「哪天这个赌注被推翻了,回来读这一段,**别重新推一遍**」。这一份就是那一天,
4588
+ * 所以这里**只记这一轮新增的判据**,四条各自的下场逐条写在 protocol 的
4589
+ * `WirePickDirectoryResponse` 上(那儿是契约,也是判据的落点)。一句话版:
4590
+ * ① 远程那条**成立**,于是有了下面那道闸;④ 信任那条**作废**(方案 55 §2.6
4591
+ * 判掉了「新建目录默认信任」,今天所有目录一律未信任,裸路径什么都没丢)。
4592
+ *
4593
+ * ## 这道闸是**四件事的合取**,而其中第一件是一句推断
4594
+ *
4595
+ * {@link resolveNativeDirPicker} 那五行的形状照抄 deepseek-harness 的
4596
+ * `packages/host/directory-picker-auto/src/resolve.ts`(同一个形态:纯浏览器页 +
4597
+ * 本机 Node 服务端),判据也是同一条:**原生框弹在服务进程那台机器的屏幕上**,
4598
+ * 所以只有在「浏览器多半就在那台机器上」时它才是对的。
4599
+ *
4600
+ * ⚠️ **继承了它一个修不掉的盲区,如实记下**:`ssh -L` 端口转发时,请求从回环
4601
+ * 进来、`SSH_*` 又不在服务进程的环境里(服务是本机先起的),于是这道闸判成
4602
+ * 「能弹」——框弹在一台没人看着的机器上。harness 的 README 明写这一档
4603
+ * per-connection 适配也修不了,那种部署要直接钉死走自绘。我们这一侧的兜底是
4604
+ * 一样的:`epoch web` 起在一台会被远程访问的机器上时,用 `--host 0.0.0.0`
4605
+ * (那时这道闸自己就关了),别用回环 + 转发。
4606
+ *
4607
+ * ## ⚠️ 第二个盲区,这一个是 2026-09-01 真机量出来的
4608
+ *
4609
+ * **macOS 上,一个连不到 WindowServer 的进程调 `choose folder` 不会报错 ——
4610
+ * 它直接回那个进程的 cwd。** 现场(同一台机器,同一条命令):
4611
+ *
4612
+ * | 跑在哪儿 | `osascript … choose folder` 的结果 |
4613
+ * | ---------------------------- | ---------------------------------------------- |
4614
+ * | 普通终端会话 | 真弹框;取消回 `execution error: 用户已取消。 (-128)` |
4615
+ * | 被 seatbelt 关起来的子进程 | **1.5 秒后回一条路径**(就是 cwd),一个框都没有 |
4616
+ *
4617
+ * 也就是说那一档下用户会**拿到一个他从没挑过的目录**,而屏幕上没有任何东西
4618
+ * 说过这件事。**这道闸拦不住它**:那台机器上 `launchctl managername` 照样是
4619
+ * `Aqua`(实测),SSH 那两格也是空的 —— 会话元数据看着完全正常,被挡掉的是
4620
+ * 真正那次连接。
4621
+ *
4622
+ * **没有为它加探测**,判据是「加一个查不出这一档的探测,比不加更坏」:
4623
+ * 它会让下一个人以为这条缝已经堵上了。今天的形态是**如实记在这儿**,
4624
+ * 而现实里踩得到它的是「epoch web 被某个沙箱化的父进程拉起来」这一种 ——
4625
+ * 用户自己在终端里敲 `epoch web` 不在其中。
4626
+ *
4627
+ * 📮 真要堵它,能想到的唯一可靠做法是**让框自己说话**:给 `choose folder` 一个
4628
+ * `default location`(比如 home),那时「回的是 cwd」就成了一个可判的信号 ——
4629
+ * 但它同时也把「系统记住的上次位置」这件事顶掉了,那是原生框最值钱的东西之一。
4630
+ * 谁要收这张 📮,先量一遍那两件哪个更贵。
4631
+ *
4632
+ * ## 平台只写两个,而这不是「先做一半」
4633
+ *
4634
+ * AGENTS.md「支持平台」那一节只有 Windows 和 macOS。Linux 分支写了也没人验
4635
+ * (harness 那边是 `zenity → kdialog` 两级降级),所以这里**一个字都不写** ——
4636
+ * 那台机器上这道闸判成假,界面回落自绘那一屏,而那一屏在任何机器上都能用。
4637
+ * 这是「不做」,不是「没做完」:加 Linux 的人要连着加它的探测(`DISPLAY` /
4638
+ * `WAYLAND_DISPLAY` + PATH 上有没有那两个二进制),别只加一条命令。
4639
+ *
4640
+ * ## 三条实现上的规矩
4641
+ *
4642
+ * 1. **`execFile` 不过 shell**,全仓同一条(`cli/src/host-actions.ts` /
4643
+ * `workspace/git.ts`)。这一条在这儿尤其硬:Windows 那一支是一段脚本文本;
4644
+ * 2. **不设 timeout。** `git.ts` 那个 10 秒在这儿是错的 —— 一个对话框本来就可以
4645
+ * 开五分钟。取而代之的是 {@link PickOptions.signal}:HTTP 请求断开(用户关了
4646
+ * 标签页)就杀掉那个子进程,否则那个框会永远留在别人屏幕上;
4647
+ * 3. **同一进程只许一个框**({@link pickNativeDirectory} 的单飞闸)。两个框叠在
4648
+ * 一台机器上,用户答的那一个未必是浏览器还等着的那一个。
4649
+ */
4650
+ /** 这台机器上能不能弹的四件事,全是**事实**不是结论 —— 判据在下面那个函数上 */
4651
+ interface NativePickerFacts {
4652
+ /** 服务绑在回环之外吗(`bind.ts` 的 `decideBinding().lanExposed`) */
4653
+ lanExposed: boolean;
4654
+ /** `process.platform` */
4655
+ platform: NodeJS.Platform;
4656
+ /** `process.env` 的一个子集 —— 只读这两格,见下 */
4657
+ env: Readonly<Partial<Record<'SSH_CONNECTION' | 'SSH_TTY', string>>>;
4658
+ }
4659
+ /**
4660
+ * 这个服务进程能不能替浏览器弹一个系统目录框。**纯函数,装配时算一次。**
4661
+ *
4662
+ * 四条缺一不可,每一条挡的是一种「框弹出来也没用 / 弹错地方」:
4663
+ *
4664
+ * | 条件 | 挡的是什么 |
4665
+ * | ------------------------ | --------------------------------------------------------- |
4666
+ * | 绑在回环上 | `--host 0.0.0.0` 那一档浏览器可能在别的机器上 |
4667
+ * | 没有 `SSH_CONNECTION` / `SSH_TTY` | 服务是被 ssh 进来的人起的 —— 框会弹在无人值守的服务器上 |
4668
+ * | 平台是 darwin / win32 | 别的平台这一份没有实现(见文件头「平台只写两个」) |
4669
+ *
4670
+ * ⚠️ **一律往「不能弹」那一侧落**:猜错成「能弹」的表现是用户点下去吃一个 403
4671
+ * 或者框弹在别人屏幕上,猜错成「不能弹」的表现只是他看到自绘那一屏 ——
4672
+ * 而那一屏在任何机器上都能用。
4673
+ */
4674
+ declare function resolveNativeDirPicker(facts: NativePickerFacts): boolean;
4675
+ /**
4676
+ * 「浏览器多半就在服务进程这台机器上」—— 上面那个判断的**正身**。
4677
+ *
4678
+ * 2026-09-02 抽出来,因为有了第二个问同一句话的地方:Web UI 的
4679
+ * 「用默认应用打开」(`workspace/file-view.ts`)。它和弹目录框是**同一个赌注**
4680
+ * —— `open` / `rundll32` 起的那个窗口和 `choose folder` 弹的那个框,
4681
+ * 出现在同一块屏幕上;那块屏幕前面没人的话,两者一样白搭。
4682
+ *
4683
+ * ⚠️ **两处共用一份,不许各写各的。** 三条判据里最容易被漏掉的是 SSH 那两格
4684
+ * (它问的是「这个服务是被谁起的」,不是「这一发请求从哪儿来」),
4685
+ * 而漏掉它的表现不是报错,是**一个窗口弹在无人值守的服务器上**。
4686
+ *
4687
+ * ⚠️ 它继承 `resolveNativeDirPicker` 文件头记的那两个盲区,一个字都没少:
4688
+ * `ssh -L` 端口转发这道闸判不出来;macOS 上被沙箱关起来的进程也判不出来。
4689
+ *
4690
+ * 📮 平台那一条对这一格其实**偏严**了:`xdg-open` 在 Linux 上是有的,
4691
+ * 而目录框那一侧没有 Linux 实现。今天故意不放宽 —— AGENTS.md 的支持平台只有
4692
+ * Windows / macOS,放宽等于多一条没人验的分支。要放宽的人先去改那一节。
4693
+ */
4694
+ declare function isSameMachine(facts: NativePickerFacts): boolean;
4695
+
3568
4696
  /**
3569
4697
  * 后台任务的取数与投影 —— `GET /api/sessions/:id/tasks`(方案 36 PR-2 的 Web 那半)。
3570
4698
  *
@@ -3675,10 +4803,12 @@ declare function toWireTask(sessionId: string, info: BackgroundTaskInfo, registr
3675
4803
  declare function collectTasks(sessionId: string, registry: TaskRegistryView): WireTasksResponse;
3676
4804
 
3677
4805
  /**
3678
- * 定时任务那九条端点([方案 45](../../../../.agents/plans/45-scheduled-automation-plan.md) PR-3)。
4806
+ * 定时任务那十条端点([方案 45](../../../../docs/verify/VERIFY_RECORD-45-automation.md)
4807
+ * PR-3 九条 + PR-4 那条 `pending`)。
3679
4808
  *
3680
4809
  * ```
3681
4810
  * GET /api/schedules 两个 tab 一次取齐
4811
+ * GET /api/schedules/pending 还欠着的那几张欠条(PR-4)
3682
4812
  * POST /api/schedules 建一条
3683
4813
  * GET /api/schedules/:id 一条的全部字段
3684
4814
  * PATCH /api/schedules/:id 改一条(含那个开关)
@@ -3904,4 +5034,4 @@ type CreateWebServerResult = {
3904
5034
  */
3905
5035
  declare function createWebServer(opts: CreateWebServerOptions): Promise<CreateWebServerResult>;
3906
5036
 
3907
- export { type ApiContext, type AuthGuardOptions, type BindDecision, type BindRequest, type BoundWorkspace, type CatalogDeletion, type CatalogHit, type CatalogRow, type CheckpointSummaryView, type CommandsView, type CreateSessionResult, type CreateWebServerOptions, type CreateWebServerResult, DEFAULT_MAX_ACTIVE_SESSIONS, DEFAULT_MAX_QUEUED_MESSAGES, DEFAULT_RING_CAPACITY, DEFAULT_WEB_HOST, DEFAULT_WEB_PORT, EnvelopeRing, type EpochWebServer, type ExpansionOutcome, type FrameSink, type HubSession, type HubSessionState, type LiveSessionView, MAX_DIFF_BYTES, MAX_DIFF_FILES, MAX_RECORDING_FRAMES, MAX_SKILL_BODY_BYTES, type ModelTurnsView, type QueuedMessage, type RewindFilePlanView, type RewindOutcomeView, type RewindPreviewView, type RewindResultView, type RuntimeCapabilities, type RuntimeCheckpoints, type RuntimeCommands, type RuntimeSecurity, type RuntimeSettings, type ScheduleCapabilityView, type ScheduleControlView, type ScheduleCreateView, type ScheduleFireView, type ScheduleRegisterView, type ScheduleSaveView, type ScheduleUpdateView, type SelectionView, type SessionCatalog, type SessionFactoryView, type SessionHistoryFacts, SessionHub, type SessionHubOptions, type SessionModelView, type SessionPermissionsView, type SessionPlanView, type SessionRoleView, type SetLevelView, type SetSelectionView, type StartResult, TASK_TAIL_BYTES, type TaskRegistryView, type TrustView, type TurnDecorator, UI_LANG_PARAM, UI_THEME_PARAM, type UiLangPref, type UiPreset, type UiThemePref, type WebRuntimeView, type WorkspaceBindFailure, type WorkspaceBindOutcome, type WorkspaceCheckpoints, type WorkspaceDiffOptions, type WorkspaceView, collectCapabilities, collectSecurity, collectSettings, collectTasks, collectWorkspaceDiff, createAuthGuard, createWebServer, decideBinding, defaultWebRoot, expandUserContent, firstScreenUrl, generateToken, readRewindInput, resolveArtifactPath, toWireCheckpoint, toWireCommand, toWirePreview, toWireRewindResult, toWireRow, toWireSkillBody, toWireTask, unavailableToHttp };
5037
+ export { type ApiContext, type AttachedMentions, type AuthGuardOptions, type BindDecision, type BindRequest, type BoundWorkspace, type CatalogDeletion, type CatalogHit, type CatalogRow, type CheckpointSummaryView, type CommandsView, type CreateSessionResult, type CreateWebServerOptions, type CreateWebServerResult, DEFAULT_MAX_ACTIVE_SESSIONS, DEFAULT_MAX_QUEUED_MESSAGES, DEFAULT_RING_CAPACITY, DEFAULT_WEB_HOST, DEFAULT_WEB_PORT, EnvelopeRing, type EpochWebServer, type ExpansionOutcome, type FrameSink, type HubSession, type HubSessionState, type LiveSessionView, MAX_CANDIDATES, MAX_DIFF_BYTES, MAX_DIFF_FILES, MAX_RECORDING_FRAMES, MAX_SKILL_BODY_BYTES, type ModelTurnsView, type NativePickerFacts, type PluginControlView, type QueuedMessage, type RewindFilePlanView, type RewindOutcomeView, type RewindPreviewView, type RewindResultView, type RuntimeCapabilities, type RuntimeCheckpoints, type RuntimeCommands, type RuntimeSecurity, type RuntimeSettings, type ScheduleCapabilityView, type ScheduleControlView, type ScheduleCreateView, type ScheduleFireView, type ScheduleRegisterView, type ScheduleSaveView, type ScheduleUpdateView, type SelectionView, type SessionCatalog, type SessionContextFacts, type SessionFactoryView, type SessionHistoryFacts, SessionHub, type SessionHubOptions, type SessionLoopView, type SessionModelView, type SessionPermissionsView, type SessionPlanView, type SessionReferencesView, type SessionRoleView, type SetLevelView, type SetSelectionView, type StartResult, TASK_TAIL_BYTES, type TaskRegistryView, type TrustView, type TurnDecorator, UI_LANG_PARAM, UI_THEME_PARAM, type UiLangPref, type UiPreset, type UiThemePref, type WebRuntimeView, type WorkspaceBindFailure, type WorkspaceBindOutcome, type WorkspaceCheckpoints, type WorkspaceDiffOptions, type WorkspaceView, attachMentions, collectCapabilities, collectSecurity, collectSettings, collectTasks, collectWorkspaceDiff, createAuthGuard, createWebServer, decideBinding, defaultWebRoot, expandUserContent, firstScreenUrl, generateToken, isSameMachine, listFileCandidates, listReferenceCandidates, readRewindInput, resolveArtifactPath, resolveNativeDirPicker, toWireCheckpoint, toWireCommand, toWirePluginEntry, toWirePreview, toWireRewindResult, toWireRow, toWireSkillBody, toWireTask, unavailableToHttp };