@sema-agent/client-core 0.44.0 → 0.46.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.
package/dist/adapt.js CHANGED
@@ -35,6 +35,8 @@ export const ADAPTER_COVERAGE = {
35
35
  'human_input',
36
36
  // #318 件①(2026-08-21):`engine_notice` 引擎结构化通告 —— 投 chrome(开集,一个码都不判)。
37
37
  'engine_notice',
38
+ // #323 / core #447:assistant 散文段边界 → chrome text_segment_end(子流断闸;本批**不**动文本缓冲)。
39
+ 'text_end',
38
40
  'prompt_suggestions',
39
41
  'retry_status',
40
42
  'task_progress',
@@ -44,6 +44,8 @@ export const INTERNAL_SDK_ARM_TYPES = new Set([
44
44
  'turn_usage',
45
45
  // #310 / #318 件①:引擎结构化通告的会话面(raw 预分派铸点,见 eventToSdkMessage 顶部)。
46
46
  'engine_notice',
47
+ // #323 / core #447:assistant 流式**散文段边界**(raw 预分派铸点,见 `textEndProjection` 头注)。
48
+ 'text_end',
47
49
  ]);
48
50
  /** Wrap neutral content blocks in the CC `assistant` message envelope. */
49
51
  function assistantArm(ctx, content) {
@@ -107,6 +109,22 @@ export function eventToSdkMessage(ev, ctx) {
107
109
  if (ev.type === 'engine_notice') {
108
110
  return engineNoticeProjection(ev, ctx);
109
111
  }
112
+ // ── `text_end` raw 预分派(#323 / core #447,core ≥5.63 / server ≥7.50;契约见 textEndProjection)──
113
+ //
114
+ // 🔴 **为什么是 raw 预分派而不是一条 `case`**(与 `engine_notice` 当年逐字同因):本臂**还没进
115
+ // 已发布 SDK 的 `AgentEvent` union`** —— 亲验 npm `@sema-agent/sdk@7.2.0`(latest,本批当日
116
+ // `npm view @sema-agent/sdk dist-tags` 直读)的真 tarball:`dist/` 全树零 `text_end`。
117
+ // 在这样的 union 上写 `case 'text_end'` 是编译错(`ev.type` 上没有这个字面量)。
118
+ // 🔴 **这不是「按源码将就接」**(接入文档宪法):消费契约取自 **core 5.63.0 的已发布 `dist/core/
119
+ // types.d.ts`**(`TaskEvent` 的 `{type:'text_end'; content: string} & TaskEventIdentity` 臂,
120
+ // 带完整语义头注)与 **server 7.50.0 的已发布 dist**(`http/routes/tasks.js` 的 live SSE 与
121
+ // durable 两处投影 + `fleet/subagent-tail-bus.js` 的 tail 投影,三腿都在发),不是抄 sdk src。
122
+ // 🔴 **到期复核(自退休,不靠人记)**:预分派用 `(ev as {type?:unknown})` 形读判别键,**不收窄** `ev`
123
+ // ⇒ 臂一进 union,switch 的 `default` 仍看得见它,B5 穷举断言 `assertNeverArm` **编译期真红**,
124
+ // 逼下一棒把它搬进 switch。搬进去时行为一字不改(下面的投影函数原样复用)。
125
+ if (ev.type === 'text_end') {
126
+ return textEndProjection(ev, ctx);
127
+ }
110
128
  switch (ev.type) {
111
129
  // ── `human_input`(core 5.14.0 design/171 / server 7.4.0 SSE,[3017]/[3020])────────────
112
130
  // 🔴 **到期复核已兑现(sdk 6.9.0 提货,2026-08-08)**:本臂此前是 switch **之前**的一条 raw
@@ -608,6 +626,70 @@ export function eventToSdkMessage(ev, ctx) {
608
626
  return dropped('unknown_arm', String(ev.type ?? 'unknown'));
609
627
  }
610
628
  }
629
+ /**
630
+ * `text_end` → 中性内部**段边界**臂(#323 / core #447)。
631
+ *
632
+ * ── 这是什么 ────────────────────────────────────────────────────────────────────────────────
633
+ * 「assistant 的这一段散文写完了」—— 模型关掉了那个 text content block,而它的字节刚刚以
634
+ * `text_delta` 流过。core 的臂注逐字:`content` = **该段的权威全文**(与那一段 delta 的拼接逐字节
635
+ * 相等,取自 brain 自己的累加),消费方**据它提交这一段**,而不是信自己的 delta 缝合。
636
+ *
637
+ * 🔴 **它的存在意义 = 让消费方撤掉 idle-flush 启发式**(core 臂注点名的那件事)。本包的
638
+ * `adapt/textStream.ts` 至今用「静默 1.5s + 句末/段末边界 + 每段一刀」猜段边界(#323 症状① 的止血
639
+ * 件,`takeAnswerSegmentOnIdle`);那是猜,这是引擎明说。CC 对位:CC 的 agent 流在 provider 的
640
+ * `content_block_stop` 上把每个写完的 content block 当作一条独立 assistant 消息发出去 —— 块结束
641
+ * **就是**分段信号,单进程消费方从 provider 流上原生读到它;本臂是同一个边界被搬到 TaskEvent wire 上。
642
+ *
643
+ * ── 为什么投**内部臂**,而不是 transcript 行、也不是 `not_in_slice` ─────────────────────────
644
+ * · **绝不铸 transcript 行**:`content` 是**已经流过**的那一段的全文(durable 腿的 `text` 臂才是
645
+ * 「这一段的唯一载体」)。在 live 腿把它再铸成一条 assistant 消息 = 同一段文字上屏两遍。
646
+ * · **绝不 `not_in_slice`**:那一档走 `kind:'none'`,是**静默**的。本帧带着消费方真正需要的判别
647
+ * 信号(段边界 + 权威全文),静默丢 = 把一个真实能力缺口做成 fail-open,正是本文件对
648
+ * `approval_request` 那段头注点名的病形。⇒ 投中性内部臂,宿主(壳 REPL 桥 / web / desktop 座位层)
649
+ * 在臂上读它、决定何时提交段。
650
+ * · 📋 **本包内的接线如实留白**(不在本批做):要让 `adapt/textStream.ts` 真的**撤掉** idle-flush,
651
+ * 得先答一个行为面问题 —— core 臂注明写这是「诚实缺席」的位(只有会报块结束的 brain 才发它,
652
+ * 自定义 brain 可能整条流一帧都没有),所以消费方必须按**每条流**判「这条流带不带边界帧」再决定
653
+ * 退不退启发式。那是一条带状态的策略,属行为面改动,按宪法三问单独走,不在本提货批里顺手加。
654
+ * 本批只把信号送到宿主手上(壳侧接线是下一棒),缺口写在这里,不留白。
655
+ *
656
+ * ── 畸形与空段(fail-closed 方向 + 对位 core 的「空段无帧」)────────────────────────────────
657
+ * · `content` **非串** ⇒ `malformed`:承重位读不动(wire 是 JSON,SDK 只 JSON.parse 不校型)。
658
+ * · `content` **空串** ⇒ `empty_payload`(不是 malformed,也不是丢帧):core 臂注逐字「只为**有
659
+ * 字节**的段发帧;空 text block 静默关闭 —— 一个没有段的边界会渲出幻影行」。所以空串要么是
660
+ * 降级中继、要么是未来的契约变形,两种都**不该**让消费方去提交一个空段。与 `diagnostics` 的
661
+ * `files: []`(ADAPTER-F6)同一档:我们投影这条臂,只是这一帧没有可提交的段。
662
+ * · `eventId` / `parentToolCallId`(`TaskEventIdentity`,server 两腿都 stamp)按既有姿势透传 ——
663
+ * 🔴 `parentToolCallId` 尤其不能剥:子流(子代 / 编排)的段边界绝不能去提交 **leader** 的段
664
+ * (与本文件 `reasoning_delta` / `status` 两臂同一条 lane 身份纪律,#9 与跨 lane 状态破坏案)。
665
+ *
666
+ * ⚠️ **UNTRUSTED、仅展示**:`content` 是模型输出,契约与 `text_delta` 同 —— 渲染,绝不回喂模型。
667
+ */
668
+ function textEndProjection(ev, ctx) {
669
+ const content = ev.content;
670
+ if (typeof content !== 'string')
671
+ return dropped('malformed', 'text_end');
672
+ // 🔴 **lane 身份位坏了就整帧 fail-closed**(异源对抗复审第三轮 [medium] 采纳;与本文件
673
+ // `applyBgNotification` 对 `parentTaskId` 的 B3-DIRTY 处置逐字同族):`parentToolCallId` 是
674
+ // **子流断闸的锚**。键在场却不是串时,若按「不是串就当没有」处理,这条**子代**的段边界会被
675
+ // 擦掉 lane 标记、当成 **leader** 的段边界上到宿主面 —— 宿主据它去提交/对账宿主自己的那一段,
676
+ // 正是本臂头注点名要防的跨 lane 状态破坏,而且是 fail-**open** 方向。
677
+ // 三态与 B3-DIRTY 同:键缺席/`undefined` ⇒ 本来就是 leader 帧(照旧);键在场却非串(含 `null`,
678
+ // wire schema 只允许缺席或 string)⇒ `malformed` 丢弃并留痕,坏值不许买路。
679
+ if (ev.parentToolCallId !== undefined && typeof ev.parentToolCallId !== 'string') {
680
+ return dropped('malformed', 'text_end');
681
+ }
682
+ if (content.length === 0)
683
+ return nothing('empty_payload');
684
+ return projected(stamp(ctx, armBody({
685
+ type: 'text_end',
686
+ content,
687
+ ...(typeof ev.eventId === 'string' ? { eventId: ev.eventId } : {}),
688
+ ...(typeof ev.parentToolCallId === 'string'
689
+ ? { parentToolCallId: ev.parentToolCallId }
690
+ : {}),
691
+ })));
692
+ }
611
693
  /**
612
694
  * `engine_notice` → 中性内部通告臂(#310 / #318 件①,契约 = server `ASSISTANT-WIRE-CONTRACT` 附录 D
613
695
  * + openapi `Event_engine_notice`)。
@@ -1,11 +1,37 @@
1
- /** 当前会话的 session 参数值;undefined = 端未装 SessionPort,或端自己也还没有值。 */
1
+ /**
2
+ * W1 带 key 变体(design/285 批 0,P-31 正位解的取值层)——**某一个会话槽**的 session 参数值。
3
+ *
4
+ * 为什么必须有它:多会话宿主每个会话一个 `sessionKey` 槽(`installHostFor(key, {session})`),
5
+ * 而零参形只认 `DEFAULT_SESSION_KEY`。fleet 台账的会话锚要跟着 ledger 的 `sessionKey` 走,
6
+ * 取值这一跳就不能再写死默认槽(在册局限 **P-31** 的第一处断点)。
7
+ *
8
+ * 🔴 [1501] 空串归一**只此一处**(恒不返回空串:要么真值要么 undefined)—— 消费点绝不重铸这条规则。
9
+ * 🔴 缺席语义与零参形逐字同:该键未装 `SessionPort`(或端自己也还没有值)⇒ `undefined` + 计该键 miss
10
+ * (`hostPortMissesFor(key)` 可点名),不是「少个查询参数」而是该键的 durable 读面整条 404。
11
+ */
12
+ export declare function engineSessionParamFor(sessionKey: string): string | undefined;
13
+ /**
14
+ * 当前会话的 session 参数值;undefined = 端未装 SessionPort,或端自己也还没有值。
15
+ * 零参形 = `engineSessionParamFor(DEFAULT_SESSION_KEY)` 的兼容层(单一取值链,不抄第二份)。
16
+ */
2
17
  export declare function engineSessionParam(): string | undefined;
3
18
  /** spread 便捷形:`{ ...engineSessionParamSpread() }` → `{ session: '<sid>' }` 或 `{}`。 */
4
19
  export declare function engineSessionParamSpread(): {
5
20
  session?: string;
6
21
  };
22
+ /**
23
+ * W1 带 key 变体(design/285 批 3):**某一个会话槽**此刻在飞的 run。
24
+ *
25
+ * 为什么必须有它(异源对抗复审 [high]):`/compact` 这类「打这条会话此刻在飞的那条 run」的动词
26
+ * 在批 3 之后按槽取 wire target 与 `?session=`,而 run id 若还从默认槽读,就成了
27
+ * 「**A 的 run id 发到 B 的服务器**」—— 比换锚之前更坏(之前三件一致地错在同一个槽上)。
28
+ * 一条动词的**槽键必须一以贯之**:目标、会话、run id、能力位、pending 状态,全按同一个 key。
29
+ */
30
+ export declare function activeEngineRunIdFor(sessionKey: string): string | undefined;
7
31
  /**
8
32
  * 当前活跃引擎 run 的 id(壳 = `engineToolDetach.getActiveEngineTaskId()`)。
9
33
  * 库内多处 durable 动词要「宿主 run」做寻址第一跳,统一从这里取,不各自 import 端模块。
34
+ * 零参形 = `activeEngineRunIdFor(DEFAULT_SESSION_KEY)` 的兼容层(`hostSession()` 本身就是
35
+ * `hostSessionFor(DEFAULT_SESSION_KEY)`,取值链逐字等价,含 miss 计数那一跳)。
10
36
  */
11
37
  export declare function activeEngineRunId(): string | undefined;
@@ -20,22 +20,53 @@
20
20
  * taskStop / subagentOutput 三面一律 404 fail-closed ⇒ 子代读面整条静默哑掉。所以
21
21
  * `hostSession()` 计 miss,宿主自检 `hostPortMisses()` 恒应为空。
22
22
  */
23
- import { hostSession } from './host.js';
24
- /** 当前会话的 session 参数值;undefined = 端未装 SessionPort,或端自己也还没有值。 */
25
- export function engineSessionParam() {
26
- const sid = hostSession()?.currentSessionId();
23
+ import { hostSessionFor } from './host.js';
24
+ import { DEFAULT_SESSION_KEY } from './sessionSlot.js';
25
+ /**
26
+ * W1 带 key 变体(design/285 批 0,P-31 正位解的取值层)——**某一个会话槽**的 session 参数值。
27
+ *
28
+ * 为什么必须有它:多会话宿主每个会话一个 `sessionKey` 槽(`installHostFor(key, {session})`),
29
+ * 而零参形只认 `DEFAULT_SESSION_KEY`。fleet 台账的会话锚要跟着 ledger 的 `sessionKey` 走,
30
+ * 取值这一跳就不能再写死默认槽(在册局限 **P-31** 的第一处断点)。
31
+ *
32
+ * 🔴 [1501] 空串归一**只此一处**(恒不返回空串:要么真值要么 undefined)—— 消费点绝不重铸这条规则。
33
+ * 🔴 缺席语义与零参形逐字同:该键未装 `SessionPort`(或端自己也还没有值)⇒ `undefined` + 计该键 miss
34
+ * (`hostPortMissesFor(key)` 可点名),不是「少个查询参数」而是该键的 durable 读面整条 404。
35
+ */
36
+ export function engineSessionParamFor(sessionKey) {
37
+ const sid = hostSessionFor(sessionKey)?.currentSessionId();
27
38
  return typeof sid === 'string' && sid.length > 0 ? sid : undefined;
28
39
  }
40
+ /**
41
+ * 当前会话的 session 参数值;undefined = 端未装 SessionPort,或端自己也还没有值。
42
+ * 零参形 = `engineSessionParamFor(DEFAULT_SESSION_KEY)` 的兼容层(单一取值链,不抄第二份)。
43
+ */
44
+ export function engineSessionParam() {
45
+ return engineSessionParamFor(DEFAULT_SESSION_KEY);
46
+ }
29
47
  /** spread 便捷形:`{ ...engineSessionParamSpread() }` → `{ session: '<sid>' }` 或 `{}`。 */
30
48
  export function engineSessionParamSpread() {
31
49
  const s = engineSessionParam();
32
50
  return s ? { session: s } : {};
33
51
  }
52
+ /**
53
+ * W1 带 key 变体(design/285 批 3):**某一个会话槽**此刻在飞的 run。
54
+ *
55
+ * 为什么必须有它(异源对抗复审 [high]):`/compact` 这类「打这条会话此刻在飞的那条 run」的动词
56
+ * 在批 3 之后按槽取 wire target 与 `?session=`,而 run id 若还从默认槽读,就成了
57
+ * 「**A 的 run id 发到 B 的服务器**」—— 比换锚之前更坏(之前三件一致地错在同一个槽上)。
58
+ * 一条动词的**槽键必须一以贯之**:目标、会话、run id、能力位、pending 状态,全按同一个 key。
59
+ */
60
+ export function activeEngineRunIdFor(sessionKey) {
61
+ const id = hostSessionFor(sessionKey)?.activeRunId();
62
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
63
+ }
34
64
  /**
35
65
  * 当前活跃引擎 run 的 id(壳 = `engineToolDetach.getActiveEngineTaskId()`)。
36
66
  * 库内多处 durable 动词要「宿主 run」做寻址第一跳,统一从这里取,不各自 import 端模块。
67
+ * 零参形 = `activeEngineRunIdFor(DEFAULT_SESSION_KEY)` 的兼容层(`hostSession()` 本身就是
68
+ * `hostSessionFor(DEFAULT_SESSION_KEY)`,取值链逐字等价,含 miss 计数那一跳)。
37
69
  */
38
70
  export function activeEngineRunId() {
39
- const id = hostSession()?.activeRunId();
40
- return typeof id === 'string' && id.length > 0 ? id : undefined;
71
+ return activeEngineRunIdFor(DEFAULT_SESSION_KEY);
41
72
  }
@@ -34,6 +34,21 @@
34
34
  * 关流封装),壳的 `engineBgProbe.ts:8-9` 自述「引擎 fleet 面只有 SSE,没有一次性口」已过期。
35
35
  * 本包出 `fleetSnapshotOptions()`;`engineBgProbe` 的手抄开流即断由壳侧换 verb(见交接报告)。
36
36
  *
37
+ * ── design/285(P-31 正位解,批 0+1)—— per-stream ingress + epoch ──────────────────────────────
38
+ * `fleetStreamOptions()` / `fleetSnapshotOptions()` 是 **module 级、只认默认槽会话**的形,对单会话
39
+ * 宿主(cli 及今天的三端)**正确**,原样保留。keyed 多会话宿主不给它们加 `*For` 兄弟 —— 一个
40
+ * module 级 keyed helper 与 ledger 的绑定状态**互不可见**,会造出两条互斥的假象(用 B 参数开了 B 流
41
+ * 而 ledger 显示未绑定 / 调了 helper 却把返回值丢掉而 ledger 显示已绑定):它测的是「你调了哪个
42
+ * helper」,不是「流是按哪个 key 开的」。
43
+ * ⇒ **keyed 开流参数的唯一正身 = `ledger.issueStream()` 发的 ingress**:它在开流那一刻**一次性捕获**
44
+ * `{sessionKey, session, epoch}` 并且**帧只能经它进入 ledger**,于是「绑定」不再是一句声明,而是
45
+ * 帧真正走过的那条通道。重连 = 新 ingress + epoch+1,旧 ingress 的迟到帧丢弃并计数
46
+ * (`droppedStaleIngress`)—— 结构性堵死「{旧 runId, 新 session}」这类错组合。
47
+ * snapshot 走**独立 ingress**,绝不改写 stream 锚。
48
+ * 🔴 本设计**挡不住**宿主把 A 会话的流喂进 B 的 ingress —— 所以可信谓词**按臂拆分**:内容自校的
49
+ * `own_root` 解封,零内容自校的 `server_fail_closed` 在 keyed 上恒封顶。见
50
+ * {@link BgNotificationAcceptEvidence} 与 `docs/INTEGRATION-CLIENTS.md` §7c P-31。
51
+ *
37
52
  * ── 单实例纪律(REF-CC-044,fleet2-06:per-sessionKey 化,W1/design/161 同款原语)────────────────
38
53
  * `liveBgViews` 是 module 级 `Map<sessionKey, Set<LiveBgView>>`:写者 = 每个 `createFleetLedger()`
39
54
  * (登记进自己的 `opts.sessionKey`,零参 = `DEFAULT_SESSION_KEY`),读者 = 引擎温切门
@@ -41,7 +56,7 @@
41
56
  * 两份实例 = 温切门恒读空 ⇒ respawn 会静默杀掉在飞 bg 子代;per-session 化同时堵住了跨会话污染
42
57
  * (A 会话的 `clearAllRetainedFleetRows` 不该清掉 B 会话的池,B 断线也不该把 A 标 fresh)。
43
58
  */
44
- import type { FleetFrame, FleetTaskRow } from '@sema-agent/sdk';
59
+ import type { FleetFrame, FleetTaskRow, FleetWorkflowRow } from '@sema-agent/sdk';
45
60
  import { type FleetTaskView, type FleetWorkflowView, type ProjectTasksOptions } from './fleetProjection.js';
46
61
  /** 终态行留存宽限窗(187 的 agents 页把完成会话留在列表;窗过即净,防长会话堆行)。 */
47
62
  export declare const TERMINAL_RETAIN_MS = 60000;
@@ -98,17 +113,91 @@ export declare function __resetFleetLedgerRegistryForTests(): void;
98
113
  /**
99
114
  * `client.fleet.stream(...)` 的会话过滤参数。缺席(端未装 SessionPort / 还没有会话 id)⇒ `{}`
100
115
  * = principal 级视图,**与 1.37 之前逐字同行为**(不是错误态)。
116
+ *
117
+ * 🔴 **默认槽形**(design/285 §2.1):读的是 `DEFAULT_SESSION_KEY` 槽的 `SessionPort`。单会话宿主
118
+ * (cli 及今天的三端)用它**正确**;**keyed 多会话宿主别用它开流** —— 用
119
+ * `createFleetLedger(hooks, { sessionKey }).issueStream()` 拿 `ingress.options`,那是本包对
120
+ * keyed 开流参数的唯一承诺形(理由见文件头 design/285 段)。
101
121
  */
102
122
  export declare function fleetStreamOptions(): {
103
123
  session?: string;
104
124
  };
105
- /** G19:`client.fleet.snapshot(...)` 的参数(同上;首屏/温切探针用)。 */
125
+ /** G19:`client.fleet.snapshot(...)` 的参数(同上,同为默认槽形;首屏/温切探针用)。
126
+ * keyed 宿主走 `ledger.issueSnapshot(opts)`。 */
106
127
  export declare function fleetSnapshotOptions(opts?: {
107
128
  timeoutMs?: number;
108
129
  }): {
109
130
  session?: string;
110
131
  timeoutMs?: number;
111
132
  };
133
+ /**
134
+ * ingress 的公共面(stream / snapshot 两形共用)。
135
+ *
136
+ * 🔴 **不可变捕获**:`session` 在 `issueStream()`/`issueSnapshot()` 的**那一刻**从本 ledger 的
137
+ * `sessionKey` 槽解析一次,之后**永不重读**(与 owner 台账 tick 腿「开流时一次性捕获,不逐帧
138
+ * 现读」同一条纪律)。
139
+ * 🔴 **帧只能经 ingress 进入 ledger**:这样「这条流是替谁开的」不再是宿主的一句声明,而是帧真正
140
+ * 走过的通道。旧 ingress(重连后 epoch 落后)或已 `close()` 的 ingress 上到达的帧一律丢弃并计
141
+ * `droppedStaleIngress`。
142
+ */
143
+ export interface FleetIngressBase {
144
+ /**
145
+ * 本 ingress 的会话锚(= `options.session`);`undefined` = principal 级流(该键未装 SessionPort
146
+ * 或端自己还没有会话 id)。
147
+ *
148
+ * 🔴 空锚下 `own_root` **结构性不可能成立** —— 它要拿 `rootSessionId` 与本锚**逐值比对**,没有锚就
149
+ * 比不了(`ownByRoot` 恒 false)。
150
+ * ⚠️ 但 `server_fail_closed` **不受空锚影响**:那一臂的前提是「这条流是替谁开的」,**与本端会话端口
151
+ * 无关**(端可能根本没装 `SessionPort`,而流是宿主自己按别的方式按会话开的)。所以默认槽 + 空锚 +
152
+ * 生产 meta 仍报 `server_fail_closed`(与 0.32.0 逐字同);keyed 上它本来就恒封顶,与锚空不空无关。
153
+ */
154
+ readonly session: string | undefined;
155
+ /** 发放本 ingress 的 ledger 的会话槽键。 */
156
+ readonly sessionKey: string;
157
+ /** 发放序号(ledger 内单调递增;`issueStream()` 一次 +1,旧 stream ingress 随即作废)。 */
158
+ readonly epoch: number;
159
+ /** 🔴 帧的唯一入口 —— 经本 ingress 进入 ledger,携带本 ingress 的不可变捕获。 */
160
+ applyFrame(frame: FleetFrame): void;
161
+ /** 关闭:此后本 ingress 的 `applyFrame` 全部丢弃并计数(迟到帧不再污染台账)。 */
162
+ close(): void;
163
+ }
164
+ /** `client.fleet.stream(...)` 的 keyed 开流参数 + 帧入口。 */
165
+ export interface FleetIngress extends FleetIngressBase {
166
+ /** 开这条流要用的参数(不可变;本 ingress 生命期内恒定)。 */
167
+ readonly options: {
168
+ readonly session?: string;
169
+ };
170
+ }
171
+ /** `client.fleet.snapshot(...)` 的 keyed 参数 + 帧入口(**独立** ingress,绝不改写 stream 锚)。 */
172
+ export interface FleetSnapshotIngress extends FleetIngressBase {
173
+ readonly options: {
174
+ readonly session?: string;
175
+ readonly timeoutMs?: number;
176
+ };
177
+ /**
178
+ * 🔴 SDK `client.fleet.snapshot(opts)` 的返回形是 **`{tasks, workflows}`,不是帧**(它内部开流→取
179
+ * `snapshot` 帧→关流,`type`/`ts`/`meta` 都在封装里被吞掉)—— 所以 `applyFrame` 那条口接不上它。
180
+ * 本口**直吃那个返回值**并在库内合成 `snapshot` 帧,省掉端手抄一份会漂的帧形。
181
+ *
182
+ * ⚠️ 一次性快照拿不到 `meta` 帧 ⇒ 本 ledger 的 `sessionScoped` / `bgNotifyFailClosed` 两位**不会**被它
183
+ * 填上(诚实:那两位说的是「这条连接」的性质,快照封装里的那条连接已经关了)。要那两位就走 stream。
184
+ *
185
+ * 🔴 **发放那一刻有活流的快照一律不落账**(「活流」含两形:`issueStream()` 发过的 ingress 流,
186
+ * **以及**兼容入口消费、由 `setConnected(true)` 自报的流)(丢弃 + 计 `droppedStaleIngress`,`applyFrame` 同闸;
187
+ * 判据固化在**发放**时,不是落账时现查 —— 否则「流活着时签发 → 快照在路上、流先 `close()` → 落账」
188
+ * 会因 close 把流模式位清零而反而通过闸,而那只快照当初跳过了连接级 meta 复位,会沿用一条**已关连接**
189
+ * 的信任位给自己带回来的行背书):
190
+ * ①两条连接两个写者而本口是无条件 REPLACE —— 「取样之后、落账之前」流又推了一帧时,照落会把更新的行
191
+ * 覆盖回旧值,而 fleet 是 latest-state 总线,没有后续帧保证修回来;②快照连接的行**自带零 scoping
192
+ * 断言**,落进一本正持着别条连接 `sessionScoped=true` 的账里会被那条不相干连接的信任位放行投影。
193
+ * 而 live stream 本身就是「snapshot 先行的 latest-state 总线」,此时再落一份一次性快照零收益。
194
+ * 一次性快照的正当场景(首屏 / 温切探针)本来就发生在**没有 live stream**的时候。
195
+ */
196
+ applySnapshot(snapshot: {
197
+ tasks: FleetTaskRow[];
198
+ workflows: FleetWorkflowRow[];
199
+ }): void;
200
+ }
112
201
  /** REF-CC-047(fleet2-09):hook_notice 具名臂(B2 禁 unknown 出公开面;Extract 同款手法已在
113
202
  * 文件内的 `BgNotificationWire` 示范过)。 */
114
203
  export type HookNoticeFrame = Extract<FleetFrame, {
@@ -125,22 +214,29 @@ export type HookNoticeFrame = Extract<FleetFrame, {
125
214
  * 「硬证据」、二版只给 `own_root` 挂了会话锚警告,都是过度声称)。强度分档如下 —— 前两格的
126
215
  * 会话级读法**带前提**,第三格永远只是进程级,第四格根本不是证据。
127
216
  *
128
- * 🔴 **前两格的共同前提:喂给本 ledger 的那条 fleet 流,是按本 ledger 的会话开的。**
129
- * 本 ledger **自己不开流**(帧体归库、连接归端),所以它无从校验这一点;而本包给出的开流参数
130
- * `fleetStreamOptions()` / `fleetSnapshotOptions()` 是 **module 级函数、只认默认槽会话**
131
- * (`engineSessionParam()` = `hostSessionFor(DEFAULT_SESSION_KEY)`),连 `opts.sessionKey` 都拿不到。
132
- * ⇒ **单会话宿主**(cli 及今天的三端)前提恒成立,前两格就是会话级证明;
133
- * ⇒ **keyed 多会话宿主**上,一条按默认槽开的流被接到非默认键 ledger 时,前两格会替**别的会话**
134
- * 作证(`server_fail_closed` 尤其是**生产 meta 组合下的那一格**)。在册局限 **P-31**
135
- * (`docs/INTEGRATION-CLIENTS.md` §7c);正位解 = 开流参数与本判据**一起**换 per-key 会话锚,
136
- * 只改一半会让「按默认槽开流、按 keyed 槽判定」自相矛盾、反而丢自己的通知。
217
+ * 🔴 **可信谓词按臂拆分**(design/285 §2.3.3,0.45.0 起;此前是「非默认 `sessionKey` 一律封顶」
218
+ * 这一刀切):两条会话级臂**不同命**,因为它们**自校能力不同**。
137
219
  *
138
- * · **会话级证明(带上述前提)**:
220
+ * · `own_root` 是**内容自校臂** —— 拿通知里的 `rootSessionId` 与**本 ledger 的会话锚**逐值比对。
221
+ * 宿主就算把 A 会话的流喂进 B 的 ingress,A 的通知带的是 `rootSessionId = sid-A ≠ sid-B` ⇒ 值不等,
222
+ * 自动拒。所以锚一换成 per-key,这一臂就**真的可信**:
223
+ * · 默认槽(零参装配的单会话宿主)恒可信 —— 与 0.32.0 逐字同;
224
+ * · keyed ledger 上,帧**经 `ledger.issueStream()` 的 ingress** 到达即可信(ingress 在开流那一刻
225
+ * 一次性捕获会话);宿主直喂 `ledger.applyFrame` 的兼容入口上仍**封顶**(没有通道证据)。
226
+ * · `server_fail_closed` **零内容自校** —— 它只读 meta 的两个布尔位,不读任何会话、不比对任何值。
227
+ * **任何**一条 session-bound 流的 meta 都长这样 ⇒ 把 A 流喂给 B 的 ingress 时,A 的每一条通知
228
+ * 都会在 B 上命中这一臂、绕过 `own_root` 与 foreign 门、被标成最强档,而 ingress 证明不了帧的
229
+ * 来源连接。⇒ **keyed ledger 上这一臂恒封顶**,直到上游在 fleet `meta` 帧上回显本连接的
230
+ * session id、本端校验相等为止(在册局限 **P-31** 的残余半场,`docs/INTEGRATION-CLIENTS.md` §7c)。
231
+ *
232
+ * · **会话级证明**:
139
233
  * · `server_fail_closed` — server 侧注入路已 fail-closed(meta `bgNotifyFailClosed` ∧ 本连接
140
234
  * session-bound):最强,到达即**这条流的**会话,端侧台账判别整体让位([1510])。
141
- * ⚠️ 这一臂**完全不读会话端口**,它信的就是「这条流是替谁开的」——前提破了它也不会报错;
142
- * · `own_root` — 通知带 `rootSessionId`(委托树 root 宿主会话,固定点语义)=== `engineSessionParam()`
143
- * (**默认槽**的 SessionPort,不是本 ledger `sessionKey` )
235
+ * ⚠️ 这一臂**完全不读会话端口**,它信的就是「这条流是替谁开的」——前提破了它也不会报错,
236
+ * 所以它只在**默认槽**(前提可成立的那一格)出现;
237
+ * · `own_root` 通知带 `rootSessionId`(委托树 root 宿主会话,固定点语义)=== **本 ledger 的
238
+ * 会话锚**(经 ingress 到达 ⇒ 该 ingress 开流那一刻捕获的会话;兼容入口 ⇒
239
+ * `engineSessionParamFor(sessionKey)` 现读)。
144
240
  * · **进程级成员证明**(只证明「本进程曾亲手驱动过这条 run」,🔴 **不区分会话代际**):
145
241
  * · `own_parent` — `parentTaskId`(spawn 该子代的 leader run)∈ 本端 own-run 台账。台账真源
146
242
  * `subagentContentStore.ownEngineRuns` 是**进程级 `Set`、按会话零分区**(它自己的头注:
@@ -148,13 +244,25 @@ export type HookNoticeFrame = Extract<FleetFrame, {
148
244
  * —— 这正是在册局限 **P-13**(`docs/INTEGRATION-CLIENTS.md` §7c)在通知面的同一张脸。
149
245
  * ⇒ 端不得把它当作「属于当前会话」的证明;要按会话归属做事,自注入会话粒度的 own-run
150
246
  * 判据(P-13 给的出路),或只认上面那两格。
247
+ * · **封顶词(非证据)**:
248
+ * · `session_anchor_untrusted` — 会话级臂放行了,但**那一臂在本 ledger 上不可自校**。今天恰有
249
+ * 两格产出它:①keyed ledger 上的 `server_fail_closed`(结构性,零内容自校);②keyed ledger
250
+ * **未经 ingress**(宿主直喂 `ledger.applyFrame`)时的 `own_root`。不冒充前两格,也不谎标
251
+ * `own_parent`(经会话级臂放行的帧,其 `parentTaskId` 可能是 foreign —— 标进程成员比错标会话
252
+ * 更糟)、不标 `absent_parent`(键可能在场)。端按非证据档自裁。
253
+ * 🔴 单会话宿主(默认槽)**结构性不出现此词**。
151
254
  * · **非证据**:
152
255
  * · `absent_parent` — 通知**没带** `parentTaskId`:🔴 **这不是证据,是 absent-放行姿势**
153
256
  * (老引擎/老帧形不带该键,fail-closed 会把自家通知整批吞掉,故按放行处理)。端要拿归属做
154
257
  * 有副作用的事(落库、跨会话搬运、翻别人的卡)时,这一格应当自裁为「未证明」。
155
258
  *
156
- * 臂序 = `applyBgNotification` 放行判据的求值序(多臂同时成立时报**第一条**);开集只在本包加臂时
157
- * 扩,端按未知词兜底(`default` `absent_parent` 一档处理最安全)。
259
+ * 臂序 = `applyBgNotification` 放行判据的求值序,**但只在「成立 ∧ 可信」的会话级臂之间排序**
260
+ * (0.45.0 收紧):生产 meta 组合下两位恒 true,若无条件先报 `server_fail_closed`,keyed 车道就
261
+ * **永远走不到** `own_root` —— 哪怕内容自校(`rootSessionId` === 本锚)已经成立。那等于把一条真的
262
+ * 会话级证明扔掉换一个封顶的非证据词。规矩:第一条**成立且可信**的会话级臂胜出;一条都没有而至少
263
+ * 有一条成立 ⇒ 封顶词 `session_anchor_untrusted`;🔴 **绝不下探到 `own_parent`**(会话级臂放行的帧
264
+ * 没验过 `parentTaskId ∈ own-run 台账`,报进程成员就是谎报)。
265
+ * 开集只在本包加臂时扩,端按未知词兜底(`default` 当 `absent_parent` 一档处理最安全)。
158
266
  */
159
267
  export type BgNotificationAcceptEvidence = 'server_fail_closed' | 'own_root' | 'session_anchor_untrusted' | 'own_parent' | 'absent_parent';
160
268
  export interface FleetLedgerHooks {
@@ -206,10 +314,58 @@ export interface FleetLedgerStatus {
206
314
  * 判别锚一旦错了,自家通知会整批落进这一格)。 */
207
315
  droppedUnknownFrame: number;
208
316
  droppedForeignBgNotification: number;
317
+ /**
318
+ * design/285 批 1 additive 三键 —— 会话锚的可观察面(黑盒判据的着力点)。
319
+ *
320
+ * `sessionAnchorEpoch` = stream ingress 的发放序号;**`0` = 不在流模式** —— 从未 `issueStream()`,
321
+ * **或当代 stream 已 `close()`**(两者都不在流模式,读面不必也不该把它们分开)。
322
+ *
323
+ * `sessionAnchor` = 本 ledger 此刻的**判据 / 内容所属**会话锚,**三态**:
324
+ * ① 有 live stream ⇒ 该 ingress 开流那一刻捕获的会话(它的帧就按这个判);
325
+ * ② 不在流模式但**账里有内容** ⇒ 内容归属锚 —— 绝不改报一个还没被用过的现读值,否则
326
+ * 「`close()` 之后槽会话轮换」那一刻本读面就会报新会话而账里装的还是旧会话的行;
327
+ * ③ 空账 + 不在流模式 ⇒ 兼容入口下一帧会**现读**的那个值。
328
+ * `undefined` = 该键未装 `SessionPort` / 端还没有会话 id。
329
+ * ⚠️ 只有第③态会真的去读端口(与兼容入口判据同一次取值口径),该键未装 `SessionPort` 时照常计
330
+ * miss —— 那正是 `hostPortMissesFor(sessionKey)` 该报的事,不是本读面的副作用。
331
+ */
332
+ sessionAnchor: string | undefined;
333
+ /** stream ingress 的发放序号;**0 = 不在流模式**(从未 `issueStream()`,或当代 stream 已 `close()`)。 */
334
+ sessionAnchorEpoch: number;
335
+ /**
336
+ * 迟到帧丢弃计数,三形共用一个量:①旧 epoch 的 ingress(重连后);②已 `close()` 的 ingress;
337
+ * ③**有 live stream 时到达的快照帧**(两个写者 + 无条件 REPLACE 会把更新的行覆盖回旧值;而且快照
338
+ * 连接的行自带零 scoping 断言,不该被另一条连接的信任位放行 —— 详见 `applySnapshot` 顶注)。
339
+ */
340
+ droppedStaleIngress: number;
209
341
  }
210
342
  export interface FleetLedger {
211
- /** 消费一帧(consumer-side active-set update-in-place / remove)。 */
343
+ /**
344
+ * 消费一帧(consumer-side active-set update-in-place / remove)。
345
+ *
346
+ * 🔴 **兼容入口**(design/285 §3.1):单会话默认槽宿主的既有姿势,语义逐字不变(`epoch` 记 0 =
347
+ * 「未经 ingress」,会话锚走 `engineSessionParamFor(sessionKey)` 现读)。**keyed 多会话宿主只许用
348
+ * `issueStream()` 的 ingress** —— 走这条入口的 keyed ledger 上,会话级臂一律封顶
349
+ * (没有通道证据,见 {@link BgNotificationAcceptEvidence})。
350
+ */
212
351
  applyFrame(frame: FleetFrame): void;
352
+ /**
353
+ * 开一条新 stream ingress(design/285 批 1):解析本 ledger `sessionKey` 槽的会话并**一次性捕获**,
354
+ * `epoch` +1,**旧 stream ingress 随即作废**(其上的迟到帧丢弃并计 `droppedStaleIngress`)。
355
+ *
356
+ * 宿主用法(keyed 开流的单一正道):
357
+ * ```ts
358
+ * const ingress = ledger.issueStream()
359
+ * const stream = client.fleet.stream({ ...ingress.options, signal })
360
+ * for await (const frame of stream) ingress.applyFrame(frame)
361
+ * ingress.close()
362
+ * ```
363
+ */
364
+ issueStream(): FleetIngress;
365
+ /** 🔴 **独立** ingress(design/285 §2.3.2):一次探针快照绝不改写正在跑的 stream 锚与 epoch。 */
366
+ issueSnapshot(opts?: {
367
+ timeoutMs?: number;
368
+ }): FleetSnapshotIngress;
213
369
  /** 连接态由宿主的 consume 循环告知(台账自己不开流)。 */
214
370
  setConnected(v: boolean): void;
215
371
  /**