@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
@@ -0,0 +1,111 @@
1
+ /**
2
+ * src/sema/headlessPermissionModeWire.ts — SEMA_HEADLESS_PERMISSION_MODE 部署旋钮
3
+ * (clay 拍板 2026-07-16:TB/bench 场景 headless `-p` 统一垫 bypassPermissions,对齐 CC bench 实跑的
4
+ * --dangerously-skip-permissions 姿势;落地形态 = 环境变量部署旋钮,产品缺省语义不动)。
5
+ *
6
+ * gap-check 现状(2026-07-16,db3c72f):
7
+ * · `--permission-mode <mode>` CLI flag 存在(main.tsx:1042,choices = PERMISSION_MODES + 'manual'),
8
+ * `--dangerously-skip-permissions` 亦在;但两者在 headless `-p` 车道【都不进 TaskRequest】——
9
+ * seamQueryEngine.ask 自建请求从不 stamp permissionMode(交互 REPL 走 seamQuery →
10
+ * permissionWireCaps.permissionModeField,print/headless 从不 stamp = server absent-mode)。
11
+ * · 即:今天 `-p` 的权限面全靠 server 对 absent-mode 的缺省解释([830]① server 1.191 五模式解释层,
12
+ * absent 不挂门)。core [885] 残余建议:显式垫,防未来缺省变化让 TB 静默变盘。
13
+ *
14
+ * 语义(件2 拍板逐字):
15
+ * 优先级:CLI flag 显式值 > SEMA_HEADLESS_PERMISSION_MODE env > 现状 absent-mode(env 是缺省态的
16
+ * 垫层,flag 恒赢——flag 在场时 env 完全不看,即使 flag 值不在 wire 词表内)。
17
+ * 值域 = 壳既有 wire permissionMode 词表(permissionWireCaps.PermissionMode:default / plan /
18
+ * acceptEdits / bypassPermissions / auto——分类器批3 [907] server 五模式表,server ≥1.209 认
19
+ * "auto")。非法 env 值 ⇒ 一次性 warn + 回落现状,绝不 fail 启动。
20
+ * stamp 规则:
21
+ * · flag 来源:非 default 的 wire 词直接 stamp(auto 自分类器批3 起入表);`default` 不 stamp
22
+ * (= 今天 flag 的实际效果,零行为变化);wire 词表外的 flag 值(dontAsk/manual…是壳内部
23
+ * 模式,server 不认)不 stamp。
24
+ * · env 来源:五词全部显式 stamp(含 `default`——env 是"显式垫层"旋钮,设了就是要显式语义,
25
+ * 不再依赖 server absent-mode 缺省解释;server 1.191 对显式 "default" 走 manual ask 门解释)。
26
+ * 车道纪律:本模块只被 seamQueryEngine.ask(headless `-p`)import;交互 REPL(seamQuery)一根毛
27
+ * 不动——单测有 source-level lane proof(detachWire 同款)。
28
+ */
29
+ import { permissionModeField } from './permissionWireCaps.js';
30
+ import { hostEnv } from './hostEnv.js';
31
+ /** 部署旋钮 env 键名(settings.json env 块 → 1a-envseed → process.env 同车道)。 */
32
+ export const HEADLESS_PERMISSION_MODE_ENV = 'SEMA_HEADLESS_PERMISSION_MODE';
33
+ /** wire 词表(permissionWireCaps.PermissionMode 的运行时镜像;大小写敏感——与 CC flag choices 一致)。
34
+ * 'auto'(分类器批3,[907]):server ≥1.209 五模式表认;老 server coerce 未知词 → "default" 只紧不松。 */
35
+ export const HEADLESS_PERMISSION_WIRE_MODES = [
36
+ 'default',
37
+ 'plan',
38
+ 'acceptEdits',
39
+ 'bypassPermissions',
40
+ 'auto',
41
+ ];
42
+ /**
43
+ * argv 里的 `--permission-mode <v>` / `--permission-mode=<v>`(后者/后出现者赢,commander 同语义)与
44
+ * `--dangerously-skip-permissions`(= bypassPermissions;`--permission-mode` 在场时让位)。
45
+ * 返回 undefined = flag 车道未显式表态。
46
+ */
47
+ export function permissionModeFromArgv(argv) {
48
+ let mode;
49
+ let dsp = false;
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i];
52
+ if (a === '--permission-mode') {
53
+ const v = argv[i + 1];
54
+ if (typeof v === 'string' && !v.startsWith('-')) {
55
+ mode = v;
56
+ i++;
57
+ }
58
+ }
59
+ else if (a?.startsWith('--permission-mode=')) {
60
+ mode = a.slice('--permission-mode='.length);
61
+ }
62
+ else if (a === '--dangerously-skip-permissions') {
63
+ dsp = true;
64
+ }
65
+ else if (a === '--') {
66
+ break; // POSIX 约定:-- 之后不是 flag
67
+ }
68
+ }
69
+ if (mode !== undefined)
70
+ return mode;
71
+ if (dsp)
72
+ return 'bypassPermissions';
73
+ return undefined;
74
+ }
75
+ // 一次性告警 latch(-p 是 one-shot 进程;测试钩子可重置)
76
+ let warnedInvalidEnv = false;
77
+ /**
78
+ * 解析 headless `-p` 的 permissionMode stamp。纯函数除告警 latch(同值只警一次)。
79
+ * 调用方(seamQueryEngine.ask)live-gated:mock/pty 车道永不经过这里。
80
+ */
81
+ export function resolveHeadlessPermissionMode(argv, env = hostEnv()) {
82
+ // 1) CLI flag 恒赢:flag 在场(含 wire 词表外的壳内部模式)⇒ env 完全不看。
83
+ const flagMode = permissionModeFromArgv(argv);
84
+ if (flagMode !== undefined) {
85
+ // permissionModeField 只放行 wire 已知的非 default 词;default/壳内部词 ⇒ {}(现状,零变化)。
86
+ return { fields: permissionModeField(flagMode), source: 'flag' };
87
+ }
88
+ // 2) env 垫层。
89
+ const raw = env[HEADLESS_PERMISSION_MODE_ENV]?.trim();
90
+ if (raw === undefined || raw === '') {
91
+ return { fields: {}, source: 'absent' };
92
+ }
93
+ if (HEADLESS_PERMISSION_WIRE_MODES.includes(raw)) {
94
+ // env 是显式垫层:五词全 stamp(含 default——stampDefault 显式垫,不依赖 absent-mode 缺省解释)。
95
+ return {
96
+ fields: permissionModeField(raw, { stampDefault: true }),
97
+ source: 'env',
98
+ };
99
+ }
100
+ // 3) 非法 env 值:一次性 warn + 回落现状(绝不 fail)。
101
+ const resolution = { fields: {}, source: 'absent' };
102
+ if (!warnedInvalidEnv) {
103
+ warnedInvalidEnv = true;
104
+ resolution.warning = `sema: ${HEADLESS_PERMISSION_MODE_ENV}="${raw}" is not a recognized permission mode (expected one of: ${HEADLESS_PERMISSION_WIRE_MODES.join(', ')}) — ignoring it, this run keeps the default permission behavior`;
105
+ }
106
+ return resolution;
107
+ }
108
+ /** 测试钩子:重置一次性告警 latch。 */
109
+ export function _resetHeadlessPermissionModeWireForTest() {
110
+ warnedInvalidEnv = false;
111
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * src/sema/headlessReconnectWire.ts — 重连腿 M2 · R1 换车道续收(design/HEADLESS-RECONNECT-DESIGN.md
3
+ * §3 R1 + §6 M2,2026-07-16)。M1(detachWire 头 + 信号 cancel 兜底,2545900)已发运;本模块把
4
+ * engine-alive-idle 判死升级为「先重连、耗尽才判死」——但只在 headless `-p` detach 车道。
5
+ *
6
+ * 触发点(design §3,四条同时成立才进重连分支):
7
+ * ① tasks/stream 以 SseIdleError 收场(流静默超 grace)——其它失败形态(engine-down/restarted/
8
+ * 4xx/断连 reset)原样穿透,现行路径一字不动;
9
+ * ② headless `-p` ∧ detach 头已随首次 POST 发出——本 wrapper 只在 liveClient.tasks.stream 的
10
+ * opts.headers 携带 x-detach-on-disconnect: 'true' 时套上(唯一 set 头的调用方 =
11
+ * seamQueryEngine.ask;交互 REPL(seamQuery)与 headless-bg worker 都不传 headers ⇒ 源码级
12
+ * 证明这两条车道一根毛不动,断连=取消语义保持,铁律 5);
13
+ * ③ durableTaskId 已捕获(X-Task-Id 早捕获,liveClient onResponse)——没有 id 无从换道,原样抛;
14
+ * ④ 每次重连前置 probeEngineAlive(批 D /health 探针复用)——引擎死了绝不空等,原样抛走
15
+ * 现行 engine-down 判死。
16
+ *
17
+ * 铁律对表:
18
+ * · signal.aborted 恒先行(铁律 3,seamQuery.ts:1073 契约):重试环每一轮入口先查 aborted,
19
+ * 用户中止绝不进重连分支(连探活都不做);续收中途 aborted → 干净 return(镜像 SDK parseSse
20
+ * 的 abort 语义),上游 print.ts 的 SIGINT 处置(M1 cancel 兜底)全权接管。
21
+ * · 有界(铁律 5/任务 §4):每 run 最多 HEADLESS_RECONNECT_MAX=2 次壳级重连;耗尽 → 重抛
22
+ * 【原始】SseIdleError(对象恒原件,后续 diagnoseSseIdleTear 打 engine-alive-idle 标 →
23
+ * seamQueryEngine 现行诚实判死文案,字节不变)。
24
+ * · 15min STREAM_MAX_DURATION 硬顶(任务 §3):runs.events 车道被 cap 断开时,SDK sse.js
25
+ * parseSse 自带 Last-Event-ID 立即重连(frame.id 锁存 → 重开带 lastEventId;无进展 5 连败
26
+ * 才放弃)——cap 重连发生在【同一次】openEvents 调用内部,计入同一次重连预算(不额外扣),
27
+ * 铁律 4「不动 SDK」同时满足:runs.events 消费面已有,本模块零 SDK 改动。
28
+ * · 与 headless-bg 有界重发腿(bgTearResends)互斥:重发腿在 seamQuery.query(REPL 形 worker),
29
+ * 其触发词表本就只含 restarted/engine-down(seamQuery.ts:1143),engine-alive-idle 从不重发;
30
+ * 本重连腿只在 -p detach 车道。两腿车道 + 词表双重不相交,结构性互斥。
31
+ *
32
+ * ── R1 去重锚选型(任务书要求写明选型与理由)────────────────────────────────────────────────
33
+ * runs.events 车道:server streamRunEvents 把账本 seq 写成 SSE `id:` 行(formatEvent id: ev.seq),
34
+ * SDK sse.js 把 frame.id 回贴到 ev.id ⇒ 每个续收帧带单调递增数字 seq。锚 = lastSeenSeq:
35
+ * a) 跨壳级 attempt 续传:第二次 openEvents 传 Last-Event-ID=lastSeenSeq(server 从其后回放);
36
+ * b) 防双投:丢弃 seq ≤ lastSeenSeq 的重放帧(server 从头回放/416 回卷都兜住)。
37
+ * 首次换道(tasks/stream → runs.events):tasks/stream 帧无 id(SDK tasks.js 不回贴 frame.id,
38
+ * server sync 腿也不写 id: 行)⇒ 无从定位,只能从起点(不带 Last-Event-ID)拉。防双投依据 =
39
+ * server 实证(1.205/1.209 dist 逐行核对):sync /v1/tasks/stream 腿【不】逐事件落账(runs.js 的
40
+ * appendEvent 只在 async runs 腿与 resume 腿;sync 腿只 createRun + setTerminal(finalResult))⇒
41
+ * sync 腿 run 的账本在终局前恒空,从起点拉不存在「重放已消费帧」面;将来 server 若开始 sync 腿
42
+ * 落账,守卫 = ①终帧唯一(本 wrapper 首终帧即 return + adapter runStream 首终帧 return + 批 B
43
+ * 双帧守卫「恰一条 result」)②seq 锚(首帧起锁存,attempt 间绝不回放)③gap-check 记账:换道前
44
+ * 已消费的 delta 与账本聚合 text 事件无逐字节对齐关系,真正的精确锚需要 server 给 tasks/stream
45
+ * 帧发 id(引擎侧诉求,走黑板,非壳侧可解)。
46
+ *
47
+ * ── 账本空(现役 server sync 腿)时的续收降级(R1b)────────────────────────────────────────
48
+ * 同一实证的另一面:detach 后 run 后台跑完,账本无事件行,runs.events 只会心跳保活直到 run row
49
+ * 落终局,然后无终帧地收流(SDK 无进展 5 连败抛错)。此时 run 的终局【已在】run row 里
50
+ * (GET /v1/runs/:id → RunRecord.result = TaskResult,含完整最终文本)——续收环的 catch 里查
51
+ * run row:已终局 → 合成终帧(done{result} / failed)喂回同一消费管线,turn 以 rc=0 正常收尾,
52
+ * 最终答案全量保真(-p 的 result 面 = done.result.result 字符串,非增量拼接);未终局(还在跑/
53
+ * suspended)→ 计预算继续重试,耗尽才判死。中途 delta 的全帧保真(R1a 环)在账本有事件行时
54
+ * (resume 腿 run / 将来 sync 腿落账的 server)自动生效——两形态由同一环处理,帧结构差异
55
+ * (text/reasoning 聚合事件 vs *_delta)由 adapter eventToSdkMessage 既有 case 'text'/'reasoning'
56
+ * 消费面兜住。
57
+ */
58
+ import type { AgentEvent } from '@sema-agent/sdk';
59
+ /** 每 run 壳级重连预算(design 铁律 5)。cap/416 的 SDK 内部续传不计入(同一次 openEvents)。 */
60
+ export declare const HEADLESS_RECONNECT_MAX = 2;
61
+ /** GET /v1/runs/:id 的 run row 终局子集(SDK RunRecord;字段防御性读)。 */
62
+ export interface RunRowLike {
63
+ status?: string;
64
+ result?: unknown;
65
+ errorCode?: string;
66
+ error?: string;
67
+ }
68
+ export interface HeadlessReconnectDeps {
69
+ /** 引擎 baseUrl(探活用;凭证不经本模块——openEvents/getRun 闭包自带)。 */
70
+ baseUrl: string;
71
+ /** X-Task-Id 早捕获读数(liveClient onResponse 闭包);null = 未捕获 ⇒ 不换道。 */
72
+ getTaskId: () => string | null;
73
+ /** runs.events 换道开流(liveClient 闭包 → client.runs.events;lastEventId = 去重锚续传)。 */
74
+ openEvents: (taskId: string, lastEventId?: string) => AsyncGenerator<AgentEvent>;
75
+ /** run row 终局读数(R1b 降级捞终局;失败返回 null,永不抛)。 */
76
+ getRun: (taskId: string) => Promise<RunRowLike | null>;
77
+ signal?: AbortSignal;
78
+ /** 测试注入位;缺省 = 批 D probeEngineAlive(/health,2s 预算)。 */
79
+ probeAlive?: (baseUrl: string) => Promise<boolean>;
80
+ }
81
+ /** run row → 合成终帧(R1b)。带 result 对象的引擎终态(全词表)合成 done{result}——与
82
+ * tasks/stream 终帧字节同构,adapter terminalToSdkResult 全枚举分流([909]B1)原样生效
83
+ * (timeout→error_max_turns、blocked→error_during_execution、park→[884]A1 臂);裸 failed
84
+ * (无 result)合成 failed 事件。running / 无 result 的 park(可能还会被 approvals 解锁)null
85
+ * —— 计预算继续重试,耗尽才判死。 */
86
+ export declare function terminalEventFromRunRow(row: RunRowLike | null): AgentEvent | null;
87
+ /**
88
+ * withHeadlessR1Reconnect(live, deps) — the M2 R1 lane-switch wrapper.
89
+ *
90
+ * 位置:liveClient.tasks.stream 管线【最内层】(demux/HITL 桥/captureSessionId/normalizeToolNames/
91
+ * diagnoseSseIdleTear 之内)——续收帧与合成终帧流经与 live 帧完全相同的下游包装(工具名规整、
92
+ * sessionId 捕获、HITL 桥、批 B 守卫),零特殊路径。重连成功 → 生成器以终帧正常 return ⇒
93
+ * diagnoseSseIdleTear/tearVerdict 永不介入,turn 正常收尾;耗尽/不可换道 → 重抛原始 SseIdleError,
94
+ * 外层打标 + 现行判死文案,字节不变。
95
+ */
96
+ export declare function withHeadlessR1Reconnect(live: AsyncGenerator<AgentEvent>, deps: HeadlessReconnectDeps): AsyncGenerator<AgentEvent>;
@@ -0,0 +1,141 @@
1
+ import { isSseIdleError, probeEngineAlive } from './sseIdleTriage.js';
2
+ import { hostEnv } from './hostEnv.js';
3
+ /** 每 run 壳级重连预算(design 铁律 5)。cap/416 的 SDK 内部续传不计入(同一次 openEvents)。 */
4
+ export const HEADLESS_RECONNECT_MAX = 2;
5
+ /** SEMA_DEBUG 探针(headless `-p` 车道 Ink 未挂载,stderr 可用;绝不带凭证/帧内容)。 */
6
+ function debugLog(msg) {
7
+ if (hostEnv().SEMA_DEBUG) {
8
+ // eslint-disable-next-line no-console
9
+ console.error(`[sema] headless reconnect: ${msg}`);
10
+ }
11
+ }
12
+ /** 引擎终态词表全集(core 1.298 TaskStatus;server runs.js setTerminal(taskId, safe.status, …)
13
+ * 把引擎 status【原样】写进 run row ⇒ row.status 可以是全部 6 词,不只 completed/failed)。
14
+ * [909]B3 复核发现的真缺口:此前只认 completed/failed,timeout/blocked 终局的 run row 永不
15
+ * 合成终帧 → 续收环重试到预算耗尽 → 假判死(终局明明就在 row 里)。 */
16
+ const RUN_ROW_TERMINAL_STATUSES = new Set([
17
+ 'completed',
18
+ 'failed',
19
+ 'timeout',
20
+ 'blocked',
21
+ 'suspended',
22
+ 'needs_review',
23
+ ]);
24
+ /** run row → 合成终帧(R1b)。带 result 对象的引擎终态(全词表)合成 done{result}——与
25
+ * tasks/stream 终帧字节同构,adapter terminalToSdkResult 全枚举分流([909]B1)原样生效
26
+ * (timeout→error_max_turns、blocked→error_during_execution、park→[884]A1 臂);裸 failed
27
+ * (无 result)合成 failed 事件。running / 无 result 的 park(可能还会被 approvals 解锁)null
28
+ * —— 计预算继续重试,耗尽才判死。 */
29
+ export function terminalEventFromRunRow(row) {
30
+ if (!row || typeof row.status !== 'string')
31
+ return null;
32
+ if (RUN_ROW_TERMINAL_STATUSES.has(row.status)) {
33
+ if (row.result && typeof row.result === 'object') {
34
+ // sync 腿 setTerminal 存的就是终帧的 TaskResult(status 含 failed/timeout/blocked 形)——
35
+ // 与 tasks/stream 终帧 done{result} 字节同构,terminalToSdkResult 文案线原样生效。
36
+ return { type: 'done', result: row.result };
37
+ }
38
+ if (row.status === 'failed') {
39
+ return {
40
+ type: 'failed',
41
+ ...(row.errorCode ? { errorCode: row.errorCode } : {}),
42
+ errorMessage: row.error ?? 'run failed',
43
+ };
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * withHeadlessR1Reconnect(live, deps) — the M2 R1 lane-switch wrapper.
50
+ *
51
+ * 位置:liveClient.tasks.stream 管线【最内层】(demux/HITL 桥/captureSessionId/normalizeToolNames/
52
+ * diagnoseSseIdleTear 之内)——续收帧与合成终帧流经与 live 帧完全相同的下游包装(工具名规整、
53
+ * sessionId 捕获、HITL 桥、批 B 守卫),零特殊路径。重连成功 → 生成器以终帧正常 return ⇒
54
+ * diagnoseSseIdleTear/tearVerdict 永不介入,turn 正常收尾;耗尽/不可换道 → 重抛原始 SseIdleError,
55
+ * 外层打标 + 现行判死文案,字节不变。
56
+ */
57
+ export async function* withHeadlessR1Reconnect(live, deps) {
58
+ let originalTearErr;
59
+ try {
60
+ for await (const ev of live) {
61
+ yield ev;
62
+ const t = ev?.type;
63
+ if (t === 'done' || t === 'failed')
64
+ return; // 终帧已到 —— 首终帧即收官(批 B 双帧守卫同侧)
65
+ }
66
+ return; // 流干净收尾(mock 车道/abort return)——无终帧也不由本腿补,现行行为不变
67
+ }
68
+ catch (e) {
69
+ originalTearErr = e;
70
+ }
71
+ // ── 重试环:每轮入口按铁律顺序分诊,非命中一律重抛【原始】错误(现行失败路径字节不变)──
72
+ let attempts = 0;
73
+ let lastSeenSeq; // R1 去重锚(runs.events SSE id = 账本 seq;见模块头选型)
74
+ const probe = deps.probeAlive ?? probeEngineAlive;
75
+ for (;;) {
76
+ if (deps.signal?.aborted)
77
+ throw originalTearErr; // 铁律 3:用户中止恒先行,绝不进重连分支
78
+ if (!isSseIdleError(originalTearErr))
79
+ throw originalTearErr; // 只救 engine-alive-idle 的静默撕裂形
80
+ const taskId = deps.getTaskId();
81
+ if (!taskId)
82
+ throw originalTearErr; // X-Task-Id 未捕获(首轮早期窗)——无从换道,接受
83
+ if (attempts >= HEADLESS_RECONNECT_MAX) {
84
+ debugLog(`budget exhausted (${attempts}/${HEADLESS_RECONNECT_MAX}) — falling to the honest death path`);
85
+ throw originalTearErr; // 耗尽 → 现行 engine-alive-idle 判死(文案已诚实,M1)
86
+ }
87
+ attempts++;
88
+ if (!(await probe(deps.baseUrl)))
89
+ throw originalTearErr; // 铁律 5:引擎死 → 现行 engine-down 路径
90
+ debugLog(`attempt ${attempts}/${HEADLESS_RECONNECT_MAX} — switching to runs.events(${taskId}${lastSeenSeq !== undefined ? `, Last-Event-ID ${lastSeenSeq}` : ''})`);
91
+ try {
92
+ const cont = deps.openEvents(taskId, lastSeenSeq !== undefined ? String(lastSeenSeq) : undefined);
93
+ for await (const ev of cont) {
94
+ if (deps.signal?.aborted)
95
+ return; // 中止 → 干净收流(print.ts SIGINT 处置 + M1 cancel 兜底接管)
96
+ const rec = ev;
97
+ const t = rec?.type;
98
+ if (t === undefined)
99
+ continue; // 心跳帧(data:{})——车道保活杂音,不进消费管线
100
+ if (t === 'error') {
101
+ // STREAM_MAX_DURATION cap 帧(server streamSseLog):SDK parseSse 已锁存 Last-Event-ID
102
+ // 并自动重开(同一次 openEvents 内部,计入同一次预算)——本帧是车道协议杂音,不下发。
103
+ debugLog(`runs.events lane frame type=error code=${String(rec?.code ?? '')} — SDK resumes with Last-Event-ID internally`);
104
+ continue;
105
+ }
106
+ const seq = typeof rec?.id === 'string' && /^\d+$/.test(rec.id) ? Number(rec.id) : undefined;
107
+ if (seq !== undefined) {
108
+ if (lastSeenSeq !== undefined && seq <= lastSeenSeq)
109
+ continue; // 重放帧 → seq 锚去重(防双投)
110
+ lastSeenSeq = seq;
111
+ }
112
+ // 终帧日志必须在 yield 之前:下游(adapter runStream)拿到首终帧即 return 弃养本生成器,
113
+ // yield 之后的语句不再执行(受控腿 leg 1 实测)。return 语义不受影响(弃养即收官)。
114
+ if (t === 'done' || t === 'failed')
115
+ debugLog(`resumed to terminal (${String(t)}) after ${attempts} reconnect(s)`); // 判死解除
116
+ yield ev;
117
+ if (t === 'done' || t === 'failed')
118
+ return;
119
+ }
120
+ // 无终帧收流(SDK 只在 signal.aborted 时干净 return;防御分支)——按撕裂再走一轮预算
121
+ if (deps.signal?.aborted)
122
+ return;
123
+ debugLog('runs.events continuation ended without a terminal frame');
124
+ }
125
+ catch (contErr) {
126
+ // 续收环失败。现役 server sync 腿账本为空 ⇒ run 落终局后 SDK 以「无进展 5 连败」收场
127
+ // ——这不是死刑:终局已在 run row(R1b),查得到就合成终帧收官。
128
+ debugLog(`runs.events continuation failed: ${contErr instanceof Error ? contErr.message : String(contErr)}`);
129
+ if (deps.signal?.aborted)
130
+ throw originalTearErr; // 铁律 3:中止后不捞终局,交给上游中止处置
131
+ const row = await deps.getRun(taskId).catch(() => null);
132
+ const terminal = terminalEventFromRunRow(row);
133
+ if (terminal) {
134
+ debugLog(`run row terminal (${String(row?.status)}) — synthesizing the terminal frame (R1b)`);
135
+ yield terminal;
136
+ return;
137
+ }
138
+ // run 还在跑 / suspended / row 读不到 —— 计预算继续重试(环顶做 aborted/预算/探活分诊)
139
+ }
140
+ }
141
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * host.ts — **包级宿主装配**(B4;B3 交接报告的建议件 + 设计稿 §5.4 「web 宿主的最小接入面」)。
3
+ *
4
+ * ## 两种口,别混
5
+ *
6
+ * · `AdapterContext`(seam.ts)= **per-turn 切片** —— 时钟、铸号、signal、定时器、攒批节拍。
7
+ * 它随每个 `adapt()` 调用传进来,因为这些东西**每个 turn 可以不一样**(比如这一 turn 可中断)。
8
+ * · `installHost()`(本文件)= **进程/端级装配** —— settings 读取、fs、通知队列。
9
+ * 它们在会话开始之前就该在位,而且被**请求构造期**的代码调用(`hooksForWire()` /
10
+ * `scratchpadDirField()`),那时候根本没有 `ctx` 可传。
11
+ *
12
+ * ⇒ 判据:**这个能力是不是每 turn 会变?** 会 ⇒ ctx;不会 ⇒ installHost。
13
+ *
14
+ * ## 缺席语义(与 AdapterContext 同一条纪律)
15
+ *
16
+ * 🔴 缺席 = **该能力不启用**,不是报错、更不是「换个方式偷偷做」。库绝不 `require('fs')`、
17
+ * 绝不 `console.log`、绝不读 `process.env` 兜底 —— 那正是搬迁要切掉的东西。
18
+ * 但「静默不启用」有个已知的失败形:**宿主漏装了,而症状是某个面悄悄空掉**。所以每个口都带
19
+ * miss 计数(`hostPortMisses()`),宿主自检断言它恒为 0 —— 与 `notificationQueuePortMisses()`
20
+ * 同一套姿势([probe-must-prove-it-speaks]:不能让「什么都没有」看起来像「一切正常」)。
21
+ */
22
+ import type { AdapterLogLevel } from './seam.js';
23
+ import { type NotificationQueuePort } from './notifications.js';
24
+ /**
25
+ * settings 口 —— 用户 settings 文件的读取面(壳=三层合并后的文件读;web=center 下发;桌面=主进程)。
26
+ *
27
+ * 🔴 `shouldSkipHookDueToTrust` 是**安全门**不是取值:未受信目录的 hook 绝不上 wire。
28
+ * 端必须真实现它;不实现(端返回恒 false)= 把信任门拆了。所以它在 required 清单里。
29
+ */
30
+ export interface SettingsPort {
31
+ /** 按来源取 settings(cli `utils/settings/settings.ts` 的 `getSettingsForSource`)。 */
32
+ getSettingsForSource(source: string): unknown;
33
+ /** 信任门:该 hook 是否因来源不受信而跳过。**返回 true = 跳过**。 */
34
+ shouldSkipHookDueToTrust(hook: unknown): boolean;
35
+ }
36
+ /**
37
+ * fs 口 —— scratchpad 目录族(cli `utils/permissions/filesystem.ts`)。
38
+ * `ensureScratchpadDir` 是**真 IO**(mkdir);浏览器宿主整个口省略即可(⇒ 不 stamp scratchpadDir,
39
+ * additive 字段缺席对引擎无害)。
40
+ */
41
+ export interface FsPort {
42
+ getScratchpadDir(): string | undefined;
43
+ isScratchpadEnabled(): boolean;
44
+ /** 🔴 壳侧真身是 **async**(mkdir 0o700);端可返 void 或 Promise,库两种都吞(fire-and-forget)。 */
45
+ ensureScratchpadDir(): void | Promise<void>;
46
+ }
47
+ /** 定时器口(进程级默认;`ctx.setTimer` 仍可按 turn 覆盖)。 */
48
+ export interface TimersPort {
49
+ setTimer(ms: number, fn: () => void): unknown;
50
+ clearTimer(handle: unknown): void;
51
+ }
52
+ /** `installHost` 的入参 —— 全部可选,缺席即该能力不启用。 */
53
+ export interface HostPorts {
54
+ log?(level: AdapterLogLevel, msg: string, data?: unknown): void;
55
+ probe?(channel: string, line: string): void;
56
+ /** 通知队列(B2 引入)。经本口装 == 直接调 `installNotificationQueuePort`,单一真源不分裂。 */
57
+ queue?: NotificationQueuePort | null;
58
+ timers?: TimersPort;
59
+ settings?: SettingsPort;
60
+ fs?: FsPort;
61
+ }
62
+ /**
63
+ * 装配宿主口。**可多次调用做增量装配**(只覆盖本次给出的键),便于分模块装。
64
+ * 传 `queue` 会直通 `installNotificationQueuePort` —— 队列的真源仍在 notifications.ts,
65
+ * 本文件不另存一份([paired-mechanisms-must-share-premise]:两处各存一份 = 装了 A 读了 B)。
66
+ * 返回一个还原函数(测试/热重载用)。
67
+ */
68
+ export declare function installHost(ports: HostPorts): () => void;
69
+ /** 日志(缺席 = 静默,**不计 miss**:诊断面本就是可选的)。 */
70
+ export declare function hostLog(level: AdapterLogLevel, msg: string, data?: unknown): void;
71
+ /** 行式探针(缺席 = 静默,不计 miss:同上)。 */
72
+ export declare function hostProbe(channel: string, line: string): void;
73
+ /** settings 口(缺席 = undefined + 计 miss:漏装会让 hooks 整条 wire 静默空掉)。 */
74
+ export declare function hostSettings(): SettingsPort | undefined;
75
+ /** fs 口(缺席 = undefined + 计 miss)。 */
76
+ export declare function hostFs(): FsPort | undefined;
77
+ /** 定时器口(缺席 = undefined,不计 miss:T40 竞速本就允许不启用)。 */
78
+ export declare function hostTimers(): TimersPort | undefined;
79
+ /**
80
+ * 🔴 宿主自检出口 —— 恒应为空对象。非空 = 有口漏装,**对应的面已经在静默失效**。
81
+ * 含通知队列的 miss(从 notifications.ts 汇过来,宿主只需看这一个数)。
82
+ */
83
+ export declare function hostPortMisses(): Record<string, number>;
84
+ /**
85
+ * 测试钩:清空装配与计数。
86
+ * 🔴 队列那一路必须走 `_resetNotificationQueuePortForTest()` 而不是 `installNotificationQueuePort(null)`
87
+ * —— 后者只卸口、**不清 miss 计数**,于是 `hostPortMisses()` 会带着上一段测试的历史 miss,
88
+ * 让「未装配时 misses 为空」这类断言恒假(B4 首写时就撞上了)。
89
+ */
90
+ export declare function _resetHostForTest(): void;
package/dist/host.js ADDED
@@ -0,0 +1,84 @@
1
+ import { _resetNotificationQueuePortForTest, installNotificationQueuePort, notificationQueuePortMisses, } from './notifications.js';
2
+ let host = {};
3
+ const misses = Object.create(null);
4
+ const warned = new Set();
5
+ function miss(name) {
6
+ misses[name] = (misses[name] ?? 0) + 1;
7
+ if (!warned.has(name)) {
8
+ warned.add(name);
9
+ // eslint-disable-next-line no-console
10
+ console.error(`[client-core] host port "${name}" NOT installed — the surface it drives is silently inert. ` +
11
+ 'Host must call installHost({ ' +
12
+ name +
13
+ ': … }) at startup (see host.ts).');
14
+ }
15
+ return undefined;
16
+ }
17
+ /**
18
+ * 装配宿主口。**可多次调用做增量装配**(只覆盖本次给出的键),便于分模块装。
19
+ * 传 `queue` 会直通 `installNotificationQueuePort` —— 队列的真源仍在 notifications.ts,
20
+ * 本文件不另存一份([paired-mechanisms-must-share-premise]:两处各存一份 = 装了 A 读了 B)。
21
+ * 返回一个还原函数(测试/热重载用)。
22
+ */
23
+ export function installHost(ports) {
24
+ const prev = { ...host };
25
+ if ('log' in ports)
26
+ host.log = ports.log;
27
+ if ('probe' in ports)
28
+ host.probe = ports.probe;
29
+ if ('timers' in ports)
30
+ host.timers = ports.timers;
31
+ if ('settings' in ports)
32
+ host.settings = ports.settings;
33
+ if ('fs' in ports)
34
+ host.fs = ports.fs;
35
+ if ('queue' in ports)
36
+ installNotificationQueuePort(ports.queue ?? null);
37
+ return () => {
38
+ host = prev;
39
+ };
40
+ }
41
+ /** 日志(缺席 = 静默,**不计 miss**:诊断面本就是可选的)。 */
42
+ export function hostLog(level, msg, data) {
43
+ host.log?.(level, msg, data);
44
+ }
45
+ /** 行式探针(缺席 = 静默,不计 miss:同上)。 */
46
+ export function hostProbe(channel, line) {
47
+ host.probe?.(channel, line);
48
+ }
49
+ /** settings 口(缺席 = undefined + 计 miss:漏装会让 hooks 整条 wire 静默空掉)。 */
50
+ export function hostSettings() {
51
+ return host.settings ?? miss('settings');
52
+ }
53
+ /** fs 口(缺席 = undefined + 计 miss)。 */
54
+ export function hostFs() {
55
+ return host.fs ?? miss('fs');
56
+ }
57
+ /** 定时器口(缺席 = undefined,不计 miss:T40 竞速本就允许不启用)。 */
58
+ export function hostTimers() {
59
+ return host.timers;
60
+ }
61
+ /**
62
+ * 🔴 宿主自检出口 —— 恒应为空对象。非空 = 有口漏装,**对应的面已经在静默失效**。
63
+ * 含通知队列的 miss(从 notifications.ts 汇过来,宿主只需看这一个数)。
64
+ */
65
+ export function hostPortMisses() {
66
+ const out = { ...misses };
67
+ const q = notificationQueuePortMisses();
68
+ if (q > 0)
69
+ out.queue = q;
70
+ return out;
71
+ }
72
+ /**
73
+ * 测试钩:清空装配与计数。
74
+ * 🔴 队列那一路必须走 `_resetNotificationQueuePortForTest()` 而不是 `installNotificationQueuePort(null)`
75
+ * —— 后者只卸口、**不清 miss 计数**,于是 `hostPortMisses()` 会带着上一段测试的历史 miss,
76
+ * 让「未装配时 misses 为空」这类断言恒假(B4 首写时就撞上了)。
77
+ */
78
+ export function _resetHostForTest() {
79
+ host = {};
80
+ for (const k of Object.keys(misses))
81
+ delete misses[k];
82
+ warned.clear();
83
+ _resetNotificationQueuePortForTest();
84
+ }
package/dist/index.d.ts 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`
@@ -85,3 +107,16 @@ export * from './adapter/downstream/turnUsageToModelUsage.js';
85
107
  export * from './adapter/downstream/eventToSdkMessage.js';
86
108
  export * from './adapter/downstream/terminalToSdkResult.js';
87
109
  export * from './adapter/runStream.js';
110
+ export * from './request/taskRequest.js';
111
+ export * from './clientSlice.js';
112
+ export * from './host.js';
113
+ export * from './scratchpadWireCaps.js';
114
+ export * from './toolResult.js';
115
+ export * from './diff/patch.js';
116
+ export * from './sandboxWire.js';
117
+ export * from './scenarioWire.js';
118
+ export * from './finalVerifyWire.js';
119
+ export * from './limitsWire.js';
120
+ export * from './interactiveToolsWire.js';
121
+ export * from './headlessPermissionModeWire.js';
122
+ export * from './headlessReconnectWire.js';