@sema-agent/client-core 0.5.0 → 0.7.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/dist/adapt.d.ts +17 -3
  2. package/dist/adapt.js +555 -36
  3. package/dist/clientSlice.d.ts +169 -0
  4. package/dist/clientSlice.js +62 -0
  5. package/dist/diff/patch.d.ts +29 -0
  6. package/dist/diff/patch.js +45 -0
  7. package/dist/engineSessionParam.d.ts +7 -0
  8. package/dist/engineSessionParam.js +35 -0
  9. package/dist/engineWireSdk.d.ts +25 -1
  10. package/dist/engineWireSdk.js +30 -4
  11. package/dist/finalVerifyWire.d.ts +74 -0
  12. package/dist/finalVerifyWire.js +63 -0
  13. package/dist/headlessPermissionModeWire.d.ts +55 -0
  14. package/dist/headlessPermissionModeWire.js +111 -0
  15. package/dist/headlessReconnectWire.d.ts +96 -0
  16. package/dist/headlessReconnectWire.js +141 -0
  17. package/dist/host.d.ts +90 -0
  18. package/dist/host.js +84 -0
  19. package/dist/index.d.ts +36 -1
  20. package/dist/index.js +48 -3
  21. package/dist/interactiveToolsWire.d.ts +48 -0
  22. package/dist/interactiveToolsWire.js +86 -0
  23. package/dist/limitsWire.d.ts +89 -0
  24. package/dist/limitsWire.js +225 -0
  25. package/dist/notifications.d.ts +7 -0
  26. package/dist/notifications.js +32 -0
  27. package/dist/request/taskRequest.d.ts +135 -0
  28. package/dist/request/taskRequest.js +176 -0
  29. package/dist/sandboxWire.d.ts +75 -0
  30. package/dist/sandboxWire.js +138 -0
  31. package/dist/scenarioWire.d.ts +62 -0
  32. package/dist/scenarioWire.js +115 -0
  33. package/dist/scratchpadWireCaps.d.ts +16 -0
  34. package/dist/scratchpadWireCaps.js +65 -0
  35. package/dist/seam.d.ts +114 -11
  36. package/dist/seam.js +31 -0
  37. package/dist/toolResult.d.ts +118 -0
  38. package/dist/toolResult.js +774 -0
  39. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -5,6 +5,22 @@
5
5
  * 沿革:@sema-agent/wire-cc-adapter 0.1.0 = seam 类型 + id 确定性派生 + 首批踩坑纯函数;
6
6
  * 0.1.2(#52a)= adapt() 管线首批(纯投影臂全落 + 壳态耦合臂投影成 ChromeEvent + 差分守卫);
7
7
  * 0.2.0 = 迁入 sema-client-core 独立仓并改名(旧 npm 名 deprecate 指本包);
8
+ * 0.7.0 = **B5 批**(设计稿 §3 B5):① **配对台账折回**([1857] —— 0.6.0 把 `notifiedRuns`/
9
+ * `cardEnqueuedRuns` 放适配器实例,与清账/记账的另一半不共享前提 = 双卡/UI 零显示/模型二收,
10
+ * 本批整体折回 notifications.ts 的 module 台账);② **B/D/E 层搬入**(`toolResult.ts`:
11
+ * structuredToToolUseResult 14 case + wireOutputToBody 3 臂 + E 层 4 臂),`adapt()` 从此
12
+ * 自己铸 `tool_result` 卡与两处开卡兜底;③ **structured 白名单吃现货**([1840]§一 30 项 ——
13
+ * 在场则 T11/T14 正则退位、T24 的 B 层惰性退位,缺席保回落,退位由**调用计数器**钉死);
14
+ * ④ **T7 诚实缺席**(§8-4:不搬 mock 合成器,改 `_sema_degraded:'wire_carried_no_output'`)与
15
+ * **T20 diff hunks 进包**(`diff/patch.ts`,本包**第一个** runtime dep `diff`)。
16
+ * 🆕 两个 chrome 臂:`bgshell_register`(#117a 判定在库、执行留宿主)· `task_ledger_sync{source:'structured'}`。
17
+ * 0.6.0 = **B4 批**(设计稿 §3 B4):A 层帧分派**收官**(第 14 臂 `task_progress` + 行绑定/
18
+ * inline stats/alias/#6 常驻台账/SubagentStart/workflow lane 三级门 + `settlePanelTasks` 三处 sweep
19
+ * 全接线)· P0-2 tool label 侧信道补漏(0.5.0 真缺口)· **请求面合一**(`request/taskRequest.ts`:
20
+ * 三个构造器的字段集与合并语义收成一份,车道差异做成 `REQUEST_FIELD_MATRIX` 数据表)·
21
+ * §8-2 构造点收编(`resolveWireAuth`)· §8-1 verb 门面接口定型(`ClientSliceLike` + `CLIENT_VERBS`,
22
+ * **编译期**双向闭合门)· `CHROME_ARMS` 覆盖率出口 · 包级宿主装配 `installHost()`(settings/fs/
23
+ * queue/timers 四口 + `hostPortMisses()` 自检)· `scratchpadWireCaps` 搬入(FsPort 端到端证明)。
8
24
  * 0.5.0 = **B3 批**(设计稿 §3 B3):`AdapterContext` 扩容(log/probe/signal/setTimer/clearTimer/
9
25
  * coalesceIntervalMs,**只加可选位 = minor 不 BREAKING**)· 适配内核搬入(`adapter/types` 运行时
10
26
  * 半场 + `adapter/downstream` 三件 + `adapter/runStream`)· runStream **两条外向边切除**
@@ -29,9 +45,15 @@
29
45
  * —— B2 新增:notifications.ts(11 个通知台账 + 队列 port + 两个 probe 槽)·
30
46
  * agentsWireCaps.ts(prepare 缓存/投影闭包/一次性告警集)· liveModelCatalog.ts(目录 + refresher)·
31
47
  * sessionModelLatch.ts(三个 latch)· ultracodeWireCaps.ts(sticky preset)。
48
+ * —— B4 新增:scratchpadWireCaps.ts(`ensured` 建目录闩)· host.ts(宿主口装配 + miss 计数);
49
+ * 适配器**实例级**(非 module 级)新增 `firedSubagentStartHookTaskIds`——cli 那份是 module 级,
50
+ * 本包放实例级是**有意的**(多 session 宿主上 module 形会让两个会话互吞 SubagentStart,见 DIVERGENCE-6)。
32
51
  * —— B3 新增:adapter/runStream.ts(`inFlightTurns` 计数,读口 `isRunStreamActive()`;
33
52
  * 两份实例 = 引擎滚动升级门恒读 false ⇒ **turn 中途热换引擎**,正是它当初要防的事)。
34
- * 🔴 **宿主装配**(B2 起本包有必须由宿主注入才完整的口):`installNotificationQueuePort()` ——
53
+ * 🔴 **宿主装配**——B4 起总入口是 `installHost({log,probe,queue,timers,settings,fs})`
54
+ * (`installNotificationQueuePort()` 仍是队列的真源出口,`installHost({queue})` 直通它,两者不分裂);
55
+ * 自检口 `hostPortMisses()` 恒应为空对象,非空 = 有口漏装、对应的面已在静默失效。
56
+ * —— B2 起的既有说明:`installNotificationQueuePort()` ——
35
57
  * 不装 = task-notification 投递静默丢失(`notificationQueuePortMisses()` 恒应为 0)。
36
58
  * —— B3 新增(可选口,缺席 = 对应 UI 面在该宿主上是哑的,不是报错):`ctx.emitChrome`
37
59
  * (runStream 的两条切边:statusline 真 usage / plan-review 审批卡)· `ctx.setTimer`
@@ -63,8 +85,8 @@ export * from './controlRouter.js';
63
85
  export * from './sseIdleTriage.js';
64
86
  export * from './engineWireSdk.js';
65
87
  export * from './classifierVerdictWire.js';
66
- // caps / wire 门族(17 个 *WireCaps —— 壳侧 hooksWireCaps / scratchpadWireCaps 因宿主耦合未搬,
67
- // README B2 记账)
88
+ // caps / wire 门族(17 个 *WireCaps。B2 hooksWireCaps / scratchpadWireCaps 因宿主耦合未搬;
89
+ // B4 已搬 scratchpadWireCaps(走 FsPort),hooksWireCaps 仍在壳里等 SettingsPort 接线 —— 见交接报告)
68
90
  export * from './agentsWireCaps.js';
69
91
  export * from './attachmentsWireCaps.js';
70
92
  export * from './clientContextWireCaps.js';
@@ -95,3 +117,26 @@ export * from './adapter/downstream/turnUsageToModelUsage.js';
95
117
  export * from './adapter/downstream/eventToSdkMessage.js';
96
118
  export * from './adapter/downstream/terminalToSdkResult.js';
97
119
  export * from './adapter/runStream.js';
120
+ // ── B4 批:A 层帧分派收官 + 请求面合一 + 构造点收编(2026-07-27)────────────────────────────────
121
+ // 请求面:三个构造器(seamQuery.toTaskRequest / seamQueryEngine / liveClient.toLiveRequest)
122
+ // 的**字段集与合并语义**收成一份;车道差异做成 REQUEST_FIELD_MATRIX 数据表(gap:true = 判为漏)。
123
+ export * from './request/taskRequest.js';
124
+ export * from './clientSlice.js';
125
+ // 包级宿主装配(per-turn 的 AdapterContext 之外的那一层:settings / fs / queue / timers)。
126
+ // 🔴 `installNotificationQueuePort` 仍是队列的真源出口,`installHost({queue})` 只是直通它。
127
+ export * from './host.js';
128
+ // B2 判给 B4 的宿主耦合件之一(小的那件,同时是 FsPort 的端到端证明)。
129
+ export * from './scratchpadWireCaps.js';
130
+ // ── B5 批:工具结果卡 B/D/E 层 + T20 客户端 diff(2026-07-27)────────────────────────────────
131
+ export * from './toolResult.js';
132
+ export * from './diff/patch.js';
133
+ // ── B5 批(B4 残项第一批):S/ 七件 headless / 部署旋钮 wire(2026-07-27)──────────────────────
134
+ // 🔴 env 默认值从 `process.env` 换成 `hostEnv()`(本包零 process;Node 下是同一对象,
135
+ // 壳侧「不传 env 参数」的调用点行为一字节不变 —— 等价性记账见 hostEnv.ts 头注)。
136
+ export * from './sandboxWire.js';
137
+ export * from './scenarioWire.js';
138
+ export * from './finalVerifyWire.js';
139
+ export * from './limitsWire.js';
140
+ export * from './interactiveToolsWire.js';
141
+ export * from './headlessPermissionModeWire.js';
142
+ export * from './headlessReconnectWire.js';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * src/sema/interactiveToolsWire.ts — headless `-p` 的 TaskRequest.interactiveTools:false stamp
3
+ * ([909]B 件3,采 [911]② core 正解,2026-07-16)。
4
+ *
5
+ * WHY:core 1.296 起 TaskSpec 有 `interactiveTools?: boolean` 三态旋钮(dist/core/types.d.ts:241);
6
+ * prepare-task.js 两处消费:
7
+ * :635 plan 门 `spec.enablePlanMode === true && spec.interactiveTools !== false`
8
+ * :974 AskUserQuestion roster `interactiveTools === true || (interactiveTools !== false &&
9
+ * (onQuestion !== undefined || durableQuestionFace))`
10
+ * ⇒ `-p` 无人值守姿势 stamp `false`,AskUserQuestion/plan 门从 roster【源头】全灭(模型压根看不到
11
+ * 该工具)= [909]A3(headless EnterPlanMode→park 弃 86% 预算)类事故的根治,比 [884]A1 的
12
+ * park→error 终帧防御纵深更靠前(现在根本不 park)。
13
+ *
14
+ * 语义(headlessPermissionModeWire 同点同型 wire):
15
+ * · 仅 headless `-p` 车道(本模块只被 seamQueryEngine.ask import;交互 REPL 一根毛不动);
16
+ * · 显式交互意图恒赢:CLI 无 --interactive-tools 类旗(gap-check 2026-07-16:上游 CC 2.1.207
17
+ * 与本壳 argv 面均无,不发明);既有旗里唯一表达「我要交互门」的是 `--permission-mode plan`
18
+ * (plan 流程本身要 plan-review 交互)——该旗在场时不 stamp(维持引擎缺省判据);
19
+ * · SEMA_HEADLESS_INTERACTIVE_TOOLS 部署旋钮(settings env 车道):true/1 = 不 stamp(回引擎
20
+ * 缺省判据),false/0 = 显式 stamp false(与缺省同效,显式垫);非法值一次性 warn + 回缺省;
21
+ * · 缺省 = stamp false(无人值守正解);
22
+ * · 旧 server 白名单 fail-soft:server resolveSpec 逐字段白名单拼 TaskSpec,未知 body 字段静默
23
+ * 忽略(limits wire 同款勘察结论)——本字段发了白发不炸。gap-check 实拆 server 1.210.0 dist:
24
+ * resolveSpec(main.js:1233-)【不】读 body.interactiveTools ⇒ 需 server 半场补一行白名单,
25
+ * 壳先备(黑板记账)。
26
+ */
27
+ import { type EnvLike } from './hostEnv.js';
28
+ /** 部署旋钮 env 键名(settings.json env 块 → 1a-envseed → process.env 同车道)。 */
29
+ export declare const HEADLESS_INTERACTIVE_TOOLS_ENV = "SEMA_HEADLESS_INTERACTIVE_TOOLS";
30
+ export type InteractiveToolsResolution = {
31
+ /** 直接展开进 TaskRequest 的字段({} = 不 stamp)。 */
32
+ fields: {
33
+ interactiveTools?: false;
34
+ };
35
+ source: 'default' | 'env' | 'plan-flag';
36
+ /** 非法 env 值的告警文案(调用层一次性打 stderr)。 */
37
+ warning?: string;
38
+ };
39
+ /** argv 里是否显式要了 plan 交互意图(`--permission-mode plan` / `--permission-mode=plan`;
40
+ * 后出现者赢,commander 同语义;`--` 之后不是 flag)。 */
41
+ export declare function planModeExplicitlyRequested(argv: readonly string[]): boolean;
42
+ /**
43
+ * 解析 headless `-p` 的 interactiveTools stamp。纯函数除告警 latch。
44
+ * 调用方(seamQueryEngine.ask)live-gated:mock/pty 车道永不经过这里。
45
+ */
46
+ export declare function resolveHeadlessInteractiveTools(argv: readonly string[], env?: EnvLike): InteractiveToolsResolution;
47
+ /** 测试钩子:重置一次性告警 latch。 */
48
+ export declare function _resetInteractiveToolsWireForTest(): void;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * src/sema/interactiveToolsWire.ts — headless `-p` 的 TaskRequest.interactiveTools:false stamp
3
+ * ([909]B 件3,采 [911]② core 正解,2026-07-16)。
4
+ *
5
+ * WHY:core 1.296 起 TaskSpec 有 `interactiveTools?: boolean` 三态旋钮(dist/core/types.d.ts:241);
6
+ * prepare-task.js 两处消费:
7
+ * :635 plan 门 `spec.enablePlanMode === true && spec.interactiveTools !== false`
8
+ * :974 AskUserQuestion roster `interactiveTools === true || (interactiveTools !== false &&
9
+ * (onQuestion !== undefined || durableQuestionFace))`
10
+ * ⇒ `-p` 无人值守姿势 stamp `false`,AskUserQuestion/plan 门从 roster【源头】全灭(模型压根看不到
11
+ * 该工具)= [909]A3(headless EnterPlanMode→park 弃 86% 预算)类事故的根治,比 [884]A1 的
12
+ * park→error 终帧防御纵深更靠前(现在根本不 park)。
13
+ *
14
+ * 语义(headlessPermissionModeWire 同点同型 wire):
15
+ * · 仅 headless `-p` 车道(本模块只被 seamQueryEngine.ask import;交互 REPL 一根毛不动);
16
+ * · 显式交互意图恒赢:CLI 无 --interactive-tools 类旗(gap-check 2026-07-16:上游 CC 2.1.207
17
+ * 与本壳 argv 面均无,不发明);既有旗里唯一表达「我要交互门」的是 `--permission-mode plan`
18
+ * (plan 流程本身要 plan-review 交互)——该旗在场时不 stamp(维持引擎缺省判据);
19
+ * · SEMA_HEADLESS_INTERACTIVE_TOOLS 部署旋钮(settings env 车道):true/1 = 不 stamp(回引擎
20
+ * 缺省判据),false/0 = 显式 stamp false(与缺省同效,显式垫);非法值一次性 warn + 回缺省;
21
+ * · 缺省 = stamp false(无人值守正解);
22
+ * · 旧 server 白名单 fail-soft:server resolveSpec 逐字段白名单拼 TaskSpec,未知 body 字段静默
23
+ * 忽略(limits wire 同款勘察结论)——本字段发了白发不炸。gap-check 实拆 server 1.210.0 dist:
24
+ * resolveSpec(main.js:1233-)【不】读 body.interactiveTools ⇒ 需 server 半场补一行白名单,
25
+ * 壳先备(黑板记账)。
26
+ */
27
+ import { hostEnv } from './hostEnv.js';
28
+ /** 部署旋钮 env 键名(settings.json env 块 → 1a-envseed → process.env 同车道)。 */
29
+ export const HEADLESS_INTERACTIVE_TOOLS_ENV = 'SEMA_HEADLESS_INTERACTIVE_TOOLS';
30
+ /** argv 里是否显式要了 plan 交互意图(`--permission-mode plan` / `--permission-mode=plan`;
31
+ * 后出现者赢,commander 同语义;`--` 之后不是 flag)。 */
32
+ export function planModeExplicitlyRequested(argv) {
33
+ let mode;
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (a === '--permission-mode') {
37
+ const v = argv[i + 1];
38
+ if (typeof v === 'string' && !v.startsWith('-')) {
39
+ mode = v;
40
+ i++;
41
+ }
42
+ }
43
+ else if (a?.startsWith('--permission-mode=')) {
44
+ mode = a.slice('--permission-mode='.length);
45
+ }
46
+ else if (a === '--') {
47
+ break;
48
+ }
49
+ }
50
+ return mode === 'plan';
51
+ }
52
+ // 一次性告警 latch(-p 是 one-shot 进程;测试钩子可重置)
53
+ let warnedInvalidEnv = false;
54
+ /**
55
+ * 解析 headless `-p` 的 interactiveTools stamp。纯函数除告警 latch。
56
+ * 调用方(seamQueryEngine.ask)live-gated:mock/pty 车道永不经过这里。
57
+ */
58
+ export function resolveHeadlessInteractiveTools(argv, env = hostEnv()) {
59
+ // 1) 显式交互意图恒赢:--permission-mode plan ⇒ 不 stamp(plan 流程要 plan-review 门)。
60
+ if (planModeExplicitlyRequested(argv)) {
61
+ return { fields: {}, source: 'plan-flag' };
62
+ }
63
+ // 2) 部署旋钮。
64
+ const raw = env[HEADLESS_INTERACTIVE_TOOLS_ENV]?.trim();
65
+ if (raw !== undefined && raw !== '') {
66
+ if (raw === 'true' || raw === '1')
67
+ return { fields: {}, source: 'env' }; // 回引擎缺省判据
68
+ if (raw === 'false' || raw === '0')
69
+ return { fields: { interactiveTools: false }, source: 'env' };
70
+ const resolution = {
71
+ fields: { interactiveTools: false },
72
+ source: 'default',
73
+ };
74
+ if (!warnedInvalidEnv) {
75
+ warnedInvalidEnv = true;
76
+ resolution.warning = `sema: ${HEADLESS_INTERACTIVE_TOOLS_ENV}="${raw}" is not a boolean (expected true/1/false/0) — ignoring it, headless -p keeps interactive tools OFF by default`;
77
+ }
78
+ return resolution;
79
+ }
80
+ // 3) 缺省:无人值守 stamp false(AskUserQuestion/plan 门 roster 源头全灭)。
81
+ return { fields: { interactiveTools: false }, source: 'default' };
82
+ }
83
+ /** 测试钩子:重置一次性告警 latch。 */
84
+ export function _resetInteractiveToolsWireForTest() {
85
+ warnedInvalidEnv = false;
86
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * src/sema/limitsWire.ts — headless `-p` run-limits wire (TB2.0 反馈批 E · P2-3-b, 2026-07-15):
3
+ * expose the engine's REQUEST-LEVEL pacing limits on the headless `-p` path via `TaskRequest.limits`.
4
+ * The server accepts `limits:{timeoutSec?,maxOutputTokens?,maxTurns?}` since 1.196.0 (positive-integer
5
+ * 400 gate, board [857]) and feeds the EXISTING pacing machinery (deadlineNudge / callCapByDeadline /
6
+ * gracefulFinalize) — the shell simply never stamped the field. This module is the CLI face.
7
+ *
8
+ * TWO FLAGS, TWO ALIGNMENT BUCKETS:
9
+ * · `--deadline <sec>` → limits.timeoutSec. Upstream CC 2.1.207 has NO deadline flag (nearest kin:
10
+ * --max-turns / --task-budget / --max-budget-usd) → SUPERSET, help copy says so.
11
+ * · `--max-turns <n>` → limits.maxTurns. Upstream CC 2.1.207 HAS `--max-turns` (source 199676 /
12
+ * 793380) → ALIGNMENT-GAP fill: the flag was already registered (main.tsx CC-inherited surface) and
13
+ * drove the local query loop, but the seam path executes turns ENGINE-side — this wire finally
14
+ * carries the cap to where the turns actually run.
15
+ *
16
+ * PRECEDENCE per knob (flag > settings > none), scenarioWire 三件套同款:
17
+ * a) explicit flag → strict validation, FAIL-LOUD on an invalid
18
+ * value (integer domain below; a mis-typed pacing bound must not quietly run unbounded).
19
+ * b) settings env lane (settings.json `env` block → 1a-envseed → process.env):
20
+ * `SEMA_HEADLESS_DEADLINE_SEC` / `SEMA_HEADLESS_MAX_TURNS` → SILENT degrade on an invalid value
21
+ * (spec'd 静默降级 — a settings typo must never brick every headless run; unlike scenarioWire we
22
+ * don't even warn, per the batch-E triage shape. SEMA_DEBUG surfaces the drop for diagnosis).
23
+ * c) neither → NO stamp = today's behaviour, verbatim.
24
+ *
25
+ * DOMAINS (integer, inclusive): timeoutSec 30..86400 · maxTurns 1..1000.
26
+ *
27
+ * VERSION GATE (triage risk ③ — an OLD server SILENTLY SWALLOWS unknown body fields, worse than not
28
+ * shipping): the self-spawned engine is release-pinned ≥1.197.0 so the dev/release lanes are safe by
29
+ * construction; but a user-supplied SEMA_ENGINE_URL may point at an older engine. When the user
30
+ * EXPLICITLY passed a flag (env-lane defaults stay quiet) and SEMA_ENGINE_URL is in play, the caller
31
+ * probes `${baseUrl}/health` (the engine self-describes `version` since 1.96) and prints ONE honest
32
+ * stderr line when version <1.196.0 — the request still goes out unchanged (warn, don't block).
33
+ * Unreachable /health or an unparsable version stays silent (can't confirm staleness — fail-soft).
34
+ */
35
+ import { type EnvLike } from './hostEnv.js';
36
+ /** Settings-lane env knobs (settings.json `env` block → 1a-envseed → process.env). */
37
+ export declare const HEADLESS_DEADLINE_ENV = "SEMA_HEADLESS_DEADLINE_SEC";
38
+ export declare const HEADLESS_MAX_TURNS_ENV = "SEMA_HEADLESS_MAX_TURNS";
39
+ /** Integer domains (inclusive). timeoutSec: 30s..24h; maxTurns: 1..1000. */
40
+ export declare const DEADLINE_SEC_MIN = 30;
41
+ export declare const DEADLINE_SEC_MAX = 86400;
42
+ export declare const MAX_TURNS_MIN = 1;
43
+ export declare const MAX_TURNS_MAX = 1000;
44
+ /** First server version whose /v1/tasks|/v1/tasks/stream accept body.limits (board [857]). */
45
+ export declare const LIMITS_WIRE_MIN_ENGINE: readonly [number, number, number];
46
+ export interface HeadlessLimits {
47
+ timeoutSec?: number;
48
+ maxTurns?: number;
49
+ }
50
+ export type LimitsParseResult = {
51
+ ok: true;
52
+ limits?: HeadlessLimits;
53
+ flaggedFlags: string[];
54
+ } | {
55
+ ok: false;
56
+ error: string;
57
+ };
58
+ /**
59
+ * Parse `--deadline` + `--max-turns` out of an argv slice. No flags ⇒ `{ok:true}` with no limits.
60
+ * Repeated flag ⇒ LAST wins (commander convention). Missing value / non-integer / out-of-domain ⇒
61
+ * fail-LOUD parse error (never a silent default — same stance as `--scenario`/`--sandbox`).
62
+ * `flaggedFlags` names the flags that EXPLICITLY contributed (the version-gate probe trigger).
63
+ */
64
+ export declare function parseLimitsArgv(argv: string[]): LimitsParseResult;
65
+ /**
66
+ * The settings-lane defaults. SILENT degrade on a bad value (batch-E triage shape — stricter than
67
+ * scenarioWire's warn-and-ignore: these are pacing hints, and a settings typo must neither brick nor
68
+ * spam every headless run). SEMA_DEBUG surfaces the drop.
69
+ */
70
+ export declare function headlessLimitsFromEnv(env?: EnvLike): HeadlessLimits | undefined;
71
+ /**
72
+ * Resolve the limits for a headless `-p` submit: explicit flags > settings env defaults > none
73
+ * (= no stamp, today's behaviour). Merge is PER-KNOB: `--deadline 300` + SEMA_HEADLESS_MAX_TURNS=50
74
+ * yields {timeoutSec:300, maxTurns:50}. Flag parse errors propagate fail-loud; the caller exits.
75
+ */
76
+ export declare function limitsForPrint(argv: string[], env?: EnvLike): LimitsParseResult;
77
+ /** True when the engine version string satisfies the limits-wire minimum (≥1.196.0). Unparsable ⇒ false. */
78
+ export declare function versionSupportsLimits(v: string | undefined): boolean;
79
+ /**
80
+ * Version-gate probe (triage risk ③): GET `${baseUrl}/health`, read `version`, and return ONE honest
81
+ * stderr line when the engine is confirmed <1.196.0 — the caller prints it and SENDS ANYWAY (the old
82
+ * server drops the field server-side; warn, don't block). Returns undefined when the engine is new
83
+ * enough, unreachable, or self-describes no parsable version (can't confirm staleness — fail-soft
84
+ * silent; the release-pinned self-spawn lane never even reaches this probe).
85
+ */
86
+ export declare function engineLimitsSupportWarning(baseUrl: string, flaggedFlags: string[], opts?: {
87
+ fetchImpl?: typeof fetch;
88
+ timeoutMs?: number;
89
+ }): Promise<string | undefined>;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * src/sema/limitsWire.ts — headless `-p` run-limits wire (TB2.0 反馈批 E · P2-3-b, 2026-07-15):
3
+ * expose the engine's REQUEST-LEVEL pacing limits on the headless `-p` path via `TaskRequest.limits`.
4
+ * The server accepts `limits:{timeoutSec?,maxOutputTokens?,maxTurns?}` since 1.196.0 (positive-integer
5
+ * 400 gate, board [857]) and feeds the EXISTING pacing machinery (deadlineNudge / callCapByDeadline /
6
+ * gracefulFinalize) — the shell simply never stamped the field. This module is the CLI face.
7
+ *
8
+ * TWO FLAGS, TWO ALIGNMENT BUCKETS:
9
+ * · `--deadline <sec>` → limits.timeoutSec. Upstream CC 2.1.207 has NO deadline flag (nearest kin:
10
+ * --max-turns / --task-budget / --max-budget-usd) → SUPERSET, help copy says so.
11
+ * · `--max-turns <n>` → limits.maxTurns. Upstream CC 2.1.207 HAS `--max-turns` (source 199676 /
12
+ * 793380) → ALIGNMENT-GAP fill: the flag was already registered (main.tsx CC-inherited surface) and
13
+ * drove the local query loop, but the seam path executes turns ENGINE-side — this wire finally
14
+ * carries the cap to where the turns actually run.
15
+ *
16
+ * PRECEDENCE per knob (flag > settings > none), scenarioWire 三件套同款:
17
+ * a) explicit flag → strict validation, FAIL-LOUD on an invalid
18
+ * value (integer domain below; a mis-typed pacing bound must not quietly run unbounded).
19
+ * b) settings env lane (settings.json `env` block → 1a-envseed → process.env):
20
+ * `SEMA_HEADLESS_DEADLINE_SEC` / `SEMA_HEADLESS_MAX_TURNS` → SILENT degrade on an invalid value
21
+ * (spec'd 静默降级 — a settings typo must never brick every headless run; unlike scenarioWire we
22
+ * don't even warn, per the batch-E triage shape. SEMA_DEBUG surfaces the drop for diagnosis).
23
+ * c) neither → NO stamp = today's behaviour, verbatim.
24
+ *
25
+ * DOMAINS (integer, inclusive): timeoutSec 30..86400 · maxTurns 1..1000.
26
+ *
27
+ * VERSION GATE (triage risk ③ — an OLD server SILENTLY SWALLOWS unknown body fields, worse than not
28
+ * shipping): the self-spawned engine is release-pinned ≥1.197.0 so the dev/release lanes are safe by
29
+ * construction; but a user-supplied SEMA_ENGINE_URL may point at an older engine. When the user
30
+ * EXPLICITLY passed a flag (env-lane defaults stay quiet) and SEMA_ENGINE_URL is in play, the caller
31
+ * probes `${baseUrl}/health` (the engine self-describes `version` since 1.96) and prints ONE honest
32
+ * stderr line when version <1.196.0 — the request still goes out unchanged (warn, don't block).
33
+ * Unreachable /health or an unparsable version stays silent (can't confirm staleness — fail-soft).
34
+ */
35
+ import { hostEnv } from './hostEnv.js';
36
+ /** Settings-lane env knobs (settings.json `env` block → 1a-envseed → process.env). */
37
+ export const HEADLESS_DEADLINE_ENV = 'SEMA_HEADLESS_DEADLINE_SEC';
38
+ export const HEADLESS_MAX_TURNS_ENV = 'SEMA_HEADLESS_MAX_TURNS';
39
+ /** Integer domains (inclusive). timeoutSec: 30s..24h; maxTurns: 1..1000. */
40
+ export const DEADLINE_SEC_MIN = 30;
41
+ export const DEADLINE_SEC_MAX = 86_400;
42
+ export const MAX_TURNS_MIN = 1;
43
+ export const MAX_TURNS_MAX = 1_000;
44
+ /** First server version whose /v1/tasks|/v1/tasks/stream accept body.limits (board [857]). */
45
+ export const LIMITS_WIRE_MIN_ENGINE = [1, 196, 0];
46
+ const DEADLINE_USAGE = `sema: --deadline expects a whole number of seconds between ${DEADLINE_SEC_MIN} and ${DEADLINE_SEC_MAX}.\n` +
47
+ ' --deadline <sec> soft wall-clock budget for this run (sema superset — no upstream equivalent):\n' +
48
+ ' the engine paces itself (nudges + call caps) and finalizes gracefully near the deadline\n' +
49
+ ' (no flag) no deadline — today\'s behaviour';
50
+ const MAX_TURNS_USAGE = `sema: --max-turns expects a whole number of turns between ${MAX_TURNS_MIN} and ${MAX_TURNS_MAX}.\n` +
51
+ ' --max-turns <n> maximum number of agentic turns for this run (upstream-aligned flag,\n' +
52
+ ' enforced engine-side on the sema seam)\n' +
53
+ ' (no flag) no turn cap — today\'s behaviour';
54
+ /** STRICT integer shape for explicit flag values: ASCII digits only (no sign/decimal/exponent/space). */
55
+ const INT_RE = /^\d+$/;
56
+ /**
57
+ * Pull the LAST value of a `--name <v>` / `--name=<v>` flag out of an argv slice (everything before a
58
+ * bare `--` — positionals are never flags; scenarioWire 纪律). Returns:
59
+ * {present:false} — flag absent
60
+ * {present:true, raw} — flag present with a value token
61
+ * {present:true, raw:undefined} — flag present but the value is missing/flag-shaped (fail-loud at caller)
62
+ */
63
+ function lastFlagValue(argv, name) {
64
+ const eq = `${name}=`;
65
+ let present = false;
66
+ let raw;
67
+ let valueMissing = false;
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const a = argv[i];
70
+ if (a === '--')
71
+ break; // positionals — never flags
72
+ if (a === name) {
73
+ present = true;
74
+ const nxt = argv[i + 1];
75
+ if (nxt === undefined || nxt.startsWith('-')) {
76
+ // note: a NEGATIVE number would look flag-shaped too — the domains are all-positive, so the
77
+ // fail-loud usage hint is the right answer for `--deadline -5` as well.
78
+ valueMissing = true;
79
+ raw = undefined;
80
+ continue;
81
+ }
82
+ valueMissing = false;
83
+ raw = nxt;
84
+ i++; // consume the value token
85
+ }
86
+ else if (a.startsWith(eq)) {
87
+ present = true;
88
+ valueMissing = false;
89
+ raw = a.slice(eq.length);
90
+ }
91
+ }
92
+ if (present && (valueMissing || raw === undefined))
93
+ return { present: true };
94
+ return present ? { present: true, raw } : { present: false };
95
+ }
96
+ /** Validate a RAW flag value against an inclusive integer domain. Returns the number or null. */
97
+ function parseIntInDomain(raw, min, max) {
98
+ const v = raw.trim();
99
+ if (!INT_RE.test(v))
100
+ return null;
101
+ const n = Number(v);
102
+ if (!Number.isSafeInteger(n) || n < min || n > max)
103
+ return null;
104
+ return n;
105
+ }
106
+ /**
107
+ * Parse `--deadline` + `--max-turns` out of an argv slice. No flags ⇒ `{ok:true}` with no limits.
108
+ * Repeated flag ⇒ LAST wins (commander convention). Missing value / non-integer / out-of-domain ⇒
109
+ * fail-LOUD parse error (never a silent default — same stance as `--scenario`/`--sandbox`).
110
+ * `flaggedFlags` names the flags that EXPLICITLY contributed (the version-gate probe trigger).
111
+ */
112
+ export function parseLimitsArgv(argv) {
113
+ const limits = {};
114
+ const flaggedFlags = [];
115
+ const dl = lastFlagValue(argv, '--deadline');
116
+ if (dl.present) {
117
+ const n = dl.raw !== undefined ? parseIntInDomain(dl.raw, DEADLINE_SEC_MIN, DEADLINE_SEC_MAX) : null;
118
+ if (n === null)
119
+ return { ok: false, error: DEADLINE_USAGE };
120
+ limits.timeoutSec = n;
121
+ flaggedFlags.push('--deadline');
122
+ }
123
+ const mt = lastFlagValue(argv, '--max-turns');
124
+ if (mt.present) {
125
+ const n = mt.raw !== undefined ? parseIntInDomain(mt.raw, MAX_TURNS_MIN, MAX_TURNS_MAX) : null;
126
+ if (n === null)
127
+ return { ok: false, error: MAX_TURNS_USAGE };
128
+ limits.maxTurns = n;
129
+ flaggedFlags.push('--max-turns');
130
+ }
131
+ return {
132
+ ok: true,
133
+ ...(flaggedFlags.length > 0 ? { limits } : {}),
134
+ flaggedFlags,
135
+ };
136
+ }
137
+ /**
138
+ * The settings-lane defaults. SILENT degrade on a bad value (batch-E triage shape — stricter than
139
+ * scenarioWire's warn-and-ignore: these are pacing hints, and a settings typo must neither brick nor
140
+ * spam every headless run). SEMA_DEBUG surfaces the drop.
141
+ */
142
+ export function headlessLimitsFromEnv(env = hostEnv()) {
143
+ const out = {};
144
+ const dl = env[HEADLESS_DEADLINE_ENV]?.trim();
145
+ if (dl) {
146
+ const n = parseIntInDomain(dl, DEADLINE_SEC_MIN, DEADLINE_SEC_MAX);
147
+ if (n !== null)
148
+ out.timeoutSec = n;
149
+ else if (env.SEMA_DEBUG) {
150
+ // eslint-disable-next-line no-console
151
+ console.error(`[sema] ${HEADLESS_DEADLINE_ENV}="${dl}" is not an integer in ${DEADLINE_SEC_MIN}..${DEADLINE_SEC_MAX} — ignoring the settings default`);
152
+ }
153
+ }
154
+ const mt = env[HEADLESS_MAX_TURNS_ENV]?.trim();
155
+ if (mt) {
156
+ const n = parseIntInDomain(mt, MAX_TURNS_MIN, MAX_TURNS_MAX);
157
+ if (n !== null)
158
+ out.maxTurns = n;
159
+ else if (env.SEMA_DEBUG) {
160
+ // eslint-disable-next-line no-console
161
+ console.error(`[sema] ${HEADLESS_MAX_TURNS_ENV}="${mt}" is not an integer in ${MAX_TURNS_MIN}..${MAX_TURNS_MAX} — ignoring the settings default`);
162
+ }
163
+ }
164
+ return out.timeoutSec !== undefined || out.maxTurns !== undefined ? out : undefined;
165
+ }
166
+ /**
167
+ * Resolve the limits for a headless `-p` submit: explicit flags > settings env defaults > none
168
+ * (= no stamp, today's behaviour). Merge is PER-KNOB: `--deadline 300` + SEMA_HEADLESS_MAX_TURNS=50
169
+ * yields {timeoutSec:300, maxTurns:50}. Flag parse errors propagate fail-loud; the caller exits.
170
+ */
171
+ export function limitsForPrint(argv, env = hostEnv()) {
172
+ const parsed = parseLimitsArgv(argv);
173
+ if (!parsed.ok)
174
+ return parsed;
175
+ const fromEnv = headlessLimitsFromEnv(env);
176
+ const merged = { ...(fromEnv ?? {}), ...(parsed.limits ?? {}) };
177
+ const any = merged.timeoutSec !== undefined || merged.maxTurns !== undefined;
178
+ return { ok: true, ...(any ? { limits: merged } : {}), flaggedFlags: parsed.flaggedFlags };
179
+ }
180
+ /** True when the engine version string satisfies the limits-wire minimum (≥1.196.0). Unparsable ⇒ false. */
181
+ export function versionSupportsLimits(v) {
182
+ const m = v ? /^(\d+)\.(\d+)\.(\d+)/.exec(v) : null;
183
+ if (!m)
184
+ return false;
185
+ const [a, b, c] = [Number(m[1]), Number(m[2]), Number(m[3])];
186
+ const [ma, mb, mc] = LIMITS_WIRE_MIN_ENGINE;
187
+ if (a !== ma)
188
+ return a > ma;
189
+ if (b !== mb)
190
+ return b > mb;
191
+ return c >= mc;
192
+ }
193
+ /**
194
+ * Version-gate probe (triage risk ③): GET `${baseUrl}/health`, read `version`, and return ONE honest
195
+ * stderr line when the engine is confirmed <1.196.0 — the caller prints it and SENDS ANYWAY (the old
196
+ * server drops the field server-side; warn, don't block). Returns undefined when the engine is new
197
+ * enough, unreachable, or self-describes no parsable version (can't confirm staleness — fail-soft
198
+ * silent; the release-pinned self-spawn lane never even reaches this probe).
199
+ */
200
+ export async function engineLimitsSupportWarning(baseUrl, flaggedFlags, opts) {
201
+ const fetchImpl = opts?.fetchImpl ?? fetch;
202
+ const controller = new AbortController();
203
+ const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? 1500);
204
+ try {
205
+ const res = await fetchImpl(`${baseUrl.replace(/\/+$/, '')}/health`, {
206
+ method: 'GET',
207
+ signal: controller.signal,
208
+ });
209
+ if (!res.ok)
210
+ return undefined;
211
+ const body = (await res.json());
212
+ const version = typeof body?.version === 'string' ? body.version : undefined;
213
+ // No version field = pre-1.96 engine — definitely older than 1.196.0, warn honestly.
214
+ if (versionSupportsLimits(version))
215
+ return undefined;
216
+ const flags = flaggedFlags.length > 0 ? flaggedFlags.join('/') : '--deadline/--max-turns';
217
+ return `[sema] engine ${version ?? '(unversioned, pre-1.96)'} does not support ${flags} (needs ≥1.196.0); the flag will be ignored by the engine`;
218
+ }
219
+ catch {
220
+ return undefined; // unreachable/timeout — cannot confirm staleness, stay silent
221
+ }
222
+ finally {
223
+ clearTimeout(timer);
224
+ }
225
+ }
@@ -90,6 +90,13 @@ export declare function dropQueuedNotificationsForRun(taskId: string): number;
90
90
  * steer-inject 的 task_notification 帧)即预标记,Channel A 对同 runId 的补发直接丢弃。
91
91
  */
92
92
  export declare function markEngineWorkflowNotified(runId: string): void;
93
+ /** 跨通道去重的**读**口(适配器 workflow_complete 臂的发射门;cli 同域)。 */
94
+ export declare function isEngineWorkflowNotified(runId: string): boolean;
95
+ /** 「这个 run 的完成卡已入队」的**写**口(cli 侧等价物 = 两个 enqueue 函数内的 `cardEnqueuedRunIds.add`)。 */
96
+ export declare function noteWorkflowCompletionCardEnqueued(runId: string): void;
97
+ /** 台账序列化面(`exportLedger`)—— 只读快照,顺序 = 插入序。 */
98
+ export declare function listNotifiedRuns(): string[];
99
+ export declare function listWorkflowCompletionCardsEnqueued(): string[];
93
100
  export declare function isWorkflowCompletionCardEnqueued(runId: string): boolean;
94
101
  type WorkflowStatusProbe = (runId: string) => Promise<{
95
102
  terminal: boolean;
@@ -215,6 +215,38 @@ export function markEngineWorkflowNotified(runId) {
215
215
  if (outstandingRuns.delete(runId))
216
216
  notifyOutstanding();
217
217
  }
218
+ // ══════════════════════════════════════════════════════════════════════════════════════════════
219
+ // B5 ①(0.7.0)—— **配对台账折回**([1857];记忆 paired-mechanisms-must-share-premise)。
220
+ // 0.6.0 把 `notifiedRuns` / `cardEnqueuedRuns` 放在**适配器实例**上,而与它们配对的另一半全在
221
+ // 本模块的 module 台账上:清账的 `dropQueuedNotificationsForRun`、记账的
222
+ // `enqueueBgChildNotification` / `enqueueEngineWorkflowNotification`、以及 idle watcher 那条
223
+ // (壳根本拦不到的调用点)。两半前提不共享 = 四个方向同时错:
224
+ // ① module 记过账 ⇒ 实例不知道 ⇒ 推送帧渲第二张卡(双卡)
225
+ // ② 宿主回钩写实例、drop 清 module ⇒ 实例只增不减 ⇒ 那一行永不上屏(a0110e88 复发)
226
+ // ③④ 跨通道去重两向失效 ⇒ 同一完成二次喂模型
227
+ // 修法 = 台账**整体**回到 module(与 cli 的 module 级 Set 逐字同域),适配器只经下面这四个口读写;
228
+ // 实例上只留 `renderedNotifications`(渲染去重,天然 per-session)与 `firedSubagentStartHookTaskIds`
229
+ // (DIVERGENCE-6 有意的实例级)。
230
+ // 🔴 多 session 宿主(web 一个页面两个会话)上本对台账是**进程级共享**的 —— 这与 cli 一致,
231
+ // 且必须如此:队列 port、idle watcher、outstanding 药丸也都是进程级的,台账单独 per-session
232
+ // 就又造出一对「前提不共享」的机制。
233
+ // ══════════════════════════════════════════════════════════════════════════════════════════════
234
+ /** 跨通道去重的**读**口(适配器 workflow_complete 臂的发射门;cli 同域)。 */
235
+ export function isEngineWorkflowNotified(runId) {
236
+ return notifiedRunIds.has(runId);
237
+ }
238
+ /** 「这个 run 的完成卡已入队」的**写**口(cli 侧等价物 = 两个 enqueue 函数内的 `cardEnqueuedRunIds.add`)。 */
239
+ export function noteWorkflowCompletionCardEnqueued(runId) {
240
+ if (runId.length > 0)
241
+ cardEnqueuedRunIds.add(runId);
242
+ }
243
+ /** 台账序列化面(`exportLedger`)—— 只读快照,顺序 = 插入序。 */
244
+ export function listNotifiedRuns() {
245
+ return [...notifiedRunIds];
246
+ }
247
+ export function listWorkflowCompletionCardsEnqueued() {
248
+ return [...cardEnqueuedRunIds];
249
+ }
218
250
  /** 双通道「完成卡」去重(clay dogfood 2026-07-19 双通知案):与 notifiedRunIds(模型通知去重,
219
251
  * external mark 也写它)**分键**——本集只由 probe 合成 enqueue 写入(workflow+bg agent 两族,
220
252
  * 对抗复审§1:bg 合成不写此集时 probe 先到→后到推送帧仍渲第二张卡)、只由推送帧渲染前读,