@sema-agent/client-core 0.15.0 → 0.16.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.
@@ -381,6 +381,19 @@ const outstandingRuns = new Map(); // runId → registeredAt(ms)
381
381
  // workflow(s) to finish」(CC 2.1.212 同形,wf-ui-cc2 ground truth)。仅通知计数变化 ——
382
382
  // notif-03 起「计数」含 `outstandingAbandonedCount()`,故 bg 半场的 TTL 放弃也发一次通知
383
383
  // (那一格变了,消费方要重渲「N 个后台任务已停止等待」)。
384
+ //
385
+ // 🔴 [2393] notif-F9(2026-08-03 全窗复审)**判据纠偏 + 契约写清**(裁定:机制不改,把口径写死)。
386
+ // 复审的观察是「bg 半场信号不对称:放弃发通知、登记不发,而订阅面两个计数口都不含 bg ⇒ 一次 bg
387
+ // 放弃叫醒全部订阅者却什么都没变(空转重渲)」。逐口核过之后这条**不成立**,但它指出的真缺口
388
+ // (契约没写清)是真的,所以写在这里:
389
+ // · 订阅契约 = 「**任一可读计数**发生变化」,可读计数恰好三个:`outstandingWorkflowCount()` /
390
+ // `outstandingDeliverableWorkflowCount()` / `outstandingAbandonedCount()`;
391
+ // · bg **放弃**必须发:它改变第三个(`abandonedCount++`)—— 不是空转;
392
+ // · bg **登记**必须不发:bg 在飞数**没有**可读口(刻意:headless 退出门只认 workflow 半场,
393
+ // 见 `outstandingDeliverableWorkflowCount` 的头注),不改变任何可读量的事件不许叫醒订阅者
394
+ // (叫醒 = 每次 async_launched 回执触发一次全树重渲,而消费方一个字都读不到新东西)。
395
+ // ⇒ 「不对称」是这条契约的**正确后果**,不是漏。谁想给 bg 在飞数开一个可读口,必须同批让
396
+ // `registerOutstandingBgTask` 发通知(两半共享前提,[paired-mechanisms-must-share-premise])。
384
397
  const outstandingListeners = new Set();
385
398
  function notifyOutstanding() {
386
399
  for (const l of [...outstandingListeners]) {
@@ -417,8 +430,20 @@ function abandonOutstanding(kind, id, reason) {
417
430
  if (!removed)
418
431
  return;
419
432
  abandonedCount++;
420
- traceNotif(`abandoned ${kind} ${id} — reason=${reason}, waited > WATCH_TTL_MS(${WATCH_TTL_MS}ms); ` +
421
- 'no completion notification will ever be delivered for it');
433
+ const line = `abandoned ${kind} ${id} — reason=${reason}, waited > WATCH_TTL_MS(${WATCH_TTL_MS}ms); ` +
434
+ 'no completion notification will ever be delivered for it';
435
+ traceNotif(line);
436
+ // 🔴 [2393] notif-F2(2026-08-03 全窗复审)**边界定谳**:本条修复到库边界为止,用户可见那一端
437
+ // 由宿主闭合,已挂提货单(`docs/refactor/README.md`「提货单 / 破坏性变更清单」节的**宿主侧待办**表,
438
+ // 背景与「库侧为什么停在这里」见 `docs/refactor/WAVE2-RESIDUALS.md` [2393] 那一节第 2 条)。
439
+ // 为什么不在库内补偿:
440
+ // · `traceNotif` 受 SEMA_DEBUG 门控是**刻意**的 —— 那是诊断面;把放弃行为默认打进用户 stderr
441
+ // 等于库替宿主决定 UI(headless `-p` 的 stdout 是协议面,污染它是真回归);
442
+ // · 走 `hostLog` 口需要 `notifications.ts` import `host.ts`,实测会把 **A 层闭包 22→24 文件**
443
+ // (`run-client-core-portability-test.mjs` 的 `MAX_ADAPT_CLOSURE_FILES` 只许降)—— 为一条诊断
444
+ // 线把 A 层拽进宿主口体系,代价大于收益([no-hasty-compensation-final-fix-first] 三问);
445
+ // · 用户可见那一端的**量**已经在场且可读:`outstandingAbandonedCount()`(+ `notificationDropCounters()`
446
+ // 六格)。缺的是三端的渲染,那是宿主工单,不是库内补偿位。
422
447
  notifyOutstanding();
423
448
  }
424
449
  /**
@@ -431,11 +456,19 @@ export function outstandingAbandonedCount() {
431
456
  }
432
457
  let bgDedupDropped = 0;
433
458
  let bgCrossChannelDropped = 0;
459
+ let workflowDedupDropped = 0;
434
460
  let stuckTickResets = 0;
435
461
  let bgWatchRevivalPromoted = 0;
436
462
  let bgWatchCollectedUnprobed = 0;
437
463
  export function notificationDropCounters() {
438
- return { bgDedupDropped, bgCrossChannelDropped, stuckTickResets, bgWatchRevivalPromoted, bgWatchCollectedUnprobed };
464
+ return {
465
+ bgDedupDropped,
466
+ bgCrossChannelDropped,
467
+ workflowDedupDropped,
468
+ stuckTickResets,
469
+ bgWatchRevivalPromoted,
470
+ bgWatchCollectedUnprobed,
471
+ };
439
472
  }
440
473
  let statusProbe = null;
441
474
  let watchTimer = null;
@@ -447,6 +480,18 @@ export function installWorkflowStatusProbe(probe) {
447
480
  }
448
481
  /** bg 子代生命周期号的首周期值(SendMessage 复活即 +1;wire 缺 seq ⇒ 按首周期解释)。 */
449
482
  const BG_FIRST_SEQ = 1;
483
+ /**
484
+ * 🔴 [2393] notif-F8(2026-08-03 全窗复审):seq 归一的**唯一铸点**。
485
+ * 修前有两处各写各的:登记口 `Number.isFinite && >= BG_FIRST_SEQ` 才收(否则钳到首周期,再 floor),
486
+ * enqueue 口只有 `n.seq ?? BG_FIRST_SEQ`(零校验),而帧臂(`fleet/fleetLedger.ts` 的
487
+ * `applyBgNotification`)是**原样透传**。后果不是「多写一遍」而是两条通道的去重键会**错开**:
488
+ * server 哪天发 0 基 seq(或小数)⇒ watcher 合成走已钳成 1 的键 `id:1:status`,帧通道走原值键
489
+ * `id:0:status`,两键不撞 ⇒ 跨通道去重整体失效,同一次完成被喂给模型两遍。
490
+ * notif-02 立的是「键必须含 seq」,归一没收成单口就等于键的**语义**没收口。
491
+ */
492
+ function normalizeBgSeq(seq) {
493
+ return typeof seq === 'number' && Number.isFinite(seq) && seq >= BG_FIRST_SEQ ? Math.floor(seq) : BG_FIRST_SEQ;
494
+ }
450
495
  /**
451
496
  * 🔴 notif-02:观察台账的键是 **(taskId, seq) 复合键**,不是裸 taskId。
452
497
  * 裸 taskId 时 seq≥2 的复活周期与首周期同键 ⇒ 复活周期结构性进不了台账,而 watcher 存在的
@@ -495,7 +540,7 @@ export function registerOutstandingBgTask(taskId, description, prompt, seq) {
495
540
  if (typeof prompt === 'string' && prompt.length > 0 && !bgTaskPrompts.has(taskId)) {
496
541
  bgTaskPrompts.set(taskId, prompt);
497
542
  }
498
- const cycle = typeof seq === 'number' && Number.isFinite(seq) && seq >= BG_FIRST_SEQ ? Math.floor(seq) : BG_FIRST_SEQ;
543
+ const cycle = normalizeBgSeq(seq); // notif-F8:唯一铸点
499
544
  const key = bgOutstandingKey(taskId, cycle);
500
545
  if (outstandingBgTasks.has(key))
501
546
  return;
@@ -595,7 +640,14 @@ function withProbeDeadline(p, timeoutMs) {
595
640
  const timer = setTimeout(() => {
596
641
  reject(new ProbeDeadlineError(timeoutMs));
597
642
  }, timeoutMs);
598
- unrefTimer(timer);
643
+ // 🔴 [2393] notif-F7(2026-08-03 全窗复审)**不 unref**:这正是 `unrefTimer.ts:6-9` 逐字写死的
644
+ // 域词表-13 定谳所指的那一类 —— 「被 await 的 race/budget 臂上的定时器绝不能 unref(会让进程
645
+ // 在它 settle 前提前退出,RB-447 同形)」。本 deadline 定时器是 `await withProbeDeadline(...)`
646
+ // **唯一的 settle 来源**(probe 永不 settle 时):unref 之后若宿主此刻没有别的活句柄
647
+ // (watchTimer 自己也是 unref 的),事件循环判定可退出,回调不再执行,那一拍 tick 永不 settle
648
+ // —— notif-01① 「必有 settle 路径」的保证就不是自持的,而是押在「宿主别处总有活句柄」上。
649
+ // 两条成对臂已经保证它不会拖住进程:①probe settle 时下面两个 `clearTimeout(timer)` 必执行;
650
+ // ②它自己到点就 reject 并被 clear,寿命上界 = timeoutMs(默认 PROBE_TIMEOUT_MS)。
599
651
  p.then(v => {
600
652
  clearTimeout(timer);
601
653
  resolve(v);
@@ -616,14 +668,46 @@ let tickGeneration = 0;
616
668
  * 🔴 必须在重入护栏**之前**跑:清扫住在 tickWatchInner 里时,一个卡死的 probe 会连坐 TTL,
617
669
  * 于是「2h 上界」这个 `outstandingDeliverableWorkflowCount()` 正当性的另一半也一起失效。
618
670
  */
671
+ /**
672
+ * 🔴 [2393] notif-F10(2026-08-03 全窗复审):**正被在飞 probe 臂持有**的键集(bg 侧存复合键,
673
+ * workflow 侧存 runId)。`abandonOutstanding` 自己的前提是「放弃必须是**真发生过**的事」,但它
674
+ * 上一版只挡住了「键不在台账」,没挡住「键正被在飞臂持有」:上一拍 probe 还在飞(它手里握着这
675
+ * 条目的快照),这一拍 sweep 判它超 TTL ⇒ 计数 +1 并打出「no completion notification will ever
676
+ * be delivered for it」,几十毫秒后那个 probe 返回 terminal ⇒ 照常合成并投递通知。
677
+ * 留痕是假话,`outstandingAbandonedCount()`(消费方据以渲「N 个后台任务已停止等待」的诚实计数)
678
+ * 被灌一格假数 —— 正是 notif-03 立案要根治的那一类观测面撒谎。
679
+ * 🔴 让位必须**自己有界**,否则它就变成 notif-01③ 刚修掉的那个形(「清扫住在 tick 体内 ⇒ 一个卡死的
680
+ * probe 连坐 TTL ⇒ headless 退出门恒真、进程永不退出」)。所以记的是**在飞臂的起始时刻**,
681
+ * 让位只在「这条臂还没超过它自己的 deadline」时成立:臂一旦活过 `probeTimeoutMs()`(= 它本该被
682
+ * 截断的时刻),它就不再是「马上会给出答案的那条臂」,TTL 照常执行。
683
+ * ⇒ 正常路径:多等一拍(5s),换来放弃计数不被灌假数;病理路径:与修前逐字同形,liveness 不变。
684
+ */
685
+ const bgProbeInFlight = new Map();
686
+ const runProbeInFlight = new Map();
687
+ /** 在飞臂是否仍在它自己的 deadline 之内(超了就不再让位 —— 见上方 notif-F10 头注)。 */
688
+ function probeArmStillWithinDeadline(startedAt, now) {
689
+ return startedAt !== undefined && now - startedAt <= probeTimeoutMs();
690
+ }
619
691
  function sweepExpiredOutstanding(now) {
620
692
  for (const [key, meta] of [...outstandingBgTasks]) {
621
- if (now - meta.registeredAt > WATCH_TTL_MS)
622
- abandonOutstanding('bg-task', key, 'ttl');
693
+ if (now - meta.registeredAt <= WATCH_TTL_MS)
694
+ continue;
695
+ if (probeArmStillWithinDeadline(bgProbeInFlight.get(key), now)) {
696
+ // notif-F10:在飞臂正握着它、且还没超它自己的 deadline —— 这一拍不判放弃
697
+ // (判了就可能马上被那条臂证伪:计数与留痕当场成假话)。
698
+ traceNotif(`ttl sweep deferred for bg-task ${key}: a probe arm is still in flight within its deadline`);
699
+ continue;
700
+ }
701
+ abandonOutstanding('bg-task', key, 'ttl');
623
702
  }
624
703
  for (const [runId, registeredAt] of [...outstandingRuns]) {
625
- if (now - registeredAt > WATCH_TTL_MS)
626
- abandonOutstanding('workflow-run', runId, 'ttl');
704
+ if (now - registeredAt <= WATCH_TTL_MS)
705
+ continue;
706
+ if (probeArmStillWithinDeadline(runProbeInFlight.get(runId), now)) {
707
+ traceNotif(`ttl sweep deferred for workflow-run ${runId}: a probe arm is still in flight within its deadline`);
708
+ continue;
709
+ }
710
+ abandonOutstanding('workflow-run', runId, 'ttl');
627
711
  }
628
712
  }
629
713
  /** @param nowMs 时钟注入(仅测试钩用;生产走 Date.now())。 */
@@ -647,7 +731,7 @@ async function tickWatch(nowMs) {
647
731
  const myGeneration = ++tickGeneration;
648
732
  tickStartedAtMs = now;
649
733
  try {
650
- await tickWatchInner();
734
+ await tickWatchInner(now);
651
735
  }
652
736
  finally {
653
737
  // 被强制复位过就别清:那面时间戳已经属于后来的那一拍了。
@@ -655,7 +739,9 @@ async function tickWatch(nowMs) {
655
739
  tickStartedAtMs = null;
656
740
  }
657
741
  }
658
- async function tickWatchInner() {
742
+ /** @param now 本拍的时钟( `sweepExpiredOutstanding` **同一个来源** —— notif-F10 的让位判据比的是
743
+ * 「在飞臂活了多久 vs 它自己的 deadline」,两边取不同的钟就会得出 3 小时前起飞的荒谬结论)。 */
744
+ async function tickWatchInner(now) {
659
745
  // bg agent 半场(与 workflow 半场同节拍;TTL 已提到 tickWatch 的护栏之前统一清扫。
660
746
  // probe 未装=mock/离线,只等推送补发)
661
747
  const bgProbe = bgStatusProbe;
@@ -686,6 +772,7 @@ async function tickWatchInner() {
686
772
  continue;
687
773
  }
688
774
  let alive;
775
+ bgProbeInFlight.set(key, now); // notif-F10:在飞期间(且未超 deadline)sweep 不许把它判成「放弃」
689
776
  try {
690
777
  const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
691
778
  if (res === null || res === undefined)
@@ -695,6 +782,9 @@ async function tickWatchInner() {
695
782
  catch {
696
783
  continue; // 探测失败(含 ProbeDeadlineError)不构成删除证据
697
784
  }
785
+ finally {
786
+ bgProbeInFlight.delete(key);
787
+ }
698
788
  if (!alive) {
699
789
  outstandingBgTasks.delete(key); // 真终局 = 已送达的那个周期,收摊
700
790
  continue;
@@ -713,6 +803,7 @@ async function tickWatchInner() {
713
803
  }
714
804
  if (!bgProbe)
715
805
  continue;
806
+ bgProbeInFlight.set(key, now); // notif-F10:同上 —— 在飞臂持有期间(未超 deadline)TTL 让位
716
807
  try {
717
808
  const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
718
809
  if (res?.terminal) {
@@ -730,6 +821,9 @@ async function tickWatchInner() {
730
821
  catch {
731
822
  // 单次探测失败(含 ProbeDeadlineError 截断)不放弃:网络抖动照旧,TTL 兜底
732
823
  }
824
+ finally {
825
+ bgProbeInFlight.delete(key);
826
+ }
733
827
  }
734
828
  const probe = statusProbe;
735
829
  for (const runId of [...outstandingRuns.keys()]) {
@@ -740,6 +834,7 @@ async function tickWatchInner() {
740
834
  }
741
835
  if (!probe)
742
836
  continue; // live client 未装(mock/离线)→ 只等推送
837
+ runProbeInFlight.set(runId, now); // notif-F10:在飞期间(且未超 deadline)sweep 不许把它判成「放弃」
743
838
  try {
744
839
  const res = await withProbeDeadline(probe(runId), probeTimeoutMs());
745
840
  if (res?.terminal) {
@@ -755,6 +850,9 @@ async function tickWatchInner() {
755
850
  catch {
756
851
  // 单次探测失败(含 ProbeDeadlineError 截断)不放弃:网络抖动照旧,TTL 兜底
757
852
  }
853
+ finally {
854
+ runProbeInFlight.delete(runId);
855
+ }
758
856
  }
759
857
  }
760
858
  /**
@@ -765,7 +863,7 @@ async function tickWatchInner() {
765
863
  */
766
864
  const bgNotifiedKeys = new Set();
767
865
  export function enqueueBgChildNotification(n) {
768
- const cycle = n.seq ?? BG_FIRST_SEQ;
866
+ const cycle = normalizeBgSeq(n.seq); // notif-F8:与登记口同一个铸点(零校验的 `?? 1` 会让两条通道的键错开)
769
867
  const key = `${n.taskId}:${cycle}:${n.status}`;
770
868
  // 🔴 notif-03 同族(C5):两条静默 return 都记账 + 留痕 —— 不记账时「按设计去重」与
771
869
  // 「seq 丢了导致完成被吞」在观测上完全等价,而后者是用户永远收不到通知的那一类。
@@ -812,8 +910,17 @@ export function enqueueBgChildNotification(n) {
812
910
  });
813
911
  }
814
912
  export function enqueueEngineWorkflowNotification(c) {
815
- if (notifiedRunIds.has(c.runId))
913
+ // 🔴 [2393] notif-F1(2026-08-03 全窗复审):这条静默 return 是 C5 收口的**第三处漏网拼法**。
914
+ // 同批把 bg 侧两条静默 return 都补了计数+留痕(上面 bgDedupDropped / bgCrossChannelDropped),
915
+ // 而它的孪生体 workflow 侧一条都没有 —— 而 `traceNotif` 的头注把执行标准写成「每条丢弃/放弃
916
+ // 路径都能在同一个前缀下 grep 到」。失败场景:`markEngineWorkflowNotified()`(外部通道 seed)
917
+ // 或 probe 链先记账后,推送帧再送同一 runId 的完成 ⇒ 这里静默吞掉,而「按设计去重」与
918
+ // 「一条真完成被误吞(runId 铸法变了 / 两条链 id 不同域)」在观测面上完全等价。
919
+ if (notifiedRunIds.has(c.runId)) {
920
+ workflowDedupDropped++;
921
+ traceNotif(`workflow notification suppressed (run already notified) runId=${c.runId} status=${c.status}`);
816
922
  return;
923
+ }
817
924
  // [2393] F-1:workflow 侧没有周期概念,周期维按首周期记(与 markEngineWorkflowNotified 同理)。
818
925
  markRunNotified(c.runId);
819
926
  cardEnqueuedRunIds.add(c.runId);
@@ -838,12 +945,26 @@ export function _resetEngineTaskNotificationForTest() {
838
945
  ownWorkflowRuns.clear();
839
946
  bgNotifiedKeys.clear();
840
947
  outstandingListeners.clear();
948
+ // 🔴 [2393] notif-F11(2026-08-03 全窗复审):这三件此前漏清 —— 头注自称「清空**全部**通知台账」。
949
+ // · `bgTaskPrompts`:首值粘性(已登记的 taskId 不覆写),门里两个用例复用同一 taskId 时
950
+ // `outstandingBgTaskPrompt()` 会跨用例返回上一用例的 prompt,reset 拿不掉(既有钉侥幸没撞上,
951
+ // 不是被挡住了)。它的 process-lifetime 无逐出是**生产**决定(bg 子代 = 人手派发量级),
952
+ // 与测试钩要不要清是两件事。
953
+ // · `tickGeneration`:世代号不复位,跨用例累加(今天只做相等比较,不是现症;但「清空全部」
954
+ // 这句话要么成立要么改掉,不留半真)。
955
+ // · `bgProbeInFlight` / `runProbeInFlight`(notif-F10 新增):上一个用例的 probe 若被用例
956
+ // 提前收尾抛下,残留的键会让下一个用例的 TTL sweep 永远让位 = 静默失效的 TTL。
957
+ bgTaskPrompts.clear();
958
+ bgProbeInFlight.clear();
959
+ runProbeInFlight.clear();
960
+ tickGeneration = 0;
841
961
  statusProbe = null;
842
962
  bgStatusProbe = null;
843
963
  tickStartedAtMs = null;
844
964
  abandonedCount = 0;
845
965
  bgDedupDropped = 0;
846
966
  bgCrossChannelDropped = 0;
967
+ workflowDedupDropped = 0;
847
968
  stuckTickResets = 0;
848
969
  bgWatchRevivalPromoted = 0;
849
970
  bgWatchCollectedUnprobed = 0;
@@ -18,7 +18,18 @@ export type SandboxParseResult = {
18
18
  * `--sandbox=<v>` forms). No flag ⇒ `{mode:'default'}`. Repeated flag ⇒ LAST wins (commander convention).
19
19
  * Missing value / flag-shaped value / empty / over-long ⇒ fail-LOUD parse error (never a silent default —
20
20
  * a mis-typed environment choice must not quietly run on the wrong image).
21
- */
21
+ *
22
+ * 🔴 [2393] sweep-F10(2026-08-03 全窗复审)**承诺范围如实收窄**(裁定:行为不改,话改):
23
+ * 「fail-LOUD,never a silent default」这句话现在**不**覆盖一种输入 —— 同一个旗**重复出现**且
24
+ * 前一次缺值/非法、后一次合法(`--sandbox --sandbox foo`):
25
+ * 收编前是 per-occurrence eager validate,在**第一个**缺值 occurrence 上就 fail-loud;收编后走
26
+ * `lastFlagValue`('invalidate' 缺省)= 后一次合法 occurrence 覆盖前一次,整体接受。
27
+ * 为什么裁定不改回去:本模块自己写着的契约就是「Repeated flag ⇒ LAST wins(commander convention)」,
28
+ * eager-validate 违反的正是这条契约(它让**被覆盖掉的**那一次决定整条命令的成败)。
29
+ * 剩下的形(唯一一次出现却缺值 / 值是旗形 / 空 / 超长 / 带空白)**仍然全部 fail-loud** —— 那才是
30
+ * 「打错一个字就悄悄跑在别的镜像上」真正要防的那一族。
31
+ * ⚠️ `docs/REFACTOR-LEDGER.md` REF-CC-143 的处置列此前写的是「缺值策略**统一** fail-loud」,
32
+ * 与落地的两策略叶(`'invalidate'` / `'latch-previous'`)相悖,已同批改正(sweep-F11)。 */
22
33
  export declare function parseSandboxArgv(argv: readonly string[]): SandboxParseResult;
23
34
  /** The request-body stamp for a selection: EXPLICIT ⇒ `{sandboxImageProfile}`, default/auto ⇒ nothing
24
35
  * (auto is advisory-by-prompt — the submit itself never carries a profile, per clay's c) 口径). */
@@ -37,7 +37,18 @@ const USAGE_HINT = "sema: --sandbox expects a profile name or 'auto'.\n" +
37
37
  * `--sandbox=<v>` forms). No flag ⇒ `{mode:'default'}`. Repeated flag ⇒ LAST wins (commander convention).
38
38
  * Missing value / flag-shaped value / empty / over-long ⇒ fail-LOUD parse error (never a silent default —
39
39
  * a mis-typed environment choice must not quietly run on the wrong image).
40
- */
40
+ *
41
+ * 🔴 [2393] sweep-F10(2026-08-03 全窗复审)**承诺范围如实收窄**(裁定:行为不改,话改):
42
+ * 「fail-LOUD,never a silent default」这句话现在**不**覆盖一种输入 —— 同一个旗**重复出现**且
43
+ * 前一次缺值/非法、后一次合法(`--sandbox --sandbox foo`):
44
+ * 收编前是 per-occurrence eager validate,在**第一个**缺值 occurrence 上就 fail-loud;收编后走
45
+ * `lastFlagValue`('invalidate' 缺省)= 后一次合法 occurrence 覆盖前一次,整体接受。
46
+ * 为什么裁定不改回去:本模块自己写着的契约就是「Repeated flag ⇒ LAST wins(commander convention)」,
47
+ * eager-validate 违反的正是这条契约(它让**被覆盖掉的**那一次决定整条命令的成败)。
48
+ * 剩下的形(唯一一次出现却缺值 / 值是旗形 / 空 / 超长 / 带空白)**仍然全部 fail-loud** —— 那才是
49
+ * 「打错一个字就悄悄跑在别的镜像上」真正要防的那一族。
50
+ * ⚠️ `docs/REFACTOR-LEDGER.md` REF-CC-143 的处置列此前写的是「缺值策略**统一** fail-loud」,
51
+ * 与落地的两策略叶(`'invalidate'` / `'latch-previous'`)相悖,已同批改正(sweep-F11)。 */
41
52
  export function parseSandboxArgv(argv) {
42
53
  // REF-CC-143(dup-07)收编:extraction 改吃共用 lastFlagValue(argvFlagValue.ts)——scan-all-then-
43
54
  // validate-once, matching this module's own documented "Repeated flag ⇒ LAST wins" contract (the
@@ -46,7 +46,18 @@ export type ScenarioParseResult = {
46
46
  * flag ⇒ LAST wins (commander convention). Missing value / flag-shaped value / name-shape violation ⇒
47
47
  * fail-LOUD parse error (never a silent default — a mis-typed routing choice must not quietly run on
48
48
  * the wrong tool surface; same stance as `--sandbox`).
49
- */
49
+ *
50
+ * 🔴 [2393] sweep-F10(2026-08-03 全窗复审)**承诺范围如实收窄**(裁定:行为不改,话改):
51
+ * 「fail-LOUD,never a silent default」这句话现在**不**覆盖一种输入 —— 同一个旗**重复出现**且
52
+ * 前一次缺值/非法、后一次合法(`--scenario --scenario foo`):
53
+ * 收编前是 per-occurrence eager validate,在**第一个**缺值 occurrence 上就 fail-loud;收编后走
54
+ * `lastFlagValue`('invalidate' 缺省)= 后一次合法 occurrence 覆盖前一次,整体接受。
55
+ * 为什么裁定不改回去:本模块自己写着的契约就是「Repeated flag ⇒ LAST wins(commander convention)」,
56
+ * eager-validate 违反的正是这条契约(它让**被覆盖掉的**那一次决定整条命令的成败)。
57
+ * 剩下的形(唯一一次出现却缺值 / 值是旗形 / 空 / 超长 / 带空白)**仍然全部 fail-loud** —— 那才是
58
+ * 「打错一个字就悄悄跑在别的镜像上」真正要防的那一族。
59
+ * ⚠️ `docs/REFACTOR-LEDGER.md` REF-CC-143 的处置列此前写的是「缺值策略**统一** fail-loud」,
60
+ * 与落地的两策略叶(`'invalidate'` / `'latch-previous'`)相悖,已同批改正(sweep-F11)。 */
50
61
  export declare function parseScenarioArgv(argv: readonly string[]): ScenarioParseResult;
51
62
  /**
52
63
  * The settings-lane default (`SEMA_HEADLESS_SCENARIO`). FAIL-SOFT on a bad value (stderr warning +
@@ -44,7 +44,18 @@ const USAGE_HINT = "sema: --scenario expects a scenario name (lowercase letters/
44
44
  * flag ⇒ LAST wins (commander convention). Missing value / flag-shaped value / name-shape violation ⇒
45
45
  * fail-LOUD parse error (never a silent default — a mis-typed routing choice must not quietly run on
46
46
  * the wrong tool surface; same stance as `--sandbox`).
47
- */
47
+ *
48
+ * 🔴 [2393] sweep-F10(2026-08-03 全窗复审)**承诺范围如实收窄**(裁定:行为不改,话改):
49
+ * 「fail-LOUD,never a silent default」这句话现在**不**覆盖一种输入 —— 同一个旗**重复出现**且
50
+ * 前一次缺值/非法、后一次合法(`--scenario --scenario foo`):
51
+ * 收编前是 per-occurrence eager validate,在**第一个**缺值 occurrence 上就 fail-loud;收编后走
52
+ * `lastFlagValue`('invalidate' 缺省)= 后一次合法 occurrence 覆盖前一次,整体接受。
53
+ * 为什么裁定不改回去:本模块自己写着的契约就是「Repeated flag ⇒ LAST wins(commander convention)」,
54
+ * eager-validate 违反的正是这条契约(它让**被覆盖掉的**那一次决定整条命令的成败)。
55
+ * 剩下的形(唯一一次出现却缺值 / 值是旗形 / 空 / 超长 / 带空白)**仍然全部 fail-loud** —— 那才是
56
+ * 「打错一个字就悄悄跑在别的镜像上」真正要防的那一族。
57
+ * ⚠️ `docs/REFACTOR-LEDGER.md` REF-CC-143 的处置列此前写的是「缺值策略**统一** fail-loud」,
58
+ * 与落地的两策略叶(`'invalidate'` / `'latch-previous'`)相悖,已同批改正(sweep-F11)。 */
48
59
  export function parseScenarioArgv(argv) {
49
60
  // REF-CC-143(dup-07)收编:extraction 改吃共用 lastFlagValue(argvFlagValue.ts)——scan-all-then-
50
61
  // validate-once, matching this function's own documented "Repeated flag ⇒ LAST wins" contract (the
@@ -73,6 +73,26 @@
73
73
  * @sema-agent/client-core), the contract shape does not.
74
74
  */
75
75
  const bad = (reason, field) => field === undefined ? { reason } : { reason, field };
76
+ /**
77
+ * 🔴 [2393] sweep-F8(2026-08-03 全窗复审):**嵌套失败的 field 路径不许在汇聚点被丢掉**。
78
+ * 修前三种写法各写各的:`chrome` 那条把已经算好的 `chrome.laneProof.lane` 整个覆盖成 `"chrome"`;
79
+ * `request`/`session`/`results[i]`/`sessions[i]` 把路径丢成父键名(reason 前缀里还留着索引,
80
+ * field 里没有)。这与 `SeatValidationFailure.field` 自己的定义(「嵌套位用点号,如
81
+ * `chrome.laneProof`」)直接相悖,也削弱 REF-CC-069 存在的理由 ——「一条被门吃掉的事件从此可诊断」:
82
+ * 宿主日志只会说「chrome 有问题」,而门明明知道是 `chrome.laneProof.lane`。
83
+ * @param prefix 父位路径(`"chrome"` / `` `results[${i}]` ``)
84
+ * @param verdict 子判据的失败(field 可能已是点号路径,也可能是裸键名,也可能缺席)
85
+ */
86
+ const nested = (prefix, verdict) => {
87
+ // 子判据自己已经带上父前缀时(checkSeatChromeEnvelope 收的就是 field 参数)不再重复拼。
88
+ const path = verdict.field === undefined
89
+ ? prefix
90
+ : verdict.field === prefix || verdict.field.startsWith(`${prefix}.`)
91
+ ? verdict.field
92
+ : `${prefix}.${verdict.field}`;
93
+ const reason = verdict.reason.startsWith(`${prefix}:`) ? verdict.reason : `${prefix}: ${verdict.reason}`;
94
+ return bad(reason, path);
95
+ };
76
96
  /** 存在性判定(§B10:`!!v` 那种真值判定会把空串/0 当缺席,对象位则会把 `{}` 当「有证」)。 */
77
97
  const isObject = (v) => typeof v === "object" && v !== null;
78
98
  const isAbsent = (v) => typeof v === "undefined";
@@ -318,7 +338,7 @@ const checkLocalSessionEvent = (v) => {
318
338
  if (!isAbsent(v.request)) {
319
339
  const verdict = checkToolPermissionRequest(v.request);
320
340
  if (verdict !== true)
321
- return bad(`request: ${verdict.reason}`, "request");
341
+ return nested("request", verdict); // sweep-F8:路径不丢
322
342
  }
323
343
  if (!optionalString(v.data))
324
344
  return bad("data must be a string when present", "data");
@@ -336,7 +356,7 @@ const checkLocalSessionEvent = (v) => {
336
356
  if (!isAbsent(v.session)) {
337
357
  const verdict = checkLocalSessionRecord(v.session);
338
358
  if (verdict !== true)
339
- return bad(`session: ${verdict.reason}`, "session");
359
+ return nested("session", verdict); // sweep-F8:路径不丢
340
360
  }
341
361
  if (!optionalString(v.userMessageUuid))
342
362
  return bad("userMessageUuid must be a string when present", "userMessageUuid");
@@ -352,7 +372,7 @@ const checkLocalSessionEvent = (v) => {
352
372
  if (!isAbsent(v.chrome)) {
353
373
  const verdict = checkSeatChromeEnvelope(v.chrome, "chrome");
354
374
  if (verdict !== true)
355
- return bad(verdict.reason, "chrome");
375
+ return nested("chrome", verdict); // sweep-F8:门算好的 chrome.laneProof.lane 不许被覆盖
356
376
  }
357
377
  return true;
358
378
  };
@@ -469,7 +489,7 @@ const checkSessionSearchEnvelope = (v) => {
469
489
  for (let i = 0; i < results.length; i += 1) {
470
490
  const verdict = checkSessionSearchResult(results[i]);
471
491
  if (verdict !== true)
472
- return bad(`results[${i}]: ${verdict.reason}`, "results");
492
+ return nested(`results[${i}]`, verdict); // sweep-F8
473
493
  }
474
494
  return true;
475
495
  };
@@ -482,7 +502,7 @@ const checkSessionsState = (v) => {
482
502
  for (let i = 0; i < sessions.length; i += 1) {
483
503
  const verdict = checkLocalSessionRecord(sessions[i]);
484
504
  if (verdict !== true)
485
- return bad(`sessions[${i}]: ${verdict.reason}`, "sessions");
505
+ return nested(`sessions[${i}]`, verdict); // sweep-F8
486
506
  }
487
507
  return true;
488
508
  };
package/dist/steering.js CHANGED
@@ -72,12 +72,21 @@ function parseBackgroundTaskLine(line) {
72
72
  const m = /^- \[([^\]]+)\] Task "([\s\S]*)$/.exec(head);
73
73
  if (!m)
74
74
  return null;
75
+ // 🔴 [2393] sweep-F16(2026-08-03 全窗复审):这里**不许**回落成空串。两个捕获组都是非可选的,
76
+ // `m` 非空时它们必在场(所以这条早退今天不可达),但 `?? ''` 的**形**是「不知道就渲空串」——
77
+ // 与同窗刚立的口径(`fleetAgentPanelProjection`「缺席就是 undefined,这里不许兜 0」/
78
+ // `seatContract`「绝不回落成 0(那会渲成 1970)」)相反,且真出问题时会静默产出一条 taskId
79
+ // 为空串的任务行(谁也定位不到)。noUncheckedIndexedAccess 适配的正解是**如实拒绝**。
80
+ const taskId = m[1];
81
+ const description = m[2];
82
+ if (taskId === undefined || description === undefined)
83
+ return null;
75
84
  return {
76
85
  type: 'task_status',
77
- taskId: m[1] ?? '',
86
+ taskId,
78
87
  taskType: 'local_bash',
79
88
  status,
80
- description: m[2] ?? '',
89
+ description,
81
90
  deltaSummary: null,
82
91
  };
83
92
  }
@@ -44,6 +44,19 @@ export type SubagentContentItem = {
44
44
  kind: 'echo';
45
45
  text: string;
46
46
  };
47
+ /**
48
+ * 🔴 [2393] sweep-F15(2026-08-03 全窗复审)**记一笔口径分叉**(裁定:留 `| undefined`,写明为什么):
49
+ * 下面五个可选位在 eopt 收官批被写成 `?: T | undefined`(= 允许显式赋 `undefined` 落键),而同窗
50
+ * 另两处的口径是相反的(`fleetAgentPanelProjection` 的「🔴 缺席即不落键(消费端据键在不在判三态)」、
51
+ * `toolResult` 的「公面键恒在形不许被 eopt 批翻成条件展开」)。两种口径都出自同一批,当时没有一处记账。
52
+ * 为什么这里走另一条:**本类型的消费端一处都不用「键在不在」判三态** —— 本文件 `apply()` 全部用
53
+ * `typeof ev.delta === 'string'` / `ev.output ?? ''` / `ev.isError === true` 这类**值级**判据
54
+ * (逐行核过,见下方 switch),所以 `{delta: undefined}` 与「不落键」在本模块里可观测等价;而生产者
55
+ * 是宿主的帧适配腿,`{ delta: maybe }` 这种直写形在 eopt 下会编译红,逼它们改写成条件展开只是把
56
+ * 噪音推给三端,换不来任何判别力。
57
+ * ⚠️ 反过来的约束:**谁想给这个类型加一个用「键在不在」判三态的位,就必须同批把这里改成条件展开**
58
+ * (那时两种口径就不能再共存了)。
59
+ */
47
60
  export interface SubagentContentEvent {
48
61
  type: 'text_delta' | 'reasoning_delta' | 'tool_start' | 'tool_end';
49
62
  taskId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",