@sema-agent/client-core 0.29.0 → 0.30.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/README.md +19 -3
  3. package/dist/adapt/arms.js +24 -1
  4. package/dist/adapt/wireShapes.d.ts +7 -0
  5. package/dist/adapt/wireShapes.js +7 -0
  6. package/dist/adapter/activeRunSelfHeal.d.ts +285 -48
  7. package/dist/adapter/activeRunSelfHeal.js +553 -19
  8. package/dist/adapter/runStream.js +13 -3
  9. package/dist/engineWireSdk.d.ts +10 -2
  10. package/dist/engineWireSdk.js +7 -3
  11. package/dist/hitl/approvalDecisionNoteAudit.d.ts +58 -0
  12. package/dist/hitl/approvalDecisionNoteAudit.js +91 -0
  13. package/dist/hitl/armedGateRegistry.d.ts +62 -4
  14. package/dist/hitl/armedGateRegistry.js +226 -14
  15. package/dist/hitl/askParkRowRouting.d.ts +150 -0
  16. package/dist/hitl/askParkRowRouting.js +183 -0
  17. package/dist/hitl/gateIdentity.d.ts +8 -0
  18. package/dist/hitl/gateIdentity.js +8 -0
  19. package/dist/hitl/hitlBridge.d.ts +7 -0
  20. package/dist/hitl/hitlBridge.js +11 -2
  21. package/dist/hitl/parkOwnership.d.ts +2 -1
  22. package/dist/hitl/parkOwnership.js +11 -3
  23. package/dist/hitl/parkRowBirthWait.d.ts +63 -0
  24. package/dist/hitl/parkRowBirthWait.js +192 -0
  25. package/dist/hitl/planReviewWire.d.ts +31 -1
  26. package/dist/hitl/planReviewWire.js +69 -30
  27. package/dist/hitl/resumeRunningCard.d.ts +134 -0
  28. package/dist/hitl/resumeRunningCard.js +177 -0
  29. package/dist/hitl/toolApprovalWire.d.ts +49 -9
  30. package/dist/hitl/toolApprovalWire.js +9 -0
  31. package/dist/index.d.ts +5 -0
  32. package/dist/index.js +14 -0
  33. package/dist/seatContract.d.ts +27 -0
  34. package/dist/seatContract.js +42 -0
  35. package/dist/subagent/engineSubagentTail.d.ts +0 -2
  36. package/dist/subagent/engineSubagentTail.js +7 -15
  37. package/dist/subagentContentStore.d.ts +58 -2
  38. package/dist/subagentContentStore.js +95 -6
  39. package/dist/toolResult.d.ts +26 -0
  40. package/dist/toolResult.js +38 -6
  41. package/dist/workflowClient.d.ts +6 -1
  42. package/docs/INTEGRATION-CLIENTS.md +844 -0
  43. package/docs/REFACTOR-LEDGER.md +392 -0
  44. package/package.json +7 -4
@@ -0,0 +1,844 @@
1
+ # @sema-agent/client-core ↔ 三端接入契约(INTEGRATION-CLIENTS)
2
+
3
+ > **本档是什么**:`@sema-agent/client-core` 作为**上游包**,对它的三个宿主端(`sema-cli` TUI /
4
+ > `sema-web` 聊天区 / `sema-desktop` session-host)的**正式接入文档**。按接入文档宪法(clay 08-12,
5
+ > 黑板 [3680])立档:消费上游先要详细全接入文档,**文档报错可直接打回**。
6
+ >
7
+ > **本档不是什么**:它**不是契约源**。契约源 = `src/**` 的实现本身 + 各文件头注/JSDoc。
8
+ > 本档的每一节都给**实现锚**(文件 + 符号名),读者据锚对账;对不上以真码为准,**当场改本档**。
9
+ > 行号会漂,故一律只锚文件 + 符号,不锚行号。JSDoc 只是线索,判定认实现([jsdoc-untrusted-verify-implementation])。
10
+ >
11
+ > **姊妹档体例**:`sema-cli/docs/INTEGRATION-SERVER.md`(cli↔server 侧)。同一套体例
12
+ > (逐域契约 / 缺席语义 / 版本对表纪律 / 新端 checklist),站在**本包作为被消费方**的位置写。
13
+
14
+ ---
15
+
16
+ ## §0 版本锚与重扫纪律
17
+
18
+ ### 0a. 版本锚(2026-08-14)
19
+
20
+ | 项 | 值 | 真源 |
21
+ |---|---|---|
22
+ | 本包 | `@sema-agent/client-core` **0.29.0** | `package.json` `version` |
23
+ | peer:wire 契约 | `@sema-agent/sdk` **>=6.17.2**(value-level,非 type-only) | `package.json` `peerDependencies` |
24
+ | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
+ | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
+ | 公开导出面 | **685** 个运行期符号(+ 33 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
+ | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
+ | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
+
30
+ ⚠️ **本表描述的是工作树(即将发布的 0.30.0),不是 npm 上那一版**:npm 的 `0.29.0` 是发布 commit
31
+ `0ab959d`,它的 peer floor 是 **`>=6.16.0`**,也没有 0.30.0 段里那几件(relay 形 / durable 卡两展示键 /
32
+ SDK 行形 type 再导出 / P-26 铸口 / #158 移交)。装着 npm `0.29.0` 的端**按 `CHANGELOG.md` 的
33
+ `## 0.29.0` 段对表**,不要按本表 —— 本表的组合在 npm 上今天还不存在。
34
+
35
+ 🔴 **本表里仍然手抄的数字都有门看着**(#252,2026-08-14):`scripts/run-integration-doc-freshness-test.mjs`
36
+ ① 段把 638 / 32 / §2b 十六域名数之和 / 191 / 4 / 33 逐个对 `public-export-baseline.json` 算出来的值,
37
+ 对不上当场红。门数那一格改成不抄数字 —— 它每加一道门就变一次,写死它等于自埋失真
38
+ (实翻:同一个 commit 加了第 26 道门却只刷了同表的 peer 行,漏了门数格,靠人眼复审才抓住)。
39
+
40
+ 🔴 **peer floor 是被见证的,不是声明**:`scripts/run-sdk-floor-test.mjs` 会拿一份**真装在 floor 线上**的
41
+ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.costMicroUsd` 仍然声明」。
42
+ 没人跑过的 floor 是承诺不是契约。
43
+
44
+ ### 0b. 本档刷版的触发事件(逐条,缺一条就会漂)
45
+
46
+ 1. **本包发包**(任一 version bump)—— 发包批必须把 §0a 的版本锚、§2 的导出计数、§7 的缺口状态一并刷。
47
+ 发包扫描门(见记忆 `release-gate-workflow-scan-review`)的产出直接喂 §7。
48
+ 2. **peer floor 抬升**(`@sema-agent/sdk` 或 `@sema-agent/agent-types`)—— 必须重跑 §3 的**穷举**
49
+ (`assertNeverArm`),并核 §4 的 ack 键集有没有新位。
50
+ 3. **公开导出面变更**(`public-export-baseline.json` 的 `count`/`names` 变动)—— §2 的域图与承重导出对表。
51
+ 4. **新增/改动任一 `install*` 端口或其缺席语义** —— §5 的 gate 义务表 + §8 的 checklist 必须同批改。
52
+ 5. **新增/改动任一回执(ack)键或其缺席语义** —— §4 逐键三列表必须同批改(**这一节是本档的重点**)。
53
+ 6. **多会话(sessionKey)语义变更** —— §6 必须同批改。
54
+
55
+ > ⚠️ 对不上时的处置:**以真码为准并当场改本档**,不要在下游端里"按文档将就接"。
56
+ > 文档报错按宪法直接打回给本包属主(cli AI)修。
57
+
58
+ ---
59
+
60
+ ## §1 消费拓扑
61
+
62
+ ```
63
+ @sema-agent/sdk (wire) + @sema-agent/agent-types (CC 词汇表)
64
+ │ 依赖方向单向,永不反向
65
+
66
+ @sema-agent/client-core ← 本包
67
+ ┌─────────┼─────────┐
68
+ ▼ ▼ ▼
69
+ sema-cli sema-web sema-desktop
70
+ (Ink TUI) (聊天区) (session-host)
71
+ ```
72
+
73
+ ### 1a. 三端各自的消费面(一句话)
74
+
75
+ | 端 | 消费面 | 接入姿势 | 备注 |
76
+ |---|---|---|---|
77
+ | **sema-cli** | **全量**(TUI 交互车道 + headless `-p` print 车道;适配内核 / HITL 全链 / fleet / 子代 / 请求面 / 通知面 / 模型目录) | 经 `sema-cli/src/seam/adapter/` 的薄绑定层消费(⚠️ **cli 树的坐标,不是本仓的**;行数以那棵树为准,本档不抄) | 单会话宿主,走 `DEFAULT_SESSION_KEY` 零参兼容层;同时是本包的属主仓 |
78
+ | **sema-web** | **聊天区**:HITL 决断卡链 + adapter 投影(事件 → transcript/chrome) | 直接 `import` | 浏览器宿主:**零 Node 内建**是硬约束(见 1c) |
79
+ | **sema-desktop** | **session-host 多会话**:每个引擎会话一个 sessionKey;HITL 归属判定 / 呈现台账 / plan 重开 | 直接 `import`,一律用 `*For(sessionKey, …)` 变体 | 多会话形是 §6 的全部理由 |
80
+
81
+ ### 1b. 依赖方向是宪法
82
+
83
+ `sdk (wire) + agent-types (vocabulary) → 本包`,**两个 peer 都不反向依赖本包**。
84
+ 配套的下游纪律([C162] 令①):三端对引擎 wire 的一切消费**必须经本包**;直连 `@sema-agent/sdk*`/
85
+ `@sema-agent/core*`(web 还包括直 `fetch` server `/v1/*`)的存量面冻结为精确集合基线、**只减不增**,
86
+ 新增通道需豁免入册 + 主模型特批。样板门 = `sema-cli/scripts/run-sdk-isolation-test.mjs` +
87
+ `sdk-isolation-baseline.json`。
88
+
89
+ ### 1c. portability 铁律(浏览器/渲染进程宿主必读)
90
+
91
+ - **零 Node 内建、零 react/ink 依赖** —— `scripts/run-client-core-portability-test.mjs` 用 esbuild
92
+ `--platform=browser` **真打包**看守,不是靠约定。
93
+ - 宿主能力一律**经端口注入**(`installHost({...})`,见 §5),库自己**绝不** `require('fs')`、
94
+ 绝不 `process.env` 直读(env 走 `hostEnv()`)、绝不全局 `fetch`(目录线上腿走注入的 `CatalogFetchJson`)。
95
+ - 闭包棘轮(零松量,逐块记账在各上限常量头注):内核 7 文件 / A 层 23 / index 119。
96
+ - **实现锚**:`scripts/run-client-core-portability-test.mjs`、`src/hostEnv.ts`、`src/host.ts`。
97
+
98
+ ---
99
+
100
+ ## §2 公共导出面地图(按域)
101
+
102
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**685** 项)。
103
+ > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
104
+ > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
105
+ > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
106
+ > 内部路径**不是**公面,`src/abortableSleep.ts` / `src/envFlag.ts` / `createSessionSlot` 是刻意不出 barrel 的包内叶)。
107
+
108
+ ### 2a. 🔴 基线是**运行期**契约,不是全部公面(端最容易误读的一格)
109
+
110
+ `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
111
+ `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
112
+ 实测:685 项 **100% 是运行期导出,零 type-only**。
113
+
114
+ **推论(端必须知道)**:
115
+ - barrel 导出的**类型**面比 685 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
116
+ `ChromeEvent` / `HostPorts` / `ApprovalCardPort` / `HitlHostSurface` / `ClientSliceLike` /
117
+ `LocalSessionEvent` / `SeatMethodName` / `ModelCatalog` 全在公面上、全**不在**基线里。
118
+ 端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
119
+ - `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
120
+
121
+ 685 项的内部构成(帮助端估读表大小):**206** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
122
+ (矩阵、键集、env 名、锚串)而非可调用物;**4** 项是 PascalCase 运行期值
123
+ (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError`);
124
+ **38** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6)。
125
+
126
+ ### 2b. 域图(16 域,逐域计数之和 = 685)
127
+
128
+ | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
129
+ |---|---|---|---|---|---|
130
+ | 1 | **适配内核(下行主链)** | 29 | `adapt` · `createWireToCcAdapter` · `runStream` · `eventToSdkMessage` · `terminalToSdkResult` · `turnUsageToModelUsage` · `isRunStreamActive` · `ADAPTER_DIVERGENCES` | 引擎 SSE `AgentEvent` → 端要渲的**双面输出**:transcript(`SDKMessage`)+ chrome(瞬态 `ChromeEvent`)。**本包存在的理由** | `src/adapt.ts`、`src/adapt/{arms,wireShapes,panelTasks}.ts`(经 `adapt.ts` 再导出)、`src/adapter/runStream.ts`、`src/adapter/downstream/*`、`src/adapter/types.ts` |
131
+ | 2 | **seam 公共契约** | 2(其余为 type-only) | `CHROME_ARMS` · `deriveTranscriptId` | 公共词汇 + **id 确定性不变量**(同一条流重放 ⇒ 同一串 id)。`CHROME_ARMS` = 端「我要消费哪些 chrome 臂」的对照清单 | `src/seam.ts` |
132
+ | 3 | **HITL 决断卡链**(§4/§5 主战场) | 112 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `waitForGateArmed(For)`/`onGateArmed(For)`/`gateArmedWaitMs`(#244 F1 呈现回执事件源) · `planReviewArmedKey(For)`/`notePlanReviewAnswered(For)`/`notePlanReviewAnsweredIfDecisive(For)`(A-024.4 plan 呈现分代) · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层) |
133
+ | 4 | **子代 wire + 面板侧信道台账** | 68 | `tailEngineSubagent` · `installSubagentActivitySink` · `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent` · `subscribeSubagentContent` · `subscribeEngineAgentPanel` · `publishQuestionFrame` / `respondToQuestion` | 驱动与观测委派子代;经 module 级台账喂活体 agent/task 面板。全部**能力位 gate**(§5b) | `src/subagent/*.ts`、`src/subagentContentStore.ts`、`src/engineAgentPanelStore.ts`、`src/engineInlineTaskStats.ts`、`src/engineToolLabelStore.ts`、`src/liveQuestionStore.ts` |
134
+ | 5 | **fleet 投影** | 43 | `createFleetLedger` · `projectTasks` · `projectWorkflows` · `projectFleetAgentRows` · `readEngineActiveBgTasks` · `FLEET_TASK_VIEW_KEYS` | 老 `fleetClient` 那一刀的成品:**帧体归库、连接归端** —— 端持 SSE 连接,库做行投影 + 保留台账 | `src/fleet/fleetProjection.ts`、`src/fleet/fleetLedger.ts`、`src/fleetAgentPanelProjection.ts`、`src/fleetTaskDesc.ts` |
135
+ | 6 | **请求装配(上行唯一构造口)** | 8 | `buildTaskRequest` · `REQUEST_FIELD_MATRIX` · `unregisteredRequestKeys` · `applyLiveRequestDefaults` · `taskNotificationToPrintFrame` | 两条车道(`interactive`/`print`)出站请求的**唯一**构造器;`unregisteredRequestKeys` 是可执行门 —— 端偷带一个未登记键上 wire 就红 | `src/request/taskRequest.ts`、`src/request/printNotification.ts` |
136
+ | 7 | **通知与 outstanding 台账** | 37 | `installNotificationQueuePort` · `normalizeTaskNotification` · `taskNotificationDedupKeyFromWire` · `registerOutstandingBgTask` / `registerOutstandingWorkflowRun` · `notificationQueuePortMisses` · `subscribeOutstandingWorkflows` · `outstandingDeliverableWorkflowCount` | `task_notification` 归一 + 去重 + 投递进宿主命令队列的**一把闸**;`outstandingDeliverableWorkflowCount()` 是 headless `-p` 的**退出门** | `src/notifications.ts`(11 个 module 台账) |
137
+ | 8 | **工具结果卡** | 25 | `structuredToToolUseResult` · `readAsyncLaunchedAgentReceipt` · `wireOutputToBody` · `parseModelFacingBash` · `getPatchFromContents` · `toolEndResultToUserFrame` · `flattenToolOutput` | 铸端要渲的 `tool_result` 卡体,含客户端 diff hunk(唯一 runtime dep 的用处) | `src/toolResult.ts`、`src/printToolResultFrame.ts`、`src/diff/patch.ts` |
138
+ | 9 | **能力/旋钮 wire 门族** | 86 | `projectAgentsForWire` / `awaitTaskAgentsWire` · `hooksForWire` · `mcpConfigsToSpecs` · `ultracodeForRequest` · `resolveWebSearch` + `buildWebSearchEnv` · `attachmentsForRequest` · `projectEffectiveBody` | 逐功能的「引擎有没有这个能力 + 这个字段怎么上 wire」投影器,由 `buildTaskRequest` 组装。🔴 `hooksForWire()` 是 **fail-closed**:无 `SettingsPort` ⇒ 返回 `undefined`(信任门,不是取值) | 17 个 `src/*WireCaps.ts` + `src/scratchpadWireCaps.ts`、`src/hooksWireCaps.ts`、`src/goalStopHook.ts`、`src/websearch/searchProviderPresets.ts` |
139
+ | 10 | **headless / 部署旋钮 wire** | 61 | `parseSandboxArgv` / `sandboxRequestFields` · `parseLimitsArgv` / `limitsForPrint` · `resolveHeadlessFinalVerify` · `resolveHeadlessPermissionMode` · `resolveHeadlessInteractiveTools` · `armDetachCancel` + `detachCancelArm` + `isDetachArmed` · `withHeadlessR1Reconnect` | `-p`/headless 车道的 env+argv 旋钮。🔴 `detachWire` 是**拆**的补偿:判定与 cancel-arm 台账在库,信号路径的裸 fetch 留宿主(`detachCancelArm()` 是取件口) | `src/sandboxWire.ts`、`scenarioWire.ts`、`finalVerifyWire.ts`、`limitsWire.ts`、`interactiveToolsWire.ts`、`headlessPermissionModeWire.ts`、`headlessReconnectWire.ts`、`detachWire.ts` |
140
+ | 11 | **模型目录与预算** | 52 | `resolveModelCatalog` · `loadCatalogWithSources` · `PROVIDER_PRESETS` / `MODEL_FAMILIES` · `defaultMaxTokensFor` · `getLiveModelCatalog` / `setLiveModelCatalogRefresher` · `providerAuthMethods` / `beginDeviceCodeAuth` | 三层 provider 目录解析(线上 URL → 包内预设 → 用户覆盖)+ per-model `maxTokens` 封顶。线上腿需注入 `CatalogFetchJson`,缺席 ⇒ 整条不启用(`online.reason='no-fetch-port'`);缓存落盘经 `CatalogCachePort` | `src/model/{catalog,catalogLoader,providerAuth,providerPresets}.ts`、`src/liveModelCatalog.ts`、`src/modelBudgetRule.ts`、`src/sessionModelLatch.ts`、`src/effortWire.ts` |
141
+ | 12 | **workflow 与后台工作视图** | 15 | `projectWorkflowRun` · `createLiveWorkflowSource` · `createBackgroundView` · `projectBackgroundView` · `recordWorkflowAgentTaskId` · `agentDisplayStatus` | 活过一个 turn 的长任务读面:workflow run + 跨 session 后台任务归一表(`assistant.tasks` 与 fleet SSE **两源独立降级**) | `src/workflow.ts`、`src/workflowClient.ts`、`src/workflowMonitor.ts`、`src/agentSession/backgroundView.ts`(+ 纯类型 `src/agentSession/contract.ts`) |
142
+ | 13 | **座位 IPC 契约** | 33 | `LOCAL_SESSIONS_SPEC` · `SEAT_METHOD_NAMES` · `SEAT_EVENT_TYPES` · `isLocalSessionEvent` · `isToolPermissionRequest` · `toolPermissionRequestId` · `SEAT_VALIDATOR_KEY_COVERAGE` | desktop↔web 座位 IPC 契约的**单一真源**(此前两边各一份、名字零重合 ⇒ 编译器永远不会告诉你它们漂了)。🔴 加 verb 忘了加 `LOCAL_SESSIONS_SPEC` **不报错**:preload 不注册 channel、渲染端读到 `undefined` | `src/seatContract.ts`(**零 import**,纯类型 + 常量 + 纯谓词) |
143
+ | 14 | **宿主端口与会话槽** | 18 | `installHost` · `installHostFor` · `hostPortMisses(For)` · `DEFAULT_SESSION_KEY` · `hostEnv` · `unrefTimer` | 进程/端级装配层(settings/fs/queue/timers/session/log/probe),与 per-turn 的 `AdapterContext` **分层**。头注的判定规则:**这个能力每 turn 都会变吗?** 会 ⇒ `ctx`;不会 ⇒ `installHost` | `src/host.ts`、`src/hostEnv.ts`、`src/sessionSlot.ts`、`src/unrefTimer.ts`、`src/env/localeGeo.ts` |
144
+ | 15 | **控制面与传输** | 60 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `INTERACTIVE_WAY_OUT`(默认出路串单源) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
145
+ | 16 | **引擎词汇表与包自检** | 36 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(29 项)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
146
+
147
+ 🔴 **`engineErrorCodes` 的开集纪律**(该文件头注逐字):这些 `ReadonlySet` / 前缀谓词一律是**识别表**,
148
+ 回答的是「我认不认得这个码」,**绝不是**「合法码只有这些」。消费点 `switch` **必须留 `default`**,
149
+ 未知码**原样透传 + 落诚实兜底臂** ——「塌进已知形 = 替 server 编了一个它没说的原因」。
150
+
151
+ ---
152
+
153
+ ## §3 事件投影契约(下行)
154
+
155
+ ### 3a. 单一投影口与四类结局
156
+
157
+ **唯一投影口** = `eventToSdkMessage(ev, ctx)`(`src/adapter/downstream/eventToSdkMessage.ts`)。
158
+ 返回**三态 Result**(0.13.0 起,REF-CC-058,**BREAKING** 于旧 `SDKMessage | null`):
159
+
160
+ ```ts
161
+ type EventProjection =
162
+ | { kind: 'message'; message: SDKMessage }
163
+ | { kind: 'none'; why: EventProjectionNoneReason }
164
+ | { kind: 'dropped'; why: EventProjectionDropReason; type: string }
165
+ ```
166
+
167
+ 🔴 **迁移判据一句话**:`const p = eventToSdkMessage(ev, ctx); if (p.kind === 'message') …`。
168
+ 旧写法 `if (msg)` 在新返回型上**恒真**(对象永远 truthy)—— 这是必须点名的一类改动。
169
+
170
+ 为什么必须三态:收编前那个 `null` 在 10 个 return 点上同时承载三种互不相同的语义
171
+ (设计内无可渲染臂 / 帧畸形被丢 / 未知新臂被吞),调用方**无法分辨**「本来就没东西渲」与
172
+ 「引擎发了一帧但我看不懂」。本文件历史上所有「帧到了没人读」事故的载体就是这个塌缩。
173
+
174
+ **四类结局逐条**(实现锚:同文件的 `EventProjectionNoneReason` / `EventProjectionDropReason`):
175
+
176
+ | 结局 | 成员 | 语义 | 是否留痕 |
177
+ |---|---|---|---|
178
+ | `message` | — | 真投影,有对位渲染物 | — |
179
+ | `none` | `usage_only`(`turn_end`:usage 折叠归 run driver `turnEndUsage`) | 本切片无独立可渲染臂 | **静默** |
180
+ | `none` | `terminal_delegated`(`done`/`failed`)| 终帧由 `terminalToSdkResult` 投影 | **静默** |
181
+ | `none` | `hitl_out_of_slice`(**五条臂**:`suspended` · `question` · `question_complete` · `elicitation` · `elicitation_complete`)| HITL 的挂起/开问/收尾归 run driver(`hitl/hitlBridge.observe`)与 `liveQuestionStore` 的 live demux 写口;本切片对它们没有对位渲染物,硬投一个 transcript 形就是替覆盖层编一条假消息。⚠️ 后四条是 sdk 6.2.0 TR-7 才进 union 的 durable 重放形 —— 重放的 `question` **不会**再打开覆盖层,见 §7 缺口 **P-5** | **静默** |
182
+ | `none` | `not_in_slice` | 已在 SDK union 里、本切片**有意**不投影(逐条理由在各 case 注释)。成员逐条见 **§3c** —— 🔴 其中 `error` 最容易读错:它是流的 15 分钟帽帧、**不是终态**,同样走静默 `none` | **静默** |
183
+ | `none` | `empty_payload` | 臂在切片内、帧不畸形,但**载荷筛完是空的**(`suggestions` 全空白 / `diagnostics.files: []` 的 LSP 正常空批)。单列一档:并进 `not_in_slice` 是假话(我们投影它,只是这一帧没内容),并进 `dropped` 是把正常空批诬告成丢帧 | **静默** |
184
+ | `dropped` | `malformed` | 必填位畸形(非串 `taskId` / 非数组 `files` / 空 `source` …)—— 引擎发了,我们读不动 | 经 `reportDroppedFrame` 走宿主 sink + console |
185
+ | `dropped` | `unknown_arm` | 类型面根本不认识这条臂(引擎比本包新) | 同上 |
186
+ | `dropped` | `unsupported_arm` | 类型面**认得**、帧也带着用户可见的决策内容,但本包**今天没有任何消费口**。🔴 不许并进 `not_in_slice`——`none` 是静默的,把一条「有内容、没人接」的帧做成静默 = 真实能力缺口做成静默 fail-open | 同上 |
187
+
188
+ 🔴 **`dropped` 与 `none` 的分界是安全判据不是分类洁癖**:只有 `dropped` 会留痕。
189
+ 判据方向逐字:**宁可每次都吼一行,也不要缺口无声**。
190
+
191
+ ### 3b. `assertNeverArm` 穷举保护与端的纪律
192
+
193
+ `switch (ev.type)` 的 `default` 臂调 `assertNeverArm(ev: never)`:SDK `AgentEvent` union 加成员
194
+ **必须在编译期打红这一行**,而不是让新臂在用户面静默丢帧。运行期仍会走到 `default`
195
+ (wire 是 JSON,引擎完全可能比本包类型新一版 —— 那正是 `unknown_arm`)。
196
+
197
+ 🔴 **消费端纪律(硬性)**:
198
+ 1. **抬 SDK floor 必重跑穷举** —— 抬 `@sema-agent/sdk` 之后本包必须重新编译并让 `assertNeverArm` 说话;
199
+ 这是全链最便宜的一道对表门。**不许用 `as` 绕过**。
200
+ 2. **端不得自建第二个投影口**。要新消费一条帧,先在本包的 `switch` 里给它一条 case(哪怕结论是
201
+ `not_in_slice`)—— 进 switch 才受穷举保护。
202
+ 3. 端消费 `dropped` 必须接丢帧 sink;**吞掉它 = 把本包刻意做响的缺口重新做哑**。
203
+ 🔴 **唯一注入位 = `EmitContext.onDroppedFrame(info: DroppedFrameInfo)`**(`src/adapter/types.ts`),
204
+ 装在你传给 `runStream`/`adapt` 的那个 **per-turn `ctx`** 上 —— 它**不是** `install*` 端口
205
+ (不进 §5a 的 `installHost` 族,也不计 `hostPortMisses`)。
206
+ `EmitContext` 与 `DroppedFrameInfo` 都在公面**类型**上(`src/index.ts` 的 `export *` 转发),
207
+ 但**不在** `public-export-baseline.json` 里 —— 那份基线只收运行期导出(§2a)。
208
+ 🔴 `reportDroppedFrame` **根本不是导出**(`src/adapter/runStream.ts` 里的包内函数),
209
+ 端写 `import { reportDroppedFrame }` 拿到的是 `undefined`。语义三条见 §5a 表末那一行。
210
+
211
+ ### 3c. 本切片有意不投影的臂(`not_in_slice`,静默但进了 switch)
212
+
213
+ <!-- ARM-SET:not_in_slice — 机读围栏(勿删):栏内的反引号臂名 = 本档声明的 `not_in_slice` 成员集合,
214
+ `scripts/run-integration-doc-freshness-test.mjs` ③ 段拿它与源码 switch 的实际集合双向对账。
215
+ 🔴 栏内只写**成员**;反例/对照臂(终态那两条之类)一律写到栏外去,否则会被读成成员。 -->
216
+
217
+ `meta`(流首帧身份信号,SDK/传输层自消费)· `file_link` / `prompt_assembled` / `model_usage` /
218
+ `context_usage` / `config_assembled` / `message_committed`(引擎可观测/审计面,CC transcript 无对位物)·
219
+ `needs_review`(壳消费的是 `done{status:'needs_review'}` 终帧,事件形重复且更早)·
220
+ `compaction_outcome`(压缩**非 compacted 结局**报告;CC transcript 无对位物,见 §7 缺口 **P-6**)·
221
+ `wiring_manifest`(引擎接线自述)· `tool_approval` / `tool_approval_complete`(**不是丢帧**:它们走
222
+ `hitl/toolApprovalWire.isToolApprovalFrame` + `hitl/frameRouter` 那条审批卡链;在这里投一条 transcript 行
223
+ 只会让同一只 ask 出现两次)· **`error`**(见下,单列)。
224
+
225
+ <!-- /ARM-SET:not_in_slice -->
226
+
227
+ 🔴 **`error` 单列点名**(端最容易据 §3a 的规则推错的一条):它的名字骗人 —— 那是流的 **15 分钟帽帧**
228
+ (server `sse-log.ts`,已知 `errorCode` = `STREAM_MAX_DURATION`),帧自己就说 **run 仍然活着**;
229
+ 终态臂只有 `done` / `failed`。本切片对它无对位渲染物,所以走 `nothing('not_in_slice')` ——
230
+ **静默、零留痕**。
231
+ - ⚠️ 端不能从 §3a 的「`dropped` 才留痕」反推出「没渲染的都会留痕」:`error` 到达时你**收不到任何
232
+ 信号**(既不是 message、也不进 `onDroppedFrame`)。要看见它必须自己在 SSE 层看,不是在投影口看。
233
+ - 🔴 **绝不把它当终态**:渲成终态 = 把一条还在跑的任务判死。正确处置 = 按 `Last-Event-ID` 重连续读
234
+ (SDK 的 `runs.events` 车道内部就做了;本包侧坐标 = `src/headlessReconnectWire.ts` 的
235
+ `STREAM_MAX_DURATION` 段)。`errorCode` 是**开集**:未知码同样按「可重连的流控信号」降级,
236
+ 不许按成员判死(词表 `src/engineErrorCodes.ts`)。
237
+
238
+ ### 3d. 键级剥离(帧投影了,但键被投影边界剥掉)
239
+
240
+ `case 'task_progress'` 的白名单**逐字**只 stamp 七键:
241
+
242
+ ```
243
+ taskId · name · usage · currentAction · workflowRunId · workflowAgentLabel · parentToolCallId
244
+ ```
245
+
246
+ **`taskType` / `status` / `parentTaskId` 三键在本层被整体剥掉** —— 见 §7 缺口 **P-1**。
247
+ lane 归属改用 id 形状 / `workflowRunId` 启发式判(`src/adapt/arms.ts`)。
248
+
249
+ **实现锚**:`src/adapter/downstream/eventToSdkMessage.ts` 的 `case 'task_progress'`。
250
+
251
+ ### 3e. 无臂帧(SDK union 里连成员都没有 ⇒ 穷举保护对它失效)
252
+
253
+ `approval_revoke`(批级撤卡帧)**不是** `AgentEvent` 的成员 —— SDK `events.d.ts` 顶注自记这是刻意的
254
+ 不对称(`#185a` 只收了三条 **durable 回放**帧;`approval_revoke` 是 live-only、从不 append,
255
+ 「Registering it is an open item for the next batch」)。
256
+
257
+ **后果链(端必须知道)**:
258
+ 1. 它**不可能**成为本包 `switch` 的一条 `case`(类型上 `case 'approval_revoke'` 编译不过);
259
+ 2. 运行期它从 live 腿到达 ⇒ 落 `default` ⇒ `dropped('unknown_arm', 'approval_revoke')`;
260
+ 3. 它对审批卡链也**不可见** —— `isToolApprovalFrame` 只认 `tool_approval` / `tool_approval_complete`。
261
+
262
+ ⇒ **本包不会在引擎撤卡时替端撤掉那张审批卡**。见 §7a 缺口 **P-4**。
263
+
264
+ 🔴 推论纪律:凡本包**没有**臂的帧,端**不能**指望 `switch (ev.type)` 的穷举保护;
265
+ 要消费必须走 `unknown` + SDK 谓词窄化,并把这件事回报到 C 板(令④)。
266
+
267
+ ---
268
+
269
+ ## §4 回执消费义务(ack contract)—— 🔴 本档重点
270
+
271
+ > 病族形状:**wire 上有一个诚实回执位,而消费端在类型层就读不到它 / 把缺席读成 false**。
272
+ > 后果不是「少个提示」,而是**把一个没有发生的事渲成发生了**(授权没落店却渲「全放行」/
273
+ > 编辑没转发却让用户以为批的就是跑的那份)。本节把每一位的**缺席语义**与**消费方义务**写死。
274
+ >
275
+ > **总纪律([honest-absence-not-fabricated-zero])**:
276
+ > **缺席 ≠ false,缺席 = 未知**。未知一律不发任何断言性告知,更不许渲成失败或成功。
277
+
278
+ ### 4a. `tool_approval` 帧腿的 respond ack(live 腿)
279
+
280
+ **产物**:`surfaceToolApprovalFrameAndRespond(frame, respond, streamArgs, signal, lane)`
281
+ → `ToolApprovalFrameOutcome = { decision, ack? }`。
282
+
283
+ **结构化读口**:`readToolApprovalRespondAck(v)` —— wire 是 JSON,注入面可能是旧 raw fetch、
284
+ 也可能是比本包新一版的 SDK。**坏形一律降 `undefined`**(「拿到一个不好用的值」不如「诚实地什么都没有」)。
285
+
286
+ 整份 ack 被判**不合形**(整体降 `undefined`)的三个前置条件:
287
+ `delivery !== 'applied'` / `approvalId` 非空串 / `decision` **不在 wire 三词闭集** `allow|allow_session|deny`。
288
+ 🔴 `decision` 判到闭集而不是「是不是串」的理由:ack 上那两个布尔位会触发**高危安全声明**
289
+ (「工具正在用原始入参跑」),一个 `decision:'garbage'` 的回执绝不许被当成合规 ack 放行。
290
+
291
+ 🔴 **相关性门**(在 `readToolApprovalRespondAck` **之外**,由编排层做):
292
+ `ack.approvalId !== frame.approvalId` 或 `ack.decision !== 发出的 decision` ⇒ **整个 ack 丢弃**(降未知)
293
+ + 响亮留痕。理由:注入面串了别人的响应 / 坏 mock / raw-fetch 包装器复用连接时,一次普通审批
294
+ 就能弹出「你的编辑没生效」这种强安全声明。
295
+
296
+ **逐键三列表**:
297
+
298
+ | 键 | 缺席语义 | 消费方义务 |
299
+ |---|---|---|
300
+ | `approvalId`(必填) | 缺席/非串/空串 ⇒ **整份 ack 不合形**,降未知 | 不得据坏形 ack 做任何告知 |
301
+ | `delivery`(必填,恒 `"applied"`) | 非该值 ⇒ **整份 ack 不合形** | 同上 |
302
+ | `decision`(必填,三词闭集) | 不在闭集 ⇒ **整份 ack 不合形** | 同上;端不得放宽成「是串就行」 |
303
+ | `rememberApplied?: boolean` | **缺席 = 未知**(旧 server / void 注入面),**绝不当 false** | `=== false` 且本次是 `allow_session` ⇒ **必须响亮告知**:三选卡第 2 项在用户心里签的是「本会话这个工具不再问我」,server 说没记住而界面照渲「全放行」= 把没发生的授权渲成发生了。包内已发通知 `surfaceRememberNotApplied()`;端的义务 = **装 `HitlHostSurface` 口**(§5)+ **不要**在 `rememberApplied` 非 true 时渲「全放行」徽标 |
304
+ | `updatedInputForwarded?: boolean` | **缺席 = 未知**,绝不当 false | `=== false` 且卡带了 `updatedInput` ⇒ **[high] 必须响亮告知**:用户把 `rm -rf /tmp/x` 改成 `rm -rf /tmp/x/build` 然后批准,而真正执行的是**改之前那条**。allow 已送达、事后**无 fail-closed 余地** ⇒ 唯一诚实处置是立刻响亮说出来(不是 debug 日志)。包内 `surfaceEditNotForwarded()` |
305
+ | `rulePersisted?: boolean`(sdk 6.14.0,#225) | **缺席 ≠ false**(未带 `persistRule` 的回决 / 旧 server 省略);非布尔降缺席 | 决定「规则存没存上」的诚实告知;透传坏形会说反话 |
306
+ | `ruleRefusal?: string`(sdk 6.14.0) | 非串/空串 ⇒ 降缺席 | 规则被拒的归因(如 `rule_not_offered`)原样呈现,不改写 |
307
+ | `noteRecorded?: boolean`(sdk 6.16.0,#229) | **缺席 ≠ false**(未带 `note` 的回决 / 老 server 省略);非布尔降缺席 | 发了 `note` 而 `!== true` ⇒ **debug 留痕即可,不惊动用户**:决断没丢,丢的只是理由的持久档(纯活卡无行可落 / 店抖但裁决照常生效 / 并发同决议先落行) |
308
+
309
+ **实现锚**:`src/hitl/toolApprovalWire.ts`(`readToolApprovalRespondAck` / `ToolApprovalFrameOutcome` /
310
+ `surfaceToolApprovalFrameAndRespond` 的相关性门与三处 ack 消费分支)、
311
+ `src/hitl/hitlHostSurface.ts`(`surfaceRememberNotApplied` / `surfaceEditNotForwarded` +
312
+ 两条测试锁字面 `REMEMBER_NOT_APPLIED_WARN_TEXT` / `EDIT_NOT_FORWARDED_WARN_TEXT`)。
313
+
314
+ ### 4b. `note`(回决备注)的**发送**前置门 —— 与 ack 成对
315
+
316
+ `RespondToolApprovalOpts.note`(#229,server ≥7.15.0)与 durable 腿 `AskDecisionBody.note`
317
+ **同词同源同一列** `decision_note`。任何 decision 都可带(deny 的「为什么拒」正是审计面最值钱的一条)。
318
+
319
+ **两道发送门,缺一不可**(实现锚:`src/hitl/toolApprovalWire.ts` 的 `MAX_RESPOND_NOTE_CHARS`
320
+ 与 `ToolApprovalFrameLaneOpts`):
321
+
322
+ | 门 | 判据 | 不过门的行为 | 理由 |
323
+ |---|---|---|---|
324
+ | 能力位 | `lane.approvalDecisionNoteCapable === true` | **整条不发** + debug 留痕 | SDK 6.16 成文「位缺席就别发」:老 server 对未知请求键**静默忽略且照回 200**,「这台不认识 note」与「记上了」在响应上不可分,发了只造「已留档」的错觉 |
325
+ | 上限 | `note.length <= 2048` | **整条不发**(不截断)+ debug 留痕 | server 超限**响亮 400 且连决断一起拒**;备注是决断的补充面,绝不许它把决断本身打失败。诚实缺席优先于静默截断 —— 「一条被悄悄砍半的审计理由比没有理由更坏」 |
326
+
327
+ 🔴 **留痕纪律**:只写元数据(在场 + 长度 + 没发的原因),**绝不把正文拷进日志** ——
328
+ 正文是宿主卡口返回的自由文本,回喂宿主 sink 零诊断增量,还是外溢/注入面。
329
+
330
+ 🔴 **方向纪律**:`reason`/`note` 只做归因,**绝不参与裁决**;带不带、内容是什么,都不改变这次仍是 deny。
331
+
332
+ ### 4c. `ReopenCardVerdict` 的 `presented` 位契约
333
+
334
+ ```ts
335
+ type ReopenCardVerdict =
336
+ | { reopened: false }
337
+ | { reopened: true; firstSight: boolean; presented?: boolean }
338
+ ```
339
+
340
+ | 键 | 缺席语义 | 消费方义务 |
341
+ |---|---|---|
342
+ | `reopened` | 必填 | `true` 的**成文语义** = 「待决材料已确认到手,且卡已交给一个当时在场的渲染面」——**不是**像素级呈现的证明 |
343
+ | `firstSight` | `reopened:true` 时必填 | 这张卡的身份键在本进程**从未登记过呈现**(判据 = `armedGateRegistry`)。文案据此分形:首见卡说「呈上了一张卡」、**零** closed/reopened 断言;复见卡才说「closed without being answered, so sema reopened it」。🔴 **对没发生过的历史下断言 = 谎报面**。只影响话怎么说,不影响动作 |
344
+ | `presented?: boolean` | **缺席 = 宿主没有回执机制**,按「已交付渲染面、未验证」处理(**不降级**) | `true` = 渲染面确认真呈现了(不降级);`false` = 宿主**确知**没呈现(如帧被会话级去重集吃掉)⇒ **`reopened` 的断言不成立**,分诊层按重开失败臂说话。说「已重开」而宿主明知没渲 = 谎报 |
345
+
346
+ **消费点**:`reopenDelivered(verdict)` = `verdict.reopened === true && verdict.presented !== false`。
347
+ 🔴 注意判据是 `!== false` 不是 `=== true` —— 缺席**不**降级。
348
+
349
+ **生产方**:`reopenPlanReviewCard(taskId, opts)` 明确**不产** `presented` 位 ——
350
+ 「包看不到像素,回执机制归宿主端」。端要用这个位,必须自己在呈现层实现回执并包一层。
351
+
352
+ 🔴 **端的硬性义务(最容易漏的一条)**:**如果你的 overlay 按 `questionId` 去重**
353
+ (cli 的 `seenQuestionIds` 就是),那么帧被去重集吃掉时你**必须**回 `presented: false` ——
354
+ 否则自愈树会对用户说「已重开」,而屏幕上什么都没渲。
355
+ 这个位没有包侧实现,**只有端能填**;填不了就让它缺席(缺席不降级),
356
+ 但**绝不许**为了"有个值"随便填 `false`(那会把成功的重开报成失败)。
357
+
358
+ **实现锚**:`src/adapter/activeRunSelfHeal.ts`(`ReopenCardVerdict` / `reopenDelivered` /
359
+ `ActiveRunSelfHealDeps.reopenPlanReview` / `.reopenAskPark`)、
360
+ `src/hitl/planReviewWire.ts`(`reopenPlanReviewCard`)。
361
+
362
+ ### 4d. durable `/decide` 腿(与 4a 不对称,**端必读**)
363
+
364
+ durable park 腿走 `HitlBridge.decideTool(outcome, toolUseID, opts, preResolvedPending)` →
365
+ `approvals.decide(sessionId, decision)`。
366
+
367
+ | 事实 | 契约 |
368
+ |---|---|
369
+ | **D-1 两元组 verbatim 回显** | `boundCallId` + `boundInputHash` 逐字回显进 `decide`,**绝不本地重算 hash**(`bindingOf`)。409 ⇒ `HitlSafetyError('binding_mismatch')`,调用方**重新呈现,绝不自动重试**(一次 decide 绝不双act)。pure 门 B7 段对这两段做**字节级**断言,改一个字符就红 |
370
+ | `reason` 上限 | `MAX_DENY_REASON_CHARS = 4096`;超限 server **413 `reason_too_large`**,丢的不是归因而是**整次决断**(413 ⇒ 决断没送达 ⇒ run 留 suspended)。包内 `denyReasonForWire(reason, tag)` 截断 + 留痕 |
371
+ | 缺省拒因 | `DEFAULT_DENY_REASON = 'The user rejected this tool use'`(不带归因时逐字不变) |
372
+ | **cancel 语义** | 🔴 取消一个 suspended run 必须**用 deny**,**绝不** `runs.cancel`(对 suspended run 会 409) |
373
+ | 空作答 fail-loud | `answerQuestion` 三形一律抛 `HitlSafetyError('empty_answer')`、**一次 decide 都不发**:空 `answers[]` / 任一条 `selected[]` 为空 / 任一条 `header` 为空串。理由:`{answers:[]}` 在 wire 上另有确切含义(question 域 deny 的 NO_HUMAN 形),当 approve 发出去 = **拿 deny 的载荷冒充 approve** |
374
+ | **回执** | ⚠️ `decideTool` / `answerQuestion` 的返回型是 **`Promise<unknown>`** —— 本包**不结构化读** durable `/decide` 的响应体,**没有** 4a 那样的 ack 消费层。见 §7b 缺口 **P-7**(不是 P-6:P-6 是 `compaction_outcome`) |
375
+ | TOCTOU | 调用方若已经用 `findPendingForTask` 取过 pending 行(呈卡用的那一行),**必须**经 `preResolvedPending` 传进来 —— 否则本方法自己再 `approvals.list()` 一次,两次独立取数可能落在**不同的行**上(「人看到的行」≠「decide 解析的行」) |
376
+
377
+ **实现锚**:`src/hitl/hitlBridge.ts`(`HitlBridge.decideTool` / `.answerQuestion` / `bindingOf` /
378
+ `denyReasonForWire` / `HitlSafetyError` / `findPendingForTask`)。
379
+
380
+ ### 4e. `HitlSafetyError` 的判型契约(跨 realm / 双实例安全)
381
+
382
+ `HitlSafetyError.code` 是**闭集**:`binding_mismatch | no_pending | wrong_gate | bad_plan_edit`
383
+ (0.29.0 另有 `empty_answer` 路径)。
384
+
385
+ 🔴 **判型必须结构化(`.code` 为主),`instanceof` 只作加强、绝不替代**:打包后同一个包出现两份副本、
386
+ 或宿主与包各持一份构造器时,同一个语义错误 `instanceof` 为假 ⇒ 良性 `no_pending` 被当成「deny 丢失」
387
+ 弹一行吓人的 warn。包内已提供 duck 判型 `isHitlSafetyErrorLike`(`src/hitl/parkOwnership.ts`)。
388
+ ⚠️ 判据不许松成「有 `.code` 就静默」—— 闭集里**只有 `no_pending` 是良性**,别的 code 照走 warn 臂。
389
+
390
+ **实现锚**:`src/hitl/hitlBridge.ts`(`HitlSafetyError`)、`src/hitl/hitlHostSurface.ts`(`isNoPendingError`)、
391
+ `src/hitl/parkOwnership.ts`(`isHitlSafetyErrorLike`)。
392
+
393
+ ---
394
+
395
+ ## §5 能力位 gate 义务与端口缺席语义
396
+
397
+ ### 5a. 端口(`install*`):缺席语义与"漏装会静默坏掉什么"
398
+
399
+ > 🔴 **总纪律**(`src/host.ts` 头注逐字):**缺席 = 该能力不启用**,不是报错、更不是「换个方式偷偷做」。
400
+ > 库绝不 `require('fs')`、绝不绕过端口。
401
+ > **但**「漏装」与「真的没屏/真的不需要」看起来一样 ⇒ 会造成静默失效的口**一律计 miss**,
402
+ > 宿主自检断言它恒为 0/空。
403
+
404
+ | 端口 | 装法 | 缺席行为 | 计 miss? | 漏装会**静默**坏掉什么 |
405
+ |---|---|---|---|---|
406
+ | `installHost({log})` | `installHost` / `installHostFor` | 静默 | **否**(诊断面本就可选) | 无诊断输出 |
407
+ | `installHost({probe})` | 同上 | 静默 | 否 | 无行式探针 |
408
+ | `installHost({settings})` | 同上 | `hostSettings()` ⇒ `undefined` | **是** | `hooksForWire()` **整体 fail-closed 返回 undefined** —— 信任门是**安全门**,库不会「读不到策略就照投」。目标设了、投不出去,**而两边代码看着都对** |
409
+ | `installHost({fs})` | 同上 | `hostFs()` ⇒ `undefined` | **是** | scratchpad 目录族不建 |
410
+ | `installHost({session})` | 同上 | `hostSession()` ⇒ `undefined` | **是** | durable 子代读面(`taskOutput`/`taskStop`/`subagentOutput`)对 session-bound run **一律 404 fail-closed** ⇒ 整条子代读面静默哑掉 |
411
+ | `installHost({timers})` | 同上 | `hostTimers()` ⇒ `undefined` | 否(T40 竞速本就允许不启用) | 长工具参数生成期已生成内容押到下一帧才上屏 |
412
+ | `installNotificationQueuePort()` | 独立口(`installHost({queue})` 直通它,**不分裂**) | 投递丢失 | **是**(`notificationQueuePortMisses()` 恒应为 0) | task-notification 投递静默丢失 |
413
+ | `installApprovalCardPort(For)` | `src/hitl/toolApprovalWire.ts` | 两条决断腿都返回 `{kind:'failed', reason:…}` | **是**(`approvalCardPortMisses()` 恒应为 0) | 🔴 **每一张写权限 gate 都走 fail-closed deny(卡根本不弹)**,而宿主那边「我装了啊」。这不是可接受的「静默不启用」:同步帧腿上引擎**正同步阻塞**,不 respond 就干等 TTL;durable 腿上 run 卡在 suspended |
414
+ | `installHitlHostSurface(For)` | `src/hitl/hitlHostSurface.ts` | 通知不上屏(与壳原文 `if (!store) return` 同语义) | **是**(`hitlHostSurfaceMisses()` 恒应为 0) | §4a 的两条**安全告知**(remember-not-applied / edit-not-forwarded)+ cancel-by-deny warn + Recent Denials 记账**全丢** |
415
+ | `installEngineWireTarget(For)` | `src/engineWireTarget.ts` | 回落 env 派生 | — | 🔴 **非 Node 宿主(web / desktop 渲染进程)没有 env** ⇒ 根本拿不到引擎目标。对它们这是**唯一**入口(设了就压过 env);不装 ⇒ `engineRowStopGate` 等按 `baseUrl` 查 caps 的门恒判假。⚠️ **凭证面有边界**:`EngineWireTarget.token` 今天是 `string`,**装不进** `{ mode: 'same-origin-relay' }` —— 浏览器同源宿主必读 **§5d** 与缺口 **P-28** |
416
+ | `installSubagentActivitySink` | `src/subagent/engineSubagentTail.ts` | Subagent Progress 段无数据 | 否 | 明确**不是**错误态:那个面就是没启用 |
417
+ | `installWorkflowStatusProbe` | `src/notifications.ts` | watcher 没有带对 baseUrl/token/principal 的 `workflows.get` | — | **workflow 完成通知永远不落地** |
418
+ | `installBgTaskStatusProbe` | `src/notifications.ts` | bg 状态 watcher 空转 | — | **后台子代完成通知永远不落地**(这个 watcher 存在的理由正是 idle 期推送不可靠) |
419
+ | `CatalogFetchJson`(注入,非 install) | `src/model/catalog.ts` | 线上腿**整条不启用**(`online.reason = 'no-fetch-port'`) | — | 目录只走包内兜底(端可据 `source` + `online.reason` 诚实渲「内置版本(离线)」) |
420
+ | `CatalogCachePort`(注入) | `src/model/catalogLoader.ts` | 缓存不落盘 | — | 每次冷取 |
421
+ | **`EmitContext.onDroppedFrame`**(🔴 **per-turn `ctx` 注入,不是 `install*` 端口**) | 填在传给 `runStream`/`adapt` 的 `ctx` 上(`src/adapter/types.ts`) | 丢帧只剩包内一行 `console.error` | **否**(可选注入,缺席 = 该宿主不消费) | Ink 类宿主**吞 console** ⇒ 丢帧对用户**零可见**(P-22),而「引擎发了一帧、这个 build 渲不出来」正是最该被看见的一类。三条语义(实现在 `src/adapter/runStream.ts` 的 `reportDroppedFrame`):**每条都发不去重**(替宿主去重 = 替它撒谎,告警面的计数就不是真的)· **装了 sink ⇒ console 让位**(sink 是更强的通道,两条腿同喊会打乱 TUI)· **sink 抛错绝不打断流**且痕迹落回 console(让位的前提是它真接住了) |
422
+
423
+ ⚠️ `installNotificationQueuePort` 必须在**启动时**装(码里的诊断行逐字:
424
+ `Host must call installNotificationQueuePort() at startup`)—— 装之前发生的投递**已经计成 miss** 了。
425
+
426
+ **两类自检口,别混用**(#252 复审 R3/R4 修正 —— 此前这一段把两类混成一句「端启动后应断言」):
427
+
428
+ **(a) 存在性读口 —— 这才是「装没装」的启动校验**(无副作用):
429
+
430
+ 🔴 **哨兵三形不同源,逐口照抄别推广**(#252 复审 R5 实翻,R7 追加逐口列举):`hasXxx()` 返回 `boolean` ·
431
+ 卡口/HITL 面/wire 目标的读口返回 `T | null` · `installHost` 族返回 `T | undefined`。
432
+ 拿 `!== undefined` 去判一个返回 `null` 的读口是**恒真**,那正是「装了个自检、结果它永远说装好了」的形。
433
+ ⚠️ 下表**逐口一行**(零参与 `*For` 变体分开列)—— 不写「变体同零参」这种总括句:总括句机器核不了,
434
+ 删掉或改错都不会响(R7 命中),而多会话宿主日用的恰恰是 `*For` 那一支。
435
+ 常驻门 `scripts/run-integration-doc-freshness-test.mjs` ⑥ 段拿本表**逐行**对源码返回型注解。
436
+
437
+ | 端口 | 存在性读口(判据形) | 备注 |
438
+ |---|---|---|
439
+ | 审批卡口(默认键) | `hasApprovalCardPort() === true` | 返回 `boolean`。🔴 **启动就该断言为 `true`** —— 漏装的后果是每张写权限 gate 走 fail-closed deny |
440
+ | 审批卡口(会话键) | `hasApprovalCardPortFor(sessionKey) === true` | 返回 `boolean`;多会话宿主用这一支 |
441
+ | 审批卡口(取值口) | `approvalCardPortFor(sessionKey) !== null` | 哨兵 `null`(`ApprovalCardPort \| null`);`hasApprovalCardPortFor` 就架在它上面 |
442
+ | HITL 宿主面 | `hitlHostSurfaceFor(sessionKey) !== null` | 🔴 **哨兵是 `null` 不是 `undefined`**(`src/hitl/hitlHostSurface.ts`:`HitlHostSurface \| null`)—— 写成 `!== undefined` 是**恒真**,完全没装也会通过(R5 命中)。漏装 = §4a 两条安全告知 + cancel-by-deny warn + Recent Denials 全丢 |
443
+ | 问答 overlay | `hasQuestionOverlayFor(sessionKey) === true` | 返回 `boolean`,无哨兵歧义 |
444
+ | settings(默认键) | `hostSettings() !== undefined` | ⚠️ **这一族才是 `undefined`**(`src/host.ts`:`Port \| undefined`),与上面几行的 `null` 不同源 |
445
+ | settings(会话键) | `hostSettingsFor(sessionKey) !== undefined` | 同上 |
446
+ | fs(默认键) | `hostFs() !== undefined` | 同上 |
447
+ | fs(会话键) | `hostFsFor(sessionKey) !== undefined` | 同上 |
448
+ | session(默认键) | `hostSession() !== undefined` | 同上 |
449
+ | session(会话键) | `hostSessionFor(sessionKey) !== undefined` | 同上 |
450
+ | timers(默认键) | `hostTimers() !== undefined` | 同上 |
451
+ | timers(会话键) | `hostTimersFor(sessionKey) !== undefined` | 同上 |
452
+ | wire 目标(默认键) | `engineWireTarget() !== null` | 哨兵 `null`;⚠️ 它**不区分**「显式装了」与「从 env 派生」——非 Node 宿主 env 恒空,所以对 web/desktop 它等价于「装没装」 |
453
+ | wire 目标(会话键) | `engineWireTargetFor(sessionKey) !== null` | 同上 |
454
+ | **通知队列口** | 🔴 **没有** | 见 §7d 缺口 **P-29**:今天只能靠端自己记得调过 `installNotificationQueuePort()` |
455
+
456
+ **(b) miss 计数 —— 「有没有已经漏过」的事后计数,不是装配证明**:
457
+ 初值全为 0/空,**完全没装口而又还没发生任何调用时它们照样全绿**。当**回归探针**用
458
+ (跑过一轮真流量之后仍为 0 才有意义):
459
+ - `hostPortMisses()` / `hostPortMissesFor(key)` 恒应为**空对象**
460
+ - `notificationQueuePortMisses()` 恒应为 **0**
461
+ - `approvalCardPortMisses(For)` 恒应为 **0**
462
+ - `hitlHostSurfaceMisses(For)` 恒应为 **0**
463
+ - `compensationSplitViolations()` 恒应为**空**
464
+
465
+ ⚠️ `hostPortMissesFor(sessionKey)`:通知队列是**包级单例**,其 miss 只折进 `DEFAULT_SESSION_KEY` 键的视图。
466
+
467
+ **实现锚**:`src/host.ts`、`src/hitl/toolApprovalWire.ts`、`src/hitl/hitlHostSurface.ts`、
468
+ `src/notifications.ts`、`src/engineWireTarget.ts`、`src/compensations.ts`。
469
+
470
+ ### 5b. 引擎能力位:包内已 gate 的位 + 宿主供给方式
471
+
472
+ | 能力位 | 包内 gate 什么 | 供给方式 | 缺席/false 行为 | 实现锚 |
473
+ |---|---|---|---|---|
474
+ | `taskHandles` | 子代行「真停止」动词 + taskOutput/taskStop 面 | `engineCapTrue(engineWireTarget()?.baseUrl, 'taskHandles')`(包**自己**从 caps 缓存读;端只需 `installEngineWireTarget` + `kickEngineCapsProbe`) | 只翻本地行不发动词 / 面不挂 | `src/subagent/engineRowStopGate.ts`、`src/subagent/engineTaskHandleWire.ts` |
475
+ | `subagentOutput` | 子代终态读面 | 包内自探(`client.capabilities()`),**true 固化 / false TTL / 失败不缓存** | 读面不启用 | `src/subagent/engineSubagentOutput.ts` |
476
+ | `subagentStream` | 子代 live tail | 同上纪律 | tail 整条 `return`(不 fallback 到轮询) | `src/subagent/engineSubagentTail.ts` |
477
+ | `manualCompact` | `runs.compact` 腿 | 包内自探,`probeManualCompactCapability()` 在首个 turn bind 时预热 | `=== false` ⇒ **verb 抑制**(TOC-local runStore-less 引擎)+ 留痕;此前端不消费该门,verb 打过去 501 被 fire-and-forget 吞掉 | `src/subagent/engineCompactWire.ts` |
478
+ | **`approvalDecisionNote`** | live 帧腿的 `note` 上 wire | 🔴 **宿主供给**(不是包自探):`ToolApprovalFrameLaneOpts.approvalDecisionNoteCapable`,端从自己的 caps 缓存填 | 缺席/false ⇒ **fail-closed 到「不发」侧**(决断照常送达,现状字节不变) | `src/hitl/toolApprovalWire.ts`(`ToolApprovalFrameLaneOpts`) |
479
+
480
+ #### 🔴 `approvalLane` 的供给链(端接 durable/live 审批帧腿时**必接**)
481
+
482
+ ```
483
+ 宿主 caps 缓存
484
+ → AskGateWireDeps.approvalLane: ToolApprovalFrameLaneOpts (src/hitl/frameRouter.ts)
485
+ → routeFrame 的 tool_approval 臂透传 deps.approvalLane
486
+ → surfaceToolApprovalFrameAndRespond(…, lane) (src/hitl/toolApprovalWire.ts)
487
+ → lane.approvalDecisionNoteCapable === true 才发 note
488
+ ```
489
+
490
+ **缺席 = note 恒不发**(fail-closed)。0.29.0 发包扫描门修的正是这条链:此前包内**唯一生产调用点**
491
+ (`routeFrame` 的 `tool_approval` 臂)**没有这个位**,于是 `note` 恒不发 —— 位建好了、腿接好了,
492
+ 生产路径上一个字节都没上 wire。
493
+
494
+ **端的义务**:接审批帧腿时**必须**在 `AskGateWireDeps` 里填 `approvalLane`;不填不是「少个功能」,
495
+ 是审计归因整条断掉且**不报错**。
496
+
497
+ ### 5c. caps 缓存的读法纪律
498
+
499
+ - `engineCapTrue(baseUrl, key)` 是**同步**读口:**未判/缺键 = false**。
500
+ - 🔴 因此 `false` 同时表示「确证没有」「探测在飞」「探测失败」三件事(`engineCapsSettled` 在探测
501
+ 失败时也 resolve 且不缓存)。**端不得拿一个「可能是没读到」的 false 去销毁一个已到达的事实**
502
+ (帧到达本身就是那条车道活着的证据)。能力位是 **affordance 面**的门,不是「已到达帧」的判据。
503
+ - `engineCapString(baseUrl, key)`:**未判即 `undefined`**,调用方必须按「探不到 ⇒ 降级」处理。
504
+ - 探测 kick:`kickEngineCapsProbe(baseUrl, probe)`(async 幂等,`probe` = `client.capabilities` 薄闭包)。
505
+
506
+ **实现锚**:`src/engineCapsCache.ts`。
507
+
508
+ ### 5d. `same-origin-relay` 凭证形(🔴 浏览器同源宿主必读)
509
+
510
+ **这是什么**([C175],0.30.0):浏览器 → **同源 BFF/反代** → worker 的中继形。凭证留在 BFF,
511
+ 浏览器**永不持有**,出站**零 `Authorization` 头**(SDK `client.d.ts` 的 `AgentAuthToken` 顶注逐字)。
512
+ `AgentClient` 的 `window` 守卫在浏览器宿主里拒掉普通凭证形(真 token / `{mode:'loopback-unauthed'}` /
513
+ `'anon'`),而 `{ mode: 'same-origin-relay' }` 这一态**不触发**该守卫,并且:
514
+ - `baseUrl` 允许相对/同源路径(`''`、`'/api'`)—— loopback 校验不参与(它防的是「非 loopback 裸 token」,
515
+ 本态无 token 可漏);
516
+ - `principal` **可省略**,由 BFF 逐请求注入 `x-agent-principal`(principal 是身份断言,与凭证同属
517
+ 服务端信任面;浏览器自报在直连门本就不被信,省略比铸 `anon:` 假值诚实);
518
+ - 401/403 时 SDK **不做任何 token 刷新/重试**(那是 BFF 的职责)。
519
+
520
+ ⚠️ **别把它读成「SDK 唯一的浏览器逃生舱」**:SDK 另有 `AgentClientConfig.allowBrowser?: boolean`
521
+ (自述 = 测试 / 「自己注入 token 的 BFF」用的逃生舱)。但 **`EngineWireClientConfig` 不暴露那个位、
522
+ 本包从不设它** —— 所以在**本包这条构造路径上**,relay 形确实是唯一走得通的浏览器形。
523
+
524
+ **两条纪律(逐字)**:
525
+ - 🔴 **只能显式传入**,绝不由 `resolveWireAuth` 的三态解析推导出来 —— 它是作者声明「我部署在同源
526
+ 反代后」,不是一个可以猜出来的状态。三态解析的语义一字不动,relay 形**直传 SDK**。
527
+ - 🔴 **唯一构造点纪律不破**:relay 形也走 `makeEngineWireClient()`(`src/engineWireSdk.ts`),
528
+ 没有第二个铸口。
529
+
530
+ **今天放宽了的入参面只有两个**(⚠️ 这一条决定端能不能真用上):
531
+
532
+ | 入参面 | 类型 | 实现锚 | 端怎么用 |
533
+ |---|---|---|---|
534
+ | `EngineWireClientConfig.token` | `string \| { mode: 'same-origin-relay' }` | `src/engineWireSdk.ts` | 自己直调 `makeEngineWireClient({ baseUrl, token: { mode:'same-origin-relay' } })` 的路径可用 |
535
+ | `LiveWorkflowConfig.authToken` | `string \| { mode: 'same-origin-relay' }` | `src/workflowClient.ts`(`createLiveWorkflowSource`) | workflow 活体读面可用 |
536
+
537
+ 🔴 **没放宽的那一半(端接之前必须知道)**:`EngineWireTarget.token` 仍是 `string`
538
+ (`src/engineWireTarget.ts`),而 `installEngineWireTarget()` 是**非 Node 宿主唯一的装配入口** ——
539
+ 包内经 `engineWireTarget()` 取址再构造 client 的那 **10 处**(plan review ×2 / 子代 tail / steer /
540
+ output / compact ×2 / taskHandle ×2 / delegated prompt)因此**装不进** relay 形。详见缺口 **P-28**。
541
+ 浏览器宿主今天能走通的只有上表那两条自带入参面的路径;走 `engineWireTarget()` 的动词
542
+ 在同源反代部署下**没有合法凭证形**可传。
543
+
544
+ 🔴 **没放宽的还有第二个入参面**(0.30.0 发包扫描补记 —— 此前本节与 P-28 只点了上面那 10 处,
545
+ 读者会以为「不经 `engineWireTarget()` 就没事」):`EngineProbeOpts.authToken` 仍是
546
+ `string | undefined`(`src/engineWireSdk.ts`),两处**探针**由它喂 —— `engineSupportsTaskAgents()`
547
+ (`src/agentsWireCaps.ts`)与 `probeScenarioTools()`(`src/liveInitToolFace.ts`)。
548
+ 它们在浏览器 relay 部署下同样构造不出 client,失效形属下表 **C 档(零日志零信号)**:
549
+ 前者 `return undefined`(= 「不知道有没有这个能力」,不是 `false`),后者 `return null`。
550
+ ⚠️ 端**不要**把这两个 `undefined`/`null` 读成「引擎不支持」——它们在 relay 部署下**恒**如此,
551
+ 与引擎能力无关;正位解同 P-28(放宽入参面 + 透传),**不许端侧侧路补救**。
552
+
553
+ ⚠️ **失效形:构造失败一律吞成 `null`,但「之后怎么办」逐点不同 —— 别当成一律静默**。
554
+ `makeEngineWireClient()` 的构造被 SDK 守卫拒时走 `catch { return null }`(`src/engineWireSdk.ts`),
555
+ **从不抛异常**;调用方拿到 `null` 之后的行为分三档:
556
+
557
+ | 档 | 构造点 | 拿到 `null` 之后 |
558
+ |---|---|---|
559
+ | **A 响亮;用户可见性有条件** | `decidePlanReview`(`src/hitl/planReviewWire.ts`) | 先 `hostLog('error', 'planReviewWire: <decision> NOT sent — makeEngineWireClient() returned null')`,再把一句 `The plan_review <decision> could NOT be sent … Tell the user plainly that the decision did not go through` 交给 **`enqueuePlanReviewOutcome()`**。🔴 **用户看不看得见取决于通知队列口装没装** —— 见下表 |
560
+ | **B 结构化 reason(调用方决定怎么说)** | `steerEngineSubagent`(`engineSubagentSteer.ts`)⇒ `{ok:false, reason:'no-wire'}` · `stopEngineTask`(`engineTaskHandleWire.ts`)⇒ `{ok:false, reason:'unavailable'}` | 不打日志;把判词交给调用方 —— 端**该**据 `reason` 说话 |
561
+ | **C 静默回落** | `tailEngineSubagent`(`engineSubagentTail.ts`)⇒ `return` · `fetchEngineSubagentReport`(`engineSubagentOutput.ts`)· `fetchEngineTaskOutput`(`engineTaskHandleWire.ts`)· `fetchDelegatedPrompt`(`engineDelegatedPrompt.ts`)⇒ `null` · `requestEngineCompact` 与能力预热(`engineCompactWire.ts` ×2)⇒ `return`(预热那条另把 `capabilitiesProbe` 置 null 以便下次重试)· plan-review 的 **decide 后状态回拉探针**(`planReviewWire.ts` 第二处)⇒ `postStatus` 留 `undefined`,措辞降到「未验证」档 | **零日志零信号**。这几条上端不要把「动词没反应」读成「引擎没这个能力」—— 能力位是 §5c 那套判据,和「wire 构造不出来」是两件事 |
562
+
563
+ 🔴 **A 档的用户可见性是有前提的,别当成保证**(#252 复审 R2 命中):`enqueuePlanReviewOutcome()`
564
+ (`src/notifications.ts`)在**队列口没装**时直接 `return false`;装了但**没有 `enqueueMetaPrompt`** 时
565
+ `queuePortMisses++` 后 `return false`。`decidePlanReview` 对 `false` 只再补一行
566
+ `hostLog('error', 'planReviewWire: outcome enqueue MISSED …')`,投递口抛错则只落一行 `debug`。所以:
567
+
568
+ | 结局 | 用户看不看得见 |
569
+ |---|---|
570
+ | `enqueuePlanReviewOutcome()` **返回 `true`**(口装了、有 `enqueueMetaPrompt`、投递没抛) | ✅ 结局作为 meta prompt 进模型回合 ⇒ 用户会被明确告知决断没送达 |
571
+ | 返回 `false`(**口没装**,或装了但**没有 `enqueueMetaPrompt`**)**或投递抛错** | ❌ **零用户通道**,只剩 `hostLog` —— 而 Ink 类宿主连 console 都吞(P-22) |
572
+
573
+ 🔴 **`notificationQueuePortMisses() === 0` 证明不了「口装上了」**(#252 复审 R3 命中,别拿它当装配自检):
574
+ 那个计数**初值就是 0**,只有真发生过一次投递并落进 `port()` 的 null 分支才 `++`
575
+ (`src/notifications.ts`)。**完全没装口 + 启动后还没有任何投递** ⇒ 它照样是 0。
576
+ ⚠️ 而**通知队列口恰恰是没有存在性读口的那一个**(审批卡口有 `hasApprovalCardPort(For)`、
577
+ HITL 面有 `hitlHostSurfaceFor` —— 见 §5a 的 (a) 表;队列口没有,登记为 **P-29**)。
578
+
579
+ ⇒ **端的正确姿势**:①按 §8-B 真的调 `installNotificationQueuePort()`(这一条**只有端自己知道**装没装,
580
+ 别指望包替你判);②`notificationQueuePortMisses()` 当**回归探针**用 —— 跑过一轮真流量之后它仍为 0
581
+ 才有意义,启动瞬间的 0 什么都不证明;③**在确认这条通道真的通(见过一次 `true` 回执/真到达)之前,
582
+ 不要撤掉自己那一层「决断未送达」的兜底告知**。
583
+ 🔴 这正是 §5a 那句「漏装会静默坏掉什么」的实例:队列口缺席会把 A 档**静默降级成 C 档**。
584
+
585
+ 🔴 汇总口径:**C 档是无条件静默**;**A 档只有在队列口真装上时才到达用户**;B 档把判词交给了端。
586
+ 写宿主兜底之前先把这两张表都看完。
587
+
588
+ ⚠️ **`baseUrl: ''` 的连带影响**:relay 形允许空 `baseUrl`,但包内 caps 门是按 `baseUrl` 查缓存的
589
+ (`engineCapTrue(engineWireTarget()?.baseUrl, key)`,§5b/§5c)—— 探测没发过的键一律 `false`,
590
+ 而 `false` 同时表示「确证没有/在飞/失败」三件事。同源部署下要让 caps 门说话,必须自己
591
+ `kickEngineCapsProbe(baseUrl, probe)` 把那把键喂进去。
592
+
593
+ **实现锚**:`src/engineWireSdk.ts`(`EngineWireClientConfig.token` / `makeEngineWireClient` /
594
+ `resolveWireAuth`)、`src/workflowClient.ts`(`LiveWorkflowConfig.authToken`)、
595
+ `src/engineWireTarget.ts`(`EngineWireTarget.token`,**未放宽的那一半**)。
596
+
597
+ ---
598
+
599
+ ## §6 多会话宿主契约(sessionKey 族)
600
+
601
+ > 背景(design/161 复审 r1 E2):包内模块级**单槽位**与多会话宿主(desktop)正面冲突 ——
602
+ > 两个并行会话互相顶盖(A 会话的审批卡弹到 B 的面上 / B 的 responder 覆盖 A 的)。
603
+ > W1 把这些槽位改成 `Map<sessionKey, T>` 注册表。
604
+
605
+ ### 6a. 双 API 形制
606
+
607
+ | 形 | 用法 | 语义 |
608
+ |---|---|---|
609
+ | **零参旧 API**(`installApprovalCardPort(port)`) | 单会话宿主(cli) | = `*For(DEFAULT_SESSION_KEY, …)` 的**兼容层**;default 键路径与改前单变量行为**逐字节等价**,cli 的 module-load 装配一行不动 |
610
+ | **`*For(sessionKey, …)` 变体** | 多会话宿主(desktop) | 每会话一键,互不顶盖 |
611
+
612
+ `DEFAULT_SESSION_KEY = '__default__'`(`src/sessionSlot.ts`)。
613
+ 🔴 **别拿这个值当会话 id 用** —— 多会话宿主每会话必须给一个真键。
614
+ 契约锚:`AgentSessionConfig.sessionKey`(`src/agentSession/contract.ts`)。
615
+
616
+ `createSessionSlot` 本身**刻意不出 barrel**(公面只承诺「键」与各槽位的 `*For` API)。
617
+
618
+ ### 6b. 已 keyed 的槽位族(逐个)
619
+
620
+ | 槽位 | `*For` API | 实现锚 |
621
+ |---|---|---|
622
+ | 宿主端口(settings/fs/timers/session/log/probe) | `installHostFor` · `hostSettingsFor` · `hostFsFor` · `hostTimersFor` · `hostSessionFor` · `hostPortMissesFor` | `src/host.ts` |
623
+ | 审批卡口 | `installApprovalCardPortFor` · `approvalCardPortFor` · `hasApprovalCardPortFor` · `approvalCardPortMissesFor` | `src/hitl/toolApprovalWire.ts` |
624
+ | HITL 宿主面 | `installHitlHostSurfaceFor` · `hitlHostSurfaceFor` · `hitlHostSurfaceMissesFor` | `src/hitl/hitlHostSurface.ts` |
625
+ | 问答 overlay | `publishQuestionFrameFor` · `hasQuestionOverlayFor` · `onQuestionFrameFor` | `src/liveQuestionStore.ts` |
626
+ | 呈现台账(决断卡首见/复见) | `registerArmedGateFor` · `wasGateArmedFor` · `clearArmedGateFor` · `registerArmedGateFromQuestionIdFor` | `src/hitl/armedGateRegistry.ts` |
627
+ | wire 目标 | `installEngineWireTargetFor` · `engineWireTargetFor` | `src/engineWireTarget.ts` |
628
+ | fleet 台账 | `isFleetSessionScopedFor` · `readEngineActiveBgTasksFor` · `clearAllRetainedFleetRowsFor` | `src/fleet/fleetLedger.ts` |
629
+ | fleet 面板投影 | `projectFleetAgentRowsFor` · `resetFleetAgentPanelProjectionFor` · `fleetAgentProjectionSizeFor` | `src/fleetAgentPanelProjection.ts` |
630
+ | 归属判据 | `pendingRowIsOwnedByThisSession({… sessionKey})` | `src/hitl/parkOwnership.ts` |
631
+ | plan 重开 | `reopenPlanReviewCard(taskId, { sessionKey })`(全链 `*For` 路由:overlay/帧/台账) | `src/hitl/planReviewWire.ts` |
632
+
633
+ ### 6c. `SessionSlot.keys()` 的读法陷阱
634
+
635
+ `keys()` 返回**有条目**的键,**不等于**「装了口的键」:`T` 含 `null`(卡口槽位就是
636
+ `ApprovalCardPort | null`)时,用 `null` 卸下的键**仍留在 keys 里**(只有 `undefined` 才删键)。
637
+ 🔴 判「装了没」**必须再读一次 `get(k)` 拿值**,别只数键。
638
+
639
+ ### 6d. 归属判定的 fail-closed 纪律
640
+
641
+ `pendingRowIsOwnedByThisSession` 是**正向归属证明**(own-run / 会话两腿),缺省走包内 `isOwnEngineRun` + `SessionPort`。
642
+ 🔴 复审收紧(0.29.0):**非默认 `sessionKey` 且未注入 `isOwnRun` 时,进程级 own-run 缺省腿整条跳过**
643
+ (fail-closed,跨会话不认领)。desktop 的 `sessionForEngineId` 单腿判定应收敛到本判据,
644
+ **传 `sessionKey` + 自注入 `isOwnRun`**。
645
+
646
+ ### 6e. 🔴 已知局限(多会话端**接之前必读**)
647
+
648
+ 见 §7c 的 **P-10 ~ P-14** 五条 —— 全部是 sessionKey 面的在册局限,
649
+ CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读完它们之前接卡口与 plan review。**
650
+
651
+ 其中两条会直接把多会话端坑到「装了但不生效」:
652
+ - **P-10**:按会话键装卡口 ⇒ 100% miss 在默认键上(今天卡口**必须装在默认键**);
653
+ - **P-13**:默认键下的 own-run 归属是**进程级**证据,不区分会话代际(`/clear` 前的 run 仍判 owned)。
654
+
655
+ ---
656
+
657
+ ## §7 已知缺口与 roadmap(按现状写,不写成「已是」)
658
+
659
+ > 定级沿用扫描口径:`high` = 有真实功能/正确性代价;`med` = 能力空转或体验缺损;`low` = 零行为面/零消费方现状。
660
+ > 台账镜像:`docs/REFACTOR-LEDGER.md` + `CHANGELOG.md`「已知局限」段。
661
+
662
+ > ⚠️ **ID 说明**:`P-n` 是**本档自己的编号**,与 cli 侧 census 的 `G` 号**不是同一套**
663
+ > (本仓 `grep -rn "G5\b" src/ docs/` 零命中 —— 那个 id 住在 cli 的普查台账里)。
664
+
665
+ ### 7a. 投影面缺口(上游铸了材料,本包没有接口交给端)
666
+
667
+ | ID | 级别 | 一句话现状 | 实现锚 | 端**今天**必须怎么办 |
668
+ |---|---|---|---|---|
669
+ | **P-1** | med | `task_progress` 白名单**只 stamp 七键**(`taskId`/`name`/`usage`/`currentAction`/`workflowRunId`/`workflowAgentLabel`/`parentToolCallId`);SDK `events.d.ts` 的 `task_progress` 臂上声明的 **`taskType` / `status` / `parentTaskId` 三键被整体剥掉**(`eventId` 也从不 stamp)。⚠️ 码里的理由注释只覆盖 `status` + EventIdentity(「service wire whitelist strips `status` + EventIdentity」)—— **`taskType` 与 `parentTaskId` 被剥掉,码里没有任何说法**,而 SDK 明写 `parentTaskId` 正是同一张 server 白名单**产出**的 | `src/adapter/downstream/eventToSdkMessage.ts` 的 `case 'task_progress'`;lane 启发式在 `src/adapt/arms.ts`(`recordWorkflowAgentTaskId` 三级门) | 别指望从一条 progress tick 上拿到委派类别(`taskType`)或嵌套归属(`parentTaskId`)。嵌套要从 `bg_notification.parentTaskId` 经 `src/fleet/fleetLedger.ts` 的 `recordBgParentRun` 恢复;workflow lane 归属走 `src/adapt/arms.ts` 的三级门。🔴 **别在端侧自己从别处补进投影**(那是绕过唯一投影口) |
670
+ | **P-2** | med | 包内注释断言「**子代从不发终态 tick**」(`src/adapt/arms.ts` 的 `toolEndResultArm` ② MF-10 段逐字;`src/adapt/panelTasks.ts` 与补偿 T36/T34 复述),而 pin 的 SDK 逐字说 **server ≥1.258 会转发 core 的 SETTLE 终态 tick**(`"completed"`/`"failed"`)。**且这个矛盾自我维持** —— P-1 的白名单删掉了 `status`,所以终态 tick 就算上了 wire,在包内也**观测不到**。源码里**没有任何一处**把它记为假断言 | `src/adapt/arms.ts`(MF-10 段)、`src/adapt/panelTasks.ts`、`src/compensations.ts`(T36 `retireOn: 'W6(引擎为每条子代发终态 tick)后…'` / T34 `retireOn: null`) | 把四处防御 sweep 当成子代行 settle 的**唯一**机制;**不要**在端侧建「等子代终态 tick」的状态机 —— 包永远不会交给你一条 |
671
+ | **P-3** | med | `approval_request`(design/172 流内审批开卡帧)在本包**零消费口** ⇒ `dropped('unsupported_arm')`(有痕、没人接) | `src/adapter/downstream/eventToSdkMessage.ts` 的 `case 'approval_request'` | 存量路径今天仍能决断(legacy `tool_approval` 腿在,同一只 ask 出两帧,顺序钉死「先 legacy、后 `approval_request`」)。🔴 **但降级路径不保证**(逐字):legacy respond 是 **live-only + same-replica**(错副本 404),重连时 pending 卡靠 `approval_request` preamble 对账 —— 「收到 legacy 帧后断线、按 `Last-Event-ID` 重连只再看到 `approval_request`」以及**非粘性多副本部署**这两条路上,今天只能等窗口到期 → park/deny。**多副本 worker 上这是已知缺口不是环境问题** |
672
+ | **P-4** | med | **`approval_revoke` 在 SDK union 里连成员都没有**(SDK 顶注:known asymmetry,`Registering it is an open item for the next batch`)⇒ 本包不可能有 case ⇒ 运行期落 `dropped('unknown_arm')`;审批链也看不见它(`isToolApprovalFrame` 只认两帧)。全仓 `grep -rn "revoke"` = **0** | `src/adapter/downstream/eventToSdkMessage.ts` 的 `default` 臂;`src/hitl/toolApprovalWire.ts` 的 `isToolApprovalFrame` | 🔴 **引擎撤卡时本包不会替你撤那张卡** —— 被撤的 ask 会一直留在屏上,直到它自己的 5 分钟 TTL / deny 路径触发。要 revoke 语义的端只能自己接 raw named-SSE 腿并撤自己的卡(`unknown_arm` 的 drop 至少留了一行痕) |
673
+ | **P-5** | 真缺口(未定级) | **durable 重放腿上,一条被重放的 `question` 今天不会再打开覆盖层** —— 覆盖层的入口是 `liveQuestionStore` 的 **live demux 写口**,不是投影函数。交互 REPL 无损,「断线后按 `Last-Event-ID` 续读」场景下是真缺口。补它属**行为面**改动(先要答「重放一条已过 5min TTL 的问题该不该弹窗」),按宪法三问单独走 | `src/adapter/downstream/eventToSdkMessage.ts` 的 `question`/`question_complete`/`elicitation`/`elicitation_complete` 臂(缺口逐字记在该处);`src/liveQuestionStore.ts` 头注(`LIVE-ONLY + SAME-REPLICA … No durable resume anchor`) | `respondToQuestion` 当 best-effort 用(404 = 「已经放掉了」,dismiss,**绝不重试**);重连后的恢复走 **409 自愈树 + 自己的 `/v1/approvals` 列举**,**不要**指望重放的 `question` 帧能弹出覆盖层 |
674
+ | **P-6** | low | `compaction_outcome`(压缩**非成功结局**:mooted/failed)在本切片 `not_in_slice`;「压缩失败让用户看见」是 chrome/HUD 面的活,**今天两端都还没接**(adapt 臂表同样无此臂) | `src/adapter/downstream/eventToSdkMessage.ts` 的 `case 'compaction_outcome'` | 压缩失败对用户**不可见**。🔴 **不许**由投影切片顺手编一个假 transcript 形来假装接上了 |
675
+
676
+ ### 7b. 决断链缺口
677
+
678
+ | ID | 级别 | 一句话现状 | 实现锚 | 端**今天**必须怎么办 |
679
+ |---|---|---|---|---|
680
+ | **P-7** | med | durable `/decide` 腿**无 ack 消费层**:`decideTool` / `answerQuestion` 返回 `Promise<unknown>`,响应体不被结构化读 —— 与 live 帧腿的 `readToolApprovalRespondAck` 五位诚实读**不对等** | `src/hitl/hitlBridge.ts`(`decideTool` / `answerQuestion` / `decideRaw` 的返回型) | durable 腿上「记住了没有 / 编辑转发了没有 / note 落行了没有」**在类型层就读不到**。端**不得**据 durable decide 的返回值渲任何断言性告知 |
681
+ | **P-8** | med | **plan review 没有 edit 态**:判决只有 `'approve' \| 'reject' \| 'dismissed'`,选项字面就两条(`Yes, approve and run the plan` / `No, reject it (keep planning)`);`dismissed` **不是 wire 上的第三种判决** —— 命中它时 park 原样留着,`deliverDecision`/`decidePlanReview` 根本不可达。包内**任何一层都没有**「编辑 plan」的 affordance | `src/hitl/planReviewWire.ts`(`planReviewDecisionFromAnswer` / `PLAN_REVIEW_APPROVE_LABEL` / `PLAN_REVIEW_REJECT_LABEL` / `dismissed` 早退分支);durable 腿的 `editedPlan?` 位见 `src/hitl/hitlBridge.ts` 头注 | 把「编辑」建模成 **reject → 继续 planning → 新 turn**。🔴 内联 plan 编辑器**不许**经 `planReviewWire` 投递,也**不许自造第三个选项标签**(core 围栏 `selected ⊆ options`,表外标签会落 `dismissed`)。要 edit 态走 C 板提需求,由包补位([C163]-2④「plan edit 三态候包位**不开洞**」) |
682
+ | **P-9** | low | `wakeSubagent` 在 `CLIENT_VERBS` 里是 `required:false` 的**声明位**,包内无实现编排 | `src/clientSlice.ts`(`CLIENT_VERBS` 的 `wakeSubagent` 行) | 端要这条腿得自己接 `runs.resumeSubagent`;取址口径受 P-1 拖累(`parentToolCallId` 不一定在场,合法退路是 `agentName`,同名多员由 server 409 `steering.ambiguous_target` 兜) |
683
+
684
+ ### 7c. 多会话(sessionKey)面在册局限 —— 多会话端**接之前必读**
685
+
686
+ | ID | 级别 | 一句话现状 | 实现锚 | 端**今天**必须怎么办 |
687
+ |---|---|---|---|---|
688
+ | **P-10** | med | 三个决断入口(`surfaceApprovalCard` / `surfaceFsApprovalAndDecide` / `surfaceToolApprovalFrameAndRespond`)**今天全硬读 `DEFAULT_SESSION_KEY`** —— keyed 卡口 API 建好了,但**没有任何调用点带非默认键透传** | `src/hitl/toolApprovalWire.ts`(`surfaceApprovalCard` + `cardPortMissReason`,REF-CC-030) | 🔴 多会话宿主若照 W1 文档**按会话键装卡口,会 100% miss 在默认键上**,而它那边「我装了啊」。今天的正解 = **卡口装在 `DEFAULT_SESSION_KEY` 上**;包内 `cardPortMissReason()` 已能把「没人装」与「装错键」分开点名。正位解要把三个入口签名改成 `sessionKey?: string` 透传(候 W1 消费者真正到位) |
689
+ | **P-11** | low | `armPlanReviewApproval` **无 `sessionKey` 形参** —— arm 臂是默认会话装配,其 responder 递交决断时清账走默认键。非默认键会话对同一 `taskId` 混用 arm 臂 + `mintFreshQuestionId:false` 重呈臂时,**该键会话槽的呈现史不被消费**(影响 = 后续首见/复见**文案**判定;动作两形一致) | `src/hitl/planReviewWire.ts`(`ReopenPlanReviewOpts.sessionKey` 的 JSDoc) | 多会话端:给 `reopenPlanReviewCard` 传 `sessionKey`,**且绝不与 `mintFreshQuestionId:false` 组合**。cli 的零参默认键装配不受影响 |
690
+ | **P-12** | low | canonical 重呈短路臂**沿用 arm responder** ⇒ `ReopenPlanReviewOpts.deliverDecision` 注入口**不生效**(成文例外 + debug 留痕) | `src/hitl/planReviewWire.ts`(`mintFreshQuestionId:false` 分支) | 任何**包装决断投递**的端(重试/退避/上屏定序,cli 的 `decideRetry` 是参照)必须走默认 `mintFreshQuestionId` 臂 —— 否则你的包装被静默旁路,跑的是裸 `decidePlanReview` 的 fire-and-forget |
691
+ | **P-13** | 成文局限(不改行为) | 默认键下的 own-run 归属缺省腿是**进程级**证据,**不区分同一宿主进程内的会话代际** —— `/clear` 前登记的 run 在新会话语境下**仍判 owned**。最坏后果逐字:`用户看到自己旧会话的审批卡` | `src/hitl/parkOwnership.ts`(`ParkOwnershipDeps.isOwnRun` JSDoc);`docs/REFACTOR-LEDGER.md` 记为**驳为成文局限** | 多会话端必须**自注入**会话粒度的 `isOwnRun`;或传非默认 `sessionKey` 并接受缺省腿被整条跳过(代价 = 多一次诚实的 reopen-failed) |
692
+ | **P-14** | high(打包面) | `activeReopenResponders` 的**单活纪律是 module 单例**:两份实例 ⇒ 各退各的,跨份的旧卡退役不掉 —— **退化回修复前的重复活卡形** | `src/hitl/planReviewWire.ts`(`activeReopenResponders`,singleton-manifest 在册) | 见 §8-G:必须保证 bundle 里只有**一份** `@sema-agent/client-core` |
693
+
694
+ ### 7d. 请求面与其它在册件
695
+
696
+ | ID | 级别 | 一句话现状 | 实现锚 | 端**今天**必须怎么办 |
697
+ |---|---|---|---|---|
698
+ | **P-15** | med | `REQUEST_FIELD_MATRIX` 有 **6 个字段登记为 `gap: true`**(表内 `gap:true` 的定义逐字 = 「这条差异**没有正当理由,是漏的**」),全部是 **print/headless 车道缺席**:`settings.ultracode` · `reasoningEffort` · `model` · `clientContext` · `scratchpadDir` · `settings.<resolved>`。表内点名的后果:`clientContext` 缺席 ⇒ **引擎误标 (UTC)**;`scratchpadDir` 缺席 ⇒ **`-p` 的工具写不进 exemptDir** | `src/request/taskRequest.ts`(`REQUEST_FIELD_MATRIX` 的 `gap` 列) | headless 车道上这六项**确实不上 wire**。端不要在 print 车道假设它们在场;补齐是**行为改动**,要单独一条测试,不许端侧偷加 |
699
+ | **P-15b** | 🔴 权限方向 | `REQUEST_FIELD_MATRIX` 的 stamp 门对**未登记键静默丢弃** —— 表里点名的真实危险形逐字:**「用户显式排除的工具被静默放回」(权限方向回归,类型层不报)**。`excludeTools` 是真 wire 键、早在 seatContract 的 `START_SESSION_OPTION_KEYS` 里,却曾长期在矩阵外;**今天只有 desktop 在发它** | `src/request/taskRequest.ts`(`excludeTools` 行)、`src/seatContract.ts`(`START_SESSION_OPTION_KEYS`) | 端自拼 taskReq 的键**必须**先进矩阵;上 wire 前跑 `unregisteredRequestKeys(req, lane)` 并**当红对待**,别当 lint |
700
+ | **P-16** | 提货单 S 组(**BREAKING**,P2 裁决**未填**) | 三条签名级 BREAKING 在册未决,**约束三端的 port 实现**:REF-CC-133(`SettingsPort.getSettingsForSource` 返回裸 `unknown`)· REF-CC-134(`ModelFacingTaskOutput`)· REF-CC-135(`errCodes`,**且是行为改动**:脏形不再原样透传) | `docs/REFACTOR-LEDGER.md` REF-CC-133/134/135 | 端实现 `SettingsPort` 时不要依赖当前的宽返回型;`errCodes` 的脏形透传行为**会变** |
701
+ | **P-17** | 在册 | 两条 wave2 残余:REF-CC-059(`NeutralDelta` 命名已落,**泄漏门没建**)· REF-CC-063(自锚已修,`gate:line-anchor` 口未做) | `docs/refactor/WAVE2-RESIDUALS.md` | 端无直接动作;别把这两条当已闭合 |
702
+ | **P-18** | 已知缺口(dueDate 2027-02-01) | fleet 行的 `currentAction`:旧端(server <1.288)只发 `currentAction` ⇒ 那一档上本视图**拿不到活动文本**(登记 `kind:'known-gap'`,自述「这是**已知缺口**不是设计取舍」) | `src/fleet/fleetProjection.ts` | 老引擎上 fleet 行没有活动文本;端做兜底渲染 |
703
+ | **P-19** | 残余缺口(候引擎位) | `approvals:true`(checkpointStore 在)而 run store 未配时,worker 仍回 `{tasks:[]}` —— **那份空列表与「真的没有任务」在 wire 上同形** | `src/agentSession/backgroundView.ts` | 空的计划任务面板是**歧义**的:🔴 **不许**把它渲成「你没有计划中的任务」 |
704
+ | **P-20** | 可见 | Stop-hook 在现代引擎上 fail-open 放行(`一次都拦不住`);`taskAgents` 三态里 `undefined` = 无法确证 ⇒ 调用层 fail-soft 照发 | `src/goalStopHook.ts`;`src/agentsWireCaps.ts`(`engineSupportsTaskAgents`) | `taskAgents === undefined` 当「照发」处理,**不要**拿它 gate UI |
705
+ | **P-21** | 已知限制(诚实披露) | 引擎工具词表按 pin 的 server/core 版本定稿;外接旧引擎或无执行底座的 worker(裸 worker 不挂 hands band)会有偏差 | `src/liveInitToolFace.ts` | init 工具清单是 best-effort 自述,**不是契约** |
706
+ | **P-22** | 已知限制 | Ink 挂载后宿主吞 console ⇒ **dropped 帧那行 warn 在 TUI 里未必上屏** | `src/adapter/runStream.ts` | 装一个**真的** drop sink,别靠 console(§3b) |
707
+ | **P-23** | low | `DEVICE_AUTH_PROVIDERS` **今天是空表**(宁空勿假:没有我们自己名下的 client_id 之前,填别的编辑器插件的 app id = 冒用) | `src/model/providerAuth.ts` | `supportsDeviceCodeAuth` 对每一家如实返回 `false`,UI **不显示**该选项。注册一家 = 加一行 |
708
+ | **P-24** | low | `model/catalog` **零默认 URL**(目录仓位候裁:机制先行、地址由调用方传) | `src/model/catalog.ts` / `src/model/catalogLoader.ts` | 不传候选链 ⇒ 只走包内 `providerPresets` 兜底;端据 `source` + `online.reason` 诚实渲「内置版本(离线)」 |
709
+ | **P-25** | 结构 | 类型小环 1 条:`seam → turnUsageToModelUsage → adapter/types → seam` | `docs/REFACTOR-LEDGER.md` 在册 | 纯类型环,无运行时影响;端无动作 |
710
+ | **P-26** | **已落**(0.29.x,2026-08-12) | seat requestId 域的**正向铸口**已在位:`toolPermissionRequestId(domain, id)` —— 往返性成契约(铸出的键必被读口 `toolPermissionRequestIdDomain` 认回同域,校验器同表放行);三类坏入参 fail-loud(未登记域含忘尾冒号 / 空串与非串 id / id 自带域前缀的双前缀键),**绝不静默吐坏键** | `src/seatContract.ts`(`toolPermissionRequestId` / `TOOL_PERMISSION_REQUEST_ID_DOMAINS` / `toolPermissionRequestIdDomain`) | 铸 requestId 一律 `toolPermissionRequestId(域, id)`,**不要**再手抄 `` `plan:${id}` `` 类模板串(手抄的失效形是运行期路由到不存在的目标,编译期不响) |
711
+ | **P-27** | 立票设计件(web [C1] 疑点③,族A 二段票同批) | `ToolPermissionDecision` **无 note 席位**且座位宿主无 caps 缓存读口 ⇒ #229 回决备注在两座位端(web/desktop)**结构性无法供给**。修形二选一未裁:decision 形补 `note?` + 能力位随 `ToolPermissionRequest` 下发,或宿主侧统一判 | `src/seatContract.ts`(`ToolPermissionDecision`,682 行域) | 座位端今天**不要**渲 note 输入位(渲了也送不出去=假 affordance);候本条落地随提货单换 |
712
+ | **P-28** | 🔴 med(浏览器面) | ⚠️ 2026-08-14 补记:除下面那 10 处外,**第二个未放宽的入参面** `EngineProbeOpts.authToken`(`src/engineWireSdk.ts`)另喂 2 处探针(`agentsWireCaps.ts` 的 `engineSupportsTaskAgents` ⇒ `undefined` / `liveInitToolFace.ts` 的 `probeScenarioTools` ⇒ `null`,均属 C 档静默),合计 **12** 处 —— 见 §5d 末尾那段。 **`same-origin-relay` 只放宽了 2 个入参面,装配入口没跟**:`EngineWireClientConfig.token` 与 `LiveWorkflowConfig.authToken` 收了 `\| { mode:'same-origin-relay' }`,而 `EngineWireTarget.token` 仍是 `string` —— `installEngineWireTarget()` 恰恰是**非 Node 宿主唯一**的装配入口。经 `engineWireTarget()` 取址再构造 client 的 **10 处**(`hitl/planReviewWire.ts` ×2 · `subagent/engineSubagentTail.ts` · `engineSubagentSteer.ts` · `engineSubagentOutput.ts` · `engineCompactWire.ts` ×2 · `engineTaskHandleWire.ts` ×2 · `engineDelegatedPrompt.ts`)在同源反代部署下**没有合法凭证形可传**。⚠️ 失效形**逐点不同**(§5d 末两表:A 响亮 + **有条件**用户可见 = `decidePlanReview`,可见性取决于通知队列口装没装 / B 结构化 reason = steer 与 taskStop / C 无条件静默 = 其余七处),但**构造失败一律吞成 null、从不抛异常** | `src/engineWireTarget.ts`(`EngineWireTarget.token`)· `src/engineWireSdk.ts`(`makeEngineWireClient` 的 catch 臂)· §5d 的两表 | 浏览器同源宿主今天**只能**走 §5d 上表那两条自带入参面的路径(直调 `makeEngineWireClient` / `createLiveWorkflowSource`);走 `engineWireTarget()` 的子代与 plan-review 动词**别指望在 relay 部署下发得出去**,也**不要**把「动词没反应」读成「引擎没这个能力」(⚠️ plan-review 那条**只在通知队列口装上时**才到达用户,见 §5d 末表 —— 队列口没装就退回零用户通道,端的兜底告知别急着撤)。🔴 正位解在包侧(放宽 `EngineWireTarget.token` + 10 处透传),**不许端侧侧路补救**(跨仓缺陷源头修复);要它落地按 [C162] 令④ 回 C 板 |
713
+ | **P-29** | low(自检面) | **通知队列口没有存在性读口**:审批卡口有 `hasApprovalCardPort(For)`、HITL 面有 `hitlHostSurfaceFor`、宿主端口族有 `hostSettings()` 等无副作用读口(见 §5a 的 (a) 表),**唯独 `installNotificationQueuePort()` 没有对偶谓词**。而它的 `notificationQueuePortMisses()` 与同族几个 miss 计数一样**初值为 0**,只有真发生过一次「用到了但没装」才递增 ⇒ 「完全没装 + 还没有任何投递」照样是 0。拿它做**启动装配自检**必然假绿 —— §5a 此前正是这么写的(#252 复审 R3/R4 命中,已按端口拆成「存在性读口」与「回归探针」两类) | `src/notifications.ts`(`installNotificationQueuePort` 无对偶读口;`queuePortMisses` 初值与 `port()` 的 null 分支) | 队列口:按 §8-B 真调 `installNotificationQueuePort()`,miss 计数只当**跑过真流量之后**的回归探针用;其余端口按 §5a (a) 表用各自的存在性读口做启动校验。要队列口的读口按 [C162] 令④ 回 C 板提(正位解在包侧:补一个 `hasNotificationQueuePort()` 谓词) |
714
+ | **P-30** | med(HITL 路由面) | **durable 审批腿不按 `gateKind` 路由,且取行有「同 taskId 任意行」回落** (0.30.0 发包扫描 codex 复审 finding① 坐实,**非本窗引入**):`findPendingForTask` 在工具名谓词无命中时走 `?? rows.find(r => r.taskId === taskId)`,而 `surfaceFsApprovalAndDecide` 拿到行之后**不校 `gateKind`** ⇒ 同一 task 上同时停着 `plan_review` / `resource_limit` 行时,会弹出一张 `toolName` 为空串的**工具审批卡**。⚠️ **不会误批**(server 侧 fail-closed):本腿打的是 `POST /v1/approvals/:sessionId/decide`,非工具门在该端点上回 **409 `gate_not_tool_approval`**(SDK `dist/errors.d.ts`;⚠️ **不是** `gate_not_resumable` / `gate_not_plan_review` —— 那两个分别是 `/resume` 与 plan-review 端点的守卫,2026-08-14 codex 复审 R2 订正本条初稿的错码)。🔴 **但后果不止「一次失败的决断」**:decide 抛错 ⇒ `surfaceFsApprovalAndDecide` 折成 `{kind:'failed'}` ⇒ `parkResolver` 走 fail-soft 结束**本次客户端 turn**;而 server 侧 checkpoint 因为 fail-closed **没被消费**,run/session 仍 suspended、仍持 claim ⇒ 重试还会再撞一次。⚠️ **终帧按入口分两形,排障别只等一个码**(2026-08-14 codex 复审 R3 订正本条初稿的单一描述):① **初始 park 入口**(`done{…park…}` 经 `frameRouter.routeDone` 进来,`park.pendingDone` **在场**)⇒ 先 `led.flushHeld()` 吐出 park 期被 HOLD 的**毒化帧**(`tool_end{isError:true, output:'Operation aborted'}`,`frameRouter.ENGINE_ABORT_TOOL_RESULT`),再原样回吐那条 `done` —— **没有**合成 `failed` 终帧、**没有** `hitl_unanswered` 错误码,可观察到的失败信号只有那条 isError 的 `tool_end`。⚠️ **它与「用户真按了拒绝」可以分辨,按 `output` 分**(2026-08-14 codex 复审 R5 订正本条初稿的「同形不可分」):fail-soft 这条是 `flushHeld()` 原样吐出的**毒化帧**,`output` 逐字是 `ENGINE_ABORT_TOOL_RESULT`(`'Operation aborted'`);真 deny 走 `frameRouter` 的 `denied-call` / `deny-stamp-next` 臂,`output` 被改写成 `HITL_REJECT_MESSAGE`(CC `REJECT_MESSAGE` 逐字)。端做归因按 `output` 判,别只看 `isError`;② **续流 / durable re-attach 入口**(`suspended` 进来,无 `pendingDone`)⇒ 才合成 `failed{errorCode:'hitl_unanswered'}`。⇒ 端做告警/埋点时**不要**只锚 `hitl_unanswered`,①那条路径上它根本不出现。⚠️ 定性要分清:这条 fail-soft 链是 durable 腿**通用**的失败路径(设计如此 —— 替代方案是谎报成功,更坏),**不是**本缺口独有;本缺口的**增量**是「弹了一张 `toolName` 为空的卡 + 发了一次注定 409 的 decide + 把用户的一次表态浪费掉」 | `src/hitl/hitlBridge.ts`(`findPendingForTask` 的第二条 `rows.find`)· `src/hitl/toolApprovalWire.ts`(`surfaceFsApprovalAndDecide` 全程零 `gateKind` 读)· 常驻登记见 `scripts/run-durable-card-display-keys-test.mjs` ⑦ 段 `gateKind` 那条未投影理由 | 端**不要**把「durable 卡弹出来了」读成「这一定是个工具门」;拿到 `toolName` 为空串的卡按异常处置、别渲成可决断卡。🔴 正位解在包侧(本腿按 `gateKind` 严格路由 + 回落收窄),要同批想好 pre-`gate_kind` 历史行 `gateKind` 缺席时的降级 —— 属独立设计件,按 [C162] 令④ 回 C 板提 |
715
+
716
+ ### 7e. 缺口的共同形状(值得单独说)
717
+
718
+ **P-1 / P-2 / P-3 / P-4 / P-5 / P-6 / P-7 是同一类**:上游(server / SDK)已经把材料铸到 wire 上了,
719
+ 而**本包没有接口把它交给端**。逐条摆出来的理由:**端在这些点上「读不到」不是端的 bug,
720
+ 补救也不该在端侧做**(跨仓缺陷源头修复,禁下游侧路补救 —— env 覆盖 / 参数旁支 / wrapper / monkey-patch
721
+ 一律违宪)。发现新的同形件请按 [C162] 令④ 回 C 板,带坐标。
722
+
723
+ **P-1 与 P-2 是一条链**:白名单剥掉 `status` ⇒ 终态 tick 不可观测 ⇒ 「子代从不发终态 tick」那句
724
+ 断言**永远无法被本包自己证伪**。修其中一个才能验另一个。
725
+
726
+ **P-10 ~ P-14 是同一类**:keyed API 建好了,而**消费路径还没带键透传**。
727
+ 多会话端必须按「今天怎么办」那一列绕行,**不要**假设 `*For` 变体已经全链贯通。
728
+
729
+ ### 7f. ⚠️ 二手材料的使用告诫
730
+
731
+ 本档只认**入库**的台账:`docs/REFACTOR-LEDGER.md`、`CHANGELOG.md`、`docs/refactor/**`。
732
+
733
+ 🔴 **发版扫描的原始产出不是可引用坐标**(#252 自查实翻):0.29.0 之前那一轮 fresh-scan 的
734
+ 538 行清单**从未入库**,只存在于属主的工作树 —— 本档 §7f 曾按路径把三端指向它,而任何
735
+ clone(web/desktop/CI)去读都扑空。凡属这一类(扫描 journal / workflow 产出 / 主会话 scratchpad)
736
+ 一律**不进本档做坐标**;要引用就先把结论**逐条重验后写进本节或 `docs/REFACTOR-LEDGER.md`**。
737
+ `scripts/run-integration-doc-freshness-test.mjs` ② 段现在把这条做成门:档内每条仓内坐标
738
+ (`src/` `scripts/` `docs/` 三个前缀)既要在盘、又要**被 git 追踪** ——
739
+ 在盘 ≠ 在库,只查 existsSync 的门对这个病是瞎的。
740
+
741
+ 🔴 **旧扫描的条目一律不许原样抄进本档或端侧台账 —— 逐条重验才算数**:那一轮里 B1
742
+ `governanceForced` 镜像、B3 BrainStatus 六相词表、B6 `32768` skill 帽三条抽验时已闭合,
743
+ 而 B9 Stop-hook fail-open 在现码里仍成立(已重验并作为 **P-20** 登记在 §7d)。
744
+ 同一份清单里**已闭合与仍成立的条目混在一起**,而清单自己不会告诉你哪条是哪条 ——
745
+ 这正是二手材料的危险形。
746
+
747
+ ---
748
+
749
+ ## §8 新端接入 checklist
750
+
751
+ > 用法:web / desktop 按 [C162] 四令提货时**逐项打勾**;每一项后面括号里是验证方式,不是靠读代码相信。
752
+
753
+ **A. 依赖与门**
754
+ - [ ] `@sema-agent/client-core` 版本对齐 §0a;**同批抬 `@sema-agent/sdk` 到 >=6.17.2**(peer floor)。
755
+ 🔴 **真实理由是类型面,不是「值级 import 上炸」**(#252 直证订正,2026-08-14):抬 floor 的那一批
756
+ **没有**新增任何值级 sdk import(值级面仍是 `AgentClient` / `SseIdleError` / `probeHealth` /
757
+ `APIError` / `TaskStopConflictError` 五个符号,零增减);硬要求来自 durable 卡两展示键读的
758
+ `PendingCheckpoint.ruleSuggestions` 与 `PendingCheckpoint.governanceForced` —— 这两位在 sdk
759
+ **6.16.0 的 `.d.ts` 上根本不存在**(实拉 6.16.0 tarball 直证:`dist/types.d.ts` 全文
760
+ `ruleSuggestions` 零命中,`governanceForced` 只长在 `PendingGateMaterial` 上),低于 6.17.2 的
761
+ 宿主**编译期**红。⚠️ 6.16.0 已经带 `same-origin-relay` 守卫、也已导出 `PendingCheckpoint` /
762
+ `RuleSuggestion` 两个名字 —— 别把 floor 的理由记在它们头上
763
+ - [ ] 跨大版本提货前先读 `CHANGELOG.md`(0.29.0 起建档)+ `docs/REFACTOR-LEDGER.md` 做 BREAKING 对表 —— 别猜。🔴 **互链**(web [C166]⑦):CHANGELOG 各版「已知局限」段只记**该版新增**,接入面局限的完整台账在本档 §6e/§7 —— **只读其一会漏**,两处都过
764
+ - [ ] 建「禁直连」物理门([C162] 令①):直连 `@sema-agent/sdk*`/`@sema-agent/core*`(web 另加直 `fetch('/v1/`)的存量面冻结成精确集合基线,**只减不增**;样板 = `sema-cli/scripts/run-sdk-isolation-test.mjs` + `sdk-isolation-baseline.json`。挂进常驻门链(**门读数 + 变异实证**才算立住)
765
+ - [ ] 浏览器/渲染进程宿主:确认自己的打包链下本包**不引入 Node 内建**(本包侧有 portability 门,端侧要确认自己没把它 externalize 掉)
766
+
767
+ **A+. 禁直连门的枚举器已知盲区(web [C166]④⑤ 收录;三端门共用同一结构性限制)**
768
+
769
+ 门的证据面是**静态文本**:枚举器锚字面量说明符与字面量 URL 前缀,凡绕开静态可见性的写法它天生看不见——
770
+ 这不是某一份实现的缺陷,是这一类门的射程边界,新增此类形一律**视同产品 value 级豁免走特批入册**,并在
771
+ reason 里写明「枚举器盲区形」。已知两形:
772
+
773
+ - **形① 计算说明符的动态导入**(web engine-launcher 案):`import(someVar)` / `import(prefix + name)` /
774
+ 表驱动间接导入——说明符非字面量,`[dynamic]` 枚举臂只认字面串。纪律:引擎 wire 依赖**不走**计算说明符;
775
+ 确需动态分派时把说明符收敛成字面量分支(`cond ? import('a') : import('b')`,两支都可枚举)。
776
+ - **形② 变量态 URL 拼接**(web workspaceArchiveHref 案):`fetch(base + path)` / URL 对象组装——
777
+ web 家族③的 `fetch('/v1/` 字面枚举对它双向盲(违规新增看不见,收编后的陈旧条目也看不见)。纪律:
778
+ `/v1/*` 路径一律以字面量模板起头(枚举器可锚),或收进 client-core 读面走正道。
779
+
780
+ **B. 启动装配(漏一个 = 一条面静默失效,见 §5a)**
781
+ - [ ] `installHost({ log, probe, settings, fs, timers, session, queue })` —— 或 `installHostFor(sessionKey, …)`
782
+ - [ ] `installNotificationQueuePort()`(或经 `installHost({queue})` 直通)
783
+ - [ ] `installApprovalCardPort(port)` —— ⚠️ 今天**装在 `DEFAULT_SESSION_KEY` 上**(§7c 缺口 **P-10**;不是 P-4:P-4 是 `approval_revoke`)
784
+ - [ ] `installHitlHostSurface(surface)` —— 不装 = §4a 的两条安全告知全丢
785
+ - [ ] `installEngineWireTarget(target)` —— **非 Node 宿主(web / desktop 渲染进程)必装**:它们没有 env,这是唯一入口
786
+ - [ ] `kickEngineCapsProbe(baseUrl, probe)`(caps 门的数据源)
787
+ - [ ] `installWorkflowStatusProbe` / `installBgTaskStatusProbe` —— 不装 = **workflow / 后台子代的完成通知永远不落地**
788
+ - [ ] `installSubagentActivitySink`(可选:Subagent Progress 段的数据源;不装不是错误态)
789
+ - [ ] **启动校验用存在性读口**(§5a 的 (a) 表,🔴 **哨兵逐口不同,逐个照抄别推广**;多会话宿主用 `*For` 那一支):`hasApprovalCardPort() === true` · `hasApprovalCardPortFor(key) === true` · **`hitlHostSurfaceFor(key) !== null`**(哨兵是 `null`,写 `!== undefined` **恒真** = 假绿) · `hostSettings() !== undefined` · `hostSettingsFor(key) !== undefined` · `hostFs() !== undefined` · `hostFsFor(key) !== undefined` · `hostSession() !== undefined` · `hostSessionFor(key) !== undefined` · `engineWireTarget() !== null` · `engineWireTargetFor(key) !== null`(`hostTimers()` / `hostTimersFor(key)` 同为 `!== undefined`,按你真装的挑)
790
+ - [ ] **miss 计数当回归探针,不当装配自检**(#252 复审 R3/R4,§5a (b) / 缺口 **P-29**):`hostPortMisses()` 空 / `notificationQueuePortMisses() === 0` / `approvalCardPortMisses() === 0` / `hitlHostSurfaceMisses() === 0` / `compensationSplitViolations()` 空 —— 🔴 **这几个数初值就是 0/空**,「完全没装口 + 还没发生任何调用」时它们照样全绿,**启动瞬间的断言证明不了装配**。正确用法 = 跑过**一轮真流量**之后再断言它们仍为 0。⚠️ **通知队列口今天没有存在性读口**(P-29),那一条只能靠你自己记得调过
791
+
792
+ **C. verb 实现(`CLIENT_VERBS`,`src/clientSlice.ts` —— 13 个 verb,以表为准别抄数字)**
793
+ - [ ] 五个 `required: true` **必须**实现:
794
+ `streamTask`(turn 入口)· `cancelRun`(不实现 = Esc 只停渲染、**引擎照跑**)·
795
+ `respondApproval`(两元组 verbatim 回显,**安全不变量**)· `respondQuestion`(不实现 = **问了没法答,turn 挂死**)·
796
+ `capabilities`(所有能力门的数据源,不实现 = **门全判假**)
797
+ - [ ] 其余 `required:false` 的不实现**不是报错**,是「那个面在该端是哑的」—— 但要**知道自己哑了哪些**
798
+ (`respondPlanReview` / `tailSubagent` / `steerSubagent` / `wakeSubagent` / `streamFleet` /
799
+ `listSessions` / `readSession` / `probeHealth`)
800
+ - [ ] 编译期闭合门:`ClientSliceLike` 与 `CLIENT_VERBS` 是**双向**钉死的 —— 加 verb 忘了加表(或反过来)编译期就红
801
+
802
+ **D. 下行投影**
803
+ - [ ] 用三态判据消费 `eventToSdkMessage`:`if (p.kind === 'message')`,**不要** `if (msg)`(§3a)
804
+ - [ ] 在 per-turn `ctx` 上填 **`onDroppedFrame`**(`EmitContext` 的可选位,**唯一**注入位;`reportDroppedFrame` 是包内实现、不出公面),**不许吞**(§3b / §5a 表末行)
805
+ - [ ] 抬 SDK floor 后**重跑穷举**(`assertNeverArm` 编译期打红),不许 `as` 绕过
806
+ - [ ] 知道自己**收不到**这几件:`approval_revoke`(卡不会被自动撤,P-4)· 重放的 `question` 不会弹覆盖层(P-5)· `task_progress` 的 `taskType`/`status`/`parentTaskId`(P-1)· 压缩失败(P-6)
807
+ - [ ] 子代行 settle **只靠防御 sweep**,别建「等终态 tick」的状态机(P-2)
808
+
809
+ **E. 上行与回执(§4,最容易漏)**
810
+ - [ ] 请求一律经 `buildTaskRequest(input, lane)`,**不自拼字面量**;上 wire 前过 `unregisteredRequestKeys(req, lane)`
811
+ - [ ] 接审批帧腿时**填 `AskGateWireDeps.approvalLane`**(缺席 = `note` 恒不发,fail-closed 且不报错)
812
+ - [ ] ack 五位按 §4a 三列表消费:**缺席一律当未知**,`rememberApplied === false` 与 `updatedInputForwarded === false` 必须响亮告知
813
+ - [ ] durable 腿:`decideTool` 前把**呈卡用的那一行** pending 经 `preResolvedPending` 传进去(TOCTOU)
814
+ - [ ] durable 腿:取消 suspended run **用 deny,不用 `runs.cancel`**
815
+ - [ ] `HitlSafetyError` 按 **`.code` 结构化判型**,`instanceof` 只作加强(跨 realm / 双实例)
816
+ - [ ] `ReopenCardVerdict.presented`:要用这个位就自己在呈现层实现回执;不实现就**缺席**(缺席不降级),**别填 `false` 当占位**
817
+
818
+ **F. 多会话宿主(desktop)额外项**
819
+ - [ ] 每会话一个真 `sessionKey`,**不拿 `DEFAULT_SESSION_KEY` 当会话 id**
820
+ - [ ] 一律用 `*For(sessionKey, …)` 变体;判「装了没」读 `get(k)` 的**值**,不数 `keys()`(§6c)
821
+ - [ ] `pendingRowIsOwnedByThisSession` 传 `sessionKey` **且自注入 `isOwnRun`**(非默认键下缺省腿会整条跳过,fail-closed;不自注入则受 P-13 的进程级归属影响)
822
+ - [ ] ⚠️ **审批卡口今天装在 `DEFAULT_SESSION_KEY` 上**(P-10:三个决断入口硬读默认键,按会话键装 = 100% miss)
823
+ - [ ] `reopenPlanReviewCard` 传 `sessionKey`,**绝不与 `mintFreshQuestionId:false` 组合**(P-11 / P-12)
824
+ - [ ] 读完 §6e / **P-10 ~ P-14** 五条局限**再接** plan review 与卡口
825
+
826
+ **G. 单实例(打包)**
827
+ - [ ] 确认本包在最终 bundle 里**只有一份实例** —— 包内多个模块持 module 级可变状态(台账/端口槽/计数),两份实例 = **写进一份、读另一份**的静默失效(不报错)。清单见 `src/index.ts` 头注的 🔴 单实例纪律段 + `docs/refactor/p1-scan/singleton-manifest.json`(常驻门 `scripts/run-client-core-singleton-test.mjs` 的 `MANIFEST_FILE` 就是这条坐标;⚠️ 该清单**不随包发**,只在仓内)
828
+
829
+ **H. 交账([C162] 令④)**
830
+ - [ ] 提货/建门/清扫过程中发现的一切本包疑点(API 不顺手 / 文档与实现不符 / **键缺席语义含糊** / 公面缺口)**逐条带坐标**回 C 板 —— 三端是本包的问题发现方与去幻觉对抗方
831
+ - [ ] **本档报错直接打回**(接入文档宪法 [3680]):不许「按源码将就接」
832
+
833
+ ---
834
+
835
+ ## §9 版本对表纪律(本包侧)
836
+
837
+ - **SDK 是唯一 wire 面**:本包不手抄 wire 形。新消费一个键之前,**先抬 `@sema-agent/sdk` floor 再写码**。
838
+ - **floor 必须被见证**:`scripts/run-sdk-floor-test.mjs` 拿真装的 SDK 核对值级符号与 `TaskStats.costMicroUsd`。
839
+ - **公面是不可撤回承诺**:进了 `public-export-baseline.json` 就是承诺,撤回 = BREAKING。
840
+ 因此包内实现叶(`abortableSleep` / `envFlag` / `createSessionSlot`)刻意**不出 barrel**。
841
+ - **本包每发一版,三端三问**:
842
+ 1. 公面有没有**新导出 / 撤回**(→ §2 域图 + 基线 diff);
843
+ 2. 有没有**新回执键或缺席语义变更**(→ §4 三列表;这一类改动最容易被当成 additive 而漏接);
844
+ 3. 有没有**新端口 / 新能力位 gate**(→ §5 + §8 checklist)。