@epoch-agent/runtime 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { GoalRoundResult, Goal, GoalWrite, GoalClear, Workspace, TrustDecision, ProjectContext, RecentWorkspace, TrustGate, RecentWorkspaces, PermissionManager, SessionManager, FtsHealth, FtsReclaim, AgentLoop, PluginCommandDir, InstallPreview, PluginRecord, PluginSourceType, PluginCounts, MarketplaceEntry, ScheduleRegistrar, HeadlessPolicy, CreateScheduleInput, ScheduleIssue, RegisterOutcome, UpdateScheduleInput, needsAllowlist, HookSourceStatus, ConfigOrigins, SkillDirSpec, ProviderRouter, AgentProvider, GoalService, MemoryManager, SkillSystem, SkillLearner, HookManager, ContextCompressor, TrackerDB, TrustManager, ModelPricing, LayerOutcome, ManagedPolicy, PluginArtifacts, CheckpointSummary, RewindPreview, RewindOutcome, ToolRevealState, CodeSandbox, ISkillSystem, ToolRegistry, QuestionAskFn, ResponseFormat, ProviderWarning, SettingValue, SettingLayer, SettingStep, SettingWriteTarget, SessionReferences, IsolationReport, RoleMergeNotice, SkillMeta, SkillIndexResidency, SkillImportResult, SkillImportPreview, SkillImportDone, ShadowFinding, BaseOrigin, PermissionAuditScope, PermissionAuditSnapshot, CachedApproval, ToolGate, SettingWriteLayer, SettingApply, SettingWriteFailure, PluginPathRef } from '@epoch-agent/core';
1
+ import { GoalRoundResult, Goal, GoalWrite, GoalClear, Workspace, TrustDecision, ProjectContext, RecentWorkspace, TrustGate, RecentWorkspaces, PermissionManager, SessionManager, FtsHealth, FtsReclaim, AgentLoop, PluginCommandDir, InstallPreview, PluginRecord, PluginSourceType, PluginCounts, MarketplaceEntry, ScheduleRegistrar, HeadlessPolicy, CreateScheduleInput, ScheduleIssue, RegisterOutcome, UpdateScheduleInput, needsAllowlist, HookSourceStatus, ConfigOrigins, SkillDirSpec, ProviderRouter, AgentProvider, GoalService, MemoryManager, SkillSystem, SkillLearner, HookManager, ContextCompressor, TrackerDB, TrustManager, ModelPricing, LayerOutcome, ManagedPolicy, PluginArtifacts, ComplianceEngine, CheckpointSummary, RewindPreview, RewindOutcome, ToolRevealState, CodeSandbox, ISkillSystem, ToolRegistry, QuestionAskFn, ResponseFormat, ProviderWarning, SettingValue, SettingLayer, SettingStep, SettingWriteTarget, SettingValueKind, SessionReferences, IsolationReport, RoleMergeNotice, SkillMeta, SkillIndexResidency, SkillImportResult, SkillImportPreview, SkillImportDone, ShadowFinding, BaseOrigin, PermissionAuditScope, PermissionAuditSnapshot, CachedApproval, ToolGate, SettingWriteLayer, SettingApply, SettingWriteFailure, PluginPathRef } from '@epoch-agent/core';
2
2
  export { DEFAULT_GOAL_MAX_ROUNDS, MAX_GOAL_MAX_ROUNDS, MAX_OBJECTIVE_CHARS, MAX_REASON_CHARS, SANDBOX_COVERS, SANDBOX_EXCLUDES, SCHEDULE_DEFAULTS, SCHEDULE_DEFAULT_ALLOWLIST, nextRunAt, readRecording, skillIndexDescription } from '@epoch-agent/core';
3
3
  import { McpServerConfig, McpServerStatus, McpServerSource, McpTransport, McpAuthStatus } from '@epoch-agent/plugin-mcp';
4
4
  export { McpAuthState, McpAuthStatus, McpServerConfig } from '@epoch-agent/plugin-mcp';
@@ -2204,6 +2204,14 @@ interface ProviderOption {
2204
2204
  envVar: string | null;
2205
2205
  /** 这台机器上这把 key 配好了没有。**不需要 key 的那家恒 `true`** */
2206
2206
  hasKey: boolean;
2207
+ /**
2208
+ * 那把 key 长什么样 —— **脱敏后的**(`maskApiKey`)。没配 / 不需要时是 `null`。
2209
+ *
2210
+ * ⚠️ 这一格是**唯一**一处让解出来的 key 沾到外面的地方,而它沾出去的是
2211
+ * 一个不可逆的展示串。判据(以及「为什么一个布尔不够」)在网线那一份
2212
+ * `WireProviderOption.keyHint` 上。
2213
+ */
2214
+ keyHint: string | null;
2207
2215
  }
2208
2216
  /** 探一家的结果。和网线那一份 `WireModelSuggestions` 逐字同形 */
2209
2217
  interface ModelSuggestions {
@@ -2218,13 +2226,25 @@ interface DiscoverOptions {
2218
2226
  /** 绕开磁盘缓存重新探。同 `epoch model --refresh` */
2219
2227
  refresh?: boolean;
2220
2228
  }
2229
+ /** {@link ModelCatalogControl.setKey} 写完之后,那把 key 落到哪儿了 */
2230
+ interface ProviderKeyWritten {
2231
+ /** 写进的那个环境变量名。回声一遍 —— 调用方手里只有 provider 类型 */
2232
+ envVar: string;
2233
+ /** `keychain` / `dpapi` / `libsecret` / `plaintext`。**必须回显给用户** */
2234
+ backend: string;
2235
+ encrypted: boolean;
2236
+ /** 明文降级时那个 `.env` 在哪儿;进了加密后端就是 `null` */
2237
+ envPath: string | null;
2238
+ /** 脱敏后的那把 key。⚠️ **不是原文**,判据同 {@link ProviderOption.keyHint} */
2239
+ keyHint: string;
2240
+ }
2221
2241
  /**
2222
- * 宿主看得见的那一面 —— **只有两个动词**。
2242
+ * 宿主看得见的那一面 —— **两个问句加一个写**。
2223
2243
  *
2224
2244
  * `clearModelCache` 刻意不单独露出来:它唯一的正当用法就是「清掉再探一次」,
2225
2245
  * 而那两步之间任何一次早退都会留下一个**被清空却没有重填**的缓存,
2226
2246
  * 下一次探不通时用户看到的是「一个模型都没有」而不是上次那份。
2227
- * 所以它被并进 {@link discover} 的 `refresh` 里,两步绑死。
2247
+ * 所以它被并进 {@link discover} 的 `refresh`(以及 {@link setKey} 的收尾)里,两步绑死。
2228
2248
  * 同 `WorkspaceControl` 刻意不露 `seed()`、`McpControl` 只有 `reconnect()`。
2229
2249
  */
2230
2250
  interface ModelCatalogControl {
@@ -2242,6 +2262,33 @@ interface ModelCatalogControl {
2242
2262
  * 探这一家有哪些模型。**从不抛,也从不回 null** —— 见 {@link ModelSuggestions.status}。
2243
2263
  */
2244
2264
  discover(provider: ProviderType, opts?: DiscoverOptions): Promise<ModelSuggestions>;
2265
+ /**
2266
+ * 给这一家配一把 key(2026-08-31)。落点由 `SecretStore` 的降级链决定,
2267
+ * 结果回声在 {@link ProviderKeyWritten} 上。
2268
+ *
2269
+ * ## ⚠️ 这一个和上面两个不同档:它**会抛**
2270
+ *
2271
+ * 上面两个是问句,问不到有一档「探不到」可落;这一个是**写**,
2272
+ * 而一次没落地的写没有「另一档结果」—— 静默吞掉等于告诉用户「配好了」
2273
+ * 而其实没有。两种抛法:
2274
+ *
2275
+ * - 这家**不要 key**(Ollama,`apiKeyEnv === null`)。这不是运行期故障,
2276
+ * 是调用方问错了问题 —— 收窄口上不该存在一个「配了也没用」的动作,
2277
+ * 所以它在这儿就断,不往下走到 store。
2278
+ * - `SecretStore` 那一发自己失败(钥匙串拒绝、磁盘满)。原样抛出去。
2279
+ *
2280
+ * ## 写完**顺手清掉这一家的模型缓存**,两步绑死
2281
+ *
2282
+ * 探测结果落盘缓存 24 小时。不清的话,用户刚配完 key 再探一次,
2283
+ * 拿到的还是没 key 那会儿回退出来的**静态目录** —— 而
2284
+ * `cli/src/commands/model.ts` 的文件头逐字记着那个后果:
2285
+ * 「后果不是『少几个模型』而是**列表完全不对**」。
2286
+ * 这一步不给调用方选,同 `discover` 的 `refresh` 不给单独的 `clear`。
2287
+ *
2288
+ * ⚠️ 「让本进程立刻看得见这把 key」那一步**不在这儿**,在 infra 的
2289
+ * `writeProviderSecret()` 里 —— `epoch model` 和这条路要的是同一件事。
2290
+ */
2291
+ setKey(provider: ProviderType, apiKey: string): Promise<ProviderKeyWritten>;
2245
2292
  }
2246
2293
  interface ModelCatalogOptions {
2247
2294
  /** 数据目录。缓存文件和凭据解析都按它分命名空间 */
@@ -2305,6 +2352,7 @@ declare function buildModelCatalogControl(opts: ModelCatalogOptions): ModelCatal
2305
2352
  * | 档 | 说的是 | 下一步 |
2306
2353
  * | ----------------- | -------------------------------------------------- | -------------- |
2307
2354
  * | `unknown-ref` | 不是 `<市场>/<插件>`,或者那个市场 / 插件不存在 | 换个引用 |
2355
+ * | `not-local` | 本地那条路收到的不是一条**本地绝对路径** | 换条绝对路径 |
2308
2356
  * | `remote-disabled` | 它是远程 source,而 `allowRemote` 关着 | 宿主去开那个开关 |
2309
2357
  * | `conflict` | 已经装过同名的 | 先卸载 |
2310
2358
  * | `stale-token` | 你确认的和现在要装的对不上,**一个字节都没写** | 重看一次预览 |
@@ -2312,7 +2360,7 @@ declare function buildModelCatalogControl(opts: ModelCatalogOptions): ModelCatal
2312
2360
  * | `nothing-to-do` | **不是出错**:软链装的插件一直是最新的,没得更新 | 什么都不用做 |
2313
2361
  * | `failed` | 其余(拉不到 / 清单坏了 / 写记录时被别的进程抢先) | 看 `detail` |
2314
2362
  */
2315
- type PluginRefuseReason = 'unknown-ref' | 'remote-disabled' | 'conflict' | 'stale-token' | 'not-installed' | 'nothing-to-do' | 'failed';
2363
+ type PluginRefuseReason = 'unknown-ref' | 'not-local' | 'remote-disabled' | 'conflict' | 'stale-token' | 'not-installed' | 'nothing-to-do' | 'failed';
2316
2364
  /** 拒绝的共同形状。`preview` 有值时说明失败发生在读完清单之后,界面可以照样画那一屏 */
2317
2365
  interface PluginRefusal {
2318
2366
  ok: false;
@@ -2389,24 +2437,46 @@ type PluginInstallOutcome = {
2389
2437
  * 所以 `HostMarketplaces.allowRemote` **默认 false**,而被它挡下的那一条
2390
2438
  * **是拒绝 + 一句话,不是静默跳过**:静默的表现是搜索里明明有那一条、点了没反应。
2391
2439
  *
2392
- * ## 四、⚠️ 这一层**只收 `<市场>/<插件>`,收不了一条路径** —— 比 `skill-import` 更紧
2440
+ * ## 四、⚠️ 装进来的动词有**两个**,各自只表达得出一种来源
2441
+ *
2442
+ * | 动词 | 入参 | 表达得出 |
2443
+ * | ----------------------------------- | -------- | ---------------------------- |
2444
+ * | {@link PluginControl.install} | `ref` | `<市场>/<插件>`,仅此 |
2445
+ * | {@link PluginControl.installLocal} | `path` | 本机一条**绝对路径**,仅此 |
2446
+ * | 没有第三个 | — | — |
2447
+ *
2448
+ * 两个入参上都**没有**「source」这个东西。于是「给我一个 `github:acme/x` 我就
2449
+ * clone」这件事在这两个形状下**都说不出口** —— 同 `HostAgentRole` 少一个 `source`
2450
+ * 的手法,不是靠 review 盯住的,是编译期的事。
2451
+ *
2452
+ * ### ⚠️ 这一节 2026-09-04 翻过案:原来只有第一行
2393
2453
  *
2394
- * {@link PluginControl.preview} / {@link PluginControl.install} 的入参上**没有**
2395
- * 「source」这个东西,只有一个市场引用。于是「指一条任意路径装一个插件」这件事
2396
- * 在这个形状下**说不出口** —— 同 `HostAgentRole` 少一个 `source` 的手法,
2397
- * 不是靠 review 盯住的,是编译期的事。逐条对着今天那条最像的路
2398
- * (`server/src/skill-import.ts`):
2454
+ * 原话是「这一层**只收 `<市场>/<插件>`,收不了一条路径** —— `skill-import`
2455
+ * 更紧」。翻案的是**本地路径那一档**,判据全文(三条)在 protocol 的
2456
+ * `wire-plugin.ts` 文件头第一节,这里只记最要紧的那一条:
2399
2457
  *
2400
- * | 判据 | `skill-import` 今天 | 这一条 |
2401
- * | ---------------- | ------------------------- | -------------------------- |
2402
- * | 能不能传字节 | 不能(只收路径) | 不能 |
2403
- * | 能不能指任意路径 | **能**(收一个任意 path) | **不能**(只能从清单里挑) |
2404
- * | 清单是谁放的 | — | 宿主主进程 / 用户自己敲的 |
2458
+ * **原来那条路在真实部署上是断的。** `epoch web --plugins` 传的是
2459
+ * `{ sources: [] }` —— 一台自己机器上起的界面没有任何预置市场,而加市场当时只能
2460
+ * 在命令行上。于是「从界面装一个插件」在 CLI 宿主上从来没有成立过。
2461
+ * 一道**只在没人走得到的路上生效**的闸门不是安全性,是一个空转的判断。
2462
+ *
2463
+ * ⚠️ 而**真正的那道闸原地不动**:本地那条在网线那一侧照样吃
2464
+ * `server/src/plugin.ts` 的 403(`lanExposed` 为真时一律拒)。
2465
+ * 本地路径这一档的全部前提就是「网线另一头就是这台机器」。
2466
+ *
2467
+ * 逐条对着今天那条最像的路(`server/src/skill-import.ts`):
2468
+ *
2469
+ * | 判据 | `skill-import` 今天 | `install` | `installLocal` |
2470
+ * | ---------------- | ------------------------- | -------------------- | ------------------- |
2471
+ * | 能不能传字节 | 不能(只收路径) | 不能 | 不能 |
2472
+ * | 能不能指任意路径 | **能**(收一个任意 path) | **不能** | **能,但只限本地** |
2473
+ * | 远程写法收不收 | — | 只收清单里那条 | **不收**(`not-local`)|
2405
2474
  *
2406
2475
  * ⚠️ 「清单」= `~/.epoch/marketplaces.json` 里那些,也就是**宿主预置的**加上
2407
- * **用户自己 `epoch plugin marketplace add` 加的**。后者照样算数:那是用户在自己
2408
- * 机器上做的决定,和宿主主进程同一档。这条路唯一挡死的是「谁都没列过的那个东西」——
2409
- * 要装清单外的插件,路仍然在,只是**在命令行上**(那一问的闸门只该有一份)。
2476
+ * **用户自己加的**(命令行那条 `epoch plugin marketplace add`,或
2477
+ * {@link PluginControl.addMarketplace})。后者照样算数:那是用户在自己机器上做的
2478
+ * 决定,和宿主主进程同一档。这条路唯一挡死的是「谁都没列过的那个**远程**东西」——
2479
+ * 要装那种,路仍然在,只是**在命令行上**。
2410
2480
  *
2411
2481
  * ## 五、preview → install 是两段,中间那条缝用 token 缝上
2412
2482
  *
@@ -2452,6 +2522,21 @@ interface PluginListEntry {
2452
2522
  */
2453
2523
  counts?: PluginCounts;
2454
2524
  }
2525
+ /**
2526
+ * 已加的一个市场 —— {@link PluginControl.marketplaces} 交出来的一行。
2527
+ *
2528
+ * `local` 是**这一层算好的结论**(拿 `isLocalSource` 判一次),不是把
2529
+ * `allowRemote` 那个开关透出去让上层自己推 —— 判据同 {@link PluginSearchHit.installable}:
2530
+ * 一个问题只该有一个答案,两处能推的那天它们就会分叉。
2531
+ */
2532
+ interface PluginMarketplaceEntry {
2533
+ name: string;
2534
+ source: string;
2535
+ /** 目录里有几条。**0 是合法的** —— 一个空市场和「没加过市场」是两回事 */
2536
+ entries: number;
2537
+ addedAt: number;
2538
+ local: boolean;
2539
+ }
2455
2540
  /** 市场里的一条,加上「在当前开关下装不装得上」 */
2456
2541
  interface PluginSearchHit {
2457
2542
  marketplace: string;
@@ -2473,12 +2558,23 @@ type PluginActionOutcome = {
2473
2558
  ok: true;
2474
2559
  detail: string;
2475
2560
  } | PluginRefusal;
2561
+ /** 加 / 移除一个市场的结局。加成功那一档带回刚进来的那一条 */
2562
+ type PluginMarketOutcome = {
2563
+ ok: true;
2564
+ marketplace?: PluginMarketplaceEntry;
2565
+ } | PluginRefusal;
2476
2566
  /**
2477
- * 收窄到六个动词的控制面。
2567
+ * 收窄到十一个动词的控制面。
2478
2568
  *
2479
- * 只有这六个,于是上层(`@epoch-agent/server` 那一页,方案 59 E2)**写不出**
2480
- * 「往任意路径装一个插件」和「改一条安装记录」—— `McpControl` 只露
2481
- * `reconnect`、`SkillControl` 只露两个导入动作。
2569
+ * 只有这些,于是上层(`@epoch-agent/server` 那一页,方案 59 E2)**写不出**
2570
+ * 「改一条安装记录」,也写不出「给我一个 `github:acme/x` 我就 clone」——
2571
+ * `McpControl` 只露 `reconnect`、`SkillControl` 只露两个导入动作。
2572
+ *
2573
+ * ⚠️ **装进来的动词有两个({@link install} / {@link installLocal}),
2574
+ * 而不是一个带 source 的**。判据全文在 protocol 的 `wire-plugin.ts` 文件头
2575
+ * 第一节(含 2026-09-04 那次翻案):一个 `install(source)` 能表达任意来源,
2576
+ * 这一对各自只表达得出一种,而它们的风险不是一个量级
2577
+ * (本地 = `symlinkSync`,市场那条可能 clone / 拉包)。
2482
2578
  *
2483
2579
  * ⚠️ 刻意**没有 enable / disable**(core 的 `setPluginEnabled` 在那儿摆着):
2484
2580
  * 那两个动作的正当性和「装 / 卸」不是一回事(停用一个插件会**静默拿掉**别人正
@@ -2511,6 +2607,17 @@ interface PluginControl {
2511
2607
  * ⚠️ 装完**不会生效**,{@link pendingRestart} 会变成 `true`。
2512
2608
  */
2513
2609
  install(ref: string, token: string, lang?: Lang): Promise<PluginInstallOutcome>;
2610
+ /**
2611
+ * 「将安装什么」,来源是**本机一条目录**(2026-09-04 补,判据在文件头第四节
2612
+ * 那次翻案)。
2613
+ *
2614
+ * ⚠️ **和 {@link preview} 是两个方法而不是一个重载**,这是形状上的事:一个
2615
+ * `preview(source)` 能表达 `github:acme/x`,而这一对方法**只表达得出一条路径**。
2616
+ * 传进来的东西不是本地绝对路径时回 `not-local`,不会被当成远程顺手装了。
2617
+ */
2618
+ previewLocal(path: string, lang?: Lang): Promise<PluginPreviewOutcome>;
2619
+ /** 真装一条本地目录。`token` 来自上一次 {@link previewLocal} */
2620
+ installLocal(path: string, token: string, lang?: Lang): Promise<PluginInstallOutcome>;
2514
2621
  /**
2515
2622
  * 「更新会带来什么」。同 {@link preview},只是来源取自那条安装记录。
2516
2623
  *
@@ -2528,6 +2635,31 @@ interface PluginControl {
2528
2635
  * **还活着** —— 同样要重建,见 {@link pendingRestart}。
2529
2636
  */
2530
2637
  uninstall(name: string, lang?: Lang): Promise<PluginActionOutcome>;
2638
+ /**
2639
+ * 已加了哪几个市场。**纯读盘**(`marketplaces.json`),一次网都不上。
2640
+ *
2641
+ * ⚠️ 和 {@link search} 是两个问题:那个答「有什么插件」,这个答「**清单是谁放的**」。
2642
+ * 一个空的 `search()` 分不出「没加过市场」和「加了但市场是空的」——
2643
+ * 而在 `epoch web --plugins`(`sources: []`)那一档下,前者是常态。
2644
+ */
2645
+ marketplaces(): PluginMarketplaceEntry[];
2646
+ /**
2647
+ * 加一个市场。**取一份目录**(本地就读文件,远程要上网),成了就写进
2648
+ * `marketplaces.json`。
2649
+ *
2650
+ * ⚠️ **远程市场吃 `allowRemote`**:取目录是一次网络动作,判据同
2651
+ * `wireHostMarketplaces` 里那个循环(那个文件头明写着「两处是同一个风险的两处
2652
+ * 出口」)。挡下的那一条是**拒绝 + 一句话**,不是静默跳过。
2653
+ *
2654
+ * ⚠️ 加一个市场**一个扩展物都不动** —— 它改的是「有什么可以装」。
2655
+ * 所以这条路**不碰** {@link pendingRestart}。
2656
+ */
2657
+ addMarketplace(source: string, lang?: Lang): Promise<PluginMarketOutcome>;
2658
+ /**
2659
+ * 移除一个市场。**只改那份清单,装着的插件一个都不动** ——
2660
+ * 从它装的那些照常在(安装记录里存的是 source,不是市场名)。
2661
+ */
2662
+ removeMarketplace(name: string, lang?: Lang): Promise<PluginMarketOutcome>;
2531
2663
  /**
2532
2664
  * **这一程动过插件、而那些改动还没生效。**
2533
2665
  *
@@ -2751,10 +2883,24 @@ interface RoleWriteControl {
2751
2883
  */
2752
2884
  type LevelChangeResult = {
2753
2885
  ok: true;
2886
+ /**
2887
+ * 「记住这一档」那一半的结局(2026-08-31)。**只在没记住时有值** ——
2888
+ * 记住了、或者这一发压根没要求记(`remember: false`)时是 `undefined`。
2889
+ *
2890
+ * ⚠️ 它**不影响 `ok`**:档位已经换成了。两件事分开报的判据在
2891
+ * {@link RememberDefaultLevel} 上。
2892
+ */
2893
+ notRemembered?: RememberFailure;
2754
2894
  } | {
2755
2895
  ok: false;
2756
2896
  reason: 'managed-bypass-disabled';
2757
2897
  };
2898
+ /** 没记住的成因。**码不是句子**,逐字对应 `WirePermissionRememberFailure`(判据在那儿) */
2899
+ type RememberFailure = {
2900
+ reason: 'no-user-config' | 'unwritable' | 'io' | 'env-pinned';
2901
+ /** 本来要写的那个文件;说不出路径的那一档整个不带 */
2902
+ path?: string;
2903
+ };
2758
2904
  /**
2759
2905
  * Plan 模式的出口(方案 35)。
2760
2906
  *
@@ -2819,7 +2965,7 @@ interface SessionPermissions {
2819
2965
  bypassDisabled: boolean;
2820
2966
  };
2821
2967
  /**
2822
- * 切到某一档。**这是改权限档位的唯一入口**,三件事必须在这一次调用里
2968
+ * 切到某一档。**这是改权限档位的唯一入口**,四件事必须在这一次调用里
2823
2969
  * 一起发生,顺序也是判据:
2824
2970
  *
2825
2971
  * 1. **托管挡 bypass**(方案 22 §2.6)。挡下时级别一个字不动,回 `ok:false`;
@@ -2827,11 +2973,20 @@ interface SessionPermissions {
2827
2973
  * 3. **`plan.forget()`**:用户接管了方向盘,plan 模式记着的「进入前那一档」
2828
2974
  * 当场作废。⚠️ 是 `forget()` 不是 `leave()`:后者会把级别**恢复**成进入前
2829
2975
  * 那一档,正好覆盖掉用户这一下刚选的那个。
2976
+ * 4. **`opts.remember` 为真时把它记成新会话的起点**(2026-08-31)。
2977
+ * 排在第 3 步之后不是随手排的:记的是「用户此刻要的那一档」,
2978
+ * 而第 1 步可能已经把这一发整个否掉了。
2830
2979
  *
2831
2980
  * 判定住在这一处而不是每个宿主抄一份 —— 抄一份的具体代价是**第三步会被漏掉**,
2832
2981
  * 而那件事只在「plan 模式 + 中途换档 + 批准计划」三件事凑齐时才看得见。
2982
+ *
2983
+ * @param opts.remember 这一发**算不算用户显式挑的**。必填,判据全文在
2984
+ * {@link DefaultLevelSlot} 那张三行表上 —— 一句话:定时任务的
2985
+ * `def.permission` 走的是同一个方法,而它绝不能改用户的默认档。
2833
2986
  */
2834
- setLevel: (level: PermissionLevel) => LevelChangeResult;
2987
+ setLevel: (level: PermissionLevel, opts: {
2988
+ remember: boolean;
2989
+ }) => LevelChangeResult;
2835
2990
  }
2836
2991
 
2837
2992
  /**
@@ -3021,7 +3176,9 @@ interface ExecutorRuntime {
3021
3176
  } | null;
3022
3177
  permission: PermissionManager | null;
3023
3178
  permissions: {
3024
- setLevel: (level: ScheduleDefinition['permission']) => LevelChangeResult;
3179
+ setLevel: (level: ScheduleDefinition['permission'], opts: {
3180
+ remember: boolean;
3181
+ }) => LevelChangeResult;
3025
3182
  rules: () => ReadonlyArray<{
3026
3183
  bucket: 'allow' | 'ask' | 'deny';
3027
3184
  rule: string;
@@ -3316,6 +3473,16 @@ interface Services {
3316
3473
  *
3317
3474
  * ⚠️ 它是**起点**不是现状:`permission.getLevel()` 才是「此刻是哪一档」,
3318
3475
  * 而那一格从这一轮起一个会话一份。
3476
+ *
3477
+ * ⚠️ **2026-08-31:它是「起点的初值」,不再是起点本身。**
3478
+ * 起点从这一轮起会动(用户显式挑一档就跟着走),那一格是
3479
+ * `session-guard.ts` 的 `DefaultLevelSlot`,`build.ts` 用这个字段给它开局。
3480
+ * 上面那句「新建的会话从这一档起步」因此要读成「**第一个**新建的会话」——
3481
+ * 用户换过档之后,起点是他挑的那一档。
3482
+ *
3483
+ * 那句「不继承别的会话此刻那一档」**一个字都没变**:改起点的是「用户挑」这个
3484
+ * 动作,不是别的会话的现状(plan 模式和定时任务都改得动某个会话此刻那一档,
3485
+ * 而起点一个字不动 —— 三行表在 `DefaultLevelSlot` 上)。
3319
3486
  */
3320
3487
  permissionLevel: PermissionLevel;
3321
3488
  contextEngine: ContextCompressor | null;
@@ -3400,6 +3567,17 @@ interface Services {
3400
3567
  * 在这儿接等于把「MCP 从哪儿来」分成两处答。
3401
3568
  */
3402
3569
  plugins: PluginArtifacts;
3570
+ /**
3571
+ * 内容合规闸门(core 的 `compliance/`)。
3572
+ *
3573
+ * **关掉时是 `null` 而不是一个「什么都不拦」的引擎**:`AgentConfig.compliance`
3574
+ * 缺席那条路上,模型输出一个字都不多走一层(判据在 `ComplianceEngine`
3575
+ * 的文件头 —— 不开这个功能的用户不该为它付任何代价)。
3576
+ *
3577
+ * 装配层每一处 `new AgentLoop` 都要把它填进去,子 agent 那条也要。
3578
+ * 会红的门禁在 `runtime/__tests__/compliance-wiring.test.ts`。
3579
+ */
3580
+ compliance: ComplianceEngine | null;
3403
3581
  /** 需要在退出时执行的清理动作,后进先出 */
3404
3582
  cleanups: Array<() => void>;
3405
3583
  }
@@ -4297,22 +4475,34 @@ declare function applyMcpConfig(mcp: McpApplySource, sync: ToolSync, opts: {
4297
4475
  * 一个已有字节都不用碰:逗号长在**新**那一段上。所以新加的排在文件最前面,
4298
4476
  * 那是这条判据的结果,不是随手定的顺序。
4299
4477
  *
4300
- * ## 扫描器只认三样东西,**不做 `JSON.parse`**
4478
+ * ## 扫描器搬走了,判据没变
4301
4479
  *
4302
- * 字符串(含转义)、`//` 行注释、`/* *\/` 块注释 —— 够定位 `servers` 那个对象的
4303
- * `{`,也够把它已有的成员名数出来(撞名要用)。
4304
- *
4305
- * ⚠️ **写口扛得住注释,而读口今天扛不住**(`parseMcpConfig` 走的是硬 `JSON.parse`)。
4306
- * 两件事都是真的,别把它们并成一句:写口的职责是「别毁掉他的文件」,不是「替读口
4307
- * 再校验一遍」。让 `mcp.json` 支持注释是**另一次决定**(它改的是「哪些配置算合法」,
4308
- * 落点在 plugin-mcp),这一轮不接 —— 但这一轮**绝不能**因为读口读不了就把那几行
4309
- * 注释顺手抹掉。
4480
+ * 那一段(`skipTrivia` / `readString` / `skipValue` / `walkObject` / `locate` /
4481
+ * `indentOf`)2026-09-02 搬进了 [mcp-json-scan.ts](./mcp-json-scan.ts),因为
4482
+ * 有了第二、第三个调用方(就地**替换**和**删除**,见下)。判据全文跟着一起搬,
4483
+ * 别在这儿读一个摘要就动手。
4310
4484
  *
4311
4485
  * ## 看不懂就一个字节都不写
4312
4486
  *
4313
- * 我们对这个文件只有**一次插入**的权利。猜错一次的代价是把他手写的配置弄坏,
4314
- * 而他知道这件事的时刻是「MCP 全没了」的那一天。所以扫描器走不到收尾的 `}`
4315
- * 就整段拒绝,并把「哪儿看不懂」说出来。
4487
+ * 猜错一次的代价是把他手写的配置弄坏,而他知道这件事的时刻是「MCP 全没了」的
4488
+ * 那一天。所以扫描器走不到收尾的 `}` 就整段拒绝,并把「哪儿看不懂」说出来。
4489
+ *
4490
+ * ## 📮 2026-09-02:「我们对这个文件只有**一次插入**的权利」这句话翻了
4491
+ *
4492
+ * 上面那一节原来的开头就是这句话,它是本文件所有判据的总纲。**这一轮把它推翻
4493
+ * 了**,而推翻它的理由不是「插入不够用」,是**模型手上那条路没有第二个选项**:
4494
+ *
4495
+ * `mcp_update` / `mcp_remove` 那两个工具([mcp-tools.ts](./mcp-tools.ts))要么
4496
+ * 走就地改写,要么走「读原文 → 整份写回」。而后者意味着**把整份 `mcp.json` 交进
4497
+ * 模型的上下文** —— 里面装着他每一台 server 的 `env` 密钥(判据在
4498
+ * `writeMcpConfigFile` 那句「新建的那一档给 0600,因为这个文件天生装着 token」)。
4499
+ * 一条为了保住排版而存在的判据,不该以「顺带把密钥全交出去」为代价来维持。
4500
+ *
4501
+ * ⚠️ **所以变的是「几次」,不是「什么」。** 就地替换和删除照旧是**定位 + 拼接
4502
+ * 文本**,`parse → serialize` 那一步在这一族里仍然不存在,上面那三节
4503
+ * (round-trip 更毒 / 只做追加不成立 / 另存一处不成立)一个字都没过期。
4504
+ * 删除是这一族里**唯一**会动到用户已经写下的字节的动作(那个逗号),
4505
+ * 它的边界逐条写在 [mcp-config-edit.ts](./mcp-config-edit.ts) 文件头。
4316
4506
  */
4317
4507
 
4318
4508
  /**
@@ -4363,6 +4553,11 @@ type McpWriteFailure = {
4363
4553
  reason: 'unwritable';
4364
4554
  detail: string;
4365
4555
  };
4556
+ /** 插进文本之后的产物,或者为什么没插 */
4557
+ type McpSpliceOutcome = {
4558
+ ok: true;
4559
+ text: string;
4560
+ } | McpWriteFailure;
4366
4561
  /** 落盘之后的结局。`configPath` 要发到界面上 —— 用户接下来多半想去改那个文件 */
4367
4562
  type McpWriteOutcome = {
4368
4563
  ok: true;
@@ -4396,6 +4591,116 @@ declare function writeMcpServer(input: McpAddInput, opts?: {
4396
4591
  taken?: Iterable<string>;
4397
4592
  }): McpWriteOutcome;
4398
4593
 
4594
+ /**
4595
+ * `~/.epoch/mcp.json` 的**就地替换与删除**(2026-09-02)—— 隔壁
4596
+ * [mcp-config-write.ts](./mcp-config-write.ts) 那条插入路的另外两个动作。
4597
+ *
4598
+ * ## 它为什么存在:模型手上那条路没有第二个选项
4599
+ *
4600
+ * 判据全文在那个文件头的「📮 2026-09-02」那一节,一句话:`mcp_update` /
4601
+ * `mcp_remove` 要么走就地改写,要么走「把整份原文交进模型的上下文再整份写回」,
4602
+ * 而那份原文里装着用户**每一台** server 的 `env` 密钥。一条为了保住排版而存在
4603
+ * 的判据,不该以「顺带把密钥全交出去」为代价来维持。
4604
+ *
4605
+ * ⚠️ **变的是「几次」,不是「什么」。** 这里照旧是**定位 + 拼接文本**,
4606
+ * `parse → serialize` 那一步在这一族里仍然不存在 —— 注释、空行、字段顺序、
4607
+ * snake_case 键名、未知键,凡是不在被改那一台身上的,一个字节都不动。
4608
+ *
4609
+ * ## ⚠️ 一、删除是这一族里唯一会动到**用户已经写下的字节**的动作
4610
+ *
4611
+ * 插入之所以能做到零改动,是因为逗号长在**新**那一段上(那个文件头第 39 行那一
4612
+ * 节)。删除做不到:被删的那台前面或后面那个逗号是**他写的**,而留着它就是一份
4613
+ * 坏 JSON。所以这一条边界必须明写:
4614
+ *
4615
+ * **我们只动那一个逗号,以及只装着空白的那一行。** 具体说:
4616
+ *
4617
+ * | 现场 | 动了什么 |
4618
+ * | -------------------------------------- | -------------------------------------------- |
4619
+ * | 它后面紧跟一个逗号(中间只有空白) | 连那个逗号一起删 |
4620
+ * | 它是最后一个成员,前面有一个逗号 | 连**前面**那个逗号一起删 |
4621
+ * | 删完那一行只剩空白 | 整行连换行一起删(不留一行空缩进) |
4622
+ * | 删完那一行还剩别的东西(注释、另一台) | **只删它自己那一段**,那一行剩下什么留什么 |
4623
+ *
4624
+ * ⚠️ **「中间只有空白」是硬条件,别放宽成 `skipTrivia`。** 那个函数会跳过注释,
4625
+ * 于是
4626
+ *
4627
+ * ```jsonc
4628
+ * "a": { … }
4629
+ * // 这台先不用
4630
+ * ,
4631
+ * ```
4632
+ *
4633
+ * 里那条注释会落在删除区间里 —— 一次静默的注释丢失,而用户看不出是谁干的。
4634
+ * 判据同插入那条「绝不能因为读口读不了就把那几行注释顺手抹掉」。
4635
+ *
4636
+ * 剩下一档**明说不管**:逗号后面同一行上挂着的行尾注释(`"a": {…}, // 关于 a`)。
4637
+ * 删掉 `a` 之后那条注释会孤零零留在原处。留着它是刻意的 —— 判它「属于 a」要
4638
+ * 猜,而猜错就是删掉用户写的一句话。**一条孤儿注释是噪音,删错一句话是损失。**
4639
+ *
4640
+ * ## ⚠️ 二、替换**不改名字**
4641
+ *
4642
+ * `replaceServerText` 拿 `input.name` 去找那一台,然后把它整段换成新渲染的一段
4643
+ * —— 键名前后一样。改名是「删一台 + 加一台」两个动作,不是这一个:一台 server
4644
+ * 的名字会原样变成工具名的一部分(`mcp__<name>__<工具>`),把改名藏在「更新」
4645
+ * 底下的表现是模型以为自己改了一格配置,而实际上它手上那一批工具全部换了名字。
4646
+ *
4647
+ * ## 三、替换沿用**原文那一台的缩进**,不按 `indent.repeat(2)` 拼
4648
+ *
4649
+ * 判据在 `renderEntry` 的 `pad` 参数上:替换是接在原文已有的缩进后面写的。
4650
+ * 按固定层数拼出来的收尾 `}` 会跑到一个和它上下文对不齐的列上,而那是一次
4651
+ * **看得见的**排版破坏 —— 恰恰是这一族判据要防的那种。
4652
+ */
4653
+
4654
+ /**
4655
+ * 「这个名字在文件里没有」单独一档。
4656
+ *
4657
+ * 不并进 `McpWriteFailure` 的 `invalid-name`:那一档说的是「这个名字不合法」
4658
+ * (换个名字),这一档说的是「合法,但文件里没有这一台」(去列一遍,或者改用
4659
+ * 添加)。两者的下一步完全不同,而模型只能照回执里那句话选下一步。
4660
+ */
4661
+ type McpEditFailure = McpWriteFailure | {
4662
+ ok: false;
4663
+ reason: 'not-found';
4664
+ detail: string;
4665
+ };
4666
+ /** 落盘之后的结局。`configPath` 要发到回执上 —— 用户接下来多半想去看那个文件 */
4667
+ type McpEditOutcome = {
4668
+ ok: true;
4669
+ configPath: string;
4670
+ } | McpEditFailure;
4671
+ /**
4672
+ * 把一台**换成**新的一段,其余字节一个不动。
4673
+ *
4674
+ * 纯函数(文本进、文本出),没有 fs —— 「注释和顺序没被抹掉」那条断言才断得住。
4675
+ */
4676
+ declare function replaceServerText(raw: string, input: McpAddInput): McpSpliceOutcome;
4677
+ /**
4678
+ * 把一台**删掉**,其余字节一个不动(除了那一个逗号,判据在文件头第一节)。
4679
+ *
4680
+ * 纯函数(文本进、文本出)。
4681
+ */
4682
+ declare function removeServerText(raw: string, name: string): McpSpliceOutcome;
4683
+ /**
4684
+ * 改一台已有 server 的配置。**名字不变**,判据在文件头第二节。
4685
+ *
4686
+ * 成员名单**一个字都不该变**(连顺序)—— 那条断言是「替换切歪了」的唯一报警。
4687
+ */
4688
+ declare function updateMcpServer(input: McpAddInput, opts?: {
4689
+ homeDir?: string;
4690
+ lang?: Lang;
4691
+ }): McpEditOutcome;
4692
+ /**
4693
+ * 删掉一台 server。
4694
+ *
4695
+ * ⚠️ **删掉最后一台是一个合法动作** —— 一份 `{"servers": {}}` 是完全正常的
4696
+ * `mcp.json`(绝大多数机器上就是这样)。拿「改完还有没有 server」当校验条件
4697
+ * 会把这一档挡掉,所以这里的校验是**名单少掉点名那一个**,不是「名单非空」。
4698
+ */
4699
+ declare function removeMcpServer(name: string, opts?: {
4700
+ homeDir?: string;
4701
+ lang?: Lang;
4702
+ }): McpEditOutcome;
4703
+
4399
4704
  /**
4400
4705
  * 工具装配 —— 插件 + 内置工具 + MCP 外部工具,全部进同一个 ToolRegistry。
4401
4706
  *
@@ -4531,6 +4836,28 @@ type McpAddOutcome = {
4531
4836
  status: McpServerStatus;
4532
4837
  configPath: string;
4533
4838
  } | McpWriteFailure;
4839
+ /**
4840
+ * 一次「改」的结局(2026-09-02)。形状**和 {@link McpAddOutcome} 一样**,
4841
+ * 因为这两件事在界面 / 回执上要说的话一样(配置落在哪儿 + 这台此刻连上没)。
4842
+ *
4843
+ * 失败那一侧多一档 `not-found`(`McpEditFailure`)—— 判据在那个类型上:
4844
+ * 「这个名字不合法」和「合法但文件里没这一台」的下一步完全不同。
4845
+ */
4846
+ type McpUpdateOutcome = {
4847
+ ok: true;
4848
+ status: McpServerStatus;
4849
+ configPath: string;
4850
+ } | McpEditFailure;
4851
+ /**
4852
+ * 一次「删」的结局(2026-09-02)。
4853
+ *
4854
+ * ⚠️ **没有 `status`**,那不是漏了:这台 server 在这个进程里已经不存在了,
4855
+ * 交一份状态出去就是交一份必然过期的东西。回执要说的只有「从哪个文件里删掉的」。
4856
+ */
4857
+ type McpRemoveOutcome = {
4858
+ ok: true;
4859
+ configPath: string;
4860
+ } | McpEditFailure;
4534
4861
  /**
4535
4862
  * 宿主看得见的那一面 —— **重连,加上「添加」**(后者 2026-08-18 补的)。
4536
4863
  *
@@ -4582,6 +4909,34 @@ interface McpControl {
4582
4909
  * 不是「能不能添加」。别拿这个方法倒推出一个 `setEnabled`。
4583
4910
  */
4584
4911
  add(input: McpAddInput): Promise<McpAddOutcome>;
4912
+ /**
4913
+ * 改一台已有 server(2026-09-02):就地换掉 `mcp.json` 里那一段,再按新配置
4914
+ * 把它重连一次。
4915
+ *
4916
+ * ## ⚠️ 它和 {@link applyConfig} 不是同一个动作,别把它实现成那个的单台版
4917
+ *
4918
+ * `applyConfig` 的宾语是**整份文件**:该断的断、该连的连,包括用户此刻在
4919
+ * 编辑器里刚存下的别的改动。这一条的宾语是**你点名的那一台** —— 别的 server
4920
+ * 一个字节都不碰,哪怕文件里有别人刚写下的待生效改动。
4921
+ *
4922
+ * 那条差别对模型侧那个工具是硬要求:一次 `mcp_update` 顺带把用户还没打算生效
4923
+ * 的三台一起重连了,屏幕上说的却是「改了一台」。
4924
+ *
4925
+ * ## ⚠️ 名字改不了,那是形状不是限制
4926
+ *
4927
+ * `input.name` 是**查询键**,不是新名字。改名等于换掉这台 server 暴露的
4928
+ * 每一个工具名(`mcp__<name>__<工具>`),判据全文在
4929
+ * [mcp-config-edit.ts](./mcp-config-edit.ts) 文件头第二节。
4930
+ */
4931
+ update(input: McpAddInput): Promise<McpUpdateOutcome>;
4932
+ /**
4933
+ * 删掉一台 server(2026-09-02):从 `mcp.json` 里去掉那一段,在这个进程里
4934
+ * 断开它,并把它那批工具从注册表里摘掉。**不可撤销。**
4935
+ *
4936
+ * ⚠️ 只删得掉 `mcp.json` 里那批(`source === 'user'`)。宿主注入的和插件带来
4937
+ * 的两批天生不在这份文件里 —— 判据同 {@link applyConfig} 那条 ⚠️。
4938
+ */
4939
+ remove(name: string): Promise<McpRemoveOutcome>;
4585
4940
  /** 读一遍 `~/.epoch/mcp.json` 的**原文**(2026-08-18)。什么都不改 */
4586
4941
  readConfig(): McpConfigReadOutcome;
4587
4942
  /**
@@ -4939,6 +5294,18 @@ declare function utilityBudgetKey(input: {
4939
5294
  * 而且**只回文本**:二进制探测在前 8KB 上做(判据同 `file_read` 那一条),
4940
5295
  * 不让一个 100MB 的 mp4 为了确认自己是二进制而整个进内存。
4941
5296
  *
5297
+ * ## ⚠️ 2026-09-02:这一格**多了第二个消费方**,而它才是它今天最要紧的用途
5298
+ *
5299
+ * Web UI 的「输出里的路径可点 → 站内查看」走的就是这三个方法
5300
+ * (`GET /api/sessions/:id/file-stat` / `/file` / `/file-bytes`)。
5301
+ * `web/src/inspector/preview.tsx` 的文件头当年拒过这件事,理由是「一个 REST
5302
+ * 端点不走工具执行器,用户写的 `deny file_read(.env*)` 拦不到它」——
5303
+ * **那条理由说的是别处,不是这儿**:上面第 3 条正是为它准备的。
5304
+ *
5305
+ * 所以这一格从今天起是一条**用户也走的路**,而不只是 `@` 补全的后台。
5306
+ * 往上面加方法时判据没变:**任何一个新方法都必须过同一道 `file_read` 闸**,
5307
+ * 少一个,浏览器就能看到模型看不到的东西 —— 而那正是当年拒绝那条端点的理由本身。
5308
+ *
4942
5309
  * ## 这一份是**镜像**,不是「把 core 的类型导出来」
4943
5310
  *
4944
5311
  * 三个函数和 core 的 `WorkspaceFiles` / `resolveInWorkspace` / `readOne` 逐个
@@ -4973,9 +5340,9 @@ interface FileCandidatesResult {
4973
5340
  /**
4974
5341
  * 装配时要递进来的 core 那一份。
4975
5342
  *
4976
- * 写成**两个函数**而不是一个对象:这一格只要这两样,而它俩今天都住在 core 的
4977
- * 两个不同文件里 —— 合成一个对象的话,宿主将来想自己供一份(比如内存文件树)
4978
- * 就得把两个不相关的东西绑在一起。
5343
+ * 写成**三个函数**而不是一个对象:这一格只要这几样,而它们今天住在 core 的
5344
+ * 三个不同文件里 —— 合成一个对象的话,宿主将来想自己供一份(比如内存文件树)
5345
+ * 就得把几个不相关的东西绑在一起。
4979
5346
  */
4980
5347
  interface WorkspaceFilesDeps {
4981
5348
  listFiles(root: string): {
@@ -4983,6 +5350,17 @@ interface WorkspaceFilesDeps {
4983
5350
  truncated: boolean;
4984
5351
  };
4985
5352
  resolveInWorkspace(root: string, userPath: string): string | null;
5353
+ /**
5354
+ * 三平台「交给系统默认应用」的命令表(core 的 `systemOpenArgv`)。
5355
+ *
5356
+ * 递进来而不是 import:判据同上面两个 —— 这个文件一行都不 import core,
5357
+ * 于是它能拿假依赖直接单测。**用例里那份假的正是这一格最要紧的用处**:
5358
+ * 真的去 `spawn` 一个 `open` 会在跑用例的那台机器上弹出窗口。
5359
+ */
5360
+ systemOpenArgv(abs: string): {
5361
+ command: string;
5362
+ args: string[];
5363
+ };
4986
5364
  }
4987
5365
  /**
4988
5366
  * 读一次文件的结果。
@@ -5002,7 +5380,58 @@ type FileReadOutcome = {
5002
5380
  bytes?: number;
5003
5381
  };
5004
5382
  /**
5005
- * `EpochRuntime.workspaceFiles` 在服务端眼里的样子 —— **三个方法,一个不多**。
5383
+ * 看一眼一个文件是什么的结果({@link WorkspaceFilesView.statFile})。
5384
+ *
5385
+ * **码比 {@link FileReadOutcome} 多两档**,因为这一格答的问题不同:`readFile`
5386
+ * 只需要回答「拿不拿得到正文」,而这一格是「路径可点」那条路的判据 ——
5387
+ * 「这儿本来就没东西」(`missing`)和「这是个目录」(`not-a-file`)在界面上是
5388
+ * 两句不同的话,糊成一个 `unreadable` 的代价是用户永远不知道自己是不是打错了路径。
5389
+ *
5390
+ * ⚠️ **`denied` 照旧一个码盖住两件事**(跑出工作区 / 权限不放行),
5391
+ * 判据逐字同 `FileReadOutcome`:分开说等于承认「这个路径存在,只是你没权限」。
5392
+ */
5393
+ type FileStatOutcome = {
5394
+ ok: true;
5395
+ /** 归一化之后的**工作区相对路径**(分隔符统一成 `/`)。调用方拿它去 `readFile` */
5396
+ rel: string;
5397
+ /** 绝对真路径(`realpath` 之后)。给 `createReadStream` 用,见 `statFile` 的 JSDoc */
5398
+ absPath: string;
5399
+ bytes: number;
5400
+ mtimeMs: number;
5401
+ /** 前 8KB 里有没有裸 NUL。判据同 `readFile` 那一处 */
5402
+ kind: 'text' | 'binary';
5403
+ } | {
5404
+ ok: false;
5405
+ reason: 'denied' | 'missing' | 'not-a-file' | 'unreadable';
5406
+ };
5407
+ /**
5408
+ * 这个路径的扩展名在白名单里吗。
5409
+ *
5410
+ * 导出是因为**服务端要拿它算 `WireFileStatOk.openable` 那一格** —— 那一格决定
5411
+ * 界面上画不画那个按钮,而「画一个点了没反应的按钮比不画更糟」(决定 20 ①)。
5412
+ * ⚠️ 它**只答扩展名这一问**:还在不在工作区里、权限放不放行、是不是同一台机器,
5413
+ * 各有各的闸,别拿它一个当结论。
5414
+ */
5415
+ declare function isSystemOpenable(path: string): boolean;
5416
+ /**
5417
+ * 「用什么命令把这个工作区文件交给系统默认应用」的结果。
5418
+ *
5419
+ * **它不含副作用** —— 真正的 `spawn` 在调用方(服务端)。理由同 core 那一侧
5420
+ * `artifactLaunchArgv` / `openArtifact` 的分法:argv 是能在三个平台上被用例
5421
+ * 逐字钉住的东西,而 `spawn` 不是(真跑一次会在跑用例那台机器上弹出窗口)。
5422
+ */
5423
+ type FileOpenPlan = {
5424
+ ok: true;
5425
+ command: string;
5426
+ args: readonly string[];
5427
+ absPath: string;
5428
+ } | {
5429
+ ok: false;
5430
+ reason: 'denied' | 'missing' | 'not-a-file' | 'unreadable' | 'extension-not-allowed';
5431
+ };
5432
+ /**
5433
+ * `EpochRuntime.workspaceFiles` 在服务端眼里的样子 —— **三个方法,一个不多**:
5434
+ * 列候选(`@` 面板)、读正文(`@` 提及 + 站内查看)、看一眼是什么(路径可点)。
5006
5435
  *
5007
5436
  * ⚠️ 每一次调用都收 `root`,**不许在门面上存一个「当前工作区」**:这个门面是
5008
5437
  * **进程级**的(像 `permission`),而工作区**一个会话绑一个**(决定 18)。存根的话,
@@ -5035,11 +5464,47 @@ interface WorkspaceFilesView {
5035
5464
  * 这一格不替调用方算(它不知道这条消息已经用掉多少预算)。
5036
5465
  */
5037
5466
  readFile(path: string, root: string): FileReadOutcome;
5467
+ /**
5468
+ * 「这个路径今天还在不在、是什么、画得出来吗」—— **不读正文**。
5469
+ *
5470
+ * Web UI 那条「路径可点」的路(`GET /api/sessions/:id/file-stat`)一发要问一批
5471
+ * 路径,而它们里面完全可能有一个 100MB 的 mp4:为了确认它存在就把它读进内存,
5472
+ * 是这一格和 {@link readFile} 分成两个方法的**全部理由**。
5473
+ *
5474
+ * 三处和 `readFile` 不一样,逐条都有理由:
5475
+ *
5476
+ * 1. **收得下绝对路径**(先按 `root` 归一化成相对路径再进闸)。`@` 那条路
5477
+ * 刻意拒绝绝对路径(`resolveInWorkspace` 的第一道),因为那是用户手打的;
5478
+ * 而这一格收的是**模型正文里印出来的路径**,那儿绝大多数是绝对路径。
5479
+ * 归一化之后跑不回工作区里的,照样是 `denied`。
5480
+ * 2. **多一道 `realpath` 复查**。`resolveInWorkspace` 只做字符串上的边界判定,
5481
+ * 看不见符号链接 —— 而工作区里完全可以躺着一个指向 `~/.ssh/id_rsa` 的链接
5482
+ * (判据同 `server/src/assets.ts` 那四道闸的第 3 道)。
5483
+ * 3. **二进制探测只 `read` 前 8KB**(`readFile` 是整个读完再看前 8KB)。
5484
+ *
5485
+ * `absPath` 回给调用方是为了**字节那条路**(`file-bytes` 要 `createReadStream`):
5486
+ * 闸在这一格,流在服务端 —— 服务端拿到它就等于拿到了一张通行证,
5487
+ * 所以这个字段只在 `ok` 那一档存在。
5488
+ */
5489
+ statFile(path: string, root: string): FileStatOutcome;
5490
+ /**
5491
+ * 「这个文件能不能交给系统默认应用,用什么命令」—— **不 spawn**。
5492
+ *
5493
+ * 闸是 {@link statFile} 那一整套(边界 → `file_read` 权限 → `realpath` 复查 →
5494
+ * 必须是普通文件),**外加一道扩展名白名单**({@link isSystemOpenable})。
5495
+ * 也就是说:**模型读不到的文件,用户也点不开** —— 这条路没有给浏览器开小灶。
5496
+ *
5497
+ * ⚠️ 这一格答不了、也不该答的那一问是「浏览器和服务在不在同一台机器上」。
5498
+ * 那是服务端的事实(`--host 0.0.0.0` / SSH),判据在
5499
+ * `server/src/workspace/native-pick.ts`。**调用方必须先过那道闸再来问这一格**,
5500
+ * 否则框会弹在别人屏幕上。
5501
+ */
5502
+ planSystemOpen(path: string, root: string): FileOpenPlan;
5038
5503
  }
5039
5504
  /**
5040
5505
  * 建那个收窄面。
5041
5506
  *
5042
- * @param deps core 递来的两样(装配那一行从 `@epoch-agent/core` import)。
5507
+ * @param deps core 递来的那几样(装配那一行从 `@epoch-agent/core` import)。
5043
5508
  * @param permission 权限管理器;**可为 null** —— 那时的语义同 core 那条路的
5044
5509
  * 判据:权限模块没起来,模型调 `file_read` 照样读得到,
5045
5510
  * `@` 单方面收紧只会让两条路给出不一致的答案。
@@ -5459,19 +5924,31 @@ interface MemoryControl {
5459
5924
  }>;
5460
5925
  }
5461
5926
  /**
5462
- * 技能的**导入**口(方案 42 §六,2026-08-18)—— 只有两个动作,都收路径。
5927
+ * 技能那一栏的**写口**(方案 42 §六,2026-08-18;2026-09-01 加上删除)——
5928
+ * 三个动作:预览、导入、删除。前两个收的是路径,第三个收的是技能名。
5463
5929
  *
5464
5930
  * ## 为什么不是把 `SkillSystem` 转出去
5465
5931
  *
5466
5932
  * 同 `MemoryControl` 那条判据,但这一条更硬:`SkillSystem` 上有 `create` /
5467
5933
  * `edit` / `patch` / `delete` / `writeFile` / `removeFile` 六个写方法,还有
5468
- * `view()`(**会 `matchCount++`**,喂着模型看得见哪些技能那本账)和
5469
- * `maintain()`(会**自动删**低效技能)。把它整个交出去,等于让网线那一侧
5470
- * 够得着「删掉用户的技能」和「污染评分」这两件事 —— 而这一轮要的只是「多一份」。
5934
+ * `view()`(**会 `matchCount++`**,喂着模型看得见哪些技能那本账)。把它整个
5935
+ * 交出去,等于让网线那一侧够得着「改写用户的技能」和「污染评分」这两件事。
5471
5936
  *
5472
- * 收窄成这两个方法之后,server 那个包**写不出**别的动作:那不是靠 review 盯住
5937
+ * 收窄成这三个方法之后,server 那个包**写不出**别的动作:那不是靠 review 盯住
5473
5938
  * 的,是编译期的事(同 `McpControl` 只露 `reconnect` 那一条)。
5474
5939
  *
5940
+ * ## ⚠️ 2026-09-01 这一格从两个动作变成三个,判据记在这儿
5941
+ *
5942
+ * 上一版这段话里写着「这一轮要的只是『多一份』」,于是删除被刻意挡在外面。
5943
+ * 那句话今天不成立了:能力页上导得进来、**却没有任何一条路把它拿掉** ——
5944
+ * 用户唯一的出路是自己去 `~/.epoch/skills/` 里 `rm -r`,而那一栏底下那句
5945
+ * 「不想要它,把那个目录挪走」正是在教他这么干。**一个只能进不能出的写口
5946
+ * 不是「更安全」,它只是把不可撤销的那一半推给了用户的终端。**
5947
+ *
5948
+ * 放进来的是 {@link SkillControl.remove} 一个,`create` / `edit` / `patch` /
5949
+ * `writeFile` / `removeFile` / `view` 一个都没跟着进来 —— 那几个是「替用户
5950
+ * 写内容」,和「把用户自己装的那份拿掉」不是一件事。
5951
+ *
5475
5952
  * ## ⚠️ 这两个动作都**不收字节,只收一条服务端本机路径**
5476
5953
  *
5477
5954
  * 这是这一条路上的安全性质本体,不是实现细节。判据全文在 core 的
@@ -5502,7 +5979,43 @@ interface SkillControl {
5502
5979
  * 一个字都不受它影响
5503
5980
  */
5504
5981
  import: (source: string, token: string, lang?: Lang) => Promise<SkillImportResult<SkillImportDone>>;
5982
+ /**
5983
+ * 删掉一个**用户级**技能(2026-09-01)—— 整个目录,递归删,**不可撤销**。
5984
+ *
5985
+ * ⚠️ **它收的是技能名,不是路径**,而那是这个动作上的闸门:宿主说得出
5986
+ * 「删掉叫 alpha 的那个技能」,说不出「删掉 `/etc/…`」。落点由
5987
+ * `SkillSystem` 自己查(它只认自己扫出来的那张表),这一层一个路径都不接。
5988
+ *
5989
+ * ⚠️ **项目 / 插件 / 宿主三档删不掉**,真闸门在 core 的 `requireWritable()`。
5990
+ * 这里额外先查一次 `list()` 只是为了给出 `readonly` 这个**原因码**(好让界面
5991
+ * 说得出下一步),不是第二道闸 —— 别把这里的判断当成安全边界。
5992
+ *
5993
+ * @param lang 回执那句话(含 core 那三句 `readonly_*` 原话)用哪个语言说。
5994
+ * ⚠️ 同 {@link SkillControl.import}:**它只影响回给这一次请求的话**,
5995
+ * 盘上删掉什么一个字都不受它影响
5996
+ */
5997
+ remove: (name: string, lang?: Lang) => Promise<SkillRemoveResult>;
5998
+ }
5999
+ /** 删不成的四档。判据(每一档对哪个下一步)在 protocol 的 `WireSkillRemoveFailure` 上 */
6000
+ type SkillRemoveFailure = 'missing' | 'not-found' | 'readonly' | 'failed';
6001
+ /**
6002
+ * 删一次的结局。
6003
+ *
6004
+ * 成了那一支带 {@link SkillRemoveDone.path} —— **不可撤销的动作,回执得说得出
6005
+ * 销毁的是盘上哪一处**(判据在 protocol 的 `WireSkillRemoveResponse.path` 上)。
6006
+ */
6007
+ interface SkillRemoveDone {
6008
+ name: string;
6009
+ /** 刚刚被递归删掉的那个目录(`<技能目录>/<分类>/<名字>`) */
6010
+ path: string;
5505
6011
  }
6012
+ type SkillRemoveResult = ({
6013
+ ok: true;
6014
+ } & SkillRemoveDone) | {
6015
+ ok: false;
6016
+ reason: SkillRemoveFailure;
6017
+ detail: string;
6018
+ };
5506
6019
  /**
5507
6020
  * 一条会话在列表里的样子。
5508
6021
  *
@@ -5739,11 +6252,17 @@ interface PermissionsControl {
5739
6252
  * `config.permission`,一个纯展示值,改它没有任何意义也没有任何危害 ——
5740
6253
  * 而宿主在那一档下本来就不该画出口(web 那侧靠 `editable: false`)。
5741
6254
  *
6255
+ * @param opts.remember 这一发**算不算用户显式挑的**(2026-08-31)。必填,
6256
+ * 判据和那张三行表在 `session-guard.ts` 的 `DefaultLevelSlot` 上。
6257
+ * ⚠️ 这个包自己就有一个该传 `false` 的调用点(`schedule/executor.ts` 拿
6258
+ * `def.permission` 起一轮定时任务)—— 那是这个参数存在的直接理由。
5742
6259
  * @returns 换成了没有。**不回「换成了哪一档」** —— 那个问 `level()`,
5743
6260
  * 一次调用只答一个问题(判据同 `server/src/plan-mode.ts` 那句「状态回读,
5744
- * 不用上一步的返回值拼」)。
6261
+ * 不用上一步的返回值拼」)。「记住了没有」是另一件事,跟在 `ok:true` 那一支上。
5745
6262
  */
5746
- setLevel: (level: PermissionLevel) => LevelChangeResult;
6263
+ setLevel: (level: PermissionLevel, opts: {
6264
+ remember: boolean;
6265
+ }) => LevelChangeResult;
5747
6266
  /**
5748
6267
  * 工具表 × 判定三轴 —— 「这个工具现在能不能调,是谁在挡它」(方案 43 §七)。
5749
6268
  *
@@ -5817,10 +6336,23 @@ interface EffectiveSetting {
5817
6336
  * 空数组 = 一层都写不了,宿主据此一个写控件都不画。三种成因和整个判据在 core 的
5818
6337
  * `settingWriteTargets()` 上。
5819
6338
  *
5820
- * ⚠️ 它和 `overridable` **今天恰好同真同假,但不是一件事** —— 前者说的是
5821
- * 「③④⑤∞ 能不能覆盖这个键」,这一个说的是「宿主能不能替用户写它」。
6339
+ * ⚠️ 它和 `overridable` **不是一件事,而且从 2026-08-31 起真的分叉了**
6340
+ * (在那之前它们恰好同真同假):前者说的是「**别人的仓库**能不能覆盖这个键」,
6341
+ * 这一个说的是「宿主能不能替用户改他自己的用户级配置」。
6342
+ * 于是 `budget.*` / `compression.*` 这些行 `overridable: false`、`chain: []`、
6343
+ * 却有一个只含 ② 用户级的 `writes`。判据在 core 的 `config/write.ts` 第二节。
5822
6344
  */
5823
6345
  writes: readonly SettingWriteTarget[];
6346
+ /**
6347
+ * 宿主该给这一行画哪种输入控件。**`writes` 非空时必有,空时必无。**
6348
+ *
6349
+ * 真源是 core 的 `settingValueKind()`(从值域 schema 上认)。宿主自己按
6350
+ * 「这一行现在的值是什么类型」去猜是**不行的** —— `budget.*` 四行没有默认值,
6351
+ * 屏幕上是「未设置」、链条上也是空的,猜出来一律是字符串。
6352
+ */
6353
+ valueKind?: SettingValueKind;
6354
+ /** 这一行只有这几个值可选(枚举)。不是枚举时**整个不带这个键** */
6355
+ choices?: readonly string[];
5824
6356
  }
5825
6357
  /** {@link SettingsControl.write} 的入参。`key` / `layer` 都由这一层再验一遍 */
5826
6358
  interface SettingWriteInput {
@@ -6257,6 +6789,33 @@ interface EpochRuntime {
6257
6789
  * 接着涨,也可能是重新开始后恰好超过。所以只能由装配方直说。
6258
6790
  */
6259
6791
  usageScope: 'session' | 'run';
6792
+ /**
6793
+ * 一段会话到此刻**累计**花了多少 —— 从落盘那本账里读,不需要 Hub 里有它。
6794
+ *
6795
+ * ## 它补的是「刷新之后那个数就没了」(2026-09-04)
6796
+ *
6797
+ * `usage` 事件是**帧**:新连上来的浏览器不可能收到过去那几帧。于是 2026-09-02
6798
+ * 搬到输入框那一格的用量在**打开一段旧会话 / 刷新页面**之后是空的 ——
6799
+ * 而同一屏上每一轮的脚注都有数(那几笔 2026-08-18 就落盘了,走
6800
+ * `EpochTurnFacts`)。屏幕上因此出现「逐轮都有账、总账没有」。
6801
+ *
6802
+ * ⚠️ **不能拿逐轮的账相加代替它。** 那两个数真的加不到一起,判据逐字在
6803
+ * `web/src/settings/spend.tsx` 文件头:这一份含 `BudgetStore` 从上个进程恢复的
6804
+ * 历史、还含一轮里最后一次模型调用之后的压缩计费,而 `usageScope === 'run'` 时
6805
+ * 它压根不是会话总额。所以真源只有这一本账。
6806
+ *
6807
+ * ## 缺席的两种情况,都不是故障
6808
+ *
6809
+ * 1. `usageScope === 'run'` —— 没有 `BudgetStore`(状态目录不可写),
6810
+ * 这个函数恒回 `undefined`。而那一档下「会话累计」本来就不存在,
6811
+ * 界面上缺席是**对的**;
6812
+ * 2. 这个会话在账本里没有记录 —— 一轮都还没跑过,或者记录过了保留期
6813
+ * (`BudgetStore` 的 `RETENTION_DAYS`)。`undefined` = 不知道,**不是 0**。
6814
+ *
6815
+ * 回的那份和 `usage` 事件里的 `cumulative` **逐字同一套算术**
6816
+ * (两处都走 `persistedUsage()`),所以直播那一帧接上来时屏幕上的数不会跳。
6817
+ */
6818
+ sessionUsage(sessionId: string): TokenUsage | undefined;
6260
6819
  /**
6261
6820
  * 自动压缩那条线的三个数(决定 8 的第三个时刻,2026-08-16)。
6262
6821
  *
@@ -6398,16 +6957,23 @@ interface EpochRuntime {
6398
6957
  */
6399
6958
  skillIndexResidency(role: string | null): ReadonlyMap<string, SkillIndexResidency>;
6400
6959
  /**
6401
- * 从**本机目录**导入技能(方案 42 §六,2026-08-18)。**不可为 null** ——
6960
+ * 往技能目录里写的那一格(方案 42 §六,2026-08-18):从**本机目录**导入,
6961
+ * 以及(2026-09-01 起)删掉一条用户级技能。**不可为 null** ——
6402
6962
  * 技能系统没起来时它照样在,只是每次都回 `missing`,而那是一句真话
6403
6963
  * (同 `mcp` 那条「一个 server 都没配也照样在」)。
6404
6964
  *
6405
6965
  * ⚠️ 收的是**路径**不是字节,判据在 {@link SkillControl} 上。
6966
+ *
6967
+ * ⚠️ **2026-09-01 从 `skillImport` 改名成 `skillWrite`**(跟着 `roleWrite`
6968
+ * 那一格的叫法)。改名不是整理:那天这一格多了一个 `remove`,而一个叫
6969
+ * `skillImport` 的字段上挂着「递归删目录」是这一层最不该有的假话 ——
6970
+ * 读到它的人会以为自己在看一个只进不出的口子。叫 `skills` 不行,
6971
+ * 那个名字已经被「列出来」那一格占了({@link skills})。
6406
6972
  */
6407
- skillImport: SkillControl;
6973
+ skillWrite: SkillControl;
6408
6974
  /**
6409
6975
  * 建一个用户级身份(2026-08-18)——「新建一个身份」那条路。**不可为 null**:
6410
- * 底下只有一次文件写,没有起不来的可能(同 `skillImport` 那条)。
6976
+ * 底下只有一次文件写,没有起不来的可能(同 `skillWrite` 那条)。
6411
6977
  *
6412
6978
  * ⚠️ **闸门不在这一格上。** 「一个可能不在本机的浏览器能不能往 system prompt
6413
6979
  * 里写一段任意文字」判成**回环绑定才给写**,落点是 `server/src/role-add.ts`
@@ -6435,7 +7001,7 @@ interface EpochRuntime {
6435
7001
  * [plugin-control.ts](./plugin-control.ts) 的文件头第二节。
6436
7002
  *
6437
7003
  * ⚠️ 这条路**只收 `<市场>/<插件>`,收不了一条路径** —— 那是这一轮的安全性质
6438
- * 本体(比 {@link skillImport} 那道闸更紧),判据在那个文件头第四节。
7004
+ * 本体(比 {@link skillWrite} 那道闸更紧),判据在那个文件头第四节。
6439
7005
  */
6440
7006
  plugins: PluginControl | null;
6441
7007
  /**
@@ -7132,4 +7698,132 @@ declare function probeMcpServer(name: string, opts?: McpAdminOptions): Promise<{
7132
7698
  needsLogin: boolean;
7133
7699
  }>;
7134
7700
 
7135
- export { type AgentEventBus, type AgentEventSubscriber, AgentRoleError, AgentSession, type AgentSessionOptions, type ApplyHostPresetOptions, ApprovalRelay, type ApprovalRevokeResult, type ApproveFn, type BindWorkspaceFailure, type BindWorkspaceResult, type BuildRuntimeOptions, type BuildToolsInput, type CommandControl, type CommandWiring, type DiscoverOptions, type EffectiveSetting, type EpochRuntime, type ExecutorRuntime, type FileCandidatesResult, type FileReadOutcome, type FireResult, type FireScheduleOptions, type GoalControl, HOST_CAPABILITIES_MODULE, HOST_MARKETPLACE_MODULE, HOST_PRESET_MODULE, type HistoryMessage, type HookStatus, type HostAgentRole, type HostCapabilities, type HostCapabilityWiring, type HostMarketplaces, type HostPreset, type HostPresetProvider, type HostPresetWorkspace, type HostSkillDir, type LevelChangeResult, type McpAddInput, type McpAddOutcome, type McpAdminOptions, type McpApplyAction, type McpApplyChange, type McpApplyOutcome, type McpApplySource, type McpConfigIssue, type McpConfigReadOutcome, type McpConfigSaveOutcome, type McpConfigSnapshot, type McpControl, type McpListResult, type McpLoginCliOptions, type McpReconnectOutcome, type McpServerBatch, type McpServerOverview, type McpWriteOutcome, type ModelCatalogControl, type ModelCatalogOptions, type ModelControl, type ModelSuggestions, type ModelTurnScope, type PermissionsControl, type PlanControl, type PluginActionOutcome, type PluginControl, type PluginInstallOutcome, type PluginListEntry, type PluginPreviewOutcome, type PluginRefusal, type PluginRefuseReason, type PluginSearchHit, type PolicyDirStatus, type PolicyStatus, type ProviderOption, QuestionRelay, type RelayClock, type RoleAddInput, type RoleAddOutcome, type RoleControl, type RoleTurnScope, type RoleWriteControl, type RunOptions, SCHEDULE_EXIT_CODES, type ScheduleCapability, type ScheduleControl, type ScheduleControlOptions, type ScheduleSaveResult, type SerializableAgentEvent, type SerializableApprovalRequest, type SerializableQuestionRequest, type SerializedEvent, type ServiceOptions, type Services, type SessionControl, type SessionDeletion, type SessionFileChange, type SessionHit, SessionStore, type SessionSummary, type SessionWorkspace, SessionWorkspaces, type SessionWorkspacesOptions, type SettingWriteDone, type SettingWriteInput, type SettingWriteRefused, type SettingWriteReport, type SettingsControl, type StoredMessage, type TurnDetail, type TurnStep, type UserActionResult, type UserActions, type UtilityControl, type UtilityGenerateInput, type UtilityGenerateResult, type WireWorkspaceInput, type WorkspaceBindingState, type WorkspaceControl, type WorkspaceFilesDeps, type WorkspaceFilesView, applyHostPreset, applyMcpConfig, buildModelCatalogControl, buildRuntime, buildServices, buildWorkspaceFilesControl, collectFileChanges, createScheduleControl, createUserActions, ensureSecretsReady, exitCodeFor, fireSchedule, listMcpServers, mcpConfigRevision, mcpLogin, mcpLogout, probeMcpServer, providerLabel, readMcpConfigFile, registerAskQuestion, registerLocalTools, registerMcpTools, serializeStream, splitDirList, toSerializable, utilityBudgetKey, wireCommands, wireHostCapabilities, wirePluginMcpServers, wireWorkspace, withModelScope, withRoleScope, withToolScope, writeMcpConfigFile, writeMcpServer };
7701
+ /**
7702
+ * `mcp_list` / `mcp_add` / `mcp_update` / `mcp_remove` —— 模型**管 MCP 自己**
7703
+ * 的那四条路(2026-09-02)。
7704
+ *
7705
+ * ## 在此之前这一格是空的,而不是「没做完」
7706
+ *
7707
+ * MCP 的管理面一直只有人侧那三条:CLI(`epoch mcp`)、HTTP(`POST /api/mcp`、
7708
+ * `GET`/`PUT /api/mcp/config`、`POST /api/mcp/config/apply`)、Web 能力页。
7709
+ * [docs/TOOLS.md](../../../docs/TOOLS.md) 那张总表里 MCP 那一格写的是「MCP
7710
+ * server **暴露出来的**工具」—— 一个管 MCP 自己的工具都没有。
7711
+ *
7712
+ * 模型想加一台的唯一出路是拿 `terminal` 去写 `~/.epoch/mcp.json`,而那条路有
7713
+ * 两处会骗人:写完不生效(落盘和生效是分开的两步),以及它绕过了名字校验和
7714
+ * 撞名检查(判据在 `isWritableName` 和 `addServer` 上)。
7715
+ *
7716
+ * ## ⚠️ 一、为什么这四个归 `command` 而不是 `file_write`
7717
+ *
7718
+ * **写一台 stdio server 进 `mcp.json` 就是配一条以后会被 `spawn` 的命令行。**
7719
+ * 归 `file_write` 的表现很具体:`PermissionManager.ruleMatches` 是按**类别**
7720
+ * 授权的(规则到不了工具名这一级,判据在 `operation-type.ts` 的
7721
+ * `COMMAND_TOOLS` 上),于是一条 `allow: mcp_add` 顺带放行的是任意进程启动。
7722
+ *
7723
+ * ⚠️ 而且这一族比 `terminal` 还多一条性质:**它的引信是延迟的**。写下去的那台
7724
+ * 在下一次 `epoch` 启动时(**任何**宿主,CLI / TUI / Web)被连上并起进程,
7725
+ * 而那时屏幕上不会有任何东西指回这一次工具调用。
7726
+ *
7727
+ * `mcp_list` 是这四个里唯一的例外,归 `file_read`:它读的是**进程内**那张状态
7728
+ * 表,一个字节都不写。归 `file_read` 还有第二个作用 —— `plan` 级别下也列得出
7729
+ * 来(同会话检索那四个的判据:调研阶段恰恰最该先看清手上有什么)。
7730
+ *
7731
+ * ### ⚠️ 写那三个**不进** `NON_SHELL_COMMAND_TOOLS`,那是判断不是漏登记
7732
+ *
7733
+ * 那张表(`core/src/permission/operation-type.ts`)摘掉的是 `checkObfuscation()`
7734
+ * ——一层**只对真 shell 命令成立**的语法启发式。按那个文件上写的判据问一句
7735
+ * 「这个工具的 `detail` 会被交给某个 shell 吗」,这三个的答案是**不会**
7736
+ * (MCP stdio 走的是 `spawn`,不过 shell),所以它们**够格**进那张表。
7737
+ *
7738
+ * 这一轮仍然不进,理由是代价不对称:不进的坏处是一个带 `(` 的 URL 在 Windows 上
7739
+ * 会多弹一次确认框、且理由那句话说得不准(`run_code` / `delegate_task` 当年就是
7740
+ * 因为这个才进表的);而进表的坏处是对一段**即将被 spawn 的命令行**少跑一层
7741
+ * 检查。前者是一次多余的确认,后者是一层真的没了 —— 在没有实测到误报之前,
7742
+ * 往严的那边偏。⚠️ **真碰到误报再进表,别照着「够格」就登记。**
7743
+ *
7744
+ * ## ⚠️ 二、`mcp.json` 的**原文一个字节都不交出去**
7745
+ *
7746
+ * 这一条是这几个工具的形状本体,不是实现细节。`McpControl` 上现成就有
7747
+ * `readConfig()` / `writeConfig()`(浏览器里那个文本编辑器走的就是它们),
7748
+ * 拿它们包两个工具是最省事的做法,**而这里刻意不那么做**:
7749
+ *
7750
+ * 那份文件里装着用户**每一台** server 的 `env` 密钥(判据在
7751
+ * `writeMcpConfigFile` 那句「新建的那一档给 0600,因为这个文件天生装着 token」)。
7752
+ * 一个「读配置」工具等于让模型每次改一台之前先把全部密钥读进上下文 —— 而上下文
7753
+ * 会进日志、进会话库、进下一轮请求体。
7754
+ *
7755
+ * 所以这一族收的全是**结构化输入**(名字 + 那几格),改哪一台就只碰那一台;
7756
+ * 就地替换 / 删除那两条路因此才必须存在(判据在
7757
+ * [mcp-config-edit.ts](./mcp-config-edit.ts) 文件头第一句)。
7758
+ *
7759
+ * ⚠️ **`mcp_list` 也不回 `env`,也不回 `command` / `url`。** 它回的是
7760
+ * `McpServerStatus` 那几格(名字 / 来源 / 连上没 / 几个工具 / 错在哪)——
7761
+ * 那是「下一步该做什么」需要的全部,而 `command` 那一格是密钥最爱藏的地方
7762
+ * 之一(`npx -y foo --token=…`)。
7763
+ *
7764
+ * ## 三、这四个工具住在 runtime 而不是 core
7765
+ *
7766
+ * 它们要的是 `McpControl`,而那是装配层的东西(`registerMcpTools` 的产物)。
7767
+ * core 那边的 `tools/builtin/` 收的是「core 自身服务的薄封装」(判据在那个目录
7768
+ * 的 `index.ts` 文件头),把 `McpControl` 提成 core 的公开 API 只为了让工具住
7769
+ * 进去,是把插件契约撑宽来换一个目录位置。
7770
+ *
7771
+ * ⚠️ 根 `__tests__/tool-registration.test.ts` 扫的是**每个包的 `src`**,
7772
+ * 所以住在这儿照样被那道门禁盯着(`operation` + `operation-type.ts` 的白名单
7773
+ * + `docs/TOOLS.md` 三处同构)。
7774
+ *
7775
+ * ## 四、错误话术为什么是英文
7776
+ *
7777
+ * 判据逐字同 [skill-view.ts](../../core/src/tools/builtin/skill-view.ts) 第七节。
7778
+ * ⚠️ 但**下一层给的 `detail` 原样转发**(`mcp_write.*` 那几句走 `t()`、
7779
+ * `EACCES` 那半截是 Node 的原文)—— 判据同 `McpReconnectOutcome.detail` 那条
7780
+ * 「原样转发底层那句话,这一层不另写一句」。
7781
+ */
7782
+
7783
+ /**
7784
+ * 四个工具名。**这是同构位置的真源** —— 另外两处认它们:
7785
+ * `core/src/permission/operation-type.ts`(`mcp_list` 进 `PROCESS_TABLE_TOOLS`,
7786
+ * 另外三个进 `COMMAND_TOOLS`)、`docs/TOOLS.md` 顶上那张表。
7787
+ *
7788
+ * ⚠️ **下面 `name:` 那几格写的是字面量,不是这些常量,别「顺手」统一** ——
7789
+ * 判据全文在 core 的 `SKILL_VIEW_TOOL` 的 JSDoc 上。
7790
+ */
7791
+ declare const MCP_LIST_TOOL = "mcp_list";
7792
+ declare const MCP_ADD_TOOL = "mcp_add";
7793
+ declare const MCP_UPDATE_TOOL = "mcp_update";
7794
+ declare const MCP_REMOVE_TOOL = "mcp_remove";
7795
+ /**
7796
+ * 这四个工具要装配层的哪两样。
7797
+ *
7798
+ * ⚠️ **`readConfig` / `writeConfig` 不在这里,那是刻意的** —— 完整判据在文件头
7799
+ * 第二节。`McpControl` 上有那两个方法,把类型放宽成整个 `McpControl` 之前先把
7800
+ * 那一节读完。
7801
+ */
7802
+ interface McpToolDeps {
7803
+ control: Pick<McpControl, 'add' | 'update' | 'remove'>;
7804
+ /**
7805
+ * 进程里此刻那张状态表。**函数而不是快照** —— 判据同 `McpWiring.status`:
7806
+ * MCP 会掉线重连,而这个工具存在的唯一理由就是回答「**现在**连上没有」。
7807
+ *
7808
+ * ⚠️ 收的是 `McpServerStatus` 而**不是** `mcp-admin.ts` 的 `McpServerOverview`
7809
+ * —— 后者上面挂着整份 `McpServerConfig`(含 `env` 里的令牌),而这一族的形状
7810
+ * 本体就是「原文一个字节都不交出去」(文件头第二节)。
7811
+ */
7812
+ servers: () => readonly McpServerStatus[];
7813
+ }
7814
+ /**
7815
+ * 建那四个 MCP 管理工具。
7816
+ *
7817
+ * ⚠️ **注册时机比 `skill_*` 那四个晚一格**:它们要 `McpControl`,而那是
7818
+ * `registerMcpTools()` 的产物,跑在 `registerLocalTools()` **之后**。所以接线
7819
+ * 点在 [build.ts](./build.ts) 里那一行 `registerMcpTools` 的后面,不在
7820
+ * `registerLocalTools` 里 —— 判据同 `wireToolRefresh` 那条「订晚一点」。
7821
+ *
7822
+ * **控制面在就全部注册,不管当下配了几台。** 一台都没配的进程正是 `mcp_add`
7823
+ * 最要紧的现场(判据在 `registerMcpTools` 里 `live()` 上那段 ⚠️:那一支照样
7824
+ * 交出活的控制面)。按「启动那一刻有没有 server」决定注册与否,等于让这条路
7825
+ * 在唯一需要它的那一档下不存在。
7826
+ */
7827
+ declare function createMcpTools(deps: McpToolDeps): EpochTool[];
7828
+
7829
+ export { type AgentEventBus, type AgentEventSubscriber, AgentRoleError, AgentSession, type AgentSessionOptions, type ApplyHostPresetOptions, ApprovalRelay, type ApprovalRevokeResult, type ApproveFn, type BindWorkspaceFailure, type BindWorkspaceResult, type BuildRuntimeOptions, type BuildToolsInput, type CommandControl, type CommandWiring, type DiscoverOptions, type EffectiveSetting, type EpochRuntime, type ExecutorRuntime, type FileCandidatesResult, type FileOpenPlan, type FileReadOutcome, type FileStatOutcome, type FireResult, type FireScheduleOptions, type GoalControl, HOST_CAPABILITIES_MODULE, HOST_MARKETPLACE_MODULE, HOST_PRESET_MODULE, type HistoryMessage, type HookStatus, type HostAgentRole, type HostCapabilities, type HostCapabilityWiring, type HostMarketplaces, type HostPreset, type HostPresetProvider, type HostPresetWorkspace, type HostSkillDir, type LevelChangeResult, MCP_ADD_TOOL, MCP_LIST_TOOL, MCP_REMOVE_TOOL, MCP_UPDATE_TOOL, type McpAddInput, type McpAddOutcome, type McpAdminOptions, type McpApplyAction, type McpApplyChange, type McpApplyOutcome, type McpApplySource, type McpConfigIssue, type McpConfigReadOutcome, type McpConfigSaveOutcome, type McpConfigSnapshot, type McpControl, type McpEditFailure, type McpEditOutcome, type McpListResult, type McpLoginCliOptions, type McpReconnectOutcome, type McpRemoveOutcome, type McpServerBatch, type McpServerOverview, type McpToolDeps, type McpUpdateOutcome, type McpWriteOutcome, type ModelCatalogControl, type ModelCatalogOptions, type ModelControl, type ModelSuggestions, type ModelTurnScope, type PermissionsControl, type PlanControl, type PluginActionOutcome, type PluginControl, type PluginInstallOutcome, type PluginListEntry, type PluginMarketOutcome, type PluginMarketplaceEntry, type PluginPreviewOutcome, type PluginRefusal, type PluginRefuseReason, type PluginSearchHit, type PolicyDirStatus, type PolicyStatus, type ProviderKeyWritten, type ProviderOption, QuestionRelay, type RelayClock, type RoleAddInput, type RoleAddOutcome, type RoleControl, type RoleTurnScope, type RoleWriteControl, type RunOptions, SCHEDULE_EXIT_CODES, type ScheduleCapability, type ScheduleControl, type ScheduleControlOptions, type ScheduleSaveResult, type SerializableAgentEvent, type SerializableApprovalRequest, type SerializableQuestionRequest, type SerializedEvent, type ServiceOptions, type Services, type SessionControl, type SessionDeletion, type SessionFileChange, type SessionHit, SessionStore, type SessionSummary, type SessionWorkspace, SessionWorkspaces, type SessionWorkspacesOptions, type SettingWriteDone, type SettingWriteInput, type SettingWriteRefused, type SettingWriteReport, type SettingsControl, type StoredMessage, type TurnDetail, type TurnStep, type UserActionResult, type UserActions, type UtilityControl, type UtilityGenerateInput, type UtilityGenerateResult, type WireWorkspaceInput, type WorkspaceBindingState, type WorkspaceControl, type WorkspaceFilesDeps, type WorkspaceFilesView, applyHostPreset, applyMcpConfig, buildModelCatalogControl, buildRuntime, buildServices, buildWorkspaceFilesControl, collectFileChanges, createMcpTools, createScheduleControl, createUserActions, ensureSecretsReady, exitCodeFor, fireSchedule, isSystemOpenable, listMcpServers, mcpConfigRevision, mcpLogin, mcpLogout, probeMcpServer, providerLabel, readMcpConfigFile, registerAskQuestion, registerLocalTools, registerMcpTools, removeMcpServer, removeServerText, replaceServerText, serializeStream, splitDirList, toSerializable, updateMcpServer, utilityBudgetKey, wireCommands, wireHostCapabilities, wirePluginMcpServers, wireWorkspace, withModelScope, withRoleScope, withToolScope, writeMcpConfigFile, writeMcpServer };