@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,3 +1,53 @@
1
+ /**
2
+ * ⇄ B6 批搬迁(2026-07-27,设计稿 §3 B6 · B2 判给后批的**宿主耦合六件**之一):cli
3
+ * `src/sema/liveInitToolFace.ts` 整搬。B2 当时不搬的理由 = 它 `await import('./engineTarget.js')`
4
+ * 取凭证,而 engineTarget 是 fs 重图;B4 的 §8-2 收编把凭证解析定到了 `resolveWireAuth`
5
+ * (engineWireSdk,零依赖),那条理由随之消失。
6
+ *
7
+ * 🔴 搬迁三处差分(行为一字节不变):
8
+ * ① 凭证:`engineTarget.resolveLiveAuthToken(baseUrl)`(其 token 缺省 = `process.env.SEMA_LIVE_TOKEN`)
9
+ * → `resolveWireAuth(baseUrl, env.SEMA_LIVE_TOKEN)`。两者是同一套 #118 三态,`resolveWireAuth`
10
+ * 的头注自述就是它的收编口。取值面完全一致:三态里只有**真 token 串**与 `'anon'` 是 string,
11
+ * `{mode:'loopback-unauthed'}` 是对象 ⇒ 原文那句 `typeof t === 'string' ? t : undefined` 语义照搬。
12
+ * ② 三个 `await import('./xxxWire.js')` fail-soft 动态 import → **静态 import**(scenarioWire /
13
+ * interactiveToolsWire / webSearchWireCaps 三件 B5 都已在本包内)。原文那三层 try/catch 的
14
+ * 目的是「模块缺席也别挡首帧」,包内它们不可能缺席;**try/catch 仍逐条保留**——它护的另一半是
15
+ * 「函数自己抛」,那半在包内照样成立。
16
+ * ③ `process.env` → 入参 `EnvLike`(签名本来就显式收 env;缺省值不存在,调用方必传)。
17
+ *
18
+ * 🔴 模块级状态:`cached`(baseUrl+scenario+旋钮 键控的一次性探测缓存)。写读口都在本文件;
19
+ * 两份实例只是多探一次(不是静默失效),但仍登记进单实例清单。
20
+ *
21
+ * ── 以下为原文件的领域说明(逐字保留)────────────────────────────────────────────────────────
22
+ *
23
+ * liveInitToolFace — 件1(core [931] 壳半场):headless `-p` live 车道 `system/init` 首帧的
24
+ * `tools` 自报面,从「壳静态工具表」改为「引擎实挂 wire 词表」生成。
25
+ *
26
+ * 根因(core [931]/CC209 裁决):壳自报静态表(TodoWrite 在、TaskCreate 族不在)而引擎 wire 实挂
27
+ * TaskCreate 族(core 1.296+ 默认)且不挂 TodoWrite ⇒ SDK/自动化消费者照自报面配
28
+ * `--disallowedTools TodoWrite` 之类静默无效(fail-open)。core 1.300 已在引擎侧加名单池审计
29
+ * advisory(config.toolpolicy.unmatched_names)兜观测面;这里是壳半场 = 自报按实挂生成。
30
+ *
31
+ * 真源层级(gap-check 2026-07-16 实测读数,server 1.214.0 / core 1.300.0):
32
+ * 1. server 无全量挂载探测面:GET /v1/capabilities 只有能力布尔;
33
+ * GET /v1/capabilities/scenarios/:name 只含【场景附加】工具(default/code →
34
+ * Now/WebFetch/Agent/TaskCreate 族七具),首方 band(Bash/Read/…)由 core prepare-task
35
+ * 另挂、不进该面 ⇒ 整表无法远端取。
36
+ * 2. 故缺省场景用壳已知 wire 词表(下表):观测 tap 记录壳 `-p` live 车道 LLM 请求体
37
+ * tools[].name(REMOTE_EXEC=host 生产姿势)实测定稿,再按壳自己 stamp 的请求旋钮修正
38
+ * (interactiveTools≠false → AskUserQuestion;settings.webSearch 在 → WebSearch)。
39
+ * 3. per-scenario 差异如实标注:非缺省 scenario(--scenario/SEMA_HEADLESS_SCENARIO)的工具面
40
+ * 由部署定义(scan/oa = repo-readonly 面,首方 band 是否在位壳无法确证)⇒ 尽力探场景详情面
41
+ * GET /v1/capabilities/scenarios/:name 报【场景附加】工具 + runner 恒挂段,并经
42
+ * SEMA_DEBUG 如实标注是探测面而非整表;探测失败回退缺省词表(fail-soft,绝不阻塞首帧)。
43
+ * 4. mock/离线车道(无 SEMA_LIVE_BASEURL)返回 null,调用方保持现状静态表 —— 宪法三问的
44
+ * 补偿轴:离线态回退静态表,行为字节不变。
45
+ *
46
+ * 已知局限(如实):词表按 pin 的 server/core 版本定稿,外接旧引擎或无执行底座的 worker
47
+ * (REMOTE_EXEC 缺省的裸 worker 不挂 hands band)会有偏差 —— 但相比旧静态表(TodoWrite 幻影 +
48
+ * TaskCreate 族缺席)已是严格更准的自报;整表探测面等引擎侧补(见 [931] 报告)。
49
+ */
50
+ import { type EngineProbeOpts } from './engineWireSdk.js';
1
51
  import type { EnvLike } from './hostEnv.js';
2
52
  /** hands band(本地/远程执行底座在位时 prepare-task 挂载;壳自 spawn 恒 REMOTE_EXEC=host ⇒ 在位)。 */
3
53
  export declare const ENGINE_HANDS_BAND: readonly string[];
@@ -30,12 +80,7 @@ export declare function _resetLiveInitToolFaceForTest(): void;
30
80
  * maxRetries=0 单发同原语义);ScenarioDetail 类型面之外仍保留逐字段运行时校验(引擎回什么不轻信)。
31
81
  * 差分:SDK Transport 恒 stamp x-agent-principal(缺省 'anon:shell-live')——原手抄在无 principal
32
82
  * 时省略该头;principal-first 宪法姿势,对 requirePrincipal 部署是 fail-open 改善。 */
33
- export declare function probeScenarioTools(baseUrl: string, scenario: string, opts?: {
34
- fetchImpl?: typeof fetch;
35
- timeoutMs?: number;
36
- authToken?: string;
37
- principal?: string;
38
- }): Promise<{
83
+ export declare function probeScenarioTools(baseUrl: string, scenario: string, opts?: EngineProbeOpts): Promise<{
39
84
  tools: string[];
40
85
  toolset?: string;
41
86
  } | null>;
@@ -1,3 +1,4 @@
1
+ import type { AskAnswer } from './hitl/hitlBridge.js';
1
2
  /**
2
3
  * src/sema/liveQuestionStore.ts — the SIDE-CHANNEL for §4④ live-stream AskUserQuestion (the conversation-seam
3
4
  * twin of liveSessionStore.ts).
@@ -54,13 +55,11 @@ export interface QuestionFrame {
54
55
  }
55
56
  /** The answer the overlay sends back — keyed by HEADER; `selected` echoes exact option labels
56
57
  * (`selected ⊆ options`, core's fence); off-list / free-text input rides `note` (untrusted-fenced).
57
- * Entry shape === hitlBridge's AskAnswer (the T23 ApprovalDecision.answer leg consumes it as-is). */
58
+ * REF-CC-038(2026-08-02):entry 直接是 hitlBridge 的 `AskAnswer`(唯一源),不再各写一份 ——
59
+ * 之前这里是一份内联匿名形的独立声明,只靠注释断言与 hitlBridge 那份「形状相同」,
60
+ * 下游(`askGateWire.ts`)因此要 `as AskAnswer[]` 兜齐两个名不同的等价形状。 */
58
61
  export interface QuestionAnswer {
59
- answers: Array<{
60
- header: string;
61
- selected: string[];
62
- note?: string;
63
- }>;
62
+ answers: AskAnswer[];
64
63
  }
65
64
  type QuestionFrameHandler = (frame: QuestionFrame) => void;
66
65
  type RespondFn = (id: string, answer: QuestionAnswer, opts?: {
@@ -85,7 +85,17 @@ export function inferFamily(modelId) {
85
85
  const ctx = capSuffix[2] === 'm' ? n * 1000000 : n * 1024;
86
86
  const base = inferFamily(raw.replace(/\[[0-9]+[km]\]$/, ''));
87
87
  // perModelCap 透传:后缀只声明 ctx,不改该模型的确证输出 cap(deepseek-chat[1m] 仍 cap 8192)
88
- return { id: base?.id ?? 'cap-suffix', name: base?.name ?? 'capacity suffix', match: '', contextWindow: ctx, maxTokens: base?.maxTokens ?? 64000, vision: base?.vision, perModelCap: base?.perModelCap };
88
+ // eopt:vision/perModelCap 是「缺省=未知不 stamp」的条件展开位(与 taskId/status 那种「键恒在」
89
+ // 公面契约不同义——那两位没有「已确认为未知」这个第三态,这两位有),诚实透传 base 的键在场与否。
90
+ return {
91
+ id: base?.id ?? 'cap-suffix',
92
+ name: base?.name ?? 'capacity suffix',
93
+ match: '',
94
+ contextWindow: ctx,
95
+ maxTokens: base?.maxTokens ?? 64000,
96
+ ...(base?.vision !== undefined ? { vision: base.vision } : {}),
97
+ ...(base?.perModelCap !== undefined ? { perModelCap: base.perModelCap } : {}),
98
+ };
89
99
  }
90
100
  const id = normalizeId(raw);
91
101
  // ① preset 大表精确(含剥变体尾巴后再试)——精确命中才带 perModelCap(F2 裁决封顶① 证据位;
@@ -176,6 +176,27 @@ export declare function outstandingWorkflowCount(): number;
176
176
  * 必然送达或 TTL 放弃(2h 上界=run 蒸发极端形,与 CC 等本地 bg 任务的无界形同族)。 */
177
177
  export declare function outstandingDeliverableWorkflowCount(): number;
178
178
  export declare function subscribeOutstandingWorkflows(listener: () => void): () => void;
179
+ /**
180
+ * 🔴 已**停止等待**的 outstanding 条目数(workflow + bg 合计,process-lifetime 只增)。
181
+ * 消费方(footer / headless 退出前的收尾行)据此把「N 个后台任务已停止等待」与「都送达了」
182
+ * 分开渲——H1:排队与放弃对消费方必须可分辨。additive:既有计数口的语义一字节不变。
183
+ */
184
+ export declare function outstandingAbandonedCount(): number;
185
+ /**
186
+ * 观测口:被**静默 return 掉**的投递次数(notif-02)。去重与丢失在行为上都是「不入队」,
187
+ * 不记账就在观测上等价 —— 分成两个键是因为它们的正当性完全不同:
188
+ * · `bgDedupDropped` = 同 (taskId,seq,status) 键重复,**正当**(at-least-once 双投自免),恒可 >0;
189
+ * · `bgCrossChannelDropped` = 首周期已由别的通道送达而被压制,**正当但需可查**(seq 透传一旦
190
+ * 回归,复活周期会伪装成首周期落进这一格 —— 这个数异常增长就是 seq 又丢了的信号);
191
+ * · `stuckTickResets` = 在飞 tick 超过 STUCK_TICK_RESET_MS 被强制复位的次数,**恒应为 0**;
192
+ * >0 = 有 probe 连 PROBE_TIMEOUT_MS 都没能截断它。
193
+ */
194
+ export interface NotificationDropCounters {
195
+ bgDedupDropped: number;
196
+ bgCrossChannelDropped: number;
197
+ stuckTickResets: number;
198
+ }
199
+ export declare function notificationDropCounters(): NotificationDropCounters;
179
200
  /** liveClient 在构造会话 client 时注册(带正确 baseUrl/token/principal 的 workflows.get)。 */
180
201
  export declare function installWorkflowStatusProbe(probe: WorkflowStatusProbe): void;
181
202
  type BgTaskStatusProbe = (taskId: string) => Promise<{
@@ -185,8 +206,18 @@ type BgTaskStatusProbe = (taskId: string) => Promise<{
185
206
  /** 委派 prompt 取件口(行消费端建行/详情页兜底用)。未登记 ⇒ undefined(诚实缺席)。 */
186
207
  export declare function outstandingBgTaskPrompt(taskId: string): string | undefined;
187
208
  export declare function installBgTaskStatusProbe(probe: BgTaskStatusProbe): void;
188
- /** bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。幂等;已通知不再登记。 */
189
- export declare function registerOutstandingBgTask(taskId: string, description: string, prompt?: string): void;
209
+ /**
210
+ * bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。按 (taskId,seq) 幂等。
211
+ *
212
+ * 🔴 notif-02(两账分离):登记闸**不再查 `notifiedRunIds`**。那个集合是「模型通知去重」账
213
+ * (哪次完成已经喂给模型了),不是「是否还要观察」账;两账混用的后果是 seq≥2 的复活周期
214
+ * 永远登记不进来(`notifiedRunIds` 全文无 delete 站点,首周期一通知就是终身黑名单)。
215
+ * 首周期若确已送达,由 watcher 的收摊臂在下一拍摘除(见 `tickWatchInner` bg 半场),
216
+ * 代价是一次 no-op 遍历,而不是一整个完成周期的结构性缺席。
217
+ *
218
+ * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释。
219
+ */
220
+ export declare function registerOutstandingBgTask(taskId: string, description: string, prompt?: string, seq?: number): void;
190
221
  export declare function isOwnWorkflowRun(runId: string): boolean;
191
222
  /** 本壳亲手启动过的 workflow run 列表(Set 插入序 = 启动序;/workflows 命令的目标 run 选择用,
192
223
  * cmd-workflows.tsx——fleet source 无行可选时的兜底 id 源)。 */
@@ -211,6 +242,21 @@ export declare function enqueueEngineWorkflowNotification(c: EngineWorkflowCompl
211
242
  /** 测试钩(B2 新增,壳侧原文件没有):清空全部通知台账 + probe 槽 + watcher。
212
243
  * 🔴 生产绝不调用 —— 台账是 process-lifetime 去重的唯一凭据,清了就会双投。 */
213
244
  export declare function _resetEngineTaskNotificationForTest(): void;
245
+ /**
246
+ * 测试钩:**驱动一拍** watcher(生产由 setInterval 驱动,外部无入口 ⇒ 护栏/TTL/deadline
247
+ * 三条链在门里全不可达,notif-01/-02/-03 才会一路活到今天)。
248
+ * @param nowMs TTL 判据的时钟注入(WATCH_TTL_MS 是 2h,不注入时钟就只能真等两小时)。
249
+ * 🔴 生产绝不调用。
250
+ */
251
+ export declare function _tickWatchOnceForTest(nowMs?: number): Promise<void>;
252
+ /**
253
+ * 测试钩:覆写 watcher 时序常量(默认是 10s/60s 量级,门里等不起)。传 null 恢复生产值。
254
+ * 🔴 生产绝不调用;覆写只影响 deadline 与强制复位门,不动 WATCH_INTERVAL_MS/WATCH_TTL_MS。
255
+ */
256
+ export declare function _setWatcherTimingForTest(o: {
257
+ probeTimeoutMs?: number;
258
+ stuckTickResetMs?: number;
259
+ } | null): void;
214
260
  /** 该 objective 是否为 task-notification 自动提交 turn 的注入 XML(整体即通知,非提及)。 */
215
261
  export declare function isTaskNotificationObjective(objective: unknown): boolean;
216
262
  /**
@@ -5,6 +5,7 @@
5
5
  * taskNotificationErrorSupplement 全文、hookNoticeStore 的判定半场 —— 见文件下半的 B2 分节。
6
6
  */
7
7
  import { hostEnv } from './hostEnv.js';
8
+ import { unrefTimer } from './unrefTimer.js';
8
9
  import { clearEnginePanelTaskResident, publishEngineAgentPanelEvent, } from './engineAgentPanelStore.js';
9
10
  function escapeXml(v) {
10
11
  return v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -203,6 +204,17 @@ export function installNotificationQueuePort(port) {
203
204
  export function notificationQueuePortMisses() {
204
205
  return queuePortMisses;
205
206
  }
207
+ /**
208
+ * 本模块唯一的 SEMA_DEBUG 留痕出口(前缀逐字保持 `[sema][notif] `,与既有 drop 那行同形)。
209
+ * 🔴 为什么要单口:C5「静默丢数据零容忍」的执行方式是**每条丢弃/放弃路径都能在同一个前缀下
210
+ * grep 到**;散写 console.error 时新增的丢弃路径很容易漏掉留痕(notif-02/-03 就是这么漏的)。
211
+ */
212
+ function traceNotif(line) {
213
+ if (!hostEnv().SEMA_DEBUG)
214
+ return;
215
+ // eslint-disable-next-line no-console
216
+ console.error(`[sema][notif] ${line}`);
217
+ }
206
218
  function port() {
207
219
  if (queuePort)
208
220
  return queuePort;
@@ -290,10 +302,7 @@ export function dropQueuedNotificationsForRun(taskId) {
290
302
  // 注入,引擎侧单投成立;这里只救 UI 半场。
291
303
  if (dropped.length > 0) {
292
304
  cardEnqueuedRunIds.delete(taskId);
293
- if (hostEnv().SEMA_DEBUG) {
294
- // eslint-disable-next-line no-console
295
- console.error(`[sema][notif] dropped ${dropped.length} queued task-notification(s) for ${taskId} — engine echo already delivered (B1); card ownership returned to the frame lane`);
296
- }
305
+ traceNotif(`dropped ${dropped.length} queued task-notification(s) for ${taskId} — engine echo already delivered (B1); card ownership returned to the frame lane`);
297
306
  }
298
307
  return dropped.length;
299
308
  }
@@ -351,7 +360,9 @@ export function isWorkflowCompletionCardEnqueued(runId) {
351
360
  }
352
361
  const outstandingRuns = new Map(); // runId → registeredAt(ms)
353
362
  // ── D4(212 对拍):outstanding 计数订阅面——REPL 空闲态渲「✻ Waiting for N dynamic
354
- // workflow(s) to finish」(CC 2.1.212 同形,wf-ui-cc2 ground truth)。仅通知计数变化。
363
+ // workflow(s) to finish」(CC 2.1.212 同形,wf-ui-cc2 ground truth)。仅通知计数变化 ——
364
+ // notif-03 起「计数」含 `outstandingAbandonedCount()`,故 bg 半场的 TTL 放弃也发一次通知
365
+ // (那一格变了,消费方要重渲「N 个后台任务已停止等待」)。
355
366
  const outstandingListeners = new Set();
356
367
  function notifyOutstanding() {
357
368
  for (const l of [...outstandingListeners]) {
@@ -378,6 +389,34 @@ export function subscribeOutstandingWorkflows(listener) {
378
389
  outstandingListeners.delete(listener);
379
390
  };
380
391
  }
392
+ let abandonedCount = 0;
393
+ /**
394
+ * 🔴 放弃观察的**唯一出口**。`id` 是各自台账的键(bg 侧 = `taskId:seq` 复合键,留痕里可直读)。
395
+ * 未命中(键已不在台账)⇒ 不计数、不留痕:放弃必须是**真发生过**的事,别把「本来就没有」算成放弃。
396
+ */
397
+ function abandonOutstanding(kind, id, reason) {
398
+ const removed = kind === 'bg-task' ? outstandingBgTasks.delete(id) : outstandingRuns.delete(id);
399
+ if (!removed)
400
+ return;
401
+ abandonedCount++;
402
+ traceNotif(`abandoned ${kind} ${id} — reason=${reason}, waited > WATCH_TTL_MS(${WATCH_TTL_MS}ms); ` +
403
+ 'no completion notification will ever be delivered for it');
404
+ notifyOutstanding();
405
+ }
406
+ /**
407
+ * 🔴 已**停止等待**的 outstanding 条目数(workflow + bg 合计,process-lifetime 只增)。
408
+ * 消费方(footer / headless 退出前的收尾行)据此把「N 个后台任务已停止等待」与「都送达了」
409
+ * 分开渲——H1:排队与放弃对消费方必须可分辨。additive:既有计数口的语义一字节不变。
410
+ */
411
+ export function outstandingAbandonedCount() {
412
+ return abandonedCount;
413
+ }
414
+ let bgDedupDropped = 0;
415
+ let bgCrossChannelDropped = 0;
416
+ let stuckTickResets = 0;
417
+ export function notificationDropCounters() {
418
+ return { bgDedupDropped, bgCrossChannelDropped, stuckTickResets };
419
+ }
381
420
  let statusProbe = null;
382
421
  let watchTimer = null;
383
422
  const WATCH_INTERVAL_MS = 5000;
@@ -386,6 +425,16 @@ const WATCH_TTL_MS = 2 * 60 * 60 * 1000; // 2h 兜底放弃(run 蒸发/owner 不
386
425
  export function installWorkflowStatusProbe(probe) {
387
426
  statusProbe = probe;
388
427
  }
428
+ /** bg 子代生命周期号的首周期值(SendMessage 复活即 +1;wire 缺 seq ⇒ 按首周期解释)。 */
429
+ const BG_FIRST_SEQ = 1;
430
+ /**
431
+ * 🔴 notif-02:观察台账的键是 **(taskId, seq) 复合键**,不是裸 taskId。
432
+ * 裸 taskId 时 seq≥2 的复活周期与首周期同键 ⇒ 复活周期结构性进不了台账,而 watcher 存在的
433
+ * 全部理由就是「bg 完成通知 idle 期没有到达通道」—— 复活周期因此退回那条已判定不可靠的推送通道。
434
+ */
435
+ function bgOutstandingKey(taskId, seq) {
436
+ return `${taskId}:${seq}`;
437
+ }
389
438
  const outstandingBgTasks = new Map();
390
439
  let bgStatusProbe = null;
391
440
  /**
@@ -404,17 +453,29 @@ export function outstandingBgTaskPrompt(taskId) {
404
453
  export function installBgTaskStatusProbe(probe) {
405
454
  bgStatusProbe = probe;
406
455
  }
407
- /** bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。幂等;已通知不再登记。 */
408
- export function registerOutstandingBgTask(taskId, description, prompt) {
456
+ /**
457
+ * bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。按 (taskId,seq) 幂等。
458
+ *
459
+ * 🔴 notif-02(两账分离):登记闸**不再查 `notifiedRunIds`**。那个集合是「模型通知去重」账
460
+ * (哪次完成已经喂给模型了),不是「是否还要观察」账;两账混用的后果是 seq≥2 的复活周期
461
+ * 永远登记不进来(`notifiedRunIds` 全文无 delete 站点,首周期一通知就是终身黑名单)。
462
+ * 首周期若确已送达,由 watcher 的收摊臂在下一拍摘除(见 `tickWatchInner` bg 半场),
463
+ * 代价是一次 no-op 遍历,而不是一整个完成周期的结构性缺席。
464
+ *
465
+ * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释。
466
+ */
467
+ export function registerOutstandingBgTask(taskId, description, prompt, seq) {
409
468
  if (!taskId)
410
469
  return;
411
470
  // prompt 台账先记(与 watcher 登记的幂等早退解耦:重复回执/已通知任务的 prompt 仍要可取)。
412
471
  if (typeof prompt === 'string' && prompt.length > 0 && !bgTaskPrompts.has(taskId)) {
413
472
  bgTaskPrompts.set(taskId, prompt);
414
473
  }
415
- if (notifiedRunIds.has(taskId) || outstandingBgTasks.has(taskId))
474
+ const cycle = typeof seq === 'number' && Number.isFinite(seq) && seq >= BG_FIRST_SEQ ? Math.floor(seq) : BG_FIRST_SEQ;
475
+ const key = bgOutstandingKey(taskId, cycle);
476
+ if (outstandingBgTasks.has(key))
416
477
  return;
417
- outstandingBgTasks.set(taskId, { registeredAt: Date.now(), description });
478
+ outstandingBgTasks.set(key, { taskId, seq: cycle, registeredAt: Date.now(), description });
418
479
  ensureWatchTimer();
419
480
  }
420
481
  /** 本壳亲手启动过的 workflow run(process-lifetime,只增不摘——outstandingRuns 会随完成摘除,
@@ -446,18 +507,99 @@ function ensureWatchTimer() {
446
507
  if (watchTimer !== null)
447
508
  return;
448
509
  watchTimer = setInterval(() => {
449
- void tickWatch();
510
+ // 定时器回调不能 await:tick 的失败必须在这里落地,否则变成 unhandledRejection 打死宿主进程。
511
+ tickWatch().catch(err => {
512
+ traceNotif(`watcher tick threw: ${err instanceof Error ? err.message : String(err)}`);
513
+ });
450
514
  }, WATCH_INTERVAL_MS);
451
- // 不阻止进程退出
452
- if (typeof watchTimer.unref === 'function') {
453
- ;
454
- watchTimer.unref?.();
515
+ // 不阻止进程退出(域词表-14:unrefTimer 单一实现)
516
+ unrefTimer(watchTimer);
517
+ }
518
+ // ── notif-01(D4 必有 settle 路径 + A5 界不得来自第三方默认值)────────────────────────────────
519
+ // 病形:重入护栏是**不带超时的单飞**,而 tickWatchInner 里两个 `await probe(...)` 没有任何
520
+ // deadline —— probe 由宿主注入,今天两处实装都只是 `await client.runs/workflows.get(...)`,
521
+ // 界完全外包给了宿主的 HTTP 栈。probe 的 promise 若不 settle:护栏永久为真 ⇒ 此后每拍早退 ⇒
522
+ // **住在 tick 里的 TTL 清扫也一并失效** ⇒ outstanding 永不清空 ⇒ watchTimer 永不停 ⇒
523
+ // `outstandingDeliverableWorkflowCount()` 恒 >0 ⇒ headless(-p)的退出门恒真、进程永不退出。
524
+ // 修法三件(缺一条链就还在):①每个 probe await 包 deadline;②护栏改时间戳 + 强制复位;
525
+ // ③TTL 清扫提到护栏**之前**,让「探测卡死」不连坐「超时放弃」。
526
+ /**
527
+ * 单次探测的截止。取值理由:节拍是 WATCH_INTERVAL_MS(5s),本值 = **2 个节拍**——
528
+ * · 下界:比一个节拍大,慢但活着的 probe(一次往返略超 5s)不会被切成「必失败」;
529
+ * · 上界:小整数倍,单个条目最坏只压住 2 拍,而不是把整条链交给宿主 HTTP 栈的默认值。
530
+ */
531
+ const PROBE_TIMEOUT_MS = 2 * WATCH_INTERVAL_MS;
532
+ /**
533
+ * 在飞 tick 的强制复位门。取值理由:一拍最坏耗时 ≈ 条目数 × PROBE_TIMEOUT_MS,故门必须显著
534
+ * 大于单条截止,否则条目一多就会误判卡死。6 倍截止 = 一拍里有 3 个条目全部吃满超时仍不算卡死。
535
+ * ⚠️ 复位**不能**中止那个已在飞的 tick(probe promise 不在我们手里),只是允许下一拍照常起 ——
536
+ * 代价是短时间内可能有两拍并发探测同一条目;相对「进程永不退出」这是明确划算的取舍,
537
+ * 且 `stuckTickResets` 恒应为 0,>0 说明有 probe 连 deadline 都截不住,是要查的账。
538
+ */
539
+ const STUCK_TICK_RESET_MS = 6 * PROBE_TIMEOUT_MS;
540
+ /** 测试可覆写的时序(仅测试钩写;生产恒 null ⇒ 用上面两个常量)。 */
541
+ let probeTimeoutOverrideMs = null;
542
+ let stuckTickResetOverrideMs = null;
543
+ const probeTimeoutMs = () => probeTimeoutOverrideMs ?? PROBE_TIMEOUT_MS;
544
+ const stuckTickResetMs = () => stuckTickResetOverrideMs ?? STUCK_TICK_RESET_MS;
545
+ /**
546
+ * probe 超时的**类型化**错误(错误判别一律 instanceof,禁按文案前缀判)。
547
+ * 不导出:它只在本模块的 catch 臂之间流动,宿主注入的 probe 永远看不到它 —— 公面每多一个名字
548
+ * 就是多一条对外承诺,没有消费者的错误类别不该上公面(要用时再导出并同批更新导出基线)。
549
+ */
550
+ class ProbeDeadlineError extends Error {
551
+ timeoutMs;
552
+ constructor(timeoutMs) {
553
+ super(`probe did not settle within ${timeoutMs}ms`);
554
+ this.name = 'ProbeDeadlineError';
555
+ this.timeoutMs = timeoutMs;
455
556
  }
456
557
  }
558
+ /**
559
+ * 给一个不受我们控制的 promise 加一道**本仓自己声明**的界(A5)。超时 ⇒ reject
560
+ * `ProbeDeadlineError`,落进调用方既有的「单次探测失败」catch 臂,TTL 继续兜底。
561
+ * 计时器 unref(存在则),绝不因为一次探测把宿主进程钉住。
562
+ */
563
+ function withProbeDeadline(p, timeoutMs) {
564
+ return new Promise((resolve, reject) => {
565
+ const timer = setTimeout(() => {
566
+ reject(new ProbeDeadlineError(timeoutMs));
567
+ }, timeoutMs);
568
+ unrefTimer(timer);
569
+ p.then(v => {
570
+ clearTimeout(timer);
571
+ resolve(v);
572
+ }, e => {
573
+ clearTimeout(timer);
574
+ reject(e instanceof Error ? e : new Error(String(e)));
575
+ });
576
+ });
577
+ }
457
578
  // 重入护栏(对抗复审§5):慢 probe(>5s)时多个 tick 并发走同一 outstanding 快照=同任务
458
579
  // 重复探测×N。单飞:在飞即跳过本 tick,下个节拍自然补上。
459
- let tickInFlight = false;
460
- async function tickWatch() {
580
+ // 🔴 布尔改**起始时间戳**(notif-01②):布尔形没有任何复位路径能对付「await 永不返回」。
581
+ let tickStartedAtMs = null;
582
+ /** 在飞 tick 的世代号:强制复位后旧 tick 收口时不许清掉**新** tick 的时间戳。 */
583
+ let tickGeneration = 0;
584
+ /**
585
+ * TTL 清扫(notif-03 的单口收敛 + notif-01③ 的位置修正)。
586
+ * 🔴 必须在重入护栏**之前**跑:清扫住在 tickWatchInner 里时,一个卡死的 probe 会连坐 TTL,
587
+ * 于是「2h 上界」这个 `outstandingDeliverableWorkflowCount()` 正当性的另一半也一起失效。
588
+ */
589
+ function sweepExpiredOutstanding(now) {
590
+ for (const [key, meta] of [...outstandingBgTasks]) {
591
+ if (now - meta.registeredAt > WATCH_TTL_MS)
592
+ abandonOutstanding('bg-task', key, 'ttl');
593
+ }
594
+ for (const [runId, registeredAt] of [...outstandingRuns]) {
595
+ if (now - registeredAt > WATCH_TTL_MS)
596
+ abandonOutstanding('workflow-run', runId, 'ttl');
597
+ }
598
+ }
599
+ /** @param nowMs 时钟注入(仅测试钩用;生产走 Date.now())。 */
600
+ async function tickWatch(nowMs) {
601
+ const now = nowMs ?? Date.now();
602
+ sweepExpiredOutstanding(now);
461
603
  if (outstandingRuns.size === 0 && outstandingBgTasks.size === 0) {
462
604
  if (watchTimer !== null) {
463
605
  clearInterval(watchTimer);
@@ -465,63 +607,66 @@ async function tickWatch() {
465
607
  }
466
608
  return;
467
609
  }
468
- if (tickInFlight)
469
- return;
470
- tickInFlight = true;
610
+ if (tickStartedAtMs !== null) {
611
+ if (now - tickStartedAtMs <= stuckTickResetMs())
612
+ return;
613
+ stuckTickResets++;
614
+ traceNotif(`forcing watcher re-entry: previous tick has been in flight for ${now - tickStartedAtMs}ms ` +
615
+ `(> ${stuckTickResetMs()}ms) — a probe outlived its deadline; next tick proceeds concurrently`);
616
+ }
617
+ const myGeneration = ++tickGeneration;
618
+ tickStartedAtMs = now;
471
619
  try {
472
620
  await tickWatchInner();
473
621
  }
474
622
  finally {
475
- tickInFlight = false;
623
+ // 被强制复位过就别清:那面时间戳已经属于后来的那一拍了。
624
+ if (tickGeneration === myGeneration)
625
+ tickStartedAtMs = null;
476
626
  }
477
627
  }
478
628
  async function tickWatchInner() {
479
- // bg agent 半场(与 workflow 半场同节拍同 TTL;probe 未装=mock/离线,只等推送补发)
629
+ // bg agent 半场(与 workflow 半场同节拍;TTL 已提到 tickWatch 的护栏之前统一清扫。
630
+ // probe 未装=mock/离线,只等推送补发)
480
631
  const bgProbe = bgStatusProbe;
481
- const bgNow = Date.now();
482
- for (const [taskId, meta] of [...outstandingBgTasks]) {
483
- if (notifiedRunIds.has(taskId)) {
484
- outstandingBgTasks.delete(taskId); // 推送/别的通道已送达 — 收摊
485
- continue;
486
- }
487
- if (bgNow - meta.registeredAt > WATCH_TTL_MS) {
488
- outstandingBgTasks.delete(taskId);
632
+ 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); // 推送/别的通道已送达 — 收摊
489
637
  continue;
490
638
  }
491
639
  if (!bgProbe)
492
640
  continue;
493
641
  try {
494
- const res = await bgProbe(taskId);
642
+ const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
495
643
  if (res?.terminal) {
496
- outstandingBgTasks.delete(taskId);
644
+ outstandingBgTasks.delete(key);
497
645
  enqueueBgChildNotification({
498
- taskId,
646
+ taskId: meta.taskId,
647
+ // 🔴 seq 必须透传:不带 seq 时键退化成 `taskId:1:status`,与首周期键碰撞 ⇒ 被去重臂
648
+ // 静默吞掉,而台账条目已经先删了 ⇒ 该完成**永久丢失**(clay 图2 案在 watcher 车道的复刻)。
649
+ seq: meta.seq,
499
650
  status: res.status,
500
651
  summary: `Background agent "${meta.description}" ${res.status}`,
501
652
  });
502
653
  }
503
654
  }
504
655
  catch {
505
- // 单次探测失败不放弃(网络抖动);TTL 兜底
656
+ // 单次探测失败(含 ProbeDeadlineError 截断)不放弃:网络抖动照旧,TTL 兜底
506
657
  }
507
658
  }
508
659
  const probe = statusProbe;
509
- const now = Date.now();
510
- for (const [runId, registeredAt] of [...outstandingRuns]) {
660
+ for (const runId of [...outstandingRuns.keys()]) {
511
661
  if (notifiedRunIds.has(runId)) {
512
662
  if (outstandingRuns.delete(runId))
513
663
  notifyOutstanding(); // 推送/终态轮询卡已送达 —— 收摊
514
664
  continue;
515
665
  }
516
- if (now - registeredAt > WATCH_TTL_MS) {
517
- if (outstandingRuns.delete(runId))
518
- notifyOutstanding();
519
- continue;
520
- }
521
666
  if (!probe)
522
667
  continue; // live client 未装(mock/离线)→ 只等推送
523
668
  try {
524
- const res = await probe(runId);
669
+ const res = await withProbeDeadline(probe(runId), probeTimeoutMs());
525
670
  if (res?.terminal) {
526
671
  if (outstandingRuns.delete(runId))
527
672
  notifyOutstanding();
@@ -533,7 +678,7 @@ async function tickWatchInner() {
533
678
  }
534
679
  }
535
680
  catch {
536
- // 单次探测失败不放弃(网络抖动);TTL 兜底
681
+ // 单次探测失败(含 ProbeDeadlineError 截断)不放弃:网络抖动照旧,TTL 兜底
537
682
  }
538
683
  }
539
684
  }
@@ -545,15 +690,24 @@ async function tickWatchInner() {
545
690
  */
546
691
  const bgNotifiedKeys = new Set();
547
692
  export function enqueueBgChildNotification(n) {
548
- const key = `${n.taskId}:${n.seq ?? 1}:${n.status}`;
549
- if (bgNotifiedKeys.has(key))
693
+ const cycle = n.seq ?? BG_FIRST_SEQ;
694
+ const key = `${n.taskId}:${cycle}:${n.status}`;
695
+ // 🔴 notif-03 同族(C5):两条静默 return 都记账 + 留痕 —— 不记账时「按设计去重」与
696
+ // 「seq 丢了导致完成被吞」在观测上完全等价,而后者是用户永远收不到通知的那一类。
697
+ if (bgNotifiedKeys.has(key)) {
698
+ bgDedupDropped++;
699
+ traceNotif(`bg notification suppressed (same key already delivered) key=${key}`);
550
700
  return;
701
+ }
551
702
  // cross-channel 去重(clay 2026-07-06 截图疑似双唤醒定谳):同一完成可能同时走
552
703
  // workflow_complete/probe 链(notifiedRunIds 键空间)与 bg_notification 帧——两空间互认,
553
704
  // 任一链注入过即跳过,并反向 seed。仅首周期查(revive 周期是新完成,probe 链只报首周期
554
705
  // 终态,不构成双投)。
555
- if ((n.seq ?? 1) <= 1 && notifiedRunIds.has(n.taskId))
706
+ if (cycle <= BG_FIRST_SEQ && notifiedRunIds.has(n.taskId)) {
707
+ bgCrossChannelDropped++;
708
+ traceNotif(`bg notification suppressed (cross-channel: run already notified) taskId=${n.taskId} seq=${cycle} status=${n.status}`);
556
709
  return;
710
+ }
557
711
  bgNotifiedKeys.add(key);
558
712
  notifiedRunIds.add(n.taskId);
559
713
  cardEnqueuedRunIds.add(n.taskId);
@@ -608,12 +762,35 @@ export function _resetEngineTaskNotificationForTest() {
608
762
  outstandingListeners.clear();
609
763
  statusProbe = null;
610
764
  bgStatusProbe = null;
611
- tickInFlight = false;
765
+ tickStartedAtMs = null;
766
+ abandonedCount = 0;
767
+ bgDedupDropped = 0;
768
+ bgCrossChannelDropped = 0;
769
+ stuckTickResets = 0;
770
+ probeTimeoutOverrideMs = null;
771
+ stuckTickResetOverrideMs = null;
612
772
  if (watchTimer !== null) {
613
773
  clearInterval(watchTimer);
614
774
  watchTimer = null;
615
775
  }
616
776
  }
777
+ /**
778
+ * 测试钩:**驱动一拍** watcher(生产由 setInterval 驱动,外部无入口 ⇒ 护栏/TTL/deadline
779
+ * 三条链在门里全不可达,notif-01/-02/-03 才会一路活到今天)。
780
+ * @param nowMs TTL 判据的时钟注入(WATCH_TTL_MS 是 2h,不注入时钟就只能真等两小时)。
781
+ * 🔴 生产绝不调用。
782
+ */
783
+ export async function _tickWatchOnceForTest(nowMs) {
784
+ await tickWatch(nowMs);
785
+ }
786
+ /**
787
+ * 测试钩:覆写 watcher 时序常量(默认是 10s/60s 量级,门里等不起)。传 null 恢复生产值。
788
+ * 🔴 生产绝不调用;覆写只影响 deadline 与强制复位门,不动 WATCH_INTERVAL_MS/WATCH_TTL_MS。
789
+ */
790
+ export function _setWatcherTimingForTest(o) {
791
+ probeTimeoutOverrideMs = o?.probeTimeoutMs ?? null;
792
+ stuckTickResetOverrideMs = o?.stuckTickResetMs ?? null;
793
+ }
617
794
  // ══════════════════════════════════════════════════════════════════════════════════════════════
618
795
  // ② taskNotificationErrorSupplement — S4-P3(clay 专项;取证 workflow-card-forensics,
619
796
  // 2026-07-24「workflow completed 卡下挂 API Error: 503 · draining」案):task-notification
@@ -44,7 +44,8 @@ import { type EnvLike } from './hostEnv.js';
44
44
  export declare const RETAIN_BACKGROUND_ENV: "SEMA_RETAIN_BACKGROUND";
45
45
  /**
46
46
  * ENV source → `true`(缺省 ON = CC `run_in_background` 契约:后台进程活过当轮),
47
- * 只有显式 falsy 逃生口(`SEMA_RETAIN_BACKGROUND=0|false|no|off`)⇒ `undefined`(不 stamp ⇒ 引擎缺省 reap)。
48
- * 返回型刻意是 `true | undefined` —— 服务端 `raw !== true ⇒ 丢弃`,opt-out 只能表达为「不 stamp」。
47
+ * 只有显式 falsy 逃生口({@link envFlagOff} 拼写集,REF-CC-141 dup-02 单源)⇒ `undefined`
48
+ * (不 stamp ⇒ 引擎缺省 reap)。返回型刻意是 `true | undefined` —— 服务端 `raw !== true ⇒ 丢弃`,
49
+ * opt-out 只能表达为「不 stamp」。
49
50
  */
50
51
  export declare function retainBackgroundFromEnv(env?: EnvLike): true | undefined;
@@ -39,20 +39,16 @@
39
39
  * PURE — callers pass `env`;caller gates to LIVE mode(mock request shape 不变)。
40
40
  */
41
41
  import { hostEnv } from './hostEnv.js';
42
+ import { envFlagOff } from './envFlag.js';
42
43
  /** The env key the shell reads(SEMA_ 命名空间,与引擎自己的 spec∨env 通道正交——这里是壳→
43
44
  * TaskRequest 的 per-request 意图面,非引擎部署面)。 */
44
45
  export const RETAIN_BACKGROUND_ENV = 'SEMA_RETAIN_BACKGROUND';
45
- /** FALSY spellings that opt OUT (case-insensitive, trimmed). Conservative allowlist —— 只有这些
46
- * 拼写能关掉 retain;其它一切(含未识别值/空串)落缺省 ON,绝不静默 opt-out。 */
47
- function isFalsy(v) {
48
- const s = typeof v === 'string' ? v.trim().toLowerCase() : '';
49
- return s === '0' || s === 'false' || s === 'no' || s === 'off';
50
- }
51
46
  /**
52
47
  * ENV source → `true`(缺省 ON = CC `run_in_background` 契约:后台进程活过当轮),
53
- * 只有显式 falsy 逃生口(`SEMA_RETAIN_BACKGROUND=0|false|no|off`)⇒ `undefined`(不 stamp ⇒ 引擎缺省 reap)。
54
- * 返回型刻意是 `true | undefined` —— 服务端 `raw !== true ⇒ 丢弃`,opt-out 只能表达为「不 stamp」。
48
+ * 只有显式 falsy 逃生口({@link envFlagOff} 拼写集,REF-CC-141 dup-02 单源)⇒ `undefined`
49
+ * (不 stamp ⇒ 引擎缺省 reap)。返回型刻意是 `true | undefined` —— 服务端 `raw !== true ⇒ 丢弃`,
50
+ * opt-out 只能表达为「不 stamp」。
55
51
  */
56
52
  export function retainBackgroundFromEnv(env = hostEnv()) {
57
- return isFalsy(env[RETAIN_BACKGROUND_ENV]) ? undefined : true;
53
+ return envFlagOff(env[RETAIN_BACKGROUND_ENV]) ? undefined : true;
58
54
  }