@sema-agent/client-core 0.52.0 → 0.54.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,15 +15,15 @@
15
15
 
16
16
  ## §0 版本锚与重扫纪律
17
17
 
18
- ### 0a. 版本锚(2026-09-03)
18
+ ### 0a. 版本锚(2026-09-05)
19
19
 
20
20
  | 项 | 值 | 真源 |
21
21
  |---|---|---|
22
- | 本包 | `@sema-agent/client-core` **0.49.0**(工作树 = npm 最新;S-81 那一批未发,进 `CHANGELOG.md` 的 `## 0.50.0(未发布)` ) | `package.json` `version` |
22
+ | 本包 | `@sema-agent/client-core` **0.54.0**(工作树**未发**;npm 最新 = **0.53.0**。design/385 那一批进 `CHANGELOG.md` 的 `## 0.54.0(2026-09-05)` 段,冻结账已按两阶段协议插 `pending` 行) | `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
- | 公开导出面 | **805** 个运行期符号(+ 41 个测试钩;= 工作树当下的值 —— 已发的 `0.51.0` 是 **803**,再加 L-69⑨ 两件未发 additive 导出;`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` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **815** 个运行期符号(+ 41 个测试钩;= 工作树当下的值 —— 再加 design/385 十件未发 additive 导出(0.54.0 段,含发包扫描补的 `AUTHORITY_ENVELOPE_TAGS`);已发的 `0.51.0` 是 **803**,再加 L-69⑨ 两件未发 additive 导出;`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
 
@@ -106,14 +106,14 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
106
106
  `--platform=browser` **真打包**看守,不是靠约定。
107
107
  - 宿主能力一律**经端口注入**(`installHost({...})`,见 §5),库自己**绝不** `require('fs')`、
108
108
  绝不 `process.env` 直读(env 走 `hostEnv()`)、绝不全局 `fetch`(目录线上腿走注入的 `CatalogFetchJson`)。
109
- - 闭包棘轮(零松量,逐块记账在各上限常量头注):内核 7 文件 / A 层 23 / index 119
109
+ - 闭包棘轮(零松量,逐块记账在各上限常量头注):内核 7 文件 / A 层 24 / index 142(0.54.0 `peerFrames.ts` 入 A 层与 index 各 +1)
110
110
  - **实现锚**:`scripts/run-client-core-portability-test.mjs`、`src/hostEnv.ts`、`src/host.ts`。
111
111
 
112
112
  ---
113
113
 
114
114
  ## §2 公共导出面地图(按域)
115
115
 
116
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**805** 项)。
116
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**815** 项)。
117
117
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
118
118
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
119
119
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -123,7 +123,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
123
123
 
124
124
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
125
125
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
126
- 实测:805 项 **100% 是运行期导出,零 type-only**。
126
+ 实测:815 项 **100% 是运行期导出,零 type-only**。
127
127
 
128
128
  **推论(端必须知道)**:
129
129
  - barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
@@ -137,12 +137,12 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
137
137
  `WorkflowsGateUnknownDenial` 四形**不在**基线里,`src/selfOrchestrationDenial.ts` 对基线贡献
138
138
  **4** 项运行期导出(三个函数 + `SELF_ORCHESTRATION_RETRY_WITHOUT`)。
139
139
 
140
- 805 项的内部构成(帮助端估读表大小):**238** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
140
+ 815 项的内部构成(帮助端估读表大小):**244** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
141
141
  (矩阵、键集、env 名、锚串)而非可调用物;**5** 项是 PascalCase 运行期值
142
142
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError` / `DecideTransportRetryExhaustedError`);
143
143
  **41** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
144
144
 
145
- ### 2b. 域图(16 域,逐域计数之和 = 805)
145
+ ### 2b. 域图(16 域,逐域计数之和 = 815)
146
146
 
147
147
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
148
148
  |---|---|---|---|---|---|
@@ -152,7 +152,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
152
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` |
153
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` |
154
154
  | 6 | **请求装配(上行唯一构造口)** | 8 | `buildTaskRequest` · `REQUEST_FIELD_MATRIX` · `unregisteredRequestKeys` · `applyLiveRequestDefaults` · `taskNotificationToPrintFrame` | 两条车道(`interactive`/`print`)出站请求的**唯一**构造器;`unregisteredRequestKeys` 是可执行门 —— 端偷带一个未登记键上 wire 就红 | `src/request/taskRequest.ts`、`src/request/printNotification.ts` |
155
- | 7 | **通知与 outstanding 台账** | 41 | `installNotificationQueuePort` · `normalizeTaskNotification` · `taskNotificationDedupKeyFromWire` · `registerOutstandingBgTask` / `registerOutstandingWorkflowRun` · `notificationQueuePortMisses` · `subscribeOutstandingWorkflows` · `outstandingDeliverableWorkflowCount` | `task_notification` 归一 + 去重 + 投递进宿主命令队列的**一把闸**;`outstandingDeliverableWorkflowCount()` 是 headless `-p` 的**退出门** | `src/notifications.ts`(11 个 module 台账) |
155
+ | 7 | **通知与 outstanding 台账** | 51 | `installNotificationQueuePort` · `normalizeTaskNotification` · `taskNotificationDedupKeyFromWire` · `registerOutstandingBgTask` / `registerOutstandingWorkflowRun` · `notificationQueuePortMisses` · `subscribeOutstandingWorkflows` · `outstandingDeliverableWorkflowCount` | `task_notification` 归一 + 去重 + 投递进宿主命令队列的**一把闸**;`outstandingDeliverableWorkflowCount()` 是 headless `-p` 的**退出门** · `classifyPeerNotification` / `renderPeerFrameTranscriptText` / `parsePeerFrameText` / `peerFrameDisplayName` + 三张闭集表(design/385,0.54.0:同一条 `task_notification` 车道上三条**引擎注入帧**的类型化投影 —— 判别位=载体在场而非 summary 文本,详见 §17) | `src/notifications.ts`(11 个 module 台账) |
156
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` |
157
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 拒绝判定层) |
158
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` |
@@ -1481,6 +1481,8 @@ CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读
1481
1481
  | **P-41** | low | 🆕 **decide 的 `currentPending` 指路键今天在标准 SDK 路径上恒缺席**(0.47.0 件②,如实登记的射程边界不是缺陷):server 的 409 `approval_stale` 臂**只在调用方回显 `checkpointToken` 时触发**(engine 7.52.1 `http/server.js` 的 `resumeCheckpoint`,`if (binding?.checkpointToken && …)` 真字节),而 sdk 的 `ApprovalDecision` 自 1.0.0 起**刻意删掉**了那一位 ⇒ 经 SDK client 的 decide 拿不到这枚 409。另:ask 腿(`GateOutcome.currentPending`)**包内无消费方** —— `GateOutcome` 是包内型、决断结局不出包;它在场的理由是**同形存量清剿**(两条 decide 失败腿一次改齐) | `src/hitl/hitlBridge.ts`(`readDecideCurrentPending`)、`src/hitl/toolApprovalWire.ts`、`src/hitl/parkResolver.ts` | 按 §4f 接:读得到就一跳重定位,**缺席时退回重拉 `GET /v1/approvals`**,绝不把缺席读成「没有别的 pending 了」。自己注入传输层(非 SDK client)的宿主今天就拿得到 |
1482
1482
 
1483
1483
  | **P-43** | med(**存量、非本批引入** —— 0.42.0 基线上逐字相同) @cli @web @desktop | 🆕 **accept-session 回退臂的错误分类比它自己的注释宽**(#363 异源复审 [high] 的**未收窄那一半**,如实登记):`allowSession` 腿先发 `approve + remember:'session'`,失败时回退一发纯 approve;那条 catch 的注释写的是「**只**兜『老 server 不识别 remember ⇒ 400 未知键』这一形」,而实际形是 **catch-all 减去三条具名再抛**(`HitlSafetyError` / `DecideTransportRetryExhaustedError` / 0.47.0 新加的『拒体带 `currentPending`』)。⇒ 一个 **404 / 5xx / 宿主自抛的无 status 错误**今天仍会被当成「老 server 不识别 remember」并**自动重发**一次纯 approve。收窄成「只认 400」是**行为改动**,不属于 0.47.0 这个 additive 批的射程 | `src/hitl/toolApprovalWire.ts`(`case 'allow'` 的内层 catch) | 端今天不需要做什么(两发都是 approve,不构成跨门的 double-act);**属主批**:下一个愿意改老引擎兼容腿宽度的批把它收窄成精确的 legacy-400,并同批给回退臂补正控/负控 |
1484
+ | **P-44** | 已知缺口(0.53.0 登记;正位解在**引擎侧**)@cli | 🆕 **L-93 两选卡的 cancel 是「查了再做」,不是原子条件取消**(异源对抗复审 R2 [high] 如实登记):卡后那一发 `runs.get` 状态复证 + 开枪前那一发 `listOwnedPendingApprovals` 待决行复证,把窗口从「人看卡的任意长时间」压到「两发复证到一发 cancel」的毫秒级,**但没有关死** —— 待决行若恰在这两步之间恢复(store 恢复 / 会话重新附着),那一枪仍会落在一条**其实还能被决断**的 run 上,而 `runs.cancel` 会把它终态化。**客户端关不死它**:真正的关法是引擎侧的**条件取消**(带审批快照版本 / checkpoint 标识,条件变了回 409 且不取消),那是 wire 能力,不是壳能自造的语义 | `src/adapter/activeRunSelfHeal.ts`(`staleParkArm` 开枪前那一段的头注);§16b 四道防线 | 端照旧只在用户显式选「停掉它」时才走这条路,**卡面必须说清这是放弃这条 run**;要彻底关死,按跨仓宪法向引擎提**条件取消**(`If-Match` 形)。在那之前这条路的剩余风险是**在册**的,不是未知的 |
1485
+ | **P-45** | 在册局限(**存量族**,非本批引入)@cli @web @desktop | 🆕 **「run 读到终态」不等于「claim 已经落地释放」**(同上 R2 [medium]):`waitForClaimRelease` 的判据是 `runs.get` 读回 {@link CLAIM_RELEASED_STATES} 里的终态词,而本档 §12 与 `running-settled` 臂自己记着:409 报的 claim 与 poll 口径**可以短暂相左**。⇒ `running-cancelled`(0.37.0 起)与 🆕 `stale-park-cancelled`(0.53.0)两条 `resending` 结局,理论上都可能在 claim 尚未落地释放时重发一次,代价 = **再吃一个 409**(自愈树当拍再分诊)。**为什么不单修新那一条**:两条问的是同一个问题,给它们两把不同的尺 = 同一判据两份实现,必漂;正位解是一条 **session/claim 专用读面**(今天 `DurableRunVerbs` 里没有这个动词) | `src/adapter/activeRunSelfHeal.ts`(`CLAIM_RELEASED_STATES` 头注 / `waitForClaimRelease` / 两条 `resending` 结局) | 端**不要**把 `resending` 读成「会话一定空了」——它的成文含义是「引擎报那条 run 已终结」。重发撞回 409 是**已知可能**,自愈树会当拍再分诊(不是新 bug);要更强的保证,得等引擎给 claim 读面。🔴 **文案面同形存量**:0.53.0 的 `stale-park-cancelled` 行已按这条边界改写(只说「引擎报它已终结」+ 「可能要再发一次」),而 0.37.0 的 `running-cancelled` 行仍写着「The engine confirmed it is no longer holding this session」—— 同一把尺、两种说法。**属主批**:下一个愿意动那条既有产品文案的批同步收紧(本批不动,因为它是三端在跑的现产品串,改它属行为面/文案面变更,要单独走验收与端对表) |
1484
1486
 
1485
1487
  ### 7c. 多会话(sessionKey)面在册局限 —— 多会话端**接之前必读**
1486
1488
 
@@ -2446,3 +2448,249 @@ web / desktop 无需动作(老宿主零行为差),件B 对三端都是治理面
2446
2448
  `src/hitl/hitlHostSurface.ts`(`ClassifierDenyContext`)、`src/classifierVerdictWire.ts`
2447
2449
  (`classifierDenyDisplay` / `CLASSIFIER_DENY_DISPLAY_MAX`)、`src/hooksWireCaps.ts`(第三道门)。
2448
2450
  **常驻门**:`scripts/run-client-core-pure-test.mjs`(B6 段 L-67④ 16 条 / B7 段 L-69⑨ 31 条)。
2451
+
2452
+ ---
2453
+
2454
+ ## §16 🆕 陈旧 park:「待决行已不在」的真出路(0.53.0;L-93)
2455
+
2456
+ ### 16a. 修的是哪一形(现网 sema-bug7)
2457
+
2458
+ 审批卡 park 住一条 run 之后过了很久(实测 27.5h),用户退出 TUI 再 `--resume`。重开链去引擎读
2459
+ 待决行时,**读面答了**、而属主待决行**恰 0** —— 行确实没了(引擎重启后 `process_local` 会话店丢行 /
2460
+ 审批被引擎 reap),可那条 run 仍是 `suspended`,**仍占着会话 claim**。修前本包对这一形只有一个结局:
2461
+ `ask-reopen-failed` ⇒ 端渲「Your message was NOT sent … 换个新会话」。用户手上一条真出路都没有 ——
2462
+ 卡不可能重开(没有行了),run 不会自己结束(park 态不在时间型 reap 射程里),只剩丢掉整段上下文。
2463
+
2464
+ 0.53.0 修的**只有这一形**:`reopened:false` **且** 宿主证出 `pendingRowGone: true`。其余全部形态
2465
+ (含宿主没证出这一位的一切情况)行为**逐字节不变**。
2466
+
2467
+ ### 16b. 宿主契约:`pendingRowGone` 什么时候才许置
2468
+
2469
+ ```ts
2470
+ export type ReopenCardVerdict =
2471
+ | { reopened: false; decidedWithoutCard?: true; pendingRowGone?: true }
2472
+ | { reopened: true; firstSight: boolean; presented?: boolean }
2473
+ ```
2474
+
2475
+ 🔴 **只在「读面答了 **且** 属主过滤后待决行数恰 0」时置 `true`**,别的一律不置(fail-closed)。
2476
+ 这一位不是「我没找到卡」的同义词 —— 它是**正面证据**:引擎回答了,答案是「没有」。
2477
+
2478
+ | 宿主实际情形 | 置 `pendingRowGone`? |
2479
+ |---|---|
2480
+ | 有界窗内 `approvals.list` 答了,属主过滤后行数 = 0 | ✅ 置 |
2481
+ | 读口(`approvals.list` 端口)整个缺席 | ❌ 不置 |
2482
+ | 请求抛错 / 网络失败 / 5xx | ❌ 不置 |
2483
+ | 窗内没答完(超时)/ 调用方中止(Esc) | ❌ 不置 |
2484
+ | 有行,但归属证不出 / 问句缺席 / 行形坏(= 找不到**能用**的那一行) | ❌ 不置 |
2485
+ | 只读了一发就返回、没有等满出生窗 | ❌ 不置 |
2486
+
2487
+ **为什么这条边界是硬约束**:`pendingRowGone` 是本包**唯一**允许对一条 park 态 run 提供 cancel 的前提。
2488
+ park 态的 cancel 语义 = 替用户否掉那个待决项;而「待决项**仍在**」时那正是**已退役**的自动 cancel 臂的
2489
+ 循环病根(cancel = deny ⇒ 模型重试 ⇒ 新 run 停在同一道门 ⇒ 再 park)。只有「确已没有任何待决项可被
2490
+ 否掉」时,那条循环在结构上不成立。含糊值(`false` / `'true'` / `1` / 缺席)一律不算,判据只认严格 `true`。
2491
+
2492
+ 包侧**不只信这一句话**,一共四道防线:
2493
+
2494
+ 1. 进臂后用 `runs.get` **一手复核**真态,读回的必须是审批 park 词(`ASK_PARK_STATES` = `suspended`)
2495
+ 才可能出卡(running / plan park / 终态 / 读不出的一律走别的臂或退回旧结局);
2496
+ 2. 卡上必须**用户逐字选 `cancel`**;
2497
+ 3. 🔴 **卡后复证 run 真态**(对抗复审 R3 [high] 采纳):第 1 道防线那一发打在**呈卡之前**,而卡是
2498
+ 人在看、可以停留任意久 —— 这中间那条 run 完全可能**恢复运行**(审批在别处被决断 / 会话重新附着 /
2499
+ 引擎自愈)。所以用户选 cancel 之后**再打一发** `runs.get`,状态仍是审批 park 词才继续;
2500
+ 变成 running / 终态 / 换了门 / 读不出 ⇒ 一律 fail-closed 退回旧结局(**绝不**沿用旧卡的授权去停
2501
+ 一条正在干活的 run),404 ⇒ `ask-run-not-found` 如实说;
2502
+ 4. 🔴 **开枪前再证一次前提**(对抗复审 R1 [high] 采纳):在离 `runs.cancel` 最近的那一刻,用
2503
+ `deps.listOwnedPendingApprovals` 再问一次属主待决行数,**恰 0 才开枪**;非 0 / 抛错 / 窗尽 /
2504
+ 中止 / 读不出一律不开枪(退回 `ask-reopen-failed`)。
2505
+ 为什么这一步不可省:`pendingRowGone` 是宿主**读那一刻**的事实,而这张卡是人在看、可以看任意久
2506
+ —— 这中间待决行完全可能重新出现(store 恢复 / 会话重新附着),宿主也可能把「读失败 / 找不到
2507
+ 能用的那一行」误报成 gone。而第 1/3 道防线**证不了这件事**:审批还在与审批已丢,`runs.get` 读回
2508
+ 的都是 `suspended`。代价不对称 —— cancel 一条**其实还能被决断**的 run,等于用户在一句假前提下
2509
+ 把整轮工作丢掉(`runs.cancel` 会把 suspended run 终态化,那一轮不会恢复)。
2510
+
2511
+ 第 3、4 两道的调用序被常驻门钉住:**呈卡 → 卡后 get 复证 → 待决行复证 → cancel**,两发复证都紧跟在
2512
+ 用户那句授权之后。
2513
+
2514
+ ⚠️ 因此 `deps.listOwnedPendingApprovals`(既有键,假死锁防御那一只,带**归属过滤**)是这张卡的
2515
+ **供给前提**:宿主不注入它 ⇒ 两选卡整个不呈,行为退回今天的 `ask-reopen-failed`。这是刻意的
2516
+ fail-closed 形:与其先呈卡、等用户按下去才发现前提证不了,不如干脆不给这条路。
2517
+
2518
+ ### 16c. 新宿主钩子:`offerStaleParkChoice`(两选卡)
2519
+
2520
+ ```ts
2521
+ export interface StaleParkChoiceRequest {
2522
+ taskId: string
2523
+ /** 引擎报的状态(如实转述)。 */
2524
+ status: string
2525
+ /** 供给位:cancel 动词在场 **且** 有 `get` 能确认 claim 真的释放。 */
2526
+ canCancel: boolean
2527
+ }
2528
+
2529
+ interface ActiveRunSelfHealDeps {
2530
+ offerStaleParkChoice?: (req: StaleParkChoiceRequest) => Promise<'cancel' | 'wait'>
2531
+ }
2532
+ ```
2533
+
2534
+ - **缺席 ⇒ 整臂不走**(退回 `ask-reopen-failed`,逐字节零行为差)。老宿主(web / desktop)不传这个键
2535
+ = 今天的行为一字不变。
2536
+ - 卡上**没有 steer**:steer 的语义是「注入正在跑的那一轮」,对一条 park 住的 run 它落 `queued`
2537
+ (排在那个**已经不存在**的 checkpoint 上)—— 递一条注定注入不进去的路是假 affordance。
2538
+ - `canCancel` 的三条供给:`runs.cancel` 在场、`runs.get` 在场(确认释放用)、
2539
+ `deps.listOwnedPendingApprovals` 在场(开枪前复证用)。任一缺席 ⇒ 卡上只剩「什么都不做」= 纯噪音,
2540
+ **整卡不呈**,退回旧结局(所以卡真到宿主手上时 `canCancel` 恒 `true`)。
2541
+ - 🔴 **卡面文案是端的事,但语义由本节钉死**:这张卡上的「停掉它」= `POST /v1/runs/:id/cancel`,
2542
+ 按 SDK `CancelAck` 契约 run 随后 settle 成 `failed` + `errorCode: "cancelled"` —— **那一轮的工作
2543
+ 不会恢复**。卡面必须让用户看懂这是「放弃这条 run 换回会话」,不是「继续它」。
2544
+ - 🔴 返回值 **fail-closed**:只有**逐字** `'cancel'` 才武装那一枪;`Esc` / 空答 / 表外词 / 抛错一律
2545
+ 当「什么都不做」(抛错那次还额外退回旧结局,并且**不登记**——用户没看到卡)。
2546
+ - 呈过一次卡且用户选了「什么都不做」⇒ 同 `(sessionKey, taskId)` 再撞不再整卡重弹,结局带
2547
+ `alreadyOffered: true` 让端降级渲一行;清口 = 既有的 `clearRunningChoiceOffer(taskId, sessionId)`
2548
+ (它**同时**清三选卡与两选卡的登记 —— 宿主「重新打开操作菜单」的语义是「让我重新表态」)。
2549
+
2550
+ ### 16d. 新结局与处置表
2551
+
2552
+ | kind | 何时 | `selfHealSubmissionDisposition` | 端要做什么 |
2553
+ |---|---|---|---|
2554
+ | `ask-run-not-found` | 复核 `runs.get` 撞 **404**(幽灵 claim 的 park 半场) | `not-delivered` | 渲行;**不**自动重发(404 = 「不存在」∪「不是你的」不可分辨) |
2555
+ | `stale-park-cancelled` | 用户选 cancel,且引擎**确认**那条 run 不再占会话 | `resending` | 重发被拒的那条消息(与 `running-cancelled` 同一条腿) |
2556
+ | `stale-park-cancel-timeout` | cancel 发了,窗内没等到释放(或用户中止) | `not-delivered` | 渲行;不重发 |
2557
+ | `stale-park-cancel-failed` | 那一枪失败且确认腿也没看到释放(`delivery` 分 `rejected`/`unknown`) | `not-delivered` | 渲行;不重发 |
2558
+ | `stale-park-wait` | 用户选「什么都不做」(带 `alreadyOffered?` 降级位) | `not-delivered` | 渲行(或降级行);不重发 |
2559
+
2560
+ - 复核读回 `running` ⇒ 交给**既有** running 三选卡臂(不新铸分臂,也不打第二发 `runs.get`);
2561
+ - 复核读回 `needs_review`(plan park)⇒ 交给**既有** plan 重开臂 —— 对它 cancel 是替用户把整个 plan
2562
+ 丢掉(禁区),而 plan 卡本身是能重开的;
2563
+ - 复核读不出 / 读回表外新词 / 用户中止 ⇒ 一律退回 `ask-reopen-failed`(不确定时不做破坏性动作)。
2564
+ - cancel 那条腿是**复用**的(`cancelAndConfirmRelease`):失败分类(`atMostOnceFailureClass`)与释放
2565
+ 判据(`CLAIM_RELEASED_STATES`)与三选卡**同源**,不是第二份实现;开枪前那一发复证走的也是共享叶
2566
+ `readOwnedPendingCount`(与假死锁防御同一只,有界/可回收/中止不采信同律)。
2567
+ - `ask-run-not-found` 的文案**不**断言「那条 run 已经不存在」:该 404 分不出「不存在」与「这个会话
2568
+ 读不到它」,行里如实把两种可能都说出来,只对「本层没有可动的东西」下结论。
2569
+
2570
+ ### 16e. 射程边界(别把本节读成比它更强)
2571
+
2572
+ - **本批只到包为止**。`pendingRowGone` 由**宿主重开链**置位;cli 半场(`askParkReopen` 置位 + 两选卡
2573
+ UI + live 证据)是另一批,未发之前 cli 的行为与今天相同(键不传 ⇒ 老宿主臂)。
2574
+ - **cancel 一条 suspended run 的引擎侧后果不在本包射程**:run 落终态(SDK `CancelAck` 契约:受理回
2575
+ `cancelling`,随后 run settle 成 `failed` + `errorCode: "cancelled"`),那条 turn 的工作**不会**恢复。
2576
+ 卡上必须让用户看懂这是「放弃这条 run 换回会话」,不是「继续它」。
2577
+ - **本包不做自动 cancel**:任何情况下都必须有一次用户的显式选择。没有钩子 = 没有卡 = 没有那一枪。
2578
+ - 🔴 **「零行为差」说的是运行期,不是类型面**(异源对抗复审 [medium] 采纳的措辞订正):
2579
+ `SelfHealOutcome` 是**开放增长**的判别联合,本批加了五个 kind。不传新键的宿主运行期结局与文案
2580
+ 逐字节不变,但对 `SelfHealOutcome` 做**穷尽 switch + assertNever** 的消费方在**编译期**要补臂
2581
+ —— 这与 0.37.0(`running-*` 五个)、0.38.0 起历次加 kind 是同一形,不是本批新长出来的义务。
2582
+ 端的既有 `default` 分支(本包自己的 `selfHealSubmissionDisposition` 就是这么写的)零改动。
2583
+ - **不承诺「重发一定成功」**:只有 `stale-park-cancelled`(引擎报那条 run 已终结)才判 `resending`;
2584
+ 其余形一律 `not-delivered`,端不许自作主张重投。
2585
+ - 🔴 **两条在册边界(第二轮对抗复审登记,详见 §7b)**:**P-44** 开枪前复证是「查了再做」不是原子
2586
+ 条件取消(残留毫秒级窗口,正位解在引擎侧条件取消);**P-45** 「读到终态」≠「claim 已落地释放」
2587
+ (存量族,与 `running-cancelled` 共用同一把尺,重发撞回 409 是已知可能)。
2588
+
2589
+ **cli / web / desktop 认领**:cli 侧接点(置位 + 两选卡)在其下一批(表态制);web / desktop **无需动作**
2590
+ (不传新键 ⇒ 逐字节零行为差)。
2591
+ **实现锚**:`src/adapter/activeRunSelfHeal.ts`(`ReopenCardVerdict.pendingRowGone` / `StaleParkChoiceRequest` /
2592
+ `offerStaleParkChoice` / `staleParkArm` / `cancelAndConfirmRelease` / 五个新 kind 的文案臂)。
2593
+ **常驻门**:`scripts/run-selfheal-reopen-test.mjs`(G11 段,含「该位缺席 ⇒ 逐字旧结局且零 cancel」负控)、
2594
+ `scripts/run-terminal-identity-copy-test.mjs`(G2 段:新 kind 的注入形与处置分类)。
2595
+
2596
+
2597
+ ## §17 🆕 design/385 三条引擎注入车道的类型化投影(0.54.0;cli L-61② / L-87 5a①5a②)
2598
+
2599
+ ### 17a. 修的是哪一形
2600
+
2601
+ 引擎把三类**根本不是同一种东西**的载荷塞进同一条 `task_notification` 车道,而 core 对这三类
2602
+ **不套** `<task-notification>` 壳(`core/task-notification.ts::renderTaskNotificationXml` 头三个分支
2603
+ 按这个顺序判):
2604
+
2605
+ | 载体键(`TaskNotificationPayload` 上) | 出处 | 模型面 |
2606
+ |---|---|---|
2607
+ | `agentMessage: {from, body}` | §1.4 d1 —— 同进程子代 `SendMessage("main")` 的 uplink | `<agent-message from="…">` + peer 纪律块 |
2608
+ | `crossSessionMessage: CrossSessionEnvelopeFields & {body}` | §4.1 —— 另一个会话的消息,从本会话自己的信箱 drain 出来 | `<cross-session-message from="…" from-session="…" from-name="…" from-mode="…">` + 跨会话纪律块 |
2609
+ | `crossSessionNotice: {kind, text}` | §4.4/§5.2 —— **本会话自己发出去**那条消息的回执 / `notify_when_idle` 的 idle 通知 | 一行纯文本(`[Cross-session delivery notice] …` / `[Cross-session idle notice] …`) |
2610
+
2611
+ 端此前照 `task_notification` 泛化卡渲 ⇒ 屏上是「后台任务完成」,而模型读到的是一条同事发来的话。
2612
+
2613
+ ### 17b. 端该怎么用(三步,端零字符串判定)
2614
+
2615
+ ```ts
2616
+ import { classifyPeerNotification, parsePeerFrameText, peerFrameDisplayName } from '@sema-agent/client-core'
2617
+
2618
+ // ① 帧面(适配器内部已接;宿主自建管线才需要):原始 wire 载荷 → 投影 | null
2619
+ const frame = classifyPeerNotification(rawTaskNotificationPayload)
2620
+
2621
+ // ② 文本面(端的消息组件只拿得到文本):转录行 → 投影 | null
2622
+ const projected = parsePeerFrameText(messageText)
2623
+ if (projected?.lane === 'agent_message') renderAgentMessageCard(peerFrameDisplayName(projected), projected.body)
2624
+ if (projected?.lane === 'cross_session_message') renderCollapsedPeerRow(peerFrameDisplayName(projected), projected.body)
2625
+ // ③ 通知车道没有标签,按普通文本行渲 —— 这就是 CC 形(无卡、无折叠、一行)
2626
+ ```
2627
+
2628
+ ### 17c. 五条判定纪律(端不许自己重判)
2629
+
2630
+ - 🔴 **判别位 = 类型化载体的在场,永远不是 `summary` 文本**。三条载体只有引擎的注入腿铸得出
2631
+ (`ExternalNotificationInput` 是 `TaskNotificationPayload` 的**真子集** —— 外部 `notify()` 一个都
2632
+ 穿不上);而 `summary`/`result` 是任何一条通知都填的字段。判据落到文本上 ⇒ 任何一个后台任务
2633
+ 只要把 `<agent-message from="…">` 写进自己的 summary 就能在用户屏上冒充一条同事消息。
2634
+ - 🔴 **fail-closed 分两档**。必填位坏(`from` 空 / `body` 非串 / notice `kind` 不在闭集)⇒ 整帧返
2635
+ `null` = 退泛化卡(诚实降级:用户仍看得见这条通知,只是没有专用形)。可选位坏(`fromMode` 写了
2636
+ 闭集外的词)⇒ **只丢那一位**:少一句注 vs 把一条同事消息从用户眼前拿走,不是同一个量级。
2637
+ - 🔴 **`_sema_provenance` 必须在场,且 `kind` 与载体车道相符**。缺席 / 读不出(非对象、访问器)/
2638
+ 矛盾,三形一律退泛化卡。**这一条 0.54.0 定稿时翻过一次面**:首版是「只否决不认证、缺席放行」,
2639
+ 理由「要求在场会让另两条车道在老引擎上恒死」被对抗复审证伪 —— 老引擎上那两条车道**连载体键都
2640
+ 没有**,判定根本走不到;而 core 契约明写 provenance「present exactly when the carrier is」,在跑的
2641
+ 7.2.0 就是同一处同时铸两者。要求在场对真载荷零代价,对畸形 / 半截注入 / 版本漂移则关上一道门。
2642
+ 🔴 判据是**四条等式 + 在场**:`kind` 与车道相符,且 `from`/`taskId`/`seq` 三位**必须在场**并分别
2643
+ 等于载体的 `from`、载荷的 `task_id`、载荷的 `seq`(core 的 `SemaProvenance` 把这三位全声明为必填,
2644
+ 三个铸点也都同址写下 ⇒「两侧都缺」不是合法兼容形,而是半截载荷)。只核 `kind` 拦得住半截载荷,拦不住**同 kind 的伪造** —— 载体署一个可信名字、
2645
+ provenance 三位全对不上,屏上照样出现一条署着那个名字的消息。后三条**各有铸点直证**(uplink 腿与
2646
+ 跨会话 drain 腿都在同一个对象字面量里同值写下这几位),铸点一改由 cli 侧 wire 锚的同址探针当场
2647
+ 喊红 ⇒ 「等式失效」不会退化成一条静默死掉的车道。
2648
+ ⚠️ 端若自建管线要注意:载荷那一边是 **snake** `task_id`、provenance 那一边是 **camel** `taskId`
2649
+ —— 同一个量两个拼法,写混了等式恒不成立。
2650
+ - 🔴 **分支序**:三车道判定必须在「完成卡已入队」这类**完成通知专用**早退**之前**做。子代续跑时
2651
+ 复用同一个 taskId,它的合法 `agentMessage` 帧会在那种早退上被整条丢掉(零输出、去重账已记 ⇒
2652
+ 重放也补不回来)。**同因第二件**:peer 帧也不该写「这个 run 的完成已经被引擎注入过」那本跨通道账
2653
+ —— 一条同事**消息**不是完成事件(三条 peer 铸点的 `status` 都是 `"event"`),写脏了它,子代自己
2654
+ 真正的空闲期完成通知随后会被补发通道整条丢掉。本包 0.54.0 的 `taskNotificationArm` 已按此两条修;
2655
+ 端若自建投影管线同理。
2656
+ 文本面同理:`parsePeerFrameText` 的 `ok` 只等于「形是规范的」,**不等于**「这条真是引擎注入的」
2657
+ —— 文本面不存在身份权威(core §4.2 三层规则同一句话)。端拿它做**呈现**分派,不许拿它做任何
2658
+ 授权判断。
2659
+ - 🔴 **`parsePeerFrameText` 的三条硬边界**(端若自建管线必须同样成立):信封整串锚定;正文里出现
2660
+ **未拆火**的同名标签 ⇒ 整条退 `null`(贪婪匹配会把「两封拼一起」读成一封、把「信封 + 尾随文本」
2661
+ 读成一封而尾随文本静默消失);拆火编码是**单射**(`unescape(escape(x)) === x` 对一切 x 成立,
2662
+ 连正文里原本就有的反斜杠一起数)—— core 的 `escapeEnvelopeTag` 是**单向**消毒、没有这条义务,
2663
+ 照抄过来会让 `<\agent-message>` 这类合法代码文本在往返之后变成一个真标签。
2664
+ - 🔴 **优先序照抄 core 的渲染腿**(agentMessage → crossSessionMessage → crossSessionNotice)。多载体
2665
+ 同在是矛盾载荷,但模型那一面**已经按 core 的顺序读过了**;端另立一套「矛盾就退泛化」会让屏上
2666
+ 那张卡与模型读到的帧对不上。
2667
+ - 🔴 **通知车道刻意不进 `parsePeerFrameText`**。它的转录行是一行没有标签的散文,要认它只能去锚
2668
+ `[Cross-session …]` 前缀 —— 用户随手打一行同样的字就会被认成引擎通知。按普通文本行渲本来就是
2669
+ CC 形。
2670
+
2671
+ ### 17d. 零行为差的边界
2672
+
2673
+ 三条载体一个都不在场的载荷(= 上游到货前的**全部**载荷)走的分支与 0.53.0 **逐字节等价**:
2674
+ `taskNotificationArm` 只换了转录行那一行的**文本来源**,去重键 / `bgshell_settle` / 面板 settle /
2675
+ module 台账一个都没动(它们判的是「哪个 task 的哪个状态」,与这条通知在模型面穿哪件外衣无关,
2676
+ 跟着换会把三条独立的账搅成一本)。
2677
+
2678
+ 🔴 **边界的另一半(发包扫描 [high] 纠偏)**:载体**在场**时,`taskNotificationArm` 对**所有**宿主(含没接 `parsePeerFrameText`
2679
+ 的老宿主 desktop/web-client)都把转录行文本换成信封形(`<agent-message …>…</agent-message>` / `<cross-session-message …>` /
2680
+ 通知纯行),不再是 `<task-notification>` XML。这是**有意的**——那一行本来就是模型读到的帧,老宿主把它当普通 user 文本行渲
2681
+ 正是 CC 形(CC 的 `<agent-message>` 就是一条 user 文本)。所以「零行为差」只对**载体缺席**的载荷成立;载体在场的老宿主差异=
2682
+ 「泛化完成卡 → 一行原文」,不是回退。
2683
+
2684
+ ### 17e. 上游供给的诚实边界(本版新增的已知局限)
2685
+
2686
+ `crossSessionMessage` / `crossSessionNotice` 两条载体在 **core 7.2.0** —— 即 cli `ENGINE_PIN 7.57.0`
2687
+ 内嵌的那一版 —— 上**零铸点**(直证:`node_modules/@sema-agent/core/dist/**` 两词零命中)。本批是
2688
+ **消费半场先落地**,上游到货前这两条车道恒不触发。`agentMessage` 一条今天就到得了
2689
+ (core 7.2.0 `dist/agents/send-message-tool.js` 的 uplink 腿直证 `_sema_provenance: { kind: "agent_message" …`)。
2690
+ 两个标签字面量已由 cli 侧的 wire 锚契约门(登记表里的 A-K24 条)对 core 真字节看着。
2691
+
2692
+ **cli / web / desktop 认领**:cli 侧接点(消息组件三形)在本批同车;web / desktop **无需动作**
2693
+ (不调新导出 ⇒ 逐字节零行为差,只是这三条车道在它们那儿仍渲泛化卡)。
2694
+ **实现锚**:`src/peerFrames.ts`(`classifyPeerNotification` / `renderPeerFrameTranscriptText` /
2695
+ `parsePeerFrameText` / `peerFrameDisplayName` / 三张闭集表)、`src/adapt/arms.ts` 的通知臂。
2696
+ **常驻门**:`scripts/run-peer-frame-projection-test.mjs`(71 checks,含适配器级的两条台账回归)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",