@sema-agent/client-core 0.13.0 → 0.15.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 (39) hide show
  1. package/README.md +3 -1
  2. package/dist/adapt/arms.d.ts +10 -1
  3. package/dist/adapt/arms.js +12 -2
  4. package/dist/adapt/panelTasks.d.ts +6 -1
  5. package/dist/adapt/textStream.d.ts +15 -1
  6. package/dist/adapt/turnFlags.d.ts +5 -1
  7. package/dist/adapt/wireShapes.d.ts +4 -1
  8. package/dist/adapt.d.ts +1 -1
  9. package/dist/adapt.js +7 -2
  10. package/dist/adapter/downstream/eventToSdkMessage.d.ts +4 -2
  11. package/dist/adapter/downstream/eventToSdkMessage.js +9 -1
  12. package/dist/adapter/downstream/terminalToSdkResult.js +4 -2
  13. package/dist/adapter/runStream.d.ts +8 -1
  14. package/dist/adapter/runStream.js +70 -4
  15. package/dist/adapter/types.d.ts +26 -0
  16. package/dist/hitl/approvalsFeed.d.ts +22 -1
  17. package/dist/hitl/approvalsFeed.js +76 -5
  18. package/dist/hitl/frameRouter.js +49 -8
  19. package/dist/hitl/gateLedger.d.ts +13 -1
  20. package/dist/hitl/gateLedger.js +2 -2
  21. package/dist/hitl/parkResolver.js +10 -1
  22. package/dist/hitl/planReviewWire.js +48 -20
  23. package/dist/hitl/toolApprovalWire.js +11 -1
  24. package/dist/hooksWireCaps.js +10 -2
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.js +13 -0
  27. package/dist/liveQuestionStore.d.ts +13 -0
  28. package/dist/liveQuestionStore.js +15 -0
  29. package/dist/model/catalogLoader.d.ts +170 -0
  30. package/dist/model/catalogLoader.js +382 -0
  31. package/dist/model/providerAuth.d.ts +155 -0
  32. package/dist/model/providerAuth.js +190 -0
  33. package/dist/model/providerPresets.d.ts +16 -0
  34. package/dist/notifications.d.ts +15 -1
  35. package/dist/notifications.js +90 -10
  36. package/dist/printToolResultFrame.d.ts +12 -4
  37. package/dist/printToolResultFrame.js +11 -21
  38. package/dist/unrefTimer.d.ts +14 -3
  39. package/package.json +1 -1
@@ -0,0 +1,190 @@
1
+ /**
2
+ * 包内实现的设备码 provider 表。
3
+ * 🔴 **今天是空的,理由见文件头**(宁空勿假:没有我们自己名下的 client_id 之前,填一行 = 冒用)。
4
+ * 加一家 = 加一行;端点必须 https(门 ④a 逐行校)。
5
+ */
6
+ export const DEVICE_AUTH_PROVIDERS = [];
7
+ /** 包内是否实现了这一家(能力真值的一半)。 */
8
+ export function deviceAuthProviderFor(providerId) {
9
+ return DEVICE_AUTH_PROVIDERS.find((p) => p.providerId === providerId);
10
+ }
11
+ /**
12
+ * UI 是否显示「设备码登录」= **目录说有** ∧ **包里有实现**。
13
+ * 任一缺席 ⇒ false(见文件头「能力真值」段)。
14
+ */
15
+ export function supportsDeviceCodeAuth(providerId, catalogHint) {
16
+ if (catalogHint?.kind !== 'device_code')
17
+ return false;
18
+ return deviceAuthProviderFor(providerId) !== undefined;
19
+ }
20
+ /**
21
+ * 一家 provider 摆给用户的轨(顺序 = UI 呈现序)。`api_key` 恒在;`device_code` 只在能力真值成立时出。
22
+ */
23
+ export function providerAuthMethods(preset) {
24
+ const out = [{ kind: 'api_key', env: preset.authEnv }];
25
+ if (supportsDeviceCodeAuth(preset.id, preset.deviceAuth)) {
26
+ out.push({ kind: 'device_code', providerId: preset.id });
27
+ }
28
+ return out;
29
+ }
30
+ /** 轮询间隔下限(RFC 8628 建议 5s;服务端给更大的值时听服务端的)。 */
31
+ export const DEVICE_CODE_MIN_INTERVAL_MS = 5000;
32
+ /** 会话墙钟上限(design/166 §5:15min)。服务端 `expires_in` 更短时听服务端的。 */
33
+ export const DEVICE_CODE_MAX_LIFETIME_MS = 15 * 60 * 1000;
34
+ /** 429 / slow_down 的退避增量(RFC 8628 §3.5 就是「加 5 秒」)。 */
35
+ export const DEVICE_CODE_BACKOFF_STEP_MS = 5000;
36
+ /** 单次 HTTP 预算。 */
37
+ export const DEVICE_CODE_HTTP_TIMEOUT_MS = 10_000;
38
+ /** 异常 → 短 detail(绝不带栈、绝不带响应体)。 */
39
+ function shortError(e) {
40
+ return (e instanceof Error ? e.message : String(e)).slice(0, 160);
41
+ }
42
+ /** POST application/x-www-form-urlencoded,收 JSON。返回 {status, body}(body 解析不动 ⇒ null)。 */
43
+ async function postForm(url, form, fetchImpl, timeoutMs, signal) {
44
+ const controller = new AbortController();
45
+ const onAbort = () => controller.abort();
46
+ if (signal.aborted)
47
+ controller.abort();
48
+ else
49
+ signal.addEventListener('abort', onAbort, { once: true });
50
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
51
+ try {
52
+ const res = await fetchImpl(url, {
53
+ method: 'POST',
54
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
55
+ body: new URLSearchParams(form).toString(),
56
+ signal: controller.signal,
57
+ });
58
+ const text = await res.text();
59
+ let body = null;
60
+ try {
61
+ body = JSON.parse(text);
62
+ }
63
+ catch {
64
+ body = null; // 非 JSON 响应 ⇒ 当作「说不出所以然」,由调用方按 status 判(绝不回显正文)
65
+ }
66
+ return { status: res.status, body };
67
+ }
68
+ finally {
69
+ clearTimeout(timer);
70
+ signal.removeEventListener('abort', onAbort);
71
+ }
72
+ }
73
+ /**
74
+ * 开一次设备码会话 —— **端点由调用方给**(包内表里那一行,或门的合成 descriptor)。
75
+ * 🔴 端点是**编译进包的常量**,不是远端数据,所以本函数不对它再做白名单;真正的门是
76
+ * 「client_id/端点绝不目录化下发」(design/165 §2 红线)+ 表内每行必须 https(门 ④a)。
77
+ *
78
+ * 失败(网络 / 非 2xx / 缺 user_code)⇒ **throw**(§C1 三选一里的 throw):开不出会话就是开不出,
79
+ * 绝不返回一个永远 pending 的假会话。
80
+ */
81
+ export async function openDeviceCodeSession(endpoints, opts) {
82
+ const fetchImpl = opts.fetchImpl ?? fetch;
83
+ const timeoutMs = opts.timeoutMs ?? DEVICE_CODE_HTTP_TIMEOUT_MS;
84
+ const abort = new AbortController();
85
+ const started = opts.nowMs();
86
+ const { status, body } = await postForm(endpoints.deviceCodeUrl, { client_id: endpoints.clientId, ...(endpoints.scope !== undefined ? { scope: endpoints.scope } : {}) }, fetchImpl, timeoutMs, abort.signal);
87
+ if (status < 200 || status >= 300) {
88
+ throw new Error(`device code request failed: HTTP ${status}`);
89
+ }
90
+ const grant = (body ?? {});
91
+ const deviceCode = typeof grant.device_code === 'string' ? grant.device_code : '';
92
+ const userCode = typeof grant.user_code === 'string' ? grant.user_code : '';
93
+ const verificationUrl = typeof grant.verification_uri_complete === 'string'
94
+ ? grant.verification_uri_complete
95
+ : typeof grant.verification_uri === 'string'
96
+ ? grant.verification_uri
97
+ : '';
98
+ if (deviceCode === '' || userCode === '' || verificationUrl === '') {
99
+ throw new Error('device code response missing device_code / user_code / verification_uri');
100
+ }
101
+ const serverLifetimeMs = typeof grant.expires_in === 'number' && grant.expires_in > 0 ? grant.expires_in * 1000 : undefined;
102
+ const lifetimeMs = Math.min(serverLifetimeMs ?? DEVICE_CODE_MAX_LIFETIME_MS, DEVICE_CODE_MAX_LIFETIME_MS);
103
+ const serverInterval = typeof grant.interval === 'number' && grant.interval > 0 ? grant.interval * 1000 : undefined;
104
+ const session = {
105
+ userCode,
106
+ verificationUrl,
107
+ intervalMs: Math.max(serverInterval ?? DEVICE_CODE_MIN_INTERVAL_MS, DEVICE_CODE_MIN_INTERVAL_MS),
108
+ expiresAtMs: started + lifetimeMs,
109
+ poll: async () => poll(),
110
+ cancel: () => {
111
+ if (terminal === null)
112
+ terminal = { status: 'cancelled', detail: 'cancelled by the host' };
113
+ abort.abort();
114
+ },
115
+ };
116
+ /** 终态一旦落定就恒定(拿到 key 之后再 poll 不重复拨号)。 */
117
+ let terminal = null;
118
+ /** 下一次允许真正拨号的墙钟时刻(间隔是硬的,不靠调用方自律)。 */
119
+ let nextDialAtMs = started;
120
+ async function poll() {
121
+ if (terminal !== null)
122
+ return terminal;
123
+ const now = opts.nowMs();
124
+ if (now >= session.expiresAtMs) {
125
+ // 本地上限先于服务端判:上限是我们对用户的承诺,不该靠服务端良心。
126
+ terminal = { status: 'expired', detail: 'device code session exceeded its lifetime — start a new one' };
127
+ return terminal;
128
+ }
129
+ if (now < nextDialAtMs) {
130
+ return { status: 'pending', detail: 'polled before the interval elapsed — no request was sent' };
131
+ }
132
+ let res;
133
+ try {
134
+ res = await postForm(endpoints.tokenUrl, {
135
+ client_id: endpoints.clientId,
136
+ device_code: deviceCode,
137
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
138
+ }, fetchImpl, timeoutMs, abort.signal);
139
+ }
140
+ catch (e) {
141
+ if (terminal !== null)
142
+ return terminal; // cancel() 把在飞请求打断了 —— 终态已落定
143
+ // 单次网络失败不是终态:设备码流本就是长轮询,下一拍再试(退避一格避免打点)。
144
+ nextDialAtMs = opts.nowMs() + session.intervalMs;
145
+ return { status: 'pending', detail: `poll failed, will retry: ${shortError(e)}` };
146
+ }
147
+ if (terminal !== null)
148
+ return terminal;
149
+ nextDialAtMs = opts.nowMs() + session.intervalMs;
150
+ const token = (res.body ?? {});
151
+ const err = typeof token.error === 'string' ? token.error : '';
152
+ if (res.status === 429 || err === 'slow_down') {
153
+ session.intervalMs += DEVICE_CODE_BACKOFF_STEP_MS;
154
+ nextDialAtMs = opts.nowMs() + session.intervalMs;
155
+ return { status: 'pending', detail: 'rate limited — backing off' };
156
+ }
157
+ if (typeof token.access_token === 'string' && token.access_token.length > 0) {
158
+ terminal = { status: 'ok', key: token.access_token };
159
+ return terminal;
160
+ }
161
+ if (err === 'authorization_pending')
162
+ return { status: 'pending' };
163
+ if (err === 'expired_token') {
164
+ terminal = { status: 'expired', detail: 'the device code expired — start a new one' };
165
+ return terminal;
166
+ }
167
+ if (err === 'access_denied') {
168
+ terminal = { status: 'denied', detail: 'the user declined the authorization request' };
169
+ return terminal;
170
+ }
171
+ if (res.status < 200 || res.status >= 300) {
172
+ return { status: 'pending', detail: `poll got HTTP ${res.status}, will retry` };
173
+ }
174
+ // 认不出的 error 码:不冒充终态(别把一个我们没见过的码说成「过期了」),下一拍再试。
175
+ return { status: 'pending', detail: err === '' ? 'unrecognised token response' : `unrecognised error: ${err}` };
176
+ }
177
+ return session;
178
+ }
179
+ /**
180
+ * 按 providerId 开设备码会话(UI 入口)。
181
+ * 包内没有这一家 ⇒ **throw**(UI 本就不该显示这个选项;走到这里说明能力判据被绕过了)。
182
+ */
183
+ export async function beginDeviceCodeAuth(providerId, opts) {
184
+ const provider = deviceAuthProviderFor(providerId);
185
+ if (provider === undefined) {
186
+ throw new Error(`device code auth is not supported for provider "${providerId}" in this build ` +
187
+ '(DEVICE_AUTH_PROVIDERS has no entry — the UI must gate on supportsDeviceCodeAuth())');
188
+ }
189
+ return openDeviceCodeSession(provider.endpoints, opts);
190
+ }
@@ -25,7 +25,23 @@ export type ProviderPreset = {
25
25
  consoleUrl?: string;
26
26
  /** 没账号时的注册入口。与 `consoleUrl` 同页的家不重复填(缺席 = 用 consoleUrl 那条)。 */
27
27
  signupUrl?: string;
28
+ /**
29
+ * 🆕 design/165 §2 + design/166 §5(2026-08-04):**设备码能力开关**(B 档)。
30
+ *
31
+ * 🔴 它只是**开关**,不携带 client_id / token 端点 / authorize 端点 —— 那些编译进
32
+ * `model/providerAuth.ts` 的 `DEVICE_AUTH_PROVIDERS`。理由:目录是远端可改的数据,
33
+ * 让它决定鉴权端点 = 把钓鱼面下发给客户端(design/165 §2 红线④,CI 的 validate.mjs
34
+ * 里有对应的机械不变式)。
35
+ * 🔴 能力真值 = **本键在场 ∧ 包内有实现**(`supportsDeviceCodeAuth`)——目录先行不造 affordance。
36
+ */
37
+ deviceAuth?: ProviderDeviceAuthHint;
28
38
  };
39
+ /** 目录里的设备码能力开关(只有 kind 与文档链接;凭证参数一律不进目录)。 */
40
+ export interface ProviderDeviceAuthHint {
41
+ kind: 'device_code';
42
+ /** 该家自己的设备码说明页(纯链接;拿不准就不填,绝不编 URL)。 */
43
+ docsUrl?: string;
44
+ }
29
45
  export type ModelFamily = {
30
46
  id: string;
31
47
  name: string;
@@ -190,11 +190,21 @@ export declare function outstandingAbandonedCount(): number;
190
190
  * 回归,复活周期会伪装成首周期落进这一格 —— 这个数异常增长就是 seq 又丢了的信号);
191
191
  * · `stuckTickResets` = 在飞 tick 超过 STUCK_TICK_RESET_MS 被强制复位的次数,**恒应为 0**;
192
192
  * >0 = 有 probe 连 PROBE_TIMEOUT_MS 都没能截断它。
193
+ *
194
+ * 🔴 [2393] F-1 补两格 —— 上面三格全是 **enqueue 内部**的记账,而 watcher 收摊臂在到达 enqueue
195
+ * **之前**就把台账条目删掉了:「复活周期被秒删」这条路径对上面三格 100% 不可见(立案时被点名当
196
+ * 信号的 `bgCrossChannelDropped` 正是这样对它零判别力的)。删除动作要挣得,也要看得见:
197
+ * · `bgWatchRevivalPromoted` = 收摊臂靠 probe 实证「任务又在跑」把条目升到新周期的次数
198
+ * —— 正是修前被静默秒删的那一类;>0 说明 wire 的 seq 没到货,壳在自己补周期号;
199
+ * · `bgWatchCollectedUnprobed` = probe 未装(mock/离线)时按裸 id 黑名单**盲摘**的次数,
200
+ * 这是唯一保留的无证据删除;>0 = 有完成周期在这类宿主上恒不可观察。
193
201
  */
194
202
  export interface NotificationDropCounters {
195
203
  bgDedupDropped: number;
196
204
  bgCrossChannelDropped: number;
197
205
  stuckTickResets: number;
206
+ bgWatchRevivalPromoted: number;
207
+ bgWatchCollectedUnprobed: number;
198
208
  }
199
209
  export declare function notificationDropCounters(): NotificationDropCounters;
200
210
  /** liveClient 在构造会话 client 时注册(带正确 baseUrl/token/principal 的 workflows.get)。 */
@@ -215,7 +225,11 @@ export declare function installBgTaskStatusProbe(probe: BgTaskStatusProbe): void
215
225
  * 首周期若确已送达,由 watcher 的收摊臂在下一拍摘除(见 `tickWatchInner` bg 半场),
216
226
  * 代价是一次 no-op 遍历,而不是一整个完成周期的结构性缺席。
217
227
  *
218
- * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释。
228
+ * 🔴 [2393] F-1:`seq` 缺席时这里仍然**按首周期解释,不在登记口猜周期号** —— 猜出来的周期号会
229
+ * 把「已终局任务的回执重放」也升成新周期,方向相反地造出重复通知。周期号只在 watcher 那侧、
230
+ * 拿到 probe 的「它又在跑」这个证据之后才前进(见 `tickWatchInner` 的收摊臂三档)。
231
+ *
232
+ * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释,复活周期由 watcher 补。
219
233
  */
220
234
  export declare function registerOutstandingBgTask(taskId: string, description: string, prompt?: string, seq?: number): void;
221
235
  export declare function isOwnWorkflowRun(runId: string): boolean;
@@ -255,6 +255,22 @@ export function _resetNotificationQueuePortForTest() {
255
255
  // (the `<tool-use-id>` line is optional in CC's own template the same way).
256
256
  // ══════════════════════════════════════════════════════════════════════════════════════════════
257
257
  const notifiedRunIds = new Set();
258
+ const notifiedBgCycleHigh = new Map();
259
+ let ledgerOrder = 0;
260
+ const nextLedgerOrder = () => ++ledgerOrder;
261
+ /** 「已送达」两账的**唯一**写口(裸 id 维 + 周期维必须同时前进,否则收摊臂又回到没有周期证据)。 */
262
+ function markRunNotified(runId, cycle = BG_FIRST_SEQ) {
263
+ notifiedRunIds.add(runId);
264
+ const prev = notifiedBgCycleHigh.get(runId);
265
+ if (prev === undefined || cycle > prev.cycle) {
266
+ notifiedBgCycleHigh.set(runId, { cycle, order: nextLedgerOrder() });
267
+ }
268
+ }
269
+ /** 「**这一个周期**已经送达过」——收摊臂唯一可用的删除证据。 */
270
+ function notifiedBgCycleAtLeast(taskId, cycle) {
271
+ const high = notifiedBgCycleHigh.get(taskId);
272
+ return high !== undefined && high.cycle >= cycle ? high : undefined;
273
+ }
258
274
  /**
259
275
  * B1(frame-lane-matrix 三节定谳)— Path A 回声到达时丢弃同 run 的 pending 队列条目。
260
276
  * 双投机理:活跃 turn 内 Path B(workflow_complete/bg_notification/probe feeder →
@@ -314,7 +330,9 @@ export function dropQueuedNotificationsForRun(taskId) {
314
330
  * steer-inject 的 task_notification 帧)即预标记,Channel A 对同 runId 的补发直接丢弃。
315
331
  */
316
332
  export function markEngineWorkflowNotified(runId) {
317
- notifiedRunIds.add(runId);
333
+ // [2393] F-1:外部 mark 只证「首周期已送达」(它没有周期号可带),周期维按首周期记 —— 与本函数
334
+ // 既有的跨通道语义逐字一致(enqueueBgChildNotification 的跨通道臂同样只对首周期成立)。
335
+ markRunNotified(runId);
318
336
  if (outstandingRuns.delete(runId))
319
337
  notifyOutstanding();
320
338
  }
@@ -414,8 +432,10 @@ export function outstandingAbandonedCount() {
414
432
  let bgDedupDropped = 0;
415
433
  let bgCrossChannelDropped = 0;
416
434
  let stuckTickResets = 0;
435
+ let bgWatchRevivalPromoted = 0;
436
+ let bgWatchCollectedUnprobed = 0;
417
437
  export function notificationDropCounters() {
418
- return { bgDedupDropped, bgCrossChannelDropped, stuckTickResets };
438
+ return { bgDedupDropped, bgCrossChannelDropped, stuckTickResets, bgWatchRevivalPromoted, bgWatchCollectedUnprobed };
419
439
  }
420
440
  let statusProbe = null;
421
441
  let watchTimer = null;
@@ -462,7 +482,11 @@ export function installBgTaskStatusProbe(probe) {
462
482
  * 首周期若确已送达,由 watcher 的收摊臂在下一拍摘除(见 `tickWatchInner` bg 半场),
463
483
  * 代价是一次 no-op 遍历,而不是一整个完成周期的结构性缺席。
464
484
  *
465
- * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释。
485
+ * 🔴 [2393] F-1:`seq` 缺席时这里仍然**按首周期解释,不在登记口猜周期号** —— 猜出来的周期号会
486
+ * 把「已终局任务的回执重放」也升成新周期,方向相反地造出重复通知。周期号只在 watcher 那侧、
487
+ * 拿到 probe 的「它又在跑」这个证据之后才前进(见 `tickWatchInner` 的收摊臂三档)。
488
+ *
489
+ * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释,复活周期由 watcher 补。
466
490
  */
467
491
  export function registerOutstandingBgTask(taskId, description, prompt, seq) {
468
492
  if (!taskId)
@@ -475,7 +499,13 @@ export function registerOutstandingBgTask(taskId, description, prompt, seq) {
475
499
  const key = bgOutstandingKey(taskId, cycle);
476
500
  if (outstandingBgTasks.has(key))
477
501
  return;
478
- outstandingBgTasks.set(key, { taskId, seq: cycle, registeredAt: Date.now(), description });
502
+ outstandingBgTasks.set(key, {
503
+ taskId,
504
+ seq: cycle,
505
+ registeredAt: Date.now(),
506
+ registeredOrder: nextLedgerOrder(),
507
+ description,
508
+ });
479
509
  ensureWatchTimer();
480
510
  }
481
511
  /** 本壳亲手启动过的 workflow run(process-lifetime,只增不摘——outstandingRuns 会随完成摘除,
@@ -630,10 +660,55 @@ async function tickWatchInner() {
630
660
  // probe 未装=mock/离线,只等推送补发)
631
661
  const bgProbe = bgStatusProbe;
632
662
  for (const [key, meta] of [...outstandingBgTasks]) {
633
- // 收摊臂**只对首周期成立**:notifiedRunIds 是裸 taskId 键空间,它只证明「首周期已送达」,
634
- // seq≥2 的新完成周期毫无判别力( enqueueBgChildNotification 的跨通道臂同一前提)。
635
- if (meta.seq <= BG_FIRST_SEQ && notifiedRunIds.has(meta.taskId)) {
636
- outstandingBgTasks.delete(key); // 推送/别的通道已送达 收摊
663
+ // 🔴 [2393] F-1:收摊臂的删除必须**挣得**。此前的判据是裸 taskId 黑名单
664
+ // (`meta.seq <= BG_FIRST_SEQ && notifiedRunIds.has(meta.taskId)`),它只证明「这个 id 曾经
665
+ // 送达过某次完成」;而 `structured.seq` 缺席时复活周期的 cycle 塌回首周期,于是这条臂拿
666
+ // **首周期的送达记录**把**第二周期**的观察条目在下一拍秒删,probe 一次不跑 —— 与 notif-02
667
+ // 修前的静默丢通知逐字相同,且删在 enqueue 之前 ⇒ 三个既有计数器一格都摸不到。
668
+ // 新判据分三档,每一档都拿得出理由:
669
+ // ① 这一周期确已送达(周期键台账)⇒ 才谈得上收摊;
670
+ // ①a 条目**登记在那次送达之前** ⇒ 它就是那次送达自己的观察条目,无歧义,零成本收摊
671
+ // (= 旧行为的正当那一半,常态路径,不多花一次探测);
672
+ // ①b 条目**登记在送达之后** ⇒ 有歧义:复活周期?还是已终局任务的回执重放?靠 probe 定夺:
673
+ // ② probe 说它**真终局** ⇒ 就是那个已送达的周期,收摊(重放回执落这里,不会二次喂模型);
674
+ // ③ probe 说它**又在跑** ⇒ 这按构造是一个**新的完成周期**(裸 id 黑名单对它零判别力),
675
+ // 把条目升到下一个周期号继续观察,并留痕。probe 未装 ⇒ 没有可挣得的证据,沿用旧的盲摘
676
+ // 但记账;probe 失败/答不上来 ⇒ 不删也不升(TTL 兜底),绝不拿探测失败当终局证据。
677
+ const delivered = notifiedBgCycleAtLeast(meta.taskId, meta.seq);
678
+ if (delivered !== undefined) {
679
+ if (meta.registeredOrder < delivered.order) {
680
+ outstandingBgTasks.delete(key); // ①a 推送/别的通道已送达本条目观察的那个周期 — 收摊
681
+ continue;
682
+ }
683
+ if (!bgProbe) {
684
+ outstandingBgTasks.delete(key);
685
+ bgWatchCollectedUnprobed++;
686
+ continue;
687
+ }
688
+ let alive;
689
+ try {
690
+ const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
691
+ if (res === null || res === undefined)
692
+ continue; // 答不上来 ≠ 终局,留给下一拍/TTL
693
+ alive = !res.terminal;
694
+ }
695
+ catch {
696
+ continue; // 探测失败(含 ProbeDeadlineError)不构成删除证据
697
+ }
698
+ if (!alive) {
699
+ outstandingBgTasks.delete(key); // 真终局 = 已送达的那个周期,收摊
700
+ continue;
701
+ }
702
+ const promoted = Math.max(meta.seq, delivered.cycle) + 1;
703
+ outstandingBgTasks.delete(key);
704
+ const promotedKey = bgOutstandingKey(meta.taskId, promoted);
705
+ if (!outstandingBgTasks.has(promotedKey)) {
706
+ outstandingBgTasks.set(promotedKey, { ...meta, seq: promoted, registeredOrder: nextLedgerOrder() });
707
+ }
708
+ bgWatchRevivalPromoted++;
709
+ traceNotif(`bg task ${meta.taskId} is running again after cycle ${meta.seq} was already delivered — ` +
710
+ `promoting the watch entry to cycle ${promoted} (wire carried no seq; without this the entry ` +
711
+ 'would be collected by the bare-taskId ledger and this completion would never reach the user)');
637
712
  continue;
638
713
  }
639
714
  if (!bgProbe)
@@ -709,7 +784,8 @@ export function enqueueBgChildNotification(n) {
709
784
  return;
710
785
  }
711
786
  bgNotifiedKeys.add(key);
712
- notifiedRunIds.add(n.taskId);
787
+ // [2393] F-1:周期维必须跟着前进 —— 收摊臂删条目的唯一证据就是这一格。
788
+ markRunNotified(n.taskId, cycle);
713
789
  cardEnqueuedRunIds.add(n.taskId);
714
790
  // #6 通知-settle 边(合成半场):bg 子代行不再被 turn sweep 假结(session 常驻台账),真终态
715
791
  // 唯二来源 = 推送帧(bridge task_notification 臂)与本合成链(probe/fleet bg_notification 收敛点)。
@@ -738,7 +814,8 @@ export function enqueueBgChildNotification(n) {
738
814
  export function enqueueEngineWorkflowNotification(c) {
739
815
  if (notifiedRunIds.has(c.runId))
740
816
  return;
741
- notifiedRunIds.add(c.runId);
817
+ // [2393] F-1:workflow 侧没有周期概念,周期维按首周期记(与 markEngineWorkflowNotified 同理)
818
+ markRunNotified(c.runId);
742
819
  cardEnqueuedRunIds.add(c.runId);
743
820
  const message = `<${TASK_NOTIFICATION_TAG}>
744
821
  <${TASK_ID_TAG}>${escapeXml(c.runId)}</${TASK_ID_TAG}>
@@ -754,6 +831,7 @@ export function enqueueEngineWorkflowNotification(c) {
754
831
  * 🔴 生产绝不调用 —— 台账是 process-lifetime 去重的唯一凭据,清了就会双投。 */
755
832
  export function _resetEngineTaskNotificationForTest() {
756
833
  notifiedRunIds.clear();
834
+ notifiedBgCycleHigh.clear();
757
835
  cardEnqueuedRunIds.clear();
758
836
  outstandingRuns.clear();
759
837
  outstandingBgTasks.clear();
@@ -767,6 +845,8 @@ export function _resetEngineTaskNotificationForTest() {
767
845
  bgDedupDropped = 0;
768
846
  bgCrossChannelDropped = 0;
769
847
  stuckTickResets = 0;
848
+ bgWatchRevivalPromoted = 0;
849
+ bgWatchCollectedUnprobed = 0;
770
850
  probeTimeoutOverrideMs = null;
771
851
  stuckTickResetOverrideMs = null;
772
852
  if (watchTimer !== null) {
@@ -36,11 +36,19 @@
36
36
  import type { SDKMessage } from '@sema-agent/agent-types';
37
37
  /**
38
38
  * Flatten the §E1 wire `output`(NON-UNIFORM: `string` | `(TextContent|ImageContent)[]`)into one
39
- * plain-text blob(upstreamBridge.flattenWireOutput 同款语义的轻量副本——避免把重量级 REPL
40
- * 拖进 `-p` 车道的 import 图)。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
39
+ * plain-text blob。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
40
+ *
41
+ * ⇄ REF-CC-008 / dup-05 兑现(ADAPT-F1,2026-08-02):此处曾是 `flattenWireOutput` 的**逐字节副本**,
42
+ * 豁免理由写着「避免把重量级 REPL 桥拖进 `-p` 车道的 import 图」。A 族拆分把该函数从 1607 行的
43
+ * `adapt.ts` 搬进了 `adapt/wireShapes.ts`,而那个文件**唯一的 import 是 `import type {Frame}`**
44
+ * (type-only,编译后整段消失)—— 正是台账要的那片「零 import 叶」,拆分创造了退役条件。
45
+ * 副本随之删除,`flattenToolOutput` 收成对唯一实现的**具名再导出**(公面名不动:它在
46
+ * public-export-baseline 里,改名会当场红)。
41
47
  */
42
- export declare function flattenToolOutput(output: unknown): string;
43
- /** 内部 tool_end_result arm(eventToSdkMessage.ts:213-231 铸造)的防御性读形。 */
48
+ export { flattenWireOutput as flattenToolOutput } from './adapt/wireShapes.js';
49
+ /** 内部 tool_end_result arm(`eventToSdkMessage.ts` 的 `case 'tool_end'` 臂铸造)的防御性读形。
50
+ * ⚠️ ADAPTER-F8 注纠(2026-08-02):原文锚的是裸行号 `213-231`,A 族拆分后那段是 `case 'text'` /
51
+ * `case 'reasoning'`,真正的 tool_end 臂已挪位。锚换成符号名(REF-CC-063 同款,腐烂不了)。 */
44
52
  export interface ToolEndResultArmLike {
45
53
  type?: unknown;
46
54
  toolCallId?: unknown;
@@ -1,26 +1,16 @@
1
1
  /**
2
2
  * Flatten the §E1 wire `output`(NON-UNIFORM: `string` | `(TextContent|ImageContent)[]`)into one
3
- * plain-text blob(upstreamBridge.flattenWireOutput 同款语义的轻量副本——避免把重量级 REPL
4
- * 拖进 `-p` 车道的 import 图)。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
3
+ * plain-text blob。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
4
+ *
5
+ * ⇄ REF-CC-008 / dup-05 兑现(ADAPT-F1,2026-08-02):此处曾是 `flattenWireOutput` 的**逐字节副本**,
6
+ * 豁免理由写着「避免把重量级 REPL 桥拖进 `-p` 车道的 import 图」。A 族拆分把该函数从 1607 行的
7
+ * `adapt.ts` 搬进了 `adapt/wireShapes.ts`,而那个文件**唯一的 import 是 `import type {Frame}`**
8
+ * (type-only,编译后整段消失)—— 正是台账要的那片「零 import 叶」,拆分创造了退役条件。
9
+ * 副本随之删除,`flattenToolOutput` 收成对唯一实现的**具名再导出**(公面名不动:它在
10
+ * public-export-baseline 里,改名会当场红)。
5
11
  */
6
- export function flattenToolOutput(output) {
7
- if (typeof output === 'string')
8
- return output;
9
- if (Array.isArray(output)) {
10
- const parts = [];
11
- for (const block of output) {
12
- if (block && typeof block === 'object') {
13
- const b = block;
14
- if (b.type === 'text' && typeof b.text === 'string')
15
- parts.push(b.text);
16
- else if (b.type === 'image')
17
- parts.push('[image]');
18
- }
19
- }
20
- return parts.join('');
21
- }
22
- return '';
23
- }
12
+ export { flattenWireOutput as flattenToolOutput } from './adapt/wireShapes.js';
13
+ import { flattenWireOutput } from './adapt/wireShapes.js';
24
14
  /**
25
15
  * Bash 臂的 is_error 派生(真行为实证,2026-07-16):引擎 tool_end.isError 语义 = 「工具本身
26
16
  * 是否执行失败」——命令非零退出时工具照常返回模型面框架文本(core runShell `exit code: N\n---
@@ -50,7 +40,7 @@ function bashExitCodeFailed(toolName, text, structured) {
50
40
  export function toolEndResultToUserFrame(arm) {
51
41
  if (typeof arm.toolCallId !== 'string' || arm.toolCallId.length === 0)
52
42
  return null;
53
- const text = flattenToolOutput(arm.output);
43
+ const text = flattenWireOutput(arm.output);
54
44
  return {
55
45
  type: 'user',
56
46
  message: {
@@ -10,9 +10,20 @@
10
10
  *
11
11
  * 泛型约束到定时器句柄型(`setTimeout`/`setInterval` 的回传值):本包 `"types": []` + 默认 DOM
12
12
  * lib 下两者都是 `number`,Node 运行时实际回传 `Timeout` 对象——真身与编译期类型不一致正是
13
- * `unref?.()` 需要可选调用的原因。约束此前是无约束 `<T>`,`unrefTimer(42)` / `unrefTimer(aPromise)`
14
- * 这类明显打错调用点的用法会静默通过;收窄后这些误用编译期就报,而五个既有调用点(均传
15
- * `setTimeout`/`setInterval` 的返回值)不受影响。
13
+ * `unref?.()` 需要可选调用的原因。约束此前是无约束 `<T>`,任何东西都能传进来。
14
+ *
15
+ * 🔴 这条约束**挡得住哪一半、挡不住哪一半**(实测,不是承诺 —— [2393] F-3 纠正:此前这里写的是
16
+ * 「`unrefTimer(42)` / `unrefTimer(aPromise)` 收窄后编译期就报」,而上一段自己写明的前提
17
+ * 「本包下句柄型就是 `number`」直接推翻了它的前半句):
18
+ * · `unrefTimer('x')` / `unrefTimer(aPromise)` —— **编译期红**(TS2345,非 number 挡得住);
19
+ * · `unrefTimer(42)` —— **编译期绿**。`42` 与真句柄在本包的类型系统里不可分辨,挡不住;
20
+ * 运行时也无害(可选调用在 number 上是 no-op),但别指望编译器替你抓这一类打错。
21
+ * 这四条判决由 `scripts/run-client-core-typeshape-test.mjs` 的 D 腿每跑机械核一次:实测与
22
+ * 这段措辞一旦分叉即红。⚠️ 别为了兑现前半句去拧类型 —— 能挡住数字字面量的写法在**装了
23
+ * @types/node 的下游**会把句柄型解成 `Timeout` 对象,五个真调用点全线编译红(用一个假承诺
24
+ * 换一个真的可移植性回归)。
25
+ *
26
+ * 五个既有调用点(均传 `setTimeout`/`setInterval` 的返回值)不受约束影响。
16
27
  */
17
28
  type TimerLikeHandle = ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>;
18
29
  export declare function unrefTimer<T extends TimerLikeHandle>(t: T): T;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.13.0",
3
+ "version": "0.15.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. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",