@sema-agent/client-core 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,22 +15,29 @@
15
15
 
16
16
  ## §0 版本锚与重扫纪律
17
17
 
18
- ### 0a. 版本锚(2026-09-01)
18
+ ### 0a. 版本锚(2026-09-03)
19
19
 
20
20
  | 项 | 值 | 真源 |
21
21
  |---|---|---|
22
- | 本包 | `@sema-agent/client-core` **0.48.0**(工作树;发布前 npm 最新是 `0.47.0`) | `package.json` `version` |
22
+ | 本包 | `@sema-agent/client-core` **0.49.0**(工作树 = npm 最新;S-81 那一批未发,进 `CHANGELOG.md` 的 `## 0.50.0(未发布)`) | `package.json` `version` |
23
23
  | peer:wire 契约 | `@sema-agent/sdk` **>=7.4.0**(value-level,非 type-only;0.48.0 抬版,四条硬理由见 `CHANGELOG.md` 0.48.0 段末的地板影响面账) | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
- | 公开导出面 | **794** 个运行期符号(+ 41 个测试钩;= 未发 0.48.0 的值,npm `0.47.0` 是 **790**,`0.46.0` 是 **787**,`0.44.0` 是 **783**,`0.43.1`/`0.43.0` 是 **776**,`0.42.0` 是 **771**,`0.41.0` 是 **767**,`0.39.0` 是 **766**,`0.38.0` 是 **764**;`0.37.0` 是 **753**,见 `CHANGELOG.md`) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **800** 个运行期符号(+ 41 个测试钩;= 工作树当下的值 —— 已发的 `0.49.0` 是 **795**,再加 S-81 五件未发 additive 导出;`0.48.0` 是 **794**,npm `0.47.0` 是 **790**,`0.46.0` 是 **787**,`0.44.0` 是 **783**,`0.43.1`/`0.43.0` 是 **776**,`0.42.0` 是 **771**,`0.41.0` 是 **767**,`0.39.0` 是 **766**,`0.38.0` 是 **764**;`0.37.0` 是 **753**,见 `CHANGELOG.md`) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
27
  | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
28
  | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
29
 
30
- ⚠️ **工作树 ≠ npm**:本表记的是**工作树**的 0.48.0,npm 上最新仍是 0.47.0(冻结账里 0.48.0
31
- `pending` 行)。装 ≤0.47.0 的端注意:0.48.0 新增的 **4 个 additive 导出**
30
+ ⚠️ ≤`0.47.0` 的端注意:`0.48.0` 新增的 **4 个 additive 导出**
32
31
  (`readSessionMemoryStatus` / `classifyMemoryStatusFailure` / `readCaptureOptOut` / `readLastCapture`)
33
32
  在旧版上按名 import 会**在 ESM 实例化当场炸**(具名导出不存在)—— 提货前先抬依赖。
33
+ 装 ≤`0.48.0` 的端同理:`0.49.0` 的 `projectCrashConverged`(L-38,§12)在旧版上不存在。
34
+ ⚠️ **工作树里还有一批比 `0.49.0` 更晚的未发 additive 导出**(S-81,见 §13):
35
+ `classifySelfOrchestrationRefusal` / `stripSelfOrchestrationIntent` / `projectWorkflowsGate` /
36
+ `SELF_ORCHESTRATION_RETRY_WITHOUT` / `CAPABILITY_SELF_ORCHESTRATION_REQUIRED`
37
+ (+ **四个** type-only 形 `SelfOrchestrationRefusal` / `SelfOrchestrationDenialReason` /
38
+ `WorkflowsGateProjection` / `WorkflowsGateUnknownDenial`)。它们随下一个版本段发出;
39
+ 在此之前按名 import 会在 ESM 实例化当场炸 —— 提货前先抬依赖。
40
+ (L-38 的 `projectCrashConverged` 已随 `0.49.0` 发出,不再是未发件。)
34
41
  🔴 **0.48.0 还抬了 peer 地板**(`@sema-agent/sdk >=7.4.0`),这是本版**唯一**的非 additive 面:
35
42
  端装 <7.4.0 的 SDK 会看到 peer 警告(运行期不因此变化)。同一条对 0.47.0 那 **3 个 additive 导出**
36
43
  成立(`planInteractiveHalt` / `RUN_LEVEL_STOP_ERROR_CODES` / `readDecideCurrentPending`)。同一条对 0.38.0 那 11 个
@@ -106,7 +113,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
106
113
 
107
114
  ## §2 公共导出面地图(按域)
108
115
 
109
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**794** 项)。
116
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**800** 项)。
110
117
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
111
118
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
112
119
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -116,7 +123,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
116
123
 
117
124
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
118
125
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
119
- 实测:794 项 **100% 是运行期导出,零 type-only**。
126
+ 实测:800 项 **100% 是运行期导出,零 type-only**。
120
127
 
121
128
  **推论(端必须知道)**:
122
129
  - barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
@@ -124,32 +131,37 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
124
131
  `LocalSessionEvent` / `SeatMethodName` / `ModelCatalog` 全在公面上、全**不在**基线里。
125
132
  端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
126
133
  - `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
134
+ - 同理:L-38 的 `CrashConvergedRow` / `ApprovalsListEnvelope` / `CrashConvergedProjection` 三个形也
135
+ **不在**基线里(纯类型),`src/hitl/crashConverged.ts` 对基线只贡献 `projectCrashConverged` 一项。
136
+ - S-81 同款:`SelfOrchestrationRefusal` / `SelfOrchestrationDenialReason` / `WorkflowsGateProjection` /
137
+ `WorkflowsGateUnknownDenial` 四形**不在**基线里,`src/selfOrchestrationDenial.ts` 对基线贡献
138
+ **4** 项运行期导出(三个函数 + `SELF_ORCHESTRATION_RETRY_WITHOUT`)。
127
139
 
128
- 794 项的内部构成(帮助端估读表大小):**233** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
140
+ 800 项的内部构成(帮助端估读表大小):**235** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
129
141
  (矩阵、键集、env 名、锚串)而非可调用物;**5** 项是 PascalCase 运行期值
130
142
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError` / `DecideTransportRetryExhaustedError`);
131
143
  **41** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
132
144
 
133
- ### 2b. 域图(16 域,逐域计数之和 = 794)
145
+ ### 2b. 域图(16 域,逐域计数之和 = 800)
134
146
 
135
147
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
136
148
  |---|---|---|---|---|---|
137
149
  | 1 | **适配内核(下行主链)** | 33 | `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` |
138
150
  | 2 | **seam 公共契约** | 2(其余为 type-only) | `CHROME_ARMS` · `deriveTranscriptId` | 公共词汇 + **id 确定性不变量**(同一条流重放 ⇒ 同一串 id)。`CHROME_ARMS` = 端「我要消费哪些 chrome 臂」的对照清单 | `src/seam.ts` |
139
- | 3 | **HITL 决断卡链**(§4/§5 主战场) | 132 | `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` · `persistedRulesLaneAvailable`/`persistedRulesGovernanceAvailable` · `classifyRulesFailure` · `listAllPersistedRules` · `classifySkippedReason` · `readRulePersistOutcome`(#244 F2:persist-ack 读口与 `readToolApprovalRespondAck` 合成一处) · `parseLocalAllowRule`(durable 腿本地落规则窄化骨架,谓词经 `LocalAllowRuleDeps` 注入) · `readToolApprovalRespondRefusal`(#225 件5,0.42.0:respond 抛错的结构化原文读口 —— 三位各自防御读、各自缺席不铸、**三位皆缺席时整只返 `undefined`**;原样交还零加工,UNTRUSTED-for-display)· `installEditedRuleTextPrechecker` / `hasEditedRuleTextPrechecker` / `precheckEditedRuleText`([5076] 转出口,0.42.0:core 5.57.0 `precheckEditedRuleText` 的**端口注入形** —— 类型面 + 注入口 + 诚实缺席读口。🔴 **不是** value 级 re-export,理由见 §7 缺口 **P-34**;未装 ⇒ 返 `undefined`,绝不编一个 `{ok:true}`)· `surfaceRuleArmNotSent` / `RULE_NOT_SENT_WARN_TEXT` + `surfaceRuleArmRejected` / `RULE_NOT_SENT_REJECTED_WARN_TEXT`(#334,0.43.0:人在卡上按下的「不再询问」被整条丢弃时的诚实告知——**两条刻意分开**:前者=**引擎能力位未确认**(换台引擎/等探测就好),后者=**这次选择没过包内表核/互斥核**(表外文本/坏下标/两臂同场,换引擎也不会变) —— 编辑臂 `respondFreeFormRules` 与批臂 `respondBatchRuleOffers` 两条同形存量共用一条,与 `surfaceRememberNotApplied` 同族纪律:决断照送、只是规则没存,静默丢掉用户明确意图 = 让人以为功能坏了)· `DecideTransportRetryExhaustedError`(Inkglow-1085 P0a:decide 出站瞬断重试耗尽的 typed 判别 —— HitlBridge 内建单次退避重试,耗尽走重呈臂不判死 turn;端一般只消费行为,不需要 instanceof) | 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`/`HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层)、`persistedRulesWire.ts`、`localAllowRule.ts`(#244 F2 规则侧) |
151
+ | 3 | **HITL 决断卡链**(§4/§5 主战场) | 133 | `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` · `persistedRulesLaneAvailable`/`persistedRulesGovernanceAvailable` · `classifyRulesFailure` · `listAllPersistedRules` · `classifySkippedReason` · `readRulePersistOutcome`(#244 F2:persist-ack 读口与 `readToolApprovalRespondAck` 合成一处) · `parseLocalAllowRule`(durable 腿本地落规则窄化骨架,谓词经 `LocalAllowRuleDeps` 注入) · `readToolApprovalRespondRefusal`(#225 件5,0.42.0:respond 抛错的结构化原文读口 —— 三位各自防御读、各自缺席不铸、**三位皆缺席时整只返 `undefined`**;原样交还零加工,UNTRUSTED-for-display)· `installEditedRuleTextPrechecker` / `hasEditedRuleTextPrechecker` / `precheckEditedRuleText`([5076] 转出口,0.42.0:core 5.57.0 `precheckEditedRuleText` 的**端口注入形** —— 类型面 + 注入口 + 诚实缺席读口。🔴 **不是** value 级 re-export,理由见 §7 缺口 **P-34**;未装 ⇒ 返 `undefined`,绝不编一个 `{ok:true}`)· `surfaceRuleArmNotSent` / `RULE_NOT_SENT_WARN_TEXT` + `surfaceRuleArmRejected` / `RULE_NOT_SENT_REJECTED_WARN_TEXT`(#334,0.43.0:人在卡上按下的「不再询问」被整条丢弃时的诚实告知——**两条刻意分开**:前者=**引擎能力位未确认**(换台引擎/等探测就好),后者=**这次选择没过包内表核/互斥核**(表外文本/坏下标/两臂同场,换引擎也不会变) —— 编辑臂 `respondFreeFormRules` 与批臂 `respondBatchRuleOffers` 两条同形存量共用一条,与 `surfaceRememberNotApplied` 同族纪律:决断照送、只是规则没存,静默丢掉用户明确意图 = 让人以为功能坏了)· `projectCrashConverged`(L-38,0.49.0:`/v1/approvals` additive 键 `crashConverged` 的分桶投影 —— 崩溃收敛的孤儿审批读面,**缺席 ≠ 空数组**、分桶恰一个合取、坏行丢弃并计数,详见 §12;同批把 `ApprovalsResourceLike.list()` 的返回位 additive 放宽成 `ApprovalsListEnvelope`,老形 `{pending}` 仍可赋值)· `DecideTransportRetryExhaustedError`(Inkglow-1085 P0a:decide 出站瞬断重试耗尽的 typed 判别 —— HitlBridge 内建单次退避重试,耗尽走重呈臂不判死 turn;端一般只消费行为,不需要 instanceof) | 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`/`HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层)、`persistedRulesWire.ts`、`localAllowRule.ts`(#244 F2 规则侧)、`crashConverged.ts`(L-38 崩溃收敛读面) |
140
152
  | 4 | **子代 wire + 面板侧信道台账** | 84 | `tailEngineSubagent` · `installSubagentActivitySink` · `installSubagentTailMetaSink`(#280 件2:tail meta 帧发布口,`contentFrames` 判别位载体)· `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent`(0.32.0 未发布 #280 件A:additive 第三参 `childTaskId` —— 端有行上下文时**应当**传,传了就走「台账优先 / 缺席即诚实缺席 + `noteBgOwnerAbsence` 留痕」的 Q3 口径,与 tail·taskOutput·subagentOutput 三腿同姿势、与孪生 resume 腿共用同一个 `resolveOwnerRunId` 判据;**不传**则逐字维持旧行为=回落在飞 run)· `resumeSettledSubagent` + `resolveSubagentResumeContext` + `resolveOwnerRunId` + `classifySubagentResumeFailure` + `subagentResumeAvailable`(#242 批 2 A-028.7:resume 判定半场上收,与 steer 孪生同居;取址三态 = 台账有行用行值 / 指名了行但台账缺席则**诚实缺席绝不回落在飞 run** / 没指名行才回落。出路文案归端)· `recordSubagentOwnerFromProgress` + `getBgParentRunOwner`(A-028.6:「子代 → 宿主 run」**单表**,宿主 run 必须由持 stream-local 值的调用方显式传入,包内绝不从 `activeEngineRunId()` 推断)· `noteBgOwnerAbsence`(#242 批 3 [4000] Q3=B:tail/taskOutput·taskStop/subagentOutput 三腿台账缺席即诚实缺席**绝不回落在飞 run**,缺席 warn 留痕每 (腿,taskId) 一条)· `clearBgTerminalFacts`(#242 批 3 扫码修:复活=新周期,旧周期终态事实作废——fleetLedger 复活两腿按尾段清账,factsAccepted 方向核不再拿上周期终态当先例)· `auditRetainWithoutWake`([4000] Q5:引擎宣示 `subagentResume` + 本端在付 `retainSubagentSessions` + 端未实现 `wakeSubagent` ⇒ 响亮一条;`CLIENT_VERBS.wakeSubagent` 维持 fail-soft)· `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` |
141
153
  | 5 | **fleet 投影** | 46 | `createFleetLedger` · `projectTasks` · `projectWorkflows` · `projectFleetAgentRows` · `readEngineActiveBgTasks` · `FLEET_TASK_VIEW_KEYS` · `escapeDisplayControlChars`(不可见字符可见化,行标签/描述消毒的共享底座)· `wireCycleSeq` / `wireRetiredBy`(0.38.0 提货补投的 #261 §2 两位:代际号 = SendMessage 复活即 +1,**缺席 ≠ 第一代**;`retiredBy` 在场 = 这条终态是对账腿从 durable run 行投影出来的、**不是**发布方亲报 —— 幽灵行与正常收尾唯一的 wire 判据。两位都只在场才落键) | 老 `fleetClient` 那一刀的成品:**帧体归库、连接归端** —— 端持 SSE 连接,库做行投影 + 保留台账 | `src/fleet/fleetProjection.ts`、`src/fleet/fleetLedger.ts`、`src/fleetAgentPanelProjection.ts`、`src/fleetTaskDesc.ts` |
142
154
  | 6 | **请求装配(上行唯一构造口)** | 8 | `buildTaskRequest` · `REQUEST_FIELD_MATRIX` · `unregisteredRequestKeys` · `applyLiveRequestDefaults` · `taskNotificationToPrintFrame` | 两条车道(`interactive`/`print`)出站请求的**唯一**构造器;`unregisteredRequestKeys` 是可执行门 —— 端偷带一个未登记键上 wire 就红 | `src/request/taskRequest.ts`、`src/request/printNotification.ts` |
143
155
  | 7 | **通知与 outstanding 台账** | 41 | `installNotificationQueuePort` · `normalizeTaskNotification` · `taskNotificationDedupKeyFromWire` · `registerOutstandingBgTask` / `registerOutstandingWorkflowRun` · `notificationQueuePortMisses` · `subscribeOutstandingWorkflows` · `outstandingDeliverableWorkflowCount` | `task_notification` 归一 + 去重 + 投递进宿主命令队列的**一把闸**;`outstandingDeliverableWorkflowCount()` 是 headless `-p` 的**退出门** | `src/notifications.ts`(11 个 module 台账) |
144
156
  | 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` |
145
- | 9 | **能力/旋钮 wire 门族** | 87 | `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` |
157
+ | 9 | **能力/旋钮 wire 门族** | 91 | `projectAgentsForWire` / `awaitTaskAgentsWire` · `hooksForWire` · `mcpConfigsToSpecs` · `ultracodeForRequest` · `resolveWebSearch` + `buildWebSearchEnv` · `attachmentsForRequest` · `projectEffectiveBody` · `classifySelfOrchestrationRefusal` / `stripSelfOrchestrationIntent` / `projectWorkflowsGate` / `SELF_ORCHESTRATION_RETRY_WITHOUT`(S-81,server 7.57.0:上面两条 stamp 腿的**背面** —— 半配置多租户形态下 server 把带 `selfOrchestration` / `settings.ultracode` 的提交 501 拒掉,判型/去键/caps 闸三处都是判定不是文案,详见 §13) | 逐功能的「引擎有没有这个能力 + 这个字段怎么上 wire」投影器,由 `buildTaskRequest` 组装。🔴 `hooksForWire()` 是 **fail-closed**:无 `SettingsPort` ⇒ 返回 `undefined`(信任门,不是取值) | 17 个 `src/*WireCaps.ts` + `src/scratchpadWireCaps.ts`、`src/hooksWireCaps.ts`、`src/goalStopHook.ts`、`src/websearch/searchProviderPresets.ts`、`src/selfOrchestrationDenial.ts`(S-81 拒绝判定层) |
146
158
  | 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` |
147
159
  | 11 | **模型目录与预算** | 67 | `resolveModelCatalog` · `loadCatalogWithSources` · `PROVIDER_PRESETS` / `MODEL_FAMILIES` · `defaultMaxTokensFor` · `getLiveModelCatalog` / `setLiveModelCatalogRefresher` · `providerAuthMethods` / `beginDeviceCodeAuth` · `providerCatalogRows` / `providerCatalogRowDetail` / `providerPresetById`(#244 F4 族D A-028.17:46 家表的规范折表层 —— 全表不重排、诚实缺席「model id typed in」,cli 目录/web 向导同一份折表)· `TIER_ORDER` / `CC_TIER_ALIASES` / `isTier` / `resolveTierBinding`(A-028.18:档位词表+校验+fail-open 降档派生单源;settings 存储归宿主)· `resolveEntryVision` / `computeDeleteBlockers` / `computeDeleteWarnings`(0.38.0 #318 件③ 上收:Model Hub 供给面的三端公共判定 —— vision 生效值+来源三态、删除断链核(拒删+指路)、删除降级后果(照删但必说)。**零 IO**,读盘那半场留各端;`doc === null` 的两义在调用方分流) | 三层 provider 目录解析(线上 URL → 包内预设 → 用户覆盖)+ per-model `maxTokens` 封顶。线上腿需注入 `CatalogFetchJson`,缺席 ⇒ 整条不启用(`online.reason='no-fetch-port'`);缓存落盘经 `CatalogCachePort` | `src/model/{catalog,catalogLoader,providerAuth,providerPresets,providerCatalog,tierVocabulary}.ts`、`src/liveModelCatalog.ts`、`src/modelBudgetRule.ts`、`src/sessionModelLatch.ts`、`src/effortWire.ts` |
148
160
  | 12 | **workflow 与后台工作视图** | 19 | `projectWorkflowRun` · `createLiveWorkflowSource` · `ensureWorkflowActivityLedger` · `readWorkflowActivityLedger` · `stopWorkflowActivityLedger` · `resetWorkflowActivityLedgers` · `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`) |
149
161
  | 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**,纯类型 + 常量 + 纯谓词)。🔴 **证据等级标注(0.42.0,test [5087] 的「语料**种类**缺口」/ cli [5088] 认领件)**:该文件里所有以「CC 如何如何」为形的断言(`212 methods` / `854-channel census` / 方法名逐字保留 / `fQe` 逐字段对照 / 一切 `.vite/build/index.chunk-*.js` 坐标)**证据等级 = 桌面 unpack,本地语料库不可复验** —— 本仓手边可复验的参照语料**只覆盖终端 CLI 形态**的静态产物,拿它去 grep 桌面壳里的符号只会零命中,而零命中在这里**既不证真也不证伪**。复核这些断言**不得**拿本仓语料当反证 |
150
162
  | 14 | **宿主端口与会话槽** | 26 | `installHost` · `installHostFor` · `hostPortMisses(For)` · `DEFAULT_SESSION_KEY` · `hostEnv` · `unrefTimer` · `parseLocaleTag` / `pickUiLanguage`(#244 F4 族D A-028.20:locale tag 手术单源 + UI 语言判定;与 `resolveRegionHint` 双出口成文 —— 语言偏好域 en/zh ≠ 地址可达域 cn/intl/unknown,`zh-Hant` 前者 zh 后者 intl 是设计)· `engineNamespaceKeyFor` / `mergeSessionMapRecord` / `mergeEngineEntry`(A-028.12:会话 id 映射单一键形 + merge 判定;存储经 `SessionMapStorePort` 归端 —— cli 文件锁/原子写,web localStorage)| 进程/端级装配层(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,localeTag,uiLanguage}.ts`、`src/sessionMap.ts` |
151
163
  | 15 | **控制面与传输** | 82 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` / `invalidateEngineCaps(baseUrl, probe?)`(#307 S25:引擎温切后的 caps 生产失效口 —— kick 自带幂等闸,同 baseUrl 重启后不显式失效就永远读到旧引擎那一版能力位;调用方 = 壳的 respawn/restartEngine。🔴 **推荐两参形**:第二参给替代探测则「推进代际 + 注册新探测」在同一同步块内完成,失效与下一次 kick 之间那个「等待者读到未判」的窗按构造不存在;单参形保留给「只丢缓存、这一刻没有替代探测」的调用方,那种情形下读到未判是诚实结局) · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `clearRunningChoiceOffer`(Inkglow-1085 P0b①:「Do nothing」登记的清口 —— 端的「重新打开操作菜单」入口;登记在场时 attemptActiveRunSelfHeal 不整卡重弹,not-parked 结局带 `alreadyOffered: true` 判别位,端据此降级渲一行)· `INTERACTIVE_WAY_OUT`(默认出路串单源)· `engineSessionParamFor`(design/285 批 0:`?session=` 派生的 **per-key** 形 —— `hostSessionFor(sessionKey)?.currentSessionId()` + [1501] 空串归一;零参 `engineSessionParam()` = 默认槽兼容层,取值链逐字等价)· `normalizeWirePrincipal`(A-028.10:principal 在场性 trim 原语 —— 全空白=缺席不发头,engineWireTarget 两臂/makeEngineWireClient/壳 livePrincipal 同尺)· `readSessionMemoryStatus` / `classifyMemoryStatusFailure` / `readCaptureOptOut` / `readLastCapture`(S-53 会话记忆姿态读面,0.48.0:失败分诊**码优先**——两个 404 分道 `not_found.session` / `not_found.route`,无码 404 不猜落 failed;五键逐键缺席语义两个合读器,`lastCapture` 三态的判别材料是 `committedCount` 不是本键;IO 归宿主注入 `MemoryStatusClientLike`,详见 §11) · `classifyTurnWireError` / `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` / `drainingRetryDelayMs` / `scenarioDenyFromError` + `WIRE_NETWORK_ERROR_PATTERN`(A-028.11/.13:turn 错误分型判定半场,人话文案与渲染归端) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`src/principalWire.ts`、`src/wireErrorTriage.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts`、`src/sessionMemoryStatus.ts` |
152
- | 16 | **引擎词汇表与包自检** | 48 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` · `DELEGATION_CAP_CODES` / `isDelegationCapCode` / `DELEGATION_CONCURRENCY_CAP` / `DELEGATION_SESSION_CAP`(0.38.0 #318 件④:core 5.48.0 design/323 委派席位到限**两码,处置不对称禁合并** —— 并发帽=**可等**(兄弟结束即有位)/ 会话累计帽=**等也没用**(这条会话的配额用尽))· `CONFIG_DELEGATION_ENTRY_CAPS`(同批入 `CONFIG_REFUSAL_CODES` 识别表)· `delegationCapDispositionOf` / `MCP_SERVER_REVOKED`(0.39.0 载体到货消费件:core 5.50.0 补 `{ error: code, code }` 孪生拼法后两码真上 `tool_end.errorCode`,0.38.0「先立词不落消费分支」的已知局限自此解除;处置轴 `wait-for-slot` / `reuse-existing-or-await-reap` 机器可读(累计帽=retained-window 帐,行回收配额即回,处置=SendMessage 复用,**非**「换会话/永久耗尽」——0.38.0 段该句系勘误),未知 `delegation.*` 码 ⇒ `undefined`;`mcp.server_revoked` = 操作员 mid-session 吊销 server 后的工具面本地闸(被吊销的 server **名**今天不过 wire 境:detail.server 是进程内位,抬升腿只 lift code——归因渲染候 core 补 typed detail,已点名);载体门 = engine-vocab G3 腿锚 core dist 铸点) | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(计数以 `scripts/public-export-baseline.json` 为准,别手抄;A-028.11/.13 补 `DRAINING_ERROR_CODE`/`SCENARIO_NOT_ALLOWED_ERROR_CODE`/`RESUME_AT_ERROR_CODE_PREFIX`;#318 件④ 补 `delegation.*` 族四位 + `config.delegation_entry_caps`;0.39.0 补三新码消费件三位)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
164
+ | 16 | **引擎词汇表与包自检** | 49 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` · `DELEGATION_CAP_CODES` / `isDelegationCapCode` / `DELEGATION_CONCURRENCY_CAP` / `DELEGATION_SESSION_CAP`(0.38.0 #318 件④:core 5.48.0 design/323 委派席位到限**两码,处置不对称禁合并** —— 并发帽=**可等**(兄弟结束即有位)/ 会话累计帽=**等也没用**(这条会话的配额用尽))· `CONFIG_DELEGATION_ENTRY_CAPS`(同批入 `CONFIG_REFUSAL_CODES` 识别表)· `delegationCapDispositionOf` / `MCP_SERVER_REVOKED`(0.39.0 载体到货消费件:core 5.50.0 补 `{ error: code, code }` 孪生拼法后两码真上 `tool_end.errorCode`,0.38.0「先立词不落消费分支」的已知局限自此解除;处置轴 `wait-for-slot` / `reuse-existing-or-await-reap` 机器可读(累计帽=retained-window 帐,行回收配额即回,处置=SendMessage 复用,**非**「换会话/永久耗尽」——0.38.0 段该句系勘误),未知 `delegation.*` 码 ⇒ `undefined`;`mcp.server_revoked` = 操作员 mid-session 吊销 server 后的工具面本地闸(被吊销的 server **名**今天不过 wire 境:detail.server 是进程内位,抬升腿只 lift code——归因渲染候 core 补 typed detail,已点名);载体门 = engine-vocab G3 腿锚 core dist 铸点)· `CAPABILITY_SELF_ORCHESTRATION_REQUIRED`(S-81,server 7.57.0:提交面的 selfOrchestration 准入拒绝码。🔴 **复用码** —— 与其它 `capability.*` 501 同体形而处置不同,消费点必须按**恰等**判、绝不放宽成前缀判;判型与「去键重发一次」归 `src/selfOrchestrationDenial.ts`,详见 §13) | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(计数以 `scripts/public-export-baseline.json` 为准,别手抄;A-028.11/.13 补 `DRAINING_ERROR_CODE`/`SCENARIO_NOT_ALLOWED_ERROR_CODE`/`RESUME_AT_ERROR_CODE_PREFIX`;#318 件④ 补 `delegation.*` 族四位 + `config.delegation_entry_caps`;0.39.0 补三新码消费件三位);S-81 补 `capability.self_orchestration_required` 一位)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
153
165
 
154
166
  🔴 **`engineErrorCodes` 的开集纪律**(该文件头注逐字):这些 `ReadonlySet` / 前缀谓词一律是**识别表**,
155
167
  回答的是「我认不认得这个码」,**绝不是**「合法码只有这些」。消费点 `switch` **必须留 `default`**,
@@ -371,6 +383,13 @@ server 7.54.0 的 `ENGINE_NOTICE_WIRE_CODES` 增至 **14 码**,新增两个 **ta
371
383
  - `task.late_approval` 的呈现要与**审批卡面**联动而不是并列:它说的是「你刚才那一决断没落到东西
372
384
  上」,端若已经把卡收掉,应当据此把那张卡的终态从「已决断」订正为「未结算」,否则用户看到的是
373
385
  一次并不存在的成功。🔴 **它不是错误**,不要渲成失败态 —— 迟到是时序事实。
386
+ ⚠️ **cli 消费批打回条目(2026-09-01,自维护上游候件,不在 0.48.0 修)**:上两句在 **TUI** 上互斥 ——
387
+ 「订正已收掉的卡的终态」与「不合成 transcript 行」不可兼得:一张已收掉的卡,其终态在 TUI 端**只活在
388
+ 转录里**(卡已退出队列与台账;观察事件账明写不参与终态判定,往那里写=inert 台账);两次壳侧自行加固
389
+ (加长通知窗 / 按 toolCallId 撤卡)均被异源复审证伪(独占共享 footer 饿死高优先级取件行 / id 复用与
390
+ 重放时撤掉另一张有效卡)。⇒ 壳 0.48.0 消费批交付的是本条款允许载体上**能说的最强的话**(点名工具+
391
+ 「工具没跑 / 什么都没结算」两句+显式免责),**不发明第三条路**。正位解=本包给一个 TUI 可用的**持久卡态
392
+ 订正面**(或明许本码破例合成一条转录订正行),登记 DEBTS-cli L-44,届时本条款改写。
374
393
  - `task.halt_unconsumed` 对应壳侧 Esc/停止腿(§10 `planInteractiveHalt` 的同一条语义轴):
375
394
  端据它把「已请求停止」的乐观态**收回**,而不是让那一行一直挂着。
376
395
  ⚠️ 与 §10 的判定**不互替**:那一条是**发起前**的判定(该发 halt 还是 cancel),本码是**发起后**
@@ -417,7 +436,7 @@ server 7.54.0 的 `ENGINE_NOTICE_WIRE_CODES` 增至 **14 码**,新增两个 **ta
417
436
  🔴 **`errorStatus` 不许用来推断该不该重试**:该不该等由 `phase` / `errClass` 两个中性桶说了算;
418
437
  本位是给操作者看的**点名**(「谁失败了」),渲进人话行即可。
419
438
 
420
- **端的消费点**:重试覆盖层那一行(`RetryStatus`)CC parity = API Error 529 · Retrying in Ns」。
439
+ **端的消费点**:重试覆盖层那一行(`RetryStatus`)。⚠️ **CC 取证订正(cli 消费批 2026-09-01,语料直证)**:CC 2.1.223 该行非终态 headline 逐字是小写 `API error` 且**不渲状态码**(`cc-decoded/pretty223.js:744413`,`Yii = !Pmf ? "API error" : …`);此前本档写的「API Error 529 · Retrying in Ns」是转述不是取证。端的正位形 = 保留 CC 的 `API error` 措辞,把状态码作为**超集**追加(`API error 529`),缺席时与 CC 逐字节相同。
421
440
  **cli 认领**:壳侧渲染在下一批。
422
441
  **实现锚**:`src/retryStatus.ts`(`BrainStatusPayload` / `RetryStatus` / `mapBrainStatusToRetry`)+
423
442
  `src/adapter/downstream/eventToSdkMessage.ts` 的 `case 'status'` + `src/adapt/arms.ts` 的 `retryStatusArm`。
@@ -1793,3 +1812,455 @@ switch (v.kind) {
1793
1812
  **cli 认领**:壳侧接点在下一批。
1794
1813
  **实现锚**:`src/sessionMemoryStatus.ts`。
1795
1814
  **常驻门**:`scripts/run-session-memory-status-test.mjs`。
1815
+
1816
+ ## §12 🆕 崩溃收敛的孤儿审批读面(L-38,0.49.0;server ≥7.55.0)
1817
+
1818
+ `GET /v1/approvals` 回体上的 **additive 键 `crashConverged`**:local 引擎在**人还挂在审批门上**的
1819
+ 时候崩了,那些孤儿 ask 被 server 重启后**收敛成 DENIED 同码**;这一键把「上一条命留下了什么」
1820
+ 交到端手上。本包提供**纯投影**(零 IO、零 module 级可变态、零文案),取件仍走既有的权威
1821
+ `client.approvals.list()`。
1822
+
1823
+ ### 12a. 信封形与 additive 放宽(现有消费点零改动)
1824
+
1825
+ ```ts
1826
+ // 放宽后的 approvals.list() 回体形(hitlBridge.ts 的 ApprovalsResourceLike.list)
1827
+ interface ApprovalsListEnvelope {
1828
+ pending: PendingCheckpoint[] // 老形的全部
1829
+ livePending?: unknown[] // additive;本包**不消费**,形属主是 server
1830
+ crashConverged?: CrashConvergedRow[] // additive;本节的主角
1831
+ }
1832
+ ```
1833
+
1834
+ 🔴 **放宽是 additive**:老形 `{ pending }` 的 mock 与实现仍然可赋值,`hitlBridge.pendingForDecide` /
1835
+ `approvalsFeed` 那两处 `.pending` 消费点**一个字节不动**(常驻门真跑一次 tsc 求值,附一份已知会红
1836
+ 的负控语料证明那台检查器会说话)。同一段门还编译**真 `AgentClient`** 的两条路 ——
1837
+ `projectCrashConverged(await client.approvals.list())` 与 `client.approvals` 赋值到本切片 ——
1838
+ 因为只拿本包自己的信封形去测**发现不了**弱类型陷阱:`projectCrashConverged` 的入参若写成
1839
+ 「带一个可选 `crashConverged` 的对象」,SDK 7.4.0 声明的 `{ pending; livePending? }` 与它一个共同
1840
+ 属性都没有 ⇒ TS2559。⇒ 入参收 **`unknown`**,窄化全在运行期。
1841
+ 🔴 `livePending` 留 `unknown[]` 还有第二条实证理由:**SDK barrel 没有导出 `LivePendingRow`**
1842
+ (7.4.0 `dist/index.d.ts` 的 `export type { … } from "./types.js"` 名单里没有它)⇒ 想用单一真源也
1843
+ 拿不到。方向是安全的:真回体的 `LivePendingRow[]` 可赋值到 `unknown[]`。
1844
+ 🔴 `livePending` 在本包留 `unknown[]`:它的形今天由 server 独占、本包零消费点,手抄一份结构 =
1845
+ 手抄一份会漂的上游形(与 `ToolApprovalFrame.probeCause` 同一条理由)。要读它的端自己窄读。
1846
+
1847
+ ### 12b. `CrashConvergedRow` 逐位(server 7.55.0 fixture 直证)
1848
+
1849
+ | 位 | 形 | 说明 |
1850
+ |---|---|---|
1851
+ | `approvalId` | `string`(非空) | 被收敛的那条 ask 的审批 id;端的**行身份** |
1852
+ | `toolName` | `string`(非空) | 崩溃时挂在门上的工具名。**UNTRUSTED-for-display**,端自己消毒控制字符 |
1853
+ | `taskId` | `string`(非空) | 归属 task |
1854
+ | `sessionId` | `string?` | 归属会话(server 在场才发) |
1855
+ | `ts` / `expiresAtMs` | `number` | 原 ask 的铸出 / 到期时刻(epoch ms) |
1856
+ | `decision` | `'denied'` | **闭集判别式**:收敛出来的行按定义就是被判 denied 的那一种 |
1857
+ | `cause` | `'crashed_before_park'` | **闭集判别式**:别族的 denied 行不该混进本面 |
1858
+ | `orphanState` | `'pending' \| 'decided'` | 分桶**主判据**:`pending` = 工具零执行;`decided` = 人当时已批 approve |
1859
+ | `originalDecision` | `'approve'?` | `decided` 臂上人按下的那一下(在场才发) |
1860
+ | `decidedAtMs` | `number?` | 人按下那一下的时刻(在场才发) |
1861
+ | `convergedAtMs` | `number` | 收敛动作自己发生的时刻 |
1862
+ | `resumeSafe` | `boolean` | 🔴 **缺省值,不是铁证** —— 见 12d caveat;它只是分桶四项合取里的一项 |
1863
+
1864
+ ### 12c. 端怎么接
1865
+
1866
+ ```ts
1867
+ import { projectCrashConverged } from '@sema-agent/client-core'
1868
+
1869
+ // 壳启动 / --resume 之后**读一次**(不需要轮询:这是上一条命的残留,不是流内协议)
1870
+ const env = await client.approvals.list({ signal })
1871
+ const orphans = projectCrashConverged(env) // 入参是 unknown ⇒ 真 AgentClient 的回体直接喂即可
1872
+
1873
+ if (orphans === undefined) {
1874
+ // 🔴 本部署没告诉我这件事(老 server / deps 不在场 / 载体读不出)⇒ **零渲染**。
1875
+ // 绝不渲「0 个」「本次无崩溃遗留」—— 那是替 server 下一个它没说过的断言。
1876
+ } else if (orphans.total === 0 && orphans.dropped === 0) {
1877
+ // server 明说「一条都没有」⇒ 这一档端**可以**渲「没有崩溃遗留」。
1878
+ } else {
1879
+ // orphans.resumeSafe[] / orphans.needsHuman[] / orphans.dropped
1880
+ }
1881
+ ```
1882
+
1883
+ | 产出位 | 语义 |
1884
+ |---|---|
1885
+ | `total` | **读得出来的**行数(恒 = 两桶长度之和)。供给总行数 = `total + dropped` |
1886
+ | `resumeSafe[]` | 进这个桶的合取有**五项**:`orphanState==='pending'` ∧ `resumeSafe===true` ∧ `originalDecision` 缺席 ∧ `decidedAtMs` 缺席 ∧ 这一行**不带 accessor**。中间两项是**矛盾闸** —— `pending` 说「一步都没执行」,而那两位是「人已按下 approve」的证据,同时在场就是自相矛盾的载荷(版本斜差 / 畸形体 / 中间层改写都造得出)。最后一项是**顺序重入闸**:带 getter 的行能在展开过程中把靠后的证据位删掉(实测:修前该行落进 resumeSafe,修后落 needsHuman)。三者都只挡「安全」这一侧,**不丢** |
1887
+ | `needsHuman[]` | 其余**一切**:`decided` 臂(人已批,可能落了半截副作用)、`resumeSafe===false`、跨位矛盾形、以及任何说不清的组合。🔴 矛盾形**落这个桶而不是被丢** —— 它是一条真孤儿,而且恰恰是最该给人看的那一条 |
1888
+ | `dropped` | 供给里**类型面读不出来**的行数(缺必填位 / 位的类型不对 / 闭集判别式对不上 / 取属性就抛)。🔴 尺子是**类型面**,不是「数据好不好看」:`ts: NaN`、`toolName: ''` 这类**退化但合型**的值只是难看,本层**不丢**(为一个装饰位吞掉一条真孤儿是更坏的方向);唯一一处越过类型面的严格是 `approvalId` 必须非空(行身份:空 id 去不了重也引用不了)。🔴 `dropped > 0` 本身是一条**要显形的事实**:上游形漂了或载体坏了,端不许静默吃掉(至少留一条 debug 痕) |
1889
+
1890
+ 🔴 **缺席 vs 空数组是两件事,两档返回形就不同**(端拿 `=== undefined` 一刀分开,不必读计数):
1891
+
1892
+ | 供给 | 产出 | 端的动作 |
1893
+ |---|---|---|
1894
+ | 键缺席(老 server / deps 不在场) | `undefined` | **零渲染** |
1895
+ | 键在场但载体不是数组(形漂了 / 中间层改写) | `undefined` | **零渲染**(读不出 ≠ 一条都没有) |
1896
+ | 载体**读不出**(已撤销 `Proxy` / `length` 不是非负整数 / 下标取值抛) | `undefined` | **零渲染**;刻意不交还半程结果 —— 一个自己都知道不全的计数,拿去渲「影响了 N 件」比不说话更坏 |
1897
+ | 载体自报行数 **> 100000**(硬上限) | `undefined` | **零渲染**。判在遍历**之前** —— 只判「非负整数」挡不住一个谎报十亿的 `length` trap,而那会让本函数在启动 / `--resume` 路上同步空转(实测:两千万行 = 18.3 秒、两千万次下标读;十亿是「回不来」)。真实队列远在这个量级之下 |
1898
+ | 键在场、空数组 | `{ total: 0, resumeSafe: [], needsHuman: [], dropped: 0 }` | 可以渲「没有崩溃遗留」 |
1899
+
1900
+ ⚠️ 两档「零渲染」合流的代价只是**都不说话**;而把「读不出」折成 `{total:0}` 的代价是一句
1901
+ 用户会照着去操作的谎 —— 在一条给人判断「能不能闭眼重跑」的面上,那是最坏方向。
1902
+
1903
+ ### 12d. 端必读的四条
1904
+
1905
+ 1. 🔴 **caveat:`resumeSafe` 是以账本完整为前提算出来的缺省值,不是铁证**(DEBTS L-38 收执逐字)。
1906
+ 崩溃现场本来就是账本最可能缺页的时刻。⇒ 文案只许写「**按记录看**可以重跑」,
1907
+ **绝不**写「已确认没有副作用」。
1908
+ 2. 🔴 **键在场 ≠ 流内协议上场**。`crashConverged` 只是这一次 `list()` 回体上的一个 additive 键:
1909
+ 它既不宣示引擎具备什么能力,也不代表会有一条推送通道再告诉端第二次。**别拿它当能力位读**,
1910
+ 也别为它去探一个不存在的 caps 位。
1911
+ 3. 🔴 **分桶只有一个合取**,保守侧是设计而不是偷懒:判**不**安全的代价是多问人一句(可恢复);
1912
+ 判**错**安全的代价是让人闭眼重跑一件已经落过副作用的事(不可恢复)。凡证不出来一律落
1913
+ `needsHuman`。端**不要**自己按 `orphanState` 或 `resumeSafe` 单读一遍 —— 单读任一键在 2×2
1914
+ 真值表上都会至少错一格。
1915
+ 4. 🔴 **行是一次性快照,零文案**:每个自有可枚举键**恰读一次**落成数据属性,校验、分桶与交还
1916
+ 全部只看这一份快照 —— 所以端读到的每一位都与分桶判据**同源**。additive 键**不剥**(拷的是全部
1917
+ 自有可枚举键,上游加键时端照样收得到);代价是交还的**不是入参那一只**(原型链与不可枚举位
1918
+ 不随行,对 JSON 回体无差别)。本包不排序、不折叠、不铸任何面向用户的串 —— 措辞、排序、
1919
+ 是否上屏全归端。
1920
+ ⚠️ 为什么必须快照:此前是「读一遍校验 → 把原对象交出去」,而分桶随后还要再读一遍同样几位。
1921
+ 一只**非幂等** getter(不抛,只是两次读返回不同值)就能在这两读之间把已批证据抹掉 ——
1922
+ 实测修前 `resumeSafe=1 / needsHuman=0`,交还的行上 `originalDecision` 读出来是缺席,
1923
+ 而它在校验那一读里明明是 `"approve"`。跨位矛盾闸只有在「判据与交付物出自同一次读」时才成立。
1924
+ ⚠️ 反过来说:**类型面读不出的行会被丢掉**(产物声明成 `CrashConvergedRow`,放一行缺必填位、
1925
+ 或位的类型不对的东西过去,就是在类型面撒谎)。丢弃是**计数**的,不是静默的 —— 见上表 `dropped`。
1926
+ 🔴 但「丢」的尺子**只到类型面为止**:退化但合型的值(`ts: NaN` / `toolName: ''` / `sessionId: ''`)
1927
+ 一律**不丢**,时刻与标签怎么渲归端。为一个装饰位吞掉一条真孤儿,比渲一个丑行坏得多。
1928
+ 5. 🔴 **三处不可信读取只认自有数据位**:信封的 `crashConverged`、载体的 `length`、载体的每个
1929
+ 数字下标 —— 都走「读自有属性描述符、只取数据描述符的 `value`」。accessor / 缺席 / 只挂在原型上
1930
+ 的东西一律**当缺席**,一次别人的代码都不执行。理由与第 7 条同源:`catch` 接得住「抛」,
1931
+ 接不住「不返回」。真供给是 `JSON.parse` 的产物,每一位都是自有数据位 ⇒ 对真行零影响。
1932
+ 6. 🔴 **本包对畸形宿主也不抛**:触碰载体的**每一处**都在保护内 —— 信封取属性、`Array.isArray()`
1933
+ 自己(对一只**已撤销**的 `Proxy` 调用它直接抛 `TypeError`)、`length` 与逐个下标取值、逐行读属性。
1934
+ ⇒ 调用点**不需要**给它套 `try`。
1935
+ 7. 🔴 **遍历按数字下标,不用载体自己的迭代协议**:`for…of` 会把「这个数组里到底有哪些行」的
1936
+ 解释权交给载体自己。一个自带 `Symbol.iterator` 覆盖的数组(中间层改写 / 反序列化器产物都造得出)
1937
+ 可以**一条都不产出** —— 本包就会答 `{total:0}`,而端把它读成「server 明说一条都没有」,
1938
+ 一条真孤儿就此蒸发;它也可以把一条 `decided` 的危险行**换成**一条 `pending/resumeSafe` 的安全行,
1939
+ 直接误导重跑。改按下标读之后这两种伪造都不成立(实测:空迭代器载体修前 `total=0`、修后 `total=1`;
1940
+ 换行载体修前产出 `fake-safe`、修后产出 `real-danger`)。
1941
+ ⚠️ **边界说清楚**:一只代理仍然能在 `length` / 下标 trap 上撒谎 —— 那与「宿主注入了一个会撒谎的
1942
+ 传输层」是同一件事,本包挡不住,也**不假装**挡得住。本条守的是**协议面**:不把「有哪些行」的
1943
+ 解释权交给载体的迭代器。
1944
+ 8. 🔴 **accessor 位一律不执行、当缺席;带 accessor 的行永远拿不到「安全」判决**:取一个 accessor
1945
+ 的值意味着**同步执行别人的代码**,而 `catch` 接得住「抛」、接不住「不返回」—— 一只死循环的
1946
+ getter 能把启动 / `--resume` 路的线程永久钉住(行数上限对这一形无效)。⇒ 那一位当缺席:
1947
+ 缺的是**必填位** ⇒ 整行计入 `dropped`(响亮,不是静默);缺的是**可选 / additive 位** ⇒ 行照留,
1948
+ 只是那一位在交还的行上缺席。JSON 回体的每一位都是纯数据属性,所以真供给
1949
+ 永远撞不上这一条;会撞上它的只有**被中间层合成过**的载荷。判据前移到**描述符**
1950
+ (`Object.getOwnPropertyDescriptors`,这一下不触发任何 getter)—— 因为展开是**按属性顺序执行**
1951
+ getter 的,靠前那一位完全可以在证据位被读到之前把它 `delete` 掉,而单读快照看到的就是
1952
+ 「证据不在」。这样的行落 needsHuman,**不丢**。
1953
+ 🔴 判据与快照出自**同一次**枚举:描述符判一遍、再展开取一遍值 = 对同一行做了两次独立观察,
1954
+ 而一只**不抛**的 `Proxy` 能让两次 `ownKeys` 给出不同答案(第一次亮出 `originalDecision:'approve'`
1955
+ ⇒ 判成纯数据行;第二次省掉这个可配置位 ⇒ 快照里证据消失)⇒ 危险行落进 resumeSafe
1956
+ (实测修前 `resumeSafe=['danger']`、`ownKeys` 被调 2 次;修后 `needsHuman=['danger']`、恰 1 次)。
1957
+ ⇒ 快照直接由那**同一份**描述符构造 —— 一次观察就没有「另一次」可以与它矛盾。
1958
+ 🔴 落键走 `Object.defineProperty`,**绝不**用普通赋值:`'__proto__'` 是一个合法的自有可枚举键
1959
+ (JSON 里就出得来),而 `o['__proto__'] = X` **不是存值** —— 它调用 `Object.prototype.__proto__`
1960
+ 的 setter,把 X 装成快照的**原型**。于是一行「自有位全是纯数据」的载荷(accessor 闸天然看不见)
1961
+ 能把一只带 `sessionId` getter 的对象注射成快照原型,校验读可选位时那只 getter 就 `delete` 掉
1962
+ 快照里的已批证据 ⇒ 危险行落进 resumeSafe(实测修前如此)。`defineProperty` 不触发任何 setter,
1963
+ `__proto__` 因此老老实实成为一个自有数据位(additive 键照样保全),快照原型仍是 `Object.prototype`。
1964
+ 🔴 **源行自带原型**也一样只挡「安全」这一侧:快照只枚举**自有**位,挂在原型上的
1965
+ `originalDecision` / `decidedAtMs` 因此进不了快照,跨位矛盾闸就看不见证据 —— 一个普通、
1966
+ 无代理、无 accessor、观察完全稳定的 `Object.assign(Object.create({originalDecision:'approve'}), row)`
1967
+ 在修前会落进 resumeSafe。⇒ 原型不是 `Object.prototype` / `null` 的行一律落 needsHuman(不丢)。
1968
+ 真供给来自 `JSON.parse`,原型恒是 `Object.prototype`,所以这条对真行零影响。
1969
+ 🔴 **校验用的字典是 null 原型,分桶判据也不回头再读交付物**:普通 `{}` 上的每一次属性查找都会
1970
+ 落到 `Object.prototype`;那份原型一旦被污染(例如 `Object.prototype.sessionId` 被装成一只
1971
+ `delete this.originalDecision` 的 getter),「校验可选位」这一步就会抹掉快照里的已批证据 ⇒
1972
+ 危险行落进 resumeSafe(实测修前如此)。null 原型没有上一层可查找,这条路径按构造消失;
1973
+ `orphanState` / `resumeSafe` / 「已批证据在不在」三个判据在校验那一刻就定下来并原样带到分桶,
1974
+ 回头再读交付物等于开第二次观察窗口。**交还给端的行仍是普通原型对象**(`hasOwnProperty` 照常用),
1975
+ 只是它的每一位都逐字来自那份 null 原型快照。
1976
+ 9. 🔴 **坏行不连坐**:一行读不出(缺必填位 / 类型不对 / 已撤销 `Proxy` / 抛错 getter)只让 `dropped`
1977
+ 加一,同批的好行照收、`total` 照数。⇒ `undefined` 与「有几行读不出」是**两件事**,端别把它们
1978
+ 合流:前者说「本部署没提供这个面」,后者说「面在,只是有 N 行读不出」。
1979
+
1980
+ ### 12e. 🔴 非目标与已知边界(对手模型成文,别把它读成缺陷)
1981
+
1982
+ 本件的**真供给**是:server 的 JSON 回体 → SDK `JSON.parse` → 端。那条路上的每一位都是**自有数据
1983
+ 属性**,没有代理、没有 accessor、原型恒是 `Object.prototype`。本节把「不在射程内」的东西写死,
1984
+ 免得下一棒把边界当缺口反复施工。
1985
+
1986
+ **在射程内(本包负责,常驻门逐条钉)**:缺席 vs 空数组不合流 / 分桶五项合取 / 坏行按类型面丢弃
1987
+ 并计数且不连坐 / 类型放宽不破坏既有 mock 与真 `AgentClient` / 永不抛 / 不同步空转。
1988
+
1989
+ **不在射程内(明确不做,也不假装做得到)**:**被中间层合成的非 JSON 载荷**、**敌意 `Proxy`**、
1990
+ **原型注射/污染**这一族。本包对它们只承诺三件——**不抛**、**不同步阻塞**、**不产出「可安全重跑」
1991
+ 这个判决**(一律落 `needsHuman` 或 `dropped`);但**不承诺**能还原出「真实内容到底是什么」:
1992
+ 一只代理在它唯一那次被观察时就可以给出假答案,而那与「宿主自己注入了一个会撒谎的传输层」是同一件
1993
+ 事——那种情况下进程里每一个对象都不可信,本包不是能修好它的那一层。
1994
+
1995
+ > 末轮对抗复审判词原文:「可发。未发现真供给可达的实质行为差:server JSON 经 SDK 解析后,
1996
+ > 缺席与空数组保持分离,pending/decided 分桶正确,坏行逐行计数且不连坐,实际 7.55.0 行形不会被
1997
+ > 误丢,既有 mock 类型仍兼容。剩余风险仅属于已成文排除的非 JSON 合成载荷、敌意 Proxy 或原型注射
1998
+ > 族。」
1999
+
2000
+ ⇒ 后续复审若再命中这一族,处置是**照此段引用、不再迭代**;要动它必须先动这一段(说明为什么边界
2001
+ 变了),而不是直接加一层防御——每加一层都在真供给上零收益,却让这条读面更难读。
2002
+
2003
+ **cli 认领**:壳侧接点(`--resume` 后的一行披露)在下一批。
2004
+ **实现锚**:`src/hitl/crashConverged.ts`(信封形放宽在 `src/hitl/hitlBridge.ts` 的 `ApprovalsResourceLike`)。
2005
+ **常驻门**:`scripts/run-crash-converged-projection-test.mjs`。
2006
+
2007
+ ---
2008
+
2009
+ ## §13 🆕 S-81 selfOrchestration 拒绝的三端同形(server ≥7.57.0;工作树未发)
2010
+
2011
+ server 7.57.0 在**半配置的多租户形态**上收窄了三条。判据只在这一种形态上成立,先把它说准:
2012
+ `REQUIRE_PRINCIPAL=true`(要求 principal)+ `SELF_ORCHESTRATION_ENABLED=true`(引擎侧开着)
2013
+ + **没有中心侧的准入解析器** ⇒ server 无法为这位 principal 判「能不能用 workflow」,fail-closed。
2014
+ 单用户 worker / 接了解析器的多租户部署都**不**走这条路,行为逐字不变。
2015
+
2016
+ | 面 | 7.56 及以前 | 7.57.0 起 |
2017
+ |---|---|---|
2018
+ | `GET /v1/capabilities` 的 `workflows` | `true` | **`false`**(它现在把「本 principal 能不能真用上」也算进去了) |
2019
+ | 同一份 caps | — | 新 additive 键 `workflowsGate: { engineCan: boolean, denial: "entitlement_resolver_absent" \| null }` |
2020
+ | `POST /v1/tasks`(及同闸的 stream 提交)带 `selfOrchestration:true` **或** `settings.ultracode:true` | 受理 | **501 `capability.self_orchestration_required`**(体形与其它 `capability.*` 501 同);去掉这两个键 ⇒ **照常受理** |
2021
+ | 非布尔的 `selfOrchestration` | — | **400**(值不对 ≠ 不给用,**不是**本码) |
2022
+
2023
+ ### 13a. 为什么这三处在库里(三端各写一遍必然各错一遍)
2024
+
2025
+ 1. **「要不要去键重发」是判定不是文案**。判据是**合取**:`status === 501` **∧** `errorCode`
2026
+ **恰等** `capability.self_orchestration_required`。🔴 这个码是**复用码** —— 按 `capability.`
2027
+ **前缀**分诊会把「别的能力位没接线」的 501 也拖进重发臂,而那些请求去掉这两个键**也不会**
2028
+ 变成可受理:白发一次请求 + 给用户一个错误的原因。**无码的 501 判不出**(落 `null`,如实说),
2029
+ 码对但状态码不是 501 同样 `null`。
2030
+ 2. **「去掉哪两个键」是结构操作不是措辞**。`selfOrchestration` 在**顶层**,`ultracode` 在
2031
+ `settings` 下 —— 两条不同的 stamp 腿(`selfOrchestrationWireCaps.ts` / `ultracodeWireCaps.ts`),
2032
+ 端各自手写 `delete` 必然有人漏掉第二处,而漏掉的后果是「重发一次、又被拒一次」:
2033
+ 用户看到的是同一个功能坏了两遍。
2034
+ 3. **caps 的「缺席」与「关着」是两件事**。老 server 压根没有 `workflowsGate` 这个键;把缺席读成
2035
+ 「引擎说不行」是替一台**什么都没说**的 server 下断言([honest-absence-not-fabricated-zero])。
2036
+ 反方向的同一种病:把一个**认不得的** `denial` 折成 `null` ⇒ 端渲出「没有任何拒绝」,
2037
+ 而真相是「拒了,只是我不认得原因」。
2038
+
2039
+ ### 13b. 端怎么接(提交失败 → 去键重发**一次**)
2040
+
2041
+ 🔴 **两条提交腿的接法不同,别只抄第一段**(异源对抗复审 R2 采纳)。`sema` 的两条腿在**什么时候
2042
+ 把 501 抛出来**这一点上不一样,而判定层要能跑到才有用:
2043
+
2044
+ | 腿 | SDK 形 | 501 什么时候抛 |
2045
+ |---|---|---|
2046
+ | `tasks.submit(req)` / `tasks.streamRaw(req)` | `async` 方法 | **调用即在飞**,在 `await` 处抛 |
2047
+ | `tasks.stream(req, {transientOk:true})` | **async generator**(`async *stream`) | 调用**不发请求**;POST 在 `streamRaw` 里,要等**第一次迭代**(`next()` / `for await` 的第一拍)才跑,501 也在那时才抛 |
2048
+
2049
+ **① 同步腿(`submit` / `streamRaw`)**
2050
+
2051
+ ```ts
2052
+ import type { TaskRequest } from '@sema-agent/sdk'
2053
+ import {
2054
+ buildTaskRequest,
2055
+ classifySelfOrchestrationRefusal,
2056
+ stripSelfOrchestrationIntent,
2057
+ } from '@sema-agent/client-core'
2058
+
2059
+ let req: TaskRequest = buildTaskRequest(input, 'interactive') as TaskRequest
2060
+ // 🔴 `...(signal ? { signal } : {})` 而不是 `{ signal }`:SDK 声明的是 `signal?: AbortSignal`,
2061
+ // 本仓(以及任何开了 `exactOptionalPropertyTypes` 的端)显式写入一个可能是 undefined 的值会 TS2379。
2062
+ const opts = (): { signal?: AbortSignal } => ({ ...(signal ? { signal } : {}) })
2063
+ try {
2064
+ return await client.tasks.submit(req, opts())
2065
+ } catch (e) {
2066
+ const denied = classifySelfOrchestrationRefusal(e)
2067
+ if (denied === null) throw e // 🔴 认不得就按普通失败呈现,绝不猜
2068
+ signal?.throwIfAborted() // 🔴 人已经喊停了就别再发第二发(见下)
2069
+ req = stripSelfOrchestrationIntent(req) // 两个键**同时**去掉(单源,端不要自己 delete)
2070
+ transcript.note(disclose(denied.retryWithout)) // 🔴 措辞归端;必须留一行诚实披露(见 13d)
2071
+ return await client.tasks.submit(req, opts()) // 🔴 **只重发一次**;它再 501 就直接外溢
2072
+ }
2073
+ ```
2074
+
2075
+ `stripSelfOrchestrationIntent` 有**两个重载**:进一份 SDK `TaskRequest` ⇒ 出来仍是 `TaskRequest`
2076
+ (本函数删的两位在那个型里都是**可选位**,所以「删完仍是一份合法 `TaskRequest`」是类型面成立的事实),
2077
+ 所以上面那句「去键之后直接重发」**不需要任何 `as` 强转**;进本包 `buildTaskRequest` 的宽形产物则
2078
+ 走宽重载,读法不变。常驻门 §G6 用真 tsc 把这两条路各编一遍(带已知会红的负控)。
2079
+
2080
+ **② 流腿(`tasks.stream`)—— 分类必须放在驱动迭代的 try 里,重发要建**新的** generator**
2081
+
2082
+ ```ts
2083
+ // 🔴 两段范式**各自自足**:端只抄第二段也必须编得过(所以 import 在这里再写一遍,不是省略)。
2084
+ import type { TaskRequest } from '@sema-agent/sdk'
2085
+ import {
2086
+ buildTaskRequest,
2087
+ classifySelfOrchestrationRefusal,
2088
+ stripSelfOrchestrationIntent,
2089
+ } from '@sema-agent/client-core'
2090
+
2091
+ async function runOnce(req: TaskRequest, signal?: AbortSignal): Promise<void> {
2092
+ // 🔴 `for await` 的第一拍才真正 POST —— 把 try 套在**迭代**上,不是套在 `stream(...)` 调用上。
2093
+ // 🔴 `signal` 必须**贯穿到每一条** generator,否则外部 AbortController 既停不掉读取、
2094
+ // 也取消不了服务端那条 run(而这是一条会真的跑工具、真的烧 token 的 live task)。
2095
+ // 写法是**条件展开**而不是 `{ signal }` —— 见上一段那条 `exactOptionalPropertyTypes` 注。
2096
+ let sawTerminal = false
2097
+ for await (const ev of client.tasks.stream(req, { transientOk: true, ...(signal ? { signal } : {}) })) {
2098
+ handle(ev)
2099
+ if (ev.type === 'done' || ev.type === 'failed') { sawTerminal = true; break }
2100
+ // 🔴 **每一个非终帧之后立刻查取消**:SSE 的一次读取会缓冲**多帧**,SDK 会连着把它们 yield 出来
2101
+ // (真实时序,不是畸形载荷)。只在整个 `for await` 结束后才查 signal 的话,人已经喊停了却还会
2102
+ // 继续消费同一 chunk 里的后续帧 —— 实测:同一 chunk 是 `turn_start` → `done`,在
2103
+ // `handle(turn_start)` 里 abort,仍会吃掉 `done` 并**报成功**;若后一帧是坏 JSON,取消还会被
2104
+ // 一条 `SyntaxError` 盖掉。终帧那一支先 `break`,让「终帧优先」在这里也成立。
2105
+ signal?.throwIfAborted()
2106
+ }
2107
+ // 🔴 **终帧优先于取消,取消又优先于截断** —— 三者的顺序都是有代价的:
2108
+ // · 已经见到终帧 ⇒ 这条 run **已经有结局了**(可能已经落了副作用)。此时哪怕 signal 也已经
2109
+ // aborted(取消恰好发生在终帧交付之后),报「取消」就是把一个真实终局盖掉,而人多半会再跑
2110
+ // 一遍 ⇒ 重复执行。所以下面两条判都**只在 `!sawTerminal` 时**才轮得到。
2111
+ // · 没见到终帧而 signal 已 aborted ⇒ 这是**用户主动取消**:`readSseFrames` 见到
2112
+ // `signal.aborted` 会**正常** return,于是 `for await` 也正常结束。少了这一判,取消会被改写
2113
+ // 成一条普通流故障 —— 靠 `AbortError` 抑制报错 / 决定要不要重试的端会把取消渲成故障,
2114
+ // 甚至照着「故障」再提交一次。取消的原因必须原样保真。
2115
+ // · 都不是 ⇒ 才是真的**被腰斩**:SDK 的 `stream()` 只在 `done` / `failed` 处 `return`,
2116
+ // 流被中途干净截断(或一帧都没产出)时 `for await` 同样正常结束。少了这一判,一次被腰斩的
2117
+ // live task 会被端静默当成功 —— 这条腿上最难发现的假绿。
2118
+ if (!sawTerminal) {
2119
+ if (signal?.aborted) signal.throwIfAborted()
2120
+ throw new Error('stream ended without a terminal frame (done/failed)')
2121
+ }
2122
+ }
2123
+
2124
+ let req: TaskRequest = buildTaskRequest(input, 'interactive') as TaskRequest
2125
+ try {
2126
+ return await runOnce(req, signal)
2127
+ } catch (e) {
2128
+ const denied = classifySelfOrchestrationRefusal(e)
2129
+ if (denied === null) throw e
2130
+ signal?.throwIfAborted() // 🔴 中断闸,见下
2131
+ // 🔴 generator 用过就不能重来:去键之后必须 `stream(...)` **建一条新的**(旧的那只已经出局)。
2132
+ req = stripSelfOrchestrationIntent(req)
2133
+ transcript.note(disclose(denied.retryWithout))
2134
+ return await runOnce(req, signal) // 🔴 仍然**只重发一次**
2135
+ }
2136
+ ```
2137
+
2138
+ 🔴 **中断闸不是可选的**(异源对抗复审 R3 采纳):首发 501 与去键重发之间隔着一次分类 + 一次
2139
+ 写披露,人完全可能就在这个窗口里按下停止。少了 `signal?.throwIfAborted()`,端会在用户已经喊停
2140
+ 之后**再发一条会跑工具、会烧 token 的 run**,并且还写一行「已按不带编排的方式继续」——
2141
+ 那是实打实的取消语义回归。同理 `signal` 要透传进**两次**调用(第二条 generator 也要受它管)。
2142
+
2143
+ 🔴 **把 try 套在 `client.tasks.stream(...)` 这一行上是无效的**:`await` 一个 async generator 对象
2144
+ 不会执行函数体,那一行永远不抛 ⇒ 分类器根本不运行 ⇒ 去键重发整条失效,用户直接看到 501。
2145
+ 实装 SDK 坐标:`node_modules/@sema-agent/sdk/dist/resources/tasks.js` 的 `async *stream`
2146
+ (它 `await this.streamRaw(req, opts)`,而 POST 在 `streamRaw` 里)。
2147
+ 本包常驻门 §G5 用**真 `TasksResource`** + 一只假传输层离线钉住这两条腿的差异。
2148
+
2149
+ 🔴 **只重发一次,不做重试环**:第二发若仍是 501,按**普通失败**呈现(不再分类、不再去键)。
2150
+ 本包**不提供**重试原语也不代端计数 —— 「重发几次」是端的编排,库只给判据。
2151
+ 🔴 **重发的是同一条用户意图,但不是同一个请求**:body 少了两个键 ⇒ 若端在用**幂等键**
2152
+ (server 7.56.0 的 idempotency 语义),这一发必须换一个新键,否则 server 会把它当成前一发的重放
2153
+ 而回放那条 501。库不碰这一位(它不在 `TaskRequestLike` 的判定面上),端自己在 `submit` 里给。
2154
+ 🔴 **不改 `deferTools`**:`stripSelfOrchestrationIntent` 只做减法、且只减那两处。把 `Workflow`
2155
+ 从 `deferTools` 里拿掉或塞回去都是**行为改动**,不是「去掉意图」。
2156
+
2157
+ `stripSelfOrchestrationIntent` 的精确语义(常驻门逐条钉):
2158
+
2159
+ | 输入 | 输出 |
2160
+ |---|---|
2161
+ | 顶层 `selfOrchestration` 在场 | 键被删(不是置 `undefined`) |
2162
+ | `settings.ultracode` 在场,`settings` 还有别的键 | 只删 `ultracode`,**其余子键与它们的值逐字保留** |
2163
+ | `settings` 里只有 `ultracode` | **整个 `settings` 键删掉**(与 `buildTaskRequest`「全缺则整键不出现」同语义) |
2164
+ | `settings` 里没有 `ultracode` | `settings` **原样带出**(同一只对象;本来就空的也不删) |
2165
+ | `settings` 不是对象(串/数组/`null`) | **一律不动**(读不懂的东西不去动它) |
2166
+ | 只挂在**原型**上的 `ultracode` | 不算在场(自有键判) |
2167
+ | 任意 additive 未知键(顶层与 `settings` 两层) | **全保**(「上游已发的键不许被静默剥掉」) |
2168
+ | 再调一次 | **幂等**,同形结果 |
2169
+ | 入参本身 | **原样不动**(返回的是新对象;端还能拿原体做诊断/日志) |
2170
+
2171
+ ### 13c. caps 侧:`projectWorkflowsGate` 的返回形
2172
+
2173
+ ```ts
2174
+ import { projectWorkflowsGate } from '@sema-agent/client-core'
2175
+
2176
+ const gate = projectWorkflowsGate(caps) // caps = GET /v1/capabilities 的回体
2177
+ if (gate === undefined) { /* 🔴 零渲染:本部署没告诉我这件事 */ }
2178
+ else {
2179
+ // gate.workflows —— **唯一**该拿来决定「给不给 /workflows、ultracode 入口」的位
2180
+ // gate.engineCan —— boolean | undefined(undefined = 这台 server 没有这个闸 ⇒ 两根轴都别渲)
2181
+ // gate.denial —— null | 'entitlement_resolver_absent' | { unknown: string }
2182
+ }
2183
+ ```
2184
+
2185
+ | caps 形 | 返回 |
2186
+ |---|---|
2187
+ | 非对象 / `null` / `workflows` 不是布尔 / `workflows` 只在原型上 | `undefined`(**诚实缺席**,端零渲染) |
2188
+ | 有 `workflows`、无 `workflowsGate`(老 server) | `{ workflows, engineCan: undefined, denial: null }` |
2189
+ | `workflowsGate` 在场却读不出(非对象 / accessor) | 同上一档(`engineCan === undefined` 就是端判这一档的那一位) |
2190
+ | `workflowsGate: { engineCan: true, denial: 'entitlement_resolver_absent' }` | `denial` 按**字面量**带出(端可以据此说人话) |
2191
+ | `denial` 缺席 / `null` | `null`(闸明说没有拒绝) |
2192
+ | `denial` 是**别的串** | `{ unknown: <原串> }` —— 🔴 **绝不** `null` |
2193
+ | `denial` 非串 / 是 accessor | `{ unknown: '' }` —— 仍然是拒绝,只是连记号都带不出来 |
2194
+
2195
+ 🔴 四处不可信读取(`caps.workflows` / `caps.workflowsGate` / `gate.engineCan` / `gate.denial`)
2196
+ 只认**自有数据描述符**,accessor 与只挂在原型上的位**一次都不执行**
2197
+ (`catch` 接得住「抛」,接不住「不返回」——一只死循环的 getter 会把这条读面所在的线程永久钉住)。
2198
+ 🔴 **但「不执行」之后落哪一档,四处并不相同**(异源对抗复审 R2 采纳的订正 —— 上一版这里写成
2199
+ 一句「一律当缺席」,与上面那张表自相矛盾,端照概括实现就会把「读不出的拒绝」折成「没有拒绝」,
2200
+ 正好违反本节的核心不变量):
2201
+ - `caps.workflows` / `caps.workflowsGate` / `gate.engineCan` 三处:accessor **按缺席处理**
2202
+ (依次 ⇒ 整只 `undefined` / 闸缺席档 / `engineCan: undefined`);
2203
+ - `gate.denial` **一处例外**:accessor ⇒ `{ unknown: '' }`,**不是** `null`。键**不在场**才是
2204
+ 「闸明说没有拒绝」;在场却执行不得是「拒了,但读不出」——两者处置相反,所以本包的读口刻意是
2205
+ **三态**(缺席 / accessor / 数据)而不是两态。
2206
+ - 只挂在**原型**上的位(不是自有位)四处**都**按缺席处理 —— 那不是这份供给自己说的话。
2207
+
2208
+ 真供给来自 `JSON.parse`,每一位都是自有数据位 ⇒ 以上对真 caps 零影响;accessor 载体属 §12e
2209
+ 明确排除的「非 JSON 合成载荷」族,这里成文只是为了让公开契约与实现**逐字**对得上。
2210
+ 🔴 `{ unknown: … }` 里的串是 **server 的内部词,不是给人看的话**:端可以记进日志/诊断面,
2211
+ **不要**当文案直接上屏。
2212
+
2213
+ ### 13d. 端必读的五条(含负控)
2214
+
2215
+ 1. 🔴 **重发必须留一行诚实披露**。用户按的是「用 ultracode 跑」,而实际跑的是**没有** workflow
2216
+ 编排的那一发 —— 不说 = 让人以为自己要的东西生效了。措辞归端(库零文案),内容至少要说清
2217
+ 「这台部署不提供 workflow 编排,已按不带编排的方式继续」+ 去掉了哪两个意图
2218
+ (`SELF_ORCHESTRATION_RETRY_WITHOUT` 就是那份清单)。
2219
+ ⚠️ 别把 `reason` 直接翻给用户:今天 server 的 501 体形**不带**机器可读的 `denial` 位 ⇒
2220
+ `reason` 在真 wire 上恒是 `'unknown'`(闭集那一臂是**防御臂**,server 补这一位的当天自动点亮)。
2221
+ 要给具体原因,材料在 **caps 侧**的 `projectWorkflowsGate(...).denial`,不在这条错误上。
2222
+ 2. 🔴 **负控一:老 server 零渲染**。`projectWorkflowsGate` 返回 `undefined` 时,`/workflows`
2223
+ 与 ultracode 入口**照旧**(别渲「不可用」、也别渲「可用」)—— 那台 server 什么都没说。
2224
+ 3. 🔴 **负控二:非 501 不重发**。`classifySelfOrchestrationRefusal` 返回 `null` 的一切情形
2225
+ (别的 501 码、无码 501、500/400/503、传输错、非对象抛出物)一律按普通失败呈现。
2226
+ 多发一次请求的代价不只是延迟:它会让一个**已经落过副作用**的失败被重放。
2227
+ 4. 🔴 **负控三:未知 `denial` 值不崩、也不静默**。`{ unknown: … }` 是一个端必须有分支的形;
2228
+ 把它 `?? null` 掉就回到了本节要根除的那句假断言。渲染上的保守做法 = 与闭集成员同档处理
2229
+ (「本部署不提供 workflow 编排」),只是不说具体原因。
2230
+ 5. 🔴 **流腿:干净 EOF 不等于跑完**。SDK 的 `tasks.stream()` 只在见到 `done` / `failed` 时 `return`;
2231
+ 流被中途干净截断、或一帧都没产出时,`for await` **同样正常结束**。所以 §13b ② 的 `runOnce`
2232
+ 带一个 `sawTerminal` 判 —— 少了它,一次**被腰斩**的 live task 会被端静默当成功,而这条腿上
2233
+ 恰恰跑着会真的执行工具、真的烧 token 的任务。这一条与本节的去键重发**正交**(它对每一条流都
2234
+ 成立),写在这里是因为 §13b 的范式是端照抄的那一份。
2235
+
2236
+ ### 13e. 射程边界
2237
+
2238
+ 与 §12e 同款:真供给 = server JSON → SDK `JSON.parse` → 端,每一位都是自有数据属性。
2239
+ **被中间层合成的非 JSON 载荷 / 敌意 `Proxy` / 原型注射**这一族**不在射程内** —— 本件对它们只承诺
2240
+ **不抛**、**绝不把一个说不清的 `denial` 折成「没有拒绝」**;**不承诺**还原出「真实内容到底是什么」。
2241
+ 后续复审若再命中这一族,处置是**照 §12e 引用、不再迭代**。
2242
+
2243
+ 🔴 **但「getter 零执行」这一条只对 `projectWorkflowsGate` 成立,对
2244
+ `classifySelfOrchestrationRefusal` 不成立**(异源对抗复审采纳的订正 —— 一句做不到的承诺比没有承诺更坏):
2245
+
2246
+ | 读的是什么 | 读法 | 承诺 |
2247
+ |---|---|---|
2248
+ | `projectWorkflowsGate(caps)` —— **wire JSON** | 只认**自有数据描述符**(四处) | accessor 与原型位一次都不执行(门钉调用数恒 0) |
2249
+ | `classifySelfOrchestrationRefusal(e)` —— **抛出物** | **普通属性读取**(沿原型、会执行 getter) | 只承诺**不抛** |
2250
+
2251
+ 抛出物按设计可能是 SDK 的 `APIError` **类实例**,`status` / `errorCode` 完全可能坐在**原型**上、
2252
+ 甚至是原型上的 getter(传输层的写法本包不拥有)。只认自有数据描述符会把一个**读得懂**的错判成
2253
+ 读不懂 —— 那正是它必须走普通读取的原因(与 `classifyMemoryStatusFailure` / `classifyRulesFailure`
2254
+ 逐字同款)。一只**挂死**的 getter 长在抛出物上时本包挡不住:那与「宿主注入了一个会撒谎的传输层」
2255
+ 是同一件事。**能收窄的那一半已经收窄**:`denial` 只在 `501 ∧ 恰码` 两条判据都通过之后才读,
2256
+ 所以一个根本不匹配的错误**不会**被跑一次它的 getter(门钉调用数恒 0)。
2257
+
2258
+ 🔴 **`SELF_ORCHESTRATION_RETRY_WITHOUT` 运行期是冻结的**(`Object.freeze`,不只是 `as const`)。
2259
+ 判决的 `retryWithout` 与它是**同一只引用**(单源的代价),所以它必须真冻:否则任意一个 JS 消费者
2260
+ `splice` 它一下,此后同进程内每一次判决都带着被改写的清单,端照它去键就会删掉别的字段。
2261
+ 端**不要**尝试写它(严格模式下当场抛)。
2262
+
2263
+ **cli / web / desktop 认领**:壳侧与两端的接点在各自下一批(表态制)。
2264
+ **实现锚**:`src/selfOrchestrationDenial.ts`(码常量单源在 `src/engineErrorCodes.ts`;
2265
+ 两条 stamp 腿仍在 `src/selfOrchestrationWireCaps.ts` / `src/ultracodeWireCaps.ts`)。
2266
+ **常驻门**:`scripts/run-self-orchestration-denial-test.mjs`。