@sema-agent/client-core 0.12.1 → 0.13.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.
Files changed (127) hide show
  1. package/README.md +15 -2
  2. package/dist/abortableSleep.d.ts +29 -0
  3. package/dist/abortableSleep.js +43 -0
  4. package/dist/adapt/arms.d.ts +58 -0
  5. package/dist/adapt/arms.js +613 -0
  6. package/dist/adapt/ids.d.ts +22 -0
  7. package/dist/adapt/ids.js +34 -0
  8. package/dist/adapt/instanceLedger.d.ts +36 -0
  9. package/dist/adapt/instanceLedger.js +50 -0
  10. package/dist/adapt/panelTasks.d.ts +54 -0
  11. package/dist/adapt/panelTasks.js +193 -0
  12. package/dist/adapt/textStream.d.ts +49 -0
  13. package/dist/adapt/textStream.js +141 -0
  14. package/dist/adapt/toolCards.d.ts +65 -0
  15. package/dist/adapt/toolCards.js +100 -0
  16. package/dist/adapt/turnFlags.d.ts +33 -0
  17. package/dist/adapt/turnFlags.js +55 -0
  18. package/dist/adapt/wireShapes.d.ts +93 -0
  19. package/dist/adapt/wireShapes.js +167 -0
  20. package/dist/adapt.d.ts +32 -59
  21. package/dist/adapt.js +78 -1206
  22. package/dist/adapter/downstream/eventToSdkMessage.d.ts +55 -13
  23. package/dist/adapter/downstream/eventToSdkMessage.js +166 -106
  24. package/dist/adapter/downstream/terminalToSdkResult.js +149 -160
  25. package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +36 -0
  26. package/dist/adapter/downstream/turnUsageToModelUsage.js +34 -6
  27. package/dist/adapter/runStream.d.ts +29 -4
  28. package/dist/adapter/runStream.js +129 -13
  29. package/dist/adapter/types.d.ts +2 -2
  30. package/dist/agentSession/backgroundView.js +4 -15
  31. package/dist/agentsWireCaps.d.ts +10 -6
  32. package/dist/agentsWireCaps.js +21 -7
  33. package/dist/argvFlagValue.d.ts +41 -0
  34. package/dist/argvFlagValue.js +69 -0
  35. package/dist/attachmentsWireCaps.d.ts +3 -2
  36. package/dist/attachmentsWireCaps.js +5 -3
  37. package/dist/classifierVerdictWire.d.ts +8 -0
  38. package/dist/classifierVerdictWire.js +8 -0
  39. package/dist/cloudConfigWireCaps.d.ts +28 -1
  40. package/dist/cloudConfigWireCaps.js +51 -11
  41. package/dist/controlRouter.d.ts +14 -8
  42. package/dist/controlRouter.js +15 -21
  43. package/dist/detachWire.d.ts +15 -5
  44. package/dist/detachWire.js +17 -7
  45. package/dist/effortWire.d.ts +0 -21
  46. package/dist/effortWire.js +6 -20
  47. package/dist/engineInlineTaskStats.d.ts +12 -6
  48. package/dist/engineWireSdk.d.ts +12 -0
  49. package/dist/env/localeGeo.js +2 -1
  50. package/dist/envFlag.d.ts +39 -0
  51. package/dist/envFlag.js +51 -0
  52. package/dist/finalVerifyWire.d.ts +7 -5
  53. package/dist/finalVerifyWire.js +6 -5
  54. package/dist/fleet/fleetLedger.d.ts +32 -9
  55. package/dist/fleet/fleetLedger.js +119 -34
  56. package/dist/fleet/fleetProjection.d.ts +44 -6
  57. package/dist/fleet/fleetProjection.js +52 -10
  58. package/dist/fleetAgentPanelProjection.d.ts +18 -1
  59. package/dist/fleetAgentPanelProjection.js +61 -14
  60. package/dist/forkWireCaps.d.ts +2 -1
  61. package/dist/forkWireCaps.js +5 -11
  62. package/dist/goalStopHook.d.ts +142 -0
  63. package/dist/goalStopHook.js +258 -0
  64. package/dist/headlessPermissionModeWire.d.ts +10 -0
  65. package/dist/headlessPermissionModeWire.js +24 -21
  66. package/dist/headlessReconnectWire.d.ts +7 -1
  67. package/dist/headlessReconnectWire.js +20 -2
  68. package/dist/hitl/approvalsFeed.js +31 -8
  69. package/dist/hitl/askGateWire.d.ts +27 -96
  70. package/dist/hitl/askGateWire.js +69 -546
  71. package/dist/hitl/frameRouter.d.ts +86 -0
  72. package/dist/hitl/frameRouter.js +342 -0
  73. package/dist/hitl/gateLedger.d.ts +107 -0
  74. package/dist/hitl/gateLedger.js +113 -0
  75. package/dist/hitl/hitlBridge.d.ts +75 -15
  76. package/dist/hitl/hitlBridge.js +94 -22
  77. package/dist/hitl/hitlHostSurface.d.ts +49 -0
  78. package/dist/hitl/hitlHostSurface.js +155 -0
  79. package/dist/hitl/parkResolver.d.ts +74 -0
  80. package/dist/hitl/parkResolver.js +241 -0
  81. package/dist/hitl/planReviewWire.d.ts +60 -2
  82. package/dist/hitl/planReviewWire.js +152 -74
  83. package/dist/hitl/toolApprovalWire.d.ts +67 -4
  84. package/dist/hitl/toolApprovalWire.js +119 -31
  85. package/dist/hooksWireCaps.d.ts +1 -82
  86. package/dist/hooksWireCaps.js +34 -235
  87. package/dist/host.d.ts +16 -5
  88. package/dist/index.d.ts +2 -0
  89. package/dist/index.js +15 -1
  90. package/dist/interactiveToolsWire.d.ts +15 -4
  91. package/dist/interactiveToolsWire.js +24 -26
  92. package/dist/limitsWire.js +7 -40
  93. package/dist/liveInitToolFace.d.ts +51 -6
  94. package/dist/liveQuestionStore.d.ts +5 -6
  95. package/dist/model/providerPresets.js +11 -1
  96. package/dist/notifications.d.ts +48 -2
  97. package/dist/notifications.js +223 -46
  98. package/dist/retainBackgroundWireCaps.d.ts +3 -2
  99. package/dist/retainBackgroundWireCaps.js +5 -9
  100. package/dist/sandboxWire.d.ts +9 -31
  101. package/dist/sandboxWire.js +51 -50
  102. package/dist/scenarioWire.d.ts +1 -1
  103. package/dist/scenarioWire.js +25 -36
  104. package/dist/seam.d.ts +23 -6
  105. package/dist/seam.js +40 -30
  106. package/dist/seatContract.d.ts +369 -83
  107. package/dist/seatContract.js +585 -198
  108. package/dist/selfOrchestrationWireCaps.d.ts +6 -5
  109. package/dist/selfOrchestrationWireCaps.js +8 -12
  110. package/dist/sessionSlot.d.ts +8 -0
  111. package/dist/sessionSlot.js +1 -0
  112. package/dist/steering.js +2 -2
  113. package/dist/subagent/engineTaskHandleWire.d.ts +3 -0
  114. package/dist/subagent/engineTaskHandleWire.js +17 -2
  115. package/dist/subagentContentStore.d.ts +5 -5
  116. package/dist/toolResult.d.ts +89 -8
  117. package/dist/toolResult.js +99 -30
  118. package/dist/typePins.d.ts +17 -0
  119. package/dist/typePins.js +1 -0
  120. package/dist/ultracodeWireCaps.js +5 -6
  121. package/dist/unrefTimer.d.ts +19 -0
  122. package/dist/unrefTimer.js +5 -0
  123. package/dist/workflow.d.ts +3 -2
  124. package/dist/workflow.js +3 -2
  125. package/dist/workflowClient.d.ts +7 -0
  126. package/dist/workflowClient.js +47 -12
  127. package/package.json +3 -3
@@ -1,576 +1,99 @@
1
- import { HitlBridge } from './hitlBridge.js';
2
- import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverlay, } from '../liveQuestionStore.js';
3
- import { hostLog } from '../host.js';
4
- import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
5
- import { isToolApprovalGate, toolNameIsFsWrite, surfaceFsApprovalAndDecide, isToolApprovalFrame, isFromSubagent, surfaceToolApprovalFrameAndRespond, } from './toolApprovalWire.js';
6
- import { classifierDenyFromToolEnd } from '../classifierVerdictWire.js';
7
- // W1(design/161):sessionKey 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层(cli 装配不动)
8
- const hostSurfaceByKey = createSessionSlot();
9
- const hostSurfaceMissesByKey = new Map();
10
- /** HITL 宿主面( null )。返回还原函数。 */
11
- export function installHitlHostSurface(surface) {
12
- return installHitlHostSurfaceFor(DEFAULT_SESSION_KEY, surface);
13
- }
14
- /** W1 key 变体:装/卸某会话的 HITL 宿主面(多会话宿主每会话一键,互不顶盖)。 */
15
- export function installHitlHostSurfaceFor(sessionKey, surface) {
16
- const prev = hostSurfaceByKey.get(sessionKey);
17
- hostSurfaceByKey.set(sessionKey, surface);
18
- return () => {
19
- hostSurfaceByKey.set(sessionKey, prev);
20
- };
21
- }
22
- /** 🔴 宿主自检:恒应为 0。非 0 = 有 HITL 副作用发生时口不在,那一行 warn / 那条记账丢了。 */
23
- export function hitlHostSurfaceMisses() {
24
- return hitlHostSurfaceMissesFor(DEFAULT_SESSION_KEY);
25
- }
26
- /** W1 带 key 变体(miss 计数逐键)。 */
27
- export function hitlHostSurfaceMissesFor(sessionKey) {
28
- return hostSurfaceMissesByKey.get(sessionKey) ?? 0;
29
- }
30
- /** W1 读端:某会话的 HITL 宿主面(未装 = null;**不计 miss** —— miss 只在副作用真发生时计)。 */
31
- export function hitlHostSurfaceFor(sessionKey) {
32
- return hostSurfaceByKey.get(sessionKey) ?? null;
33
- }
34
- /** 测试钩:卸口 + 清计数。 */
35
- export function _resetHitlHostSurfaceForTest() {
36
- hostSurfaceByKey.clear();
37
- hostSurfaceMissesByKey.clear();
38
- }
39
- function surface() {
40
- const hostSurface = hitlHostSurfaceFor(DEFAULT_SESSION_KEY);
41
- if (hostSurface === null) {
42
- hostSurfaceMissesByKey.set(DEFAULT_SESSION_KEY, (hostSurfaceMissesByKey.get(DEFAULT_SESSION_KEY) ?? 0) + 1);
43
- }
44
- return hostSurface;
45
- }
46
- /**
47
- * CC `utils/messages.ts` 的 `REJECT_MESSAGE` **逐字**(搬迁差分 2)。deny 后重放 tool_end 的
48
- * render 面 stamp 用 —— vendored `renderToolUseRejectedMessage` 渲 `User rejected <op> to <path>`。
49
- * 🔴 pure 门 B7 段:包内冻结字面量(无条件)+ 壳树全树扫描「每一处声明都逐字节相同」(壳树缺席=跳过)。
50
- */
51
- export const HITL_REJECT_MESSAGE = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
52
- function rejectMessageForRender() {
53
- return HITL_REJECT_MESSAGE;
54
- }
55
- /** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
56
- const MAX_GATE_HOPS = 24;
57
- /** core 对被 gate/连坐 abort 的 call 铸的 tool_end 载体(逐字;desktop session-host 真引擎实测
58
- * 同款)——HOLD 谓词锚它做**精确等值**,普通工具错的输出是各自错误文案,永不进 HOLD。 */
59
- export const ENGINE_ABORT_TOOL_RESULT = 'Operation aborted';
60
- function isAskTool(name) {
61
- return typeof name === 'string' && name.replace(/[\s_-]+/g, '').toLowerCase() === 'askuserquestion';
62
- }
63
- /** done 帧的 AskUserQuestion park 形状(结构性读;别的终态一律 false)。 */
64
- function isAskGatePark(result) {
65
- const r = result;
66
- return (!!r &&
67
- r.status === 'suspended' &&
68
- isAskTool(r.checkpointGate?.toolName) &&
69
- typeof r.taskId === 'string' &&
70
- r.taskId.length > 0);
71
- }
72
- /** done 帧的工具审批 park 形状([816] 放宽腿 + [2150] Bash 半场):status suspended +
73
- * checkpointGate 指 fs 写三件 / Bash / 一等 kind==='tool_approval'(超集谓词 isToolApprovalGate,
74
- * decide 腿对两族同构、卡形归壳分发)。Ask gate 恒先判(问答 overlay 原路)。 */
75
- function isFsApprovalPark(result) {
76
- const r = result;
77
- return (!!r &&
78
- r.status === 'suspended' &&
79
- typeof r.taskId === 'string' &&
80
- r.taskId.length > 0 &&
81
- !isAskTool(r.checkpointGate?.toolName) &&
82
- isToolApprovalGate(r.checkpointGate));
83
- }
84
- /** 把 wire 答案({answers:[{header,selected,note?}]})折回 CC 卡片的 Record<question,string> 形状
85
- * (multiSelect 与对话框同款 ", " lossy join;note → annotations.notes)。 */
86
- export function toAnsweredOutput(questions, answer) {
87
- const answers = {};
88
- const annotations = {};
89
- for (const entry of answer.answers ?? []) {
90
- const q = questions.find(qq => qq.header === entry.header);
91
- const key = typeof q?.question === 'string' ? q.question : entry.header;
92
- answers[key] = (entry.selected ?? []).join(', ');
93
- if (entry.note)
94
- annotations[key] = { notes: entry.note };
95
- }
96
- return {
97
- type: 'ask-user-question',
98
- questions,
99
- answers,
100
- ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
101
- };
102
- }
103
- // ── 件3(中断事故修复批 G,2026-07-15)—— 中断 deny 的有界观察 ─────────────────────────────────
104
- /** cancel-by-deny 的后台 settle 预算。decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测
105
- * 4-5s+),但 DENY-abort 语义上引擎收到即终结 run;2s 内连收都没收到 ⇒ 按丢失警示(晚到成功
106
- * 只是多一行良性 warn,比锁死无线索诚实)。 */
107
- export const CANCEL_DENY_BUDGET_MS = 2000;
108
- /** warn 行文案(测试锁字面)。 */
109
- export const CANCEL_DENY_WARN_TEXT = 'could not cancel the pending question — the session may stay locked; the run may need engine-side recovery';
110
- const CANCEL_DENY_WARN_KEY = 'hitl-cancel-deny-warn';
111
- const CANCEL_DENY_WARN_TIMEOUT_MS = 10_000;
112
- /** 失败 warn 上屏:借 footer notifications 通道。壳原文经 `appStateRef` 直写 store
113
- * (immediate 语义:立即顶到 current + 到点自清「仅当仍是本 warn 时」);本包把那两次
114
- * setState 拆成宿主口的两个动词,**时序纪律(2s 预算 / 10s 自清)留在库内**——
115
- * [paired-mechanisms-must-share-premise]:只给「显示」不给「清」,端各写各的清法必然漂。
116
- * 无口(print/headless 没屏可上)⇒ 静默,与壳原文 `if (!store) return` 同语义。 */
117
- function surfaceCancelDenyWarn() {
118
- try {
119
- const s = surface();
120
- if (!s)
121
- return;
122
- s.showNotice({
123
- key: CANCEL_DENY_WARN_KEY,
124
- text: CANCEL_DENY_WARN_TEXT,
125
- color: 'warning',
126
- priority: 'immediate',
127
- timeoutMs: CANCEL_DENY_WARN_TIMEOUT_MS,
128
- });
129
- const timer = setTimeout(() => {
130
- try {
131
- s.clearNoticeIfCurrent(CANCEL_DENY_WARN_KEY);
132
- }
133
- catch {
134
- /* fail-soft */
135
- }
136
- }, CANCEL_DENY_WARN_TIMEOUT_MS);
137
- timer.unref?.();
138
- }
139
- catch {
140
- /* fail-soft:上屏失败绝不再伤害 abort 流 */
141
- }
142
- }
143
- /**
144
- * 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面)。
145
- * 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
146
- * - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
147
- * - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
148
- * 撞 409;配合件2b 的专属文案,用户知道现场 + 出路);
149
- * - HitlSafetyError code==='no_pending' ⇒ 良性静默(pending 已被别处消解/过期 —— run 没锁;
150
- * hitlBridge decideTool 的同款语义,那里的静默维持不动)。
151
- */
152
- export function observeCancelByDeny(settle, taskId) {
153
- let timer;
154
- const budget = new Promise((_, reject) => {
155
- timer = setTimeout(() => reject(new Error(`cancel-by-deny did not settle within ${CANCEL_DENY_BUDGET_MS}ms`)), CANCEL_DENY_BUDGET_MS);
156
- timer.unref?.();
157
- });
158
- void Promise.race([settle, budget])
159
- .then(() => {
160
- hostLog('debug', `[hitl-ask] cancel-by-deny settled for run ${taskId} (session unlocked)`);
161
- })
162
- .catch((e) => {
163
- // ⚠️ 这里的 `.code` 是 **client-core 自家 HitlSafetyError 的契约属性**(hitlBridge.ts,词表
164
- // binding_mismatch|no_pending|wrong_gate|bad_plan_edit)—— 不是 SDK 错误的 `errorCode`。
165
- // 2026-07-30 `.code` 普查曾把本处误判成 SDK 属性名病改成 errorCode,pure 门 B7 腿当场拦下
166
- // (反向修复);判 `.code` 读点的病与否,先看**谁抛的**,别按属性名模式匹配。
167
- if (e?.code === 'no_pending') {
168
- hostLog('debug', `[hitl-ask] cancel-by-deny no_pending for run ${taskId} (benign — already resolved)`);
169
- return;
170
- }
171
- hostLog('error', `[hitl-ask] cancel-by-deny FAILED for run ${taskId}: ${String(e)}`);
172
- surfaceCancelDenyWarn();
173
- })
174
- .finally(() => {
175
- if (timer !== undefined)
176
- clearTimeout(timer);
177
- });
178
- // 超时后原 promise 迟到 reject 不得变成 unhandledRejection。
179
- void settle.catch(() => { });
180
- }
181
- /**
182
- * 弹既有 AskUserQuestion overlay → 等作答 → 经 HitlBridge decide(答案/拒答)。
183
- * decide 成功即引擎已把 run 驱到下一状态(sync);调用方随后 attach runs.events 续流。
184
- */
185
- async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
186
- if (!hasQuestionOverlay()) {
187
- return { kind: 'failed', reason: 'no question overlay mounted (print/non-REPL mode)' };
188
- }
189
- // pending 行 = 问题 payload 的权威源 + D-1 绑定的唯一 surface(PendingCheckpoint.input)。
190
- let pending;
191
- try {
192
- const rows = (await deps.client.approvals.list(signal ? { signal } : undefined)).pending;
193
- pending =
194
- rows.find(r => r.taskId === taskId && isAskTool(r.toolName ?? undefined)) ??
195
- rows.find(r => r.taskId === taskId);
196
- }
197
- catch (e) {
198
- return { kind: 'failed', reason: `approvals.list failed: ${String(e)}` };
199
- }
200
- if (!pending)
201
- return { kind: 'failed', reason: 'no pending checkpoint for this run (resolved/expired?)' };
202
- const gatedCallId = pending.toolCallId ?? pending.boundCallId ?? undefined;
203
- // 问题 payload:优先流上 tool_start.args(零额外语义),缺则 pending.input(service 已 redact + 限长)。
204
- const fromArgs = gatedCallId
205
- ? askArgsByCall.get(gatedCallId)?.questions
206
- : undefined;
207
- const fromPending = pending.input?.questions;
208
- const questions = Array.isArray(fromArgs) && fromArgs.length > 0 ? fromArgs : fromPending;
209
- if (!Array.isArray(questions) || questions.length === 0) {
210
- // [1543]②:input 超引擎入参帽(MAX_TOOL_INPUT_CHARS,preview 富文本题易中)时 server 降级为
211
- // {truncated:true} ——无题可渲不是引擎代差,是 payload 被截。分形出诚实理由(fail-soft 链
212
- // 会把 reason 记入 SEMA_DEBUG,别再误导成 pre-CHANNEL[56] 老引擎)。
213
- const truncated = pending.input?.truncated === true;
214
- return {
215
- kind: 'failed',
216
- gatedCallId,
217
- reason: truncated
218
- ? 'question payload exceeded the engine input cap (input={truncated:true}) — dialog cannot be re-rendered; run stays suspended for out-of-band decide'
219
- : 'gate has no question payload (pre-CHANNEL[56] engine?)',
220
- };
221
- }
222
- // overlay 往返:合成 frame(id 独占命名空间 hitl-ask:)→ local responder 一次性收答。
223
- const questionId = `hitl-ask:${gatedCallId ?? taskId}`;
224
- const answer = await new Promise(resolve => {
225
- const unregister = registerLocalQuestionResponder(questionId, async (_id, a) => {
226
- cleanup();
227
- resolve(a);
228
- return { ok: true };
229
- });
230
- const onAbort = () => {
231
- cleanup();
232
- // 撤下还开着的对话框(question_complete = overlay 的既有 dismiss 语义)
233
- publishQuestionFrame({ type: 'question_complete', questionId });
234
- resolve(null);
235
- };
236
- const cleanup = () => {
237
- unregister();
238
- signal?.removeEventListener('abort', onAbort);
239
- };
240
- if (signal?.aborted) {
241
- onAbort();
242
- return;
243
- }
244
- signal?.addEventListener('abort', onAbort, { once: true });
245
- publishQuestionFrame({ type: 'question', questionId, questions });
246
- });
247
- const bridge = new HitlBridge(deps.client, taskId);
248
- if (answer === null) {
249
- // turn 被中断(Esc/Ctrl+C):cancel-by-deny(contract/04 §2.4 —— suspended run 不 runs.cancel)。
250
- // 件3(中断事故修复批 G,2026-07-15,症状1 壳侧配套):此前 .catch(()=>{}) 全吞 = deny 丢失时
251
- // run 永卡 suspended,session 锁死,用户下一条消息撞 409「active run」还全无线索。改为有界观察
252
- // (observeCancelByDeny,2s 预算):abort 仍立即返回用户控制(不 await,交互时序不变),后台
253
- // settle 失败/超时上屏一行 warn + SEMA_DEBUG 记失败原因。引擎侧解锁腿([866] server:cancel
254
- // suspended 改语义 + reapSuspended TTL)到货前,这是壳能做的最诚实半场。
255
- observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId), taskId);
256
- return { kind: 'aborted', gatedCallId };
257
- }
258
- try {
259
- const entries = (answer.answers ?? []);
260
- const hasContent = entries.some(a => (a.selected?.length ?? 0) > 0 || (a.note?.length ?? 0) > 0);
261
- if (hasContent) {
262
- await bridge.answerQuestion(entries, gatedCallId, signal ? { signal } : undefined);
263
- return { kind: 'decided', gatedCallId, answered: toAnsweredOutput(questions, answer) };
264
- }
265
- else {
266
- // 用户拒答(overlay reject/dismiss 送空 answers)= 诚实 deny;模型收 denied 结果自续。
267
- await bridge.decideTool({ decision: 'deny', reason: 'User declined to answer' }, gatedCallId, signal ? { signal } : undefined);
268
- }
269
- return { kind: 'decided', gatedCallId };
270
- }
271
- catch (e) {
272
- return { kind: 'failed', gatedCallId, reason: `decide failed: ${String(e)}` };
273
- }
274
- }
1
+ import { createGateLedger } from './gateLedger.js';
2
+ import { isHostProgressFrame, routeFrame, } from './frameRouter.js';
3
+ import { resolvePark } from './parkResolver.js';
4
+ // ── 宿主面口(搬迁差分 3)────────────────────────────────────────────────────────────────────
5
+ //
6
+ // 壳里这三件都是 `getAppStateStoreRef()` 直写 `AppState` + `require('../utils/autoModeDenials.js')`。
7
+ // `AppState.notifications` Recent Denials 面板都是**端的状态形状**(web/桌面各有各的通知系统),
8
+ // 所以做成口。🔴 缺席 = 那一行 warn 不上屏 —— 壳原文在无 provider(print/headless)时**本来就是
9
+ // 静默**(`if (!store) return`),所以缺席语义与原文一致;但**漏装**与「真的没屏」看起来一样,
10
+ // 于是计 miss(`hitlHostSurfaceMisses()` 宿主自检恒应为 0)
11
+ // REF-CC-023(2026-08-02,P3 wave2 卡C-bridge):口本体(`HitlHostSurface` 接口 / install·misses·for
12
+ // 读写口 / `surfaceForCurrentSession`)与 cancel-by-deny 有界观察(`observeCancelByDeny`)
13
+ // 已整条搬进 `./hitlHostSurface.js` —— `toolApprovalWire.ts` 的中断-deny 臂也要用同一份观察器,
14
+ // `toolApprovalWire.ts` 是本文件的上游 import,反向 import 会成环;单源到一个两边都能到达的
15
+ // 独立模块解环。REF-CC-032 之后这两处的消费点在 `frameRouter.ts` / `parkResolver.ts`。
16
+ // ── 公开面(REF-CC-032 三刀后原位 re-export;`index.ts` 的 `export *` 经本文件出去)──────────
17
+ //
18
+ // 🔴 名字与语义一个都没动:值四个(`HITL_REJECT_MESSAGE` / `ENGINE_ABORT_TOOL_RESULT` /
19
+ // `toAnsweredOutput` / 下方 `bridgeAskUserQuestionGates`)、类型四个。定义搬去了实现所在的
20
+ // 那一刀,本文件是它们对外的**唯一门牌**——下游 import 路径不变,public-export 基线不变。
21
+ export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
22
+ export { toAnsweredOutput, isAlreadyResolvedGateReason } from './parkResolver.js';
275
23
  /**
276
24
  * 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
277
25
  * 其它事件原样透传;非 AskUserQuestion 的 gate 保持现状。fail-soft:任何桥内失败回退为
278
26
  * 「flush 毒化 tool_end + 原样终帧」(= 修复前行为)。
279
27
  *
28
+ * 双层循环(REF-CC-032 后只剩驱动骨架):
29
+ * · 内环 `for await` 读流 —— 每帧先记 seq、再判扣留帧放行、再交 `routeFrame` 定处置;
30
+ * `park` 动作 break 出内环。
31
+ * · 外环 `while` —— `resolvePark` 决断:`reattach` 就换一条 `runs.events` 流接着读
32
+ * (hop 预算在 resolver 内按 `hops` 判),`failsoft` 就吐完 events 收场。
33
+ *
280
34
  * @param source 上游 AgentEvent 流(tasks.stream 或 runs.events,已过 demuxQuestionFrames)。
281
35
  * @param opts.taskId runs.events 消费时已知的 run handle(sync leg 从 suspended done 捕获)。
282
36
  */
283
37
  export async function* bridgeAskUserQuestionGates(source, deps, opts) {
284
- const startedCalls = new Set();
285
- const endedCalls = new Set();
286
- /** park 毒化的 gated tool_end(isError:true "Operation aborted")——HOLD 到 gate 定性
287
- * (AskUserQuestion + [816] 放宽后的 fs 写工具)。 */
288
- const heldAskEnds = new Map();
289
- /** gated 工具的 tool_start.args(toolCallId → args)= 对话框/三选卡 payload 的一手源。 */
290
- const askArgsByCall = new Map();
291
- /** 三选卡 deny 过的 call:重放 tool_end 的 render 面 stamp REJECT_MESSAGE(vendored
292
- * renderToolUseRejectedMessage 渲 `User rejected <op> to <path>`,CC 逐字)。 */
293
- const deniedCalls = new Set();
294
- /** started-未-ended 的 fs 写 call(顺序保留)——同步 tool_approval 帧的 callId 关联面
295
- * (帧契约不带 toolCallId;引擎阻塞在帧上,最后一张未收口 fs 写 call 即被 gate 的 call)。 */
296
- const pendingFsCalls = [];
297
- /** 同步帧腿 deny 但 callId 关联不上(引擎在 tool_start 前出帧的形状):stamp 下一张 fs 写报错帧。 */
298
- let denyStampNextFsEnd = false;
299
- /** 已作答的 gate(toolCallId → CC 卡片形状的真实答案)——续流重放的解答 tool_end 无 output,
300
- * 不补就会渲成结果不可用;这里 stamp `structured` 让卡片渲真实选择。 */
301
- const resolvedAnswers = new Map();
302
- let lastSeq;
303
- let realTaskId = opts?.taskId;
38
+ const led = createGateLedger();
39
+ const taskId = { current: opts?.taskId };
40
+ const ctx = {
41
+ led,
42
+ deps,
43
+ taskId,
44
+ ...(opts?.signal ? { signal: opts.signal } : {}),
45
+ };
304
46
  let stream = source;
305
47
  let hops = 0;
306
- function* flushHeld() {
307
- for (const [callId, held] of heldAskEnds) {
308
- endedCalls.add(callId);
309
- yield held;
310
- }
311
- heldAskEnds.clear();
312
- }
313
48
  while (true) {
314
- /** park 请求:遇 AskUserQuestion / fs 写权限 gate 时置位后 break 内环。 */
49
+ /** park 请求:遇 AskUserQuestion / 工具审批 gate 时置位后 break 内环。 */
315
50
  let park = null;
316
51
  for await (const ev of stream) {
317
- const seq = ev.id;
318
- if (typeof seq === 'string' && seq.length > 0)
319
- lastSeq = seq;
320
- // ── [2084]① 扣留帧「模型推进即放行」(2026-07-30;同日 web 复审纠偏收窄触发集)──────────
321
- // HOLD 只护 park 窗口。放行触发集 = host-lane **模型推进**帧:text_delta/reasoning_delta/
322
- // tool_start —— park 路径上这三种在 done{suspended} 之前不会出现,真错路径上下一轮必来其一。
323
- // 🔴 turn_end 明确**不在**触发集(初版在,web 复审真机轨迹逮红):core agent-loop 的 turn_end
324
- // emit 在 executeToolCalls 之后**无 abort/suspend 守卫**(邻臂都有,唯此一处没有)⇒ 真 park
325
- // 的帧序是 tool_end(毒化)→ message_committed → turn_end → done{suspended},turn_end 若放行
326
- // 会把毒化帧漏上屏,且 flushHeld 的 endedCalls 记账让 decide 后重放的解答帧被当已收口丢弃
327
- // (真实答案卡永不出现)。代价 = turn 末尾无后续推进帧的 abort 形帧等到终帧 flush,与修前同。
328
- // parentToolCallId 在场 = sub-agent 行帧,park 期间孙代照常说话,不算 host 推进(负控①C)。
329
- // (普通工具错自 [2084]①-b 谓词收窄后根本不进 HOLD,零滞后不依赖本触发集。)
330
- if (heldAskEnds.size > 0 &&
331
- (ev.type === 'text_delta' || ev.type === 'reasoning_delta' || ev.type === 'tool_start') &&
332
- ev.parentToolCallId === undefined) {
333
- yield* flushHeld();
334
- }
335
- // ── server 1.191 同步帧腿([830]①):`tool_approval` 帧 → 三选卡 → respond ────────────────
336
- // 引擎此刻同步阻塞在帧上(fail-closed:无 respond/TTL 5min/abort ⇒ deny),await 卡决断安全。
337
- // 帧是 HITL 词汇非 AgentEvent arm——两种帧都不外泄到渲染管道。
338
- if (isToolApprovalFrame(ev)) {
339
- // #51: `ToolApprovalFrame` frames are documented as NOT part of the
340
- // `AgentEvent` union (a separate SSE wire vocabulary riding the same
341
- // stream) — intersecting the type-predicate's asserted type with the
342
- // unrelated `AgentEvent` union collapses every arm to `never`. Local
343
- // rebind restores the real (already-checked) shape.
344
- const frame = ev;
345
- if (frame.type === 'tool_approval_complete')
346
- continue; // 收口帧:卡已由决断路径撤下,吞掉
347
- if (!deps.respondToolApproval) {
348
- hostLog('debug', `liveHitlAskWire: tool_approval frame ${frame.approvalId} but no respond wire — engine self-settles (TTL deny)`);
349
- continue;
350
- }
351
- // 补验批 askq 案定谳(2026-07-23,黑板 [1534],引擎 dist 逐坐标读定):core 的 durable
352
- // question policy 对 AskUserQuestion 出 action:"ask",本意让 durable gate park(suspended
353
- // → 本函数下方 bridge 弹卡收答);但 core suspendAsk 的退位条件(safety 缺席 + onAsk 在场)
354
- // 把问题门也抢进了同步审批帧腿(壳为 fs 写权限三选卡开的 TOOL_APPROVAL_ENABLED)——同步帧
355
- // 二值 respond 装不下「答案」,链路死锁:deny=模型误信问题已展示原地等;allow=执行工具
356
- // 本体炸 durable 装配的 onQuestion 占位(QUESTION_AWAITS_RESUME)。此处取两害之轻恒 allow:
357
- // 模型收到明确错误后退化成正文提问,用户尚可下一条消息作答(deny 形连这条退化路都没有)。
358
- // 承重域收敛史([1543]§一 cli 注意):core 1.375 修 question gate 恒 park(健康路),
359
- // 1.376 又断了降级路(checkpoint pre-commit/put 失败 fallback 改 typed refusal)与继承链
360
- // 两臂——1.376+ 下 ask 工具在任何路径都不再出 tool_approval 帧,本分支唯一剩余承重
361
- // = 旧引擎(<1.375)兼容,askq pty fence @1.375+ 已四门全绿实证([1542])。
362
- if (isAskTool(ev.toolName)) {
363
- try {
364
- await deps.respondToolApproval(frame.approvalId, 'allow', {
365
- ...(opts?.signal && !opts.signal.aborted ? { signal: opts.signal } : {}),
366
- });
367
- hostLog('debug', `liveHitlAskWire: ask-tool approval frame ${frame.approvalId} → allow(问题门放行,候 park 弹卡)`);
368
- }
369
- catch (e) {
370
- hostLog('debug', `liveHitlAskWire: ask-tool approval allow failed for ${frame.approvalId}: ${String(e)} — engine self-settles (TTL)`);
371
- }
372
- continue;
373
- }
374
- // [1535]/server 1.258:子代的审批帧(broker 腿浮到宿主流,判别=fromSubagent 显式键
375
- // core 1.378 优先/sourceTaskId 在场性兜底)——子代的 tool_start 不在宿主行流上,绝不把
376
- // 宿主自己的 pending fs call 关联给它(args 会错配成宿主的 diff、deny 会误标宿主 call 的
377
- // REJECT 收口)。args 从帧上取,deny 收口归子代 lane 自理。
378
- const fromSubagent = isFromSubagent(ev);
379
- hostLog('debug', `liveHitlAskWire: tool_approval frame ${frame.approvalId} tool=${String(ev.toolName)}${fromSubagent ? ` from-subagent=${String(ev.sourceTaskId ?? ev.sourceAgentName ?? 'explicit')}` : ''}`);
380
- const gatedCallId = !fromSubagent && pendingFsCalls.length > 0 ? pendingFsCalls[pendingFsCalls.length - 1] : undefined;
381
- const decision = await surfaceToolApprovalFrameAndRespond(ev, deps.respondToolApproval, gatedCallId !== undefined ? askArgsByCall.get(gatedCallId) : undefined, opts?.signal);
382
- if (decision === 'deny' && !fromSubagent) {
383
- // 该 call 的报错收口帧 stamp REJECT 文案(vendored `User rejected <op> to <path>` 卡)。
384
- if (gatedCallId !== undefined)
385
- deniedCalls.add(gatedCallId);
386
- else
387
- denyStampNextFsEnd = true;
388
- }
52
+ led.noteSeq(ev);
53
+ // HOLD 只护 park 窗口:host lane 的模型推进帧一到就放行扣留帧(触发集的来龙去脉见
54
+ // `frameRouter.isHostProgressFrame` 上方长注)。
55
+ if (led.heldCount() > 0 && isHostProgressFrame(ev))
56
+ yield* led.flushHeld();
57
+ const action = await routeFrame(ev, ctx);
58
+ if (action.kind === 'skip')
389
59
  continue;
390
- }
391
- if (ev.type === 'tool_start' && typeof ev.toolCallId === 'string') {
392
- if (startedCalls.has(ev.toolCallId))
393
- continue; // durable 重放的已渲 call
394
- startedCalls.add(ev.toolCallId);
395
- if (isAskTool(ev.toolName) || (typeof ev.toolName === 'string' && toolNameIsFsWrite(ev.toolName))) {
396
- askArgsByCall.set(ev.toolCallId, ev.args);
397
- if (typeof ev.toolName === 'string' && toolNameIsFsWrite(ev.toolName)) {
398
- pendingFsCalls.push(ev.toolCallId);
399
- }
400
- }
401
- yield ev;
402
- continue;
403
- }
404
- if (ev.type === 'tool_end' && typeof ev.toolCallId === 'string') {
405
- if (endedCalls.has(ev.toolCallId))
406
- continue; // durable 重放/park 复写的已收口 call
407
- {
408
- const pi = pendingFsCalls.indexOf(ev.toolCallId);
409
- if (pi !== -1)
410
- pendingFsCalls.splice(pi, 1);
411
- }
412
- // ── 分类器批3([907]):auto-mode 分类器 deny 裁决帧 —— 结构性识别(core 机器签名)优先于
413
- // fs HOLD/REJECT 分支(签名比"fs 写报错"更特定;分类器 deny 从不产 tool_approval 帧,
414
- // deniedCalls/denyStampNextFsEnd 与之互斥)。副作用 = CC 207 通知行 + Recent Denials 记账;
415
- // render 面 stamp 去 system-reminder 包裹渲裁决原文(render-only,模型面原文不动)。
416
- if (ev.isError === true && !deniedCalls.has(ev.toolCallId)) {
417
- const verdict = classifierDenyFromToolEnd(ev);
418
- if (verdict) {
419
- endedCalls.add(ev.toolCallId);
420
- heldAskEnds.delete(ev.toolCallId);
421
- surface()?.surfaceClassifierDeny(typeof ev.toolName === 'string' ? ev.toolName : 'tool', verdict);
422
- hostLog('debug', `liveHitlAskWire: classifier deny verdict on ${ev.toolCallId} (${String(ev.toolName)}) — labeled render + notice`);
423
- yield { ...ev, output: verdict.message, structured: undefined };
424
- continue;
425
- }
426
- }
427
- if (ev.isError === true &&
428
- typeof ev.toolName === 'string' &&
429
- toolNameIsFsWrite(ev.toolName) &&
430
- denyStampNextFsEnd &&
431
- !deniedCalls.has(ev.toolCallId)) {
432
- // 同步帧腿 deny 且 callId 没关联上:下一张 fs 写报错帧即该 gate 的收口帧,stamp REJECT 文案。
433
- denyStampNextFsEnd = false;
434
- endedCalls.add(ev.toolCallId);
435
- heldAskEnds.delete(ev.toolCallId);
436
- yield { ...ev, output: rejectMessageForRender(), structured: undefined };
437
- continue;
438
- }
439
- if (ev.isError === true &&
440
- (isAskTool(ev.toolName) || (typeof ev.toolName === 'string' && toolNameIsFsWrite(ev.toolName)))) {
441
- if (deniedCalls.has(ev.toolCallId)) {
442
- // 三选卡 No:重放的报错帧 stamp REJECT 文案 → vendored 卡渲 `User rejected <op> to <path>`。
443
- deniedCalls.delete(ev.toolCallId);
444
- endedCalls.add(ev.toolCallId);
445
- heldAskEnds.delete(ev.toolCallId);
446
- yield { ...ev, output: rejectMessageForRender(), structured: undefined };
447
- continue;
448
- }
449
- // [2084]①-b HOLD 谓词收窄(2026-07-30,desktop 复审建议采纳):park 毒化帧的载体是
450
- // **确切的引擎 abort 标记**(core 对被 gate/连坐 abort 的 call 铸
451
- // `tool_end{isError:true, output:"Operation aborted"}`;desktop session-host 2026-07-29
452
- // 对真引擎实测同款)。普通工具错(old_string 不命中/read-gate 拒绝…)输出是各自的错误
453
- // 文案,**根本不该进 HOLD**——收窄后它们零滞后直达转录,不再依赖「推进信号放行」。
454
- if (ev.output === ENGINE_ABORT_TOOL_RESULT) {
455
- heldAskEnds.set(ev.toolCallId, ev); // park 毒化帧,先 HOLD(gate 定性前不渲 Error 卡)
456
- continue;
457
- }
458
- // 普通失败:落到下方通用收口路径,立即上屏。
459
- }
460
- endedCalls.add(ev.toolCallId);
461
- heldAskEnds.delete(ev.toolCallId); // 正常收口(重放的 isError:false 解答帧)赢
462
- const answered = resolvedAnswers.get(ev.toolCallId);
463
- if (answered &&
464
- ev.isError !== true &&
465
- ev.structured === undefined) {
466
- resolvedAnswers.delete(ev.toolCallId);
467
- yield { ...ev, structured: answered }; // 真实答案卡(见 resolvedAnswers 注)
468
- }
469
- else {
470
- yield ev;
471
- }
472
- continue;
473
- }
474
- if (ev.type === 'suspended') {
475
- // durable leg 的 park arm(续流中模型再次提问/再次撞写权限)。gate 只带 kind/toolName,
476
- // payload 走 approvals。
477
- if (ev.gate && isAskTool(ev.gate.toolName) && realTaskId) {
478
- park = { gate: 'ask' };
479
- break;
480
- }
481
- if (ev.gate && isToolApprovalGate(ev.gate) && realTaskId) {
482
- park = { gate: 'fs' };
483
- break;
484
- }
485
- yield ev; // 其余 gate:透传(现状,eventToSdkMessage 出 null)
60
+ if (action.kind === 'yield') {
61
+ for (const out of action.events)
62
+ yield out;
486
63
  continue;
487
64
  }
488
- if (ev.type === 'done') {
489
- if (isAskGatePark(ev.result)) {
490
- realTaskId = realTaskId ?? ev.result.taskId;
491
- park = { pendingDone: ev, gate: 'ask' };
492
- break;
493
- }
494
- if (isFsApprovalPark(ev.result)) {
495
- realTaskId = realTaskId ?? ev.result.taskId;
496
- park = { pendingDone: ev, gate: 'fs' };
497
- break;
498
- }
499
- yield* flushHeld(); // 真错(非 gate)的 Ask tool_end 此刻诚实渲染
500
- // durable 终帧的 result.taskId === sessionId(引擎 durable quirk)会让 captureSessionId 跳过
501
- // rewind 绑定 —— 用 sync leg 捕的真 handle 修正回去。
502
- const r = ev.result;
503
- if (realTaskId && r && r.taskId === r.sessionId && r.taskId !== realTaskId) {
504
- yield { ...ev, result: { ...ev.result, taskId: realTaskId } };
505
- }
506
- else {
507
- yield ev;
508
- }
509
- return;
510
- }
511
- if (ev.type === 'failed') {
512
- yield* flushHeld();
513
- yield ev;
65
+ if (action.kind === 'end') {
66
+ for (const out of action.events)
67
+ yield out;
514
68
  return;
515
69
  }
516
- yield ev;
70
+ park = {
71
+ gate: action.gate,
72
+ ...(action.pendingDone !== undefined ? { pendingDone: action.pendingDone } : {}),
73
+ };
74
+ break;
517
75
  }
518
76
  if (!park) {
519
77
  // 源流走完没终帧(disconnect/abort)——flush 后结束,与现状一致。
520
- yield* flushHeld();
78
+ yield* led.flushHeld();
521
79
  return;
522
80
  }
523
81
  hops++;
524
- let outcome;
525
- if (hops > MAX_GATE_HOPS) {
526
- outcome = { kind: 'failed', reason: `gate hop limit (${MAX_GATE_HOPS}) exceeded` };
527
- }
528
- else if (park.gate === 'fs') {
529
- // [816] 放宽腿:fs 写权限 gate CC 三选卡(vendored PermissionRequest)→ decide。
530
- outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, realTaskId, askArgsByCall, opts?.signal);
531
- // #51: `outcome`'s declared type is the wider `GateOutcome |
532
- // FsApprovalOutcome`; both unions share a 'decided' kind with
533
- // different optional fields (`answered` vs `denied`), so a plain
534
- // `.denied` access doesn't hold for every 'decided' member even
535
- // though this branch only ever assigns the FsApprovalOutcome shape.
536
- // 'in' narrowing sidesteps it.
537
- const outcomeDenied = 'denied' in outcome ? outcome.denied : undefined;
538
- if (outcome.kind === 'decided' && outcomeDenied === true && outcome.gatedCallId) {
539
- deniedCalls.add(outcome.gatedCallId); // 重放的报错帧渲 `User rejected …`(见 tool_end 手柄)
540
- }
541
- }
542
- else {
543
- outcome = await surfaceGateAndDecide(deps, realTaskId, askArgsByCall, opts?.signal);
544
- }
545
- if (outcome.kind !== 'decided') {
546
- hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
547
- yield* flushHeld(); // 回退:毒化帧照旧渲染(= 修复前的诚实红)
548
- if (park.pendingDone) {
549
- yield park.pendingDone;
550
- }
551
- else if (outcome.kind === 'failed') {
552
- // 记案(#87 评审② minor,不修):durable re-attach 的第二问(park.pendingDone 无)走 `aborted`
553
- // 时(仅用户主动 Esc/Ctrl+C 中断触发)不吐终帧——下游正在拆流,合成终帧也没人渲;flushHeld
554
- // 已把毒化帧诚实吐出。真正的 failed 才合成下面的可见终帧。
555
- // durable-leg park 无 done 可回吐 —— 合成 failed 让用户看得见为什么停了
556
- yield {
557
- type: 'failed',
558
- errorCode: 'hitl_unanswered',
559
- errorMessage: `${park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion'} gate could not be answered: ${outcome.reason}`,
560
- };
561
- }
82
+ const resolution = await resolvePark(park, {
83
+ deps,
84
+ led,
85
+ taskId: taskId.current,
86
+ hops,
87
+ ...(opts?.signal ? { signal: opts.signal } : {}),
88
+ });
89
+ if (resolution.kind === 'failsoft') {
90
+ for (const out of resolution.events)
91
+ yield out;
562
92
  return;
563
93
  }
564
- // decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
565
- // 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
566
- if (outcome.gatedCallId) {
567
- heldAskEnds.delete(outcome.gatedCallId);
568
- if ('answered' in outcome && outcome.answered)
569
- resolvedAnswers.set(outcome.gatedCallId, outcome.answered);
570
- }
571
- hostLog('debug', `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) — attaching runs.events(${realTaskId})${lastSeq ? ` from seq ${lastSeq}` : ''}`);
572
- stream = deps.runsEvents(realTaskId, {
573
- ...(lastSeq !== undefined ? { lastEventId: lastSeq } : {}),
94
+ const seq = led.lastSeq();
95
+ stream = deps.runsEvents(taskId.current, {
96
+ ...(seq !== undefined ? { lastEventId: seq } : {}),
574
97
  ...(opts?.signal ? { signal: opts.signal } : {}),
575
98
  });
576
99
  }