@sema-agent/client-core 0.69.0 → 0.69.1

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/CHANGELOG.md CHANGED
@@ -49,6 +49,37 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.69.1(2026-09-16)
53
+
54
+ > 主题:0.69.0 的**安全面收尾**(cli 接入回执打回 + 对抗复审在合并树上复现):CC-09 runStream 去重**留痕** / CC-10 段末帧子流断闸改按三键 / CC-11 跨工具卡推理段归属交全 / CC-12 park 两新键进视图 + 文档批。CC-04 技能帽退役改预算式改随 server 7.78.1 进 **0.69.2**。接入面 `docs/INTEGRATION-CLIENTS.md` **§37**。
55
+
56
+ ### Fixed
57
+
58
+ - **`runStream` 的 seq 去重丢帧从此留痕**(CC-09)—— 修前 `adapter/runStream.ts` 对「已见过的 durable seq」是一条裸 `continue`
59
+ (零痕)。server 7.77.0 给 `reasoning_end` 的 SSE `id:` **复用**该段首枚 `reasoning_delta` 的 id,于是 `-p` / runStream 链上
60
+ 权威推理段被当作重放帧**静默**丢掉(`text_end` 自带 id 故不受影响);归口 = server 改铸唯一 id(7.78.1)。本层**不改去重律**
61
+ (契约 02 §1.1),只把这次丢弃交给 `ctx.onDroppedFrame({ type, why: 'duplicate_seq' })`(开集 why 词,措辞表加行)。
62
+ 🔴 已知边界:**交互车道 `adapt()` 不经这只去重**(源码直证),`thinking_segment_end` 在交互车道照到;`-p` / runStream 链在
63
+ server <7.78.1 上权威推理段不到端,宿主只能靠留痕知道。门:`run-text-segment-authority-test.mjs` U 段。
64
+ - **段末帧子流断闸改按身份三键**(CC-10;cli 接入回执 ① 实抓的 fail-open)—— 修前 `segmentEndProjection` 只透
65
+ `parentToolCallId`、两臂只按它断闸,而 sdk `EventIdentity` 的子代身份是三键(`parentToolCallId` / `sourceTaskId` / `bgAgentId`,
66
+ core 顶注「stamped ONLY on … AS A SUB-AGENT」):只带 `sourceTaskId` 的子代 `reasoning_end` / `text_end` 被当 leader 帧投上来,
67
+ 端据它换 leader 的思考/文本行 = 子代覆盖 leader。现在三键原样穿上内部臂(`text_end` / `reasoning_end`),任一在场即断,
68
+ 在场非串 ⇒ malformed。门 TSA V1–V3。
69
+ - **跨工具卡的推理段归属交全**(CC-11;回执 ②)—— 修前 `feedThinking` 开新块清对照物,一段推理跨工具卡时 `thinking_segment_end`
70
+ 只交末条已提交行的 `committedUuid`,前面的行无人说属于这一段(端只能整块换末条,其余未脱敏原样留盘)。现在段内已提交思考行
71
+ 按序累积(turn 内有界,`reasoning_end` 用过即清),事件新带 **`committedUuids[]`**(全部按序;`committedUuid` = 末条兼容位),
72
+ `committedPrefixLen` = 全部之和;判分歧按 `concat(rows) + buffer` 对 `content`:前缀对得上 ⇒ 只换还押着的后段(b1),对不上 ⇒
73
+ 整段归宿主(b2,缓冲清空不再交)。**宿主义务改口**:`committedPrefixDiverged` ⇒ 删 `committedUuids` 全部行、首行位置放 `content`。
74
+ 门 TSA V4–V10;臂表义务同文。
75
+ - **park 两新键进 `WorkflowRunState`**(CC-12;回执 ④)—— 0.69.0 只出了 `readWorkflowParks` / `readWorkflowResumeAdmissionIncomplete`
76
+ 两读器,`projectWorkflowRun` 逐键铸视图时丢了它们、`LiveWorkflowController` 不出裸体 ⇒ 端渲染面结构性造不出。现在
77
+ `WorkflowRunState.parks?: readonly WorkflowParkRowView[]` / `resumeAdmissionIncomplete?: true`(additive,由两读器铸,缺席两义同读器)。
78
+ 门 park H 段。
79
+ - 三件文档批(回执 ③⑤⑥):`mcpPanel.ts` jsdoc「四句」订正为五句;`sdkWireTransit` 型面转口加 `SessionPermissionRules`
80
+ (`loosenReasons` 两参的型);§36d 补「`hasCoreValuePorts()`(整袋)与 `coreValuePortMisses()`(逐件)在部分装载时分歧」与
81
+ 「镜像按 core 7.18.0 写,7.17.x 同形」;§37 注明 §35z #15「候 sdk 9.5」已由 §36z #18 收口。
82
+
52
83
  ## 0.69.0(2026-09-15)
53
84
 
54
85
  > 主题:**提货批**(原排 0.68.3;因 peer 地板抬升改 minor)—— sdk 9.4.0 / core 7.18.0 / settings-schema 2.0.0 三家换钉,
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.69.0
38
+ **Version:** 0.69.1
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -357,7 +357,7 @@ public-surface guard checks that last one).
357
357
  | `scripts/run-approval-frame-chrome-arms-test.mjs` | The two in-stream approval frames finally reaching every host through the shared pipeline instead of one shell's private branch — the shape of a layering defect: hosts that only consume the package could not rebuild their pending cards after a reconnect, and did not clear a card the engine had withdrawn. The payload is deliberately carried as the **envelope** the upstream types declare rather than the first-version card: the stream parser applies no predicate, so narrowing here would let a legitimately newer frame pass as the older shape and invite consumers to read keys a newer card never promised. The guard therefore pins that every open key survives untouched, that an unknown version still passes through, and that narrowing is left to the host's own predicates — with the fallback being a generic card and a person, **never** an automatic denial. A frame whose version cannot be read at all is reported as malformed rather than dropped in silence, because both frames carry user-visible decisions and state changes. Both arms are registered as **required** host duties, and their duty text names the load-bearing rules a host would otherwise have to rediscover: which predicate to narrow with, that the reconnect preamble — not a replayed historical frame — is the authority on which cards exist, and that a withdrawal frame can be lost entirely. Unlike the sibling arms, these carry **no** sub-stream cutoff: an approval raised under a delegated call still has to reach a person, and filtering it by ownership is the host's job, not a reason to discard it. Finally the upstream bytes that justify the envelope discipline are checked to still be there, since the whole design rests on them |
358
358
  | `scripts/run-terminal-status-vocabulary-test.mjs` | One place that decides whether a run has **ended** and whether it ended badly — written because that judgement had already been hand-copied three times, so the day the engine added a word for *the agent itself reported it cannot continue*, every copy missed it and a panel settled a self-reported failure as a success. The distinction the table exists for is pinned from both sides: that word belongs in it, while the two words meaning *waiting for a person to decide* deliberately do **not** — reading those as endings would bury a run that is actively waiting on the reader. A word this client does not know answers *no*, and the guard states plainly that *no* is not evidence of success: proving success means reading the positive side, so negating this predicate is the very mistake that caused two earlier incidents. The fleet lane gets the same treatment from the other direction: a workflow parked on a durable approval used to fall through to *running*, leaving the person with no hint that a card was waiting, and it now lands on the same rendered word the task lane already used — same fact, same word, checked end to end on a real row. Why the word was added directly rather than carried as a private superset key is checked mechanically against the upstream declaration being open, so the day it closes this reds and the decision gets revisited. The residue sweep is the point: the source tree must contain **no** further inlined copy of the judgement, each of the three former sites is checked to really read the single predicate, and the one reviewed exemption carries its reason **and** a liveness assertion, so an exemption whose justification expires cannot quietly keep standing |
359
359
  | `scripts/run-terminal-word-source-test.mjs` | Two tables of ending words, kept apart by **who owns them** — because they used to be one. The engine's own closed set of reasons a run ended, and the server's set of row states a run can finish in, overlap in three words but not in all of them: one word for *something outside stopped it* exists only on the server side, and one for *it paused and can be resumed* exists only on the engine side and means very nearly the opposite of an ending. Merged into a single list, those two sources became indistinguishable, so a new word on either side looked the same as a new word on the other, and the safest-looking move — folding the unknown word into a known one — is the exact mistake that has caused incidents here before. The engine-owned table is checked as a **copy, not an opinion**: it is reconciled word-for-word and in order against the installed engine package, read from both its declaration and its runtime bytes with the two required to agree, so the day upstream adds a fifth reason this reds before anything ships. The two dividing words are each pinned from both sides, including against the upstream declaration directly rather than only against this package's own list. Why the table is copied rather than re-exported is itself an assertion with an expiry: the day upstream publishes the set as a value, this guard reds and the decision gets revisited. The renamed tables leave **no alias** behind, since an alias would let a reader keep consuming the merged list and the split would have bought nothing |
360
- | `scripts/run-workflow-park-truth-projection-test.mjs` | The read face for *which approvals a workflow run left parked* — and the credential that must never ride along with it. Upstream strips the redemption token from that response, and this package's reader is built so the token **cannot** come back: each row is assembled field by field from the three identity keys, never copied wholesale, so an extra key appearing upstream is structurally unable to reach anything this package hands a UI. The guard proves that rather than asserting it — a poisoned row carrying a secret is read, and the secret is searched for across the **entire** serialized result, with the same search proven to find it in the input so a blind search cannot pass; renaming the credential key does not help it through, because the rule is *only these three*, not a blocklist; and the reader's own source is checked to contain no object spread, since one such line would quietly void all of it. The other half is an absence distinction with opposite consequences: a record with **no** parks field at all was written by an older engine and proves nothing about whether approvals are waiting, while an empty list is a positive statement that none are — collapsing those two would let a run whose parked approvals cannot be proven be resumed anyway, so they are kept literally distinguishable, and a payload whose rows are all unreadable answers *unknown* rather than *none*. The four refusal codes for this family are checked code by code against the engine's real bytes, never matched by name prefix, and the older umbrella code they were split out of is asserted to still be **alive** — treating the whole code as retired would make a family of real refusals vanish silently **0.68.3 (core 7.18.0):** two more keys ride the same projection duty as `parks` itself: `originUnconfirmed: true` on a row (never `false`; absence is the confirmed state) and `resumeAdmissionIncomplete: true` on the run (presence means "not a resume base"). Dropping either would turn a refused record back into an admissible one, so the guard pins both, including that neither folds into the other |
360
+ | `scripts/run-workflow-park-truth-projection-test.mjs` | The read face for *which approvals a workflow run left parked* — and the credential that must never ride along with it. Upstream strips the redemption token from that response, and this package's reader is built so the token **cannot** come back: each row is assembled field by field from the three identity keys, never copied wholesale, so an extra key appearing upstream is structurally unable to reach anything this package hands a UI. The guard proves that rather than asserting it — a poisoned row carrying a secret is read, and the secret is searched for across the **entire** serialized result, with the same search proven to find it in the input so a blind search cannot pass; renaming the credential key does not help it through, because the rule is *only these three*, not a blocklist; and the reader's own source is checked to contain no object spread, since one such line would quietly void all of it. The other half is an absence distinction with opposite consequences: a record with **no** parks field at all was written by an older engine and proves nothing about whether approvals are waiting, while an empty list is a positive statement that none are — collapsing those two would let a run whose parked approvals cannot be proven be resumed anyway, so they are kept literally distinguishable, and a payload whose rows are all unreadable answers *unknown* rather than *none*. The four refusal codes for this family are checked code by code against the engine's real bytes, never matched by name prefix, and the older umbrella code they were split out of is asserted to still be **alive** — treating the whole code as retired would make a family of real refusals vanish silently **0.68.3 (core 7.18.0):** two more keys ride the same projection duty as `parks` itself: `originUnconfirmed: true` on a row (never `false`; absence is the confirmed state) and `resumeAdmissionIncomplete: true` on the run (presence means "not a resume base"). Dropping either would turn a refused record back into an admissible one, so the guard pins both, including that neither folds into the other **0.69.1 (CC-12):** both keys now also ride the projected `WorkflowRunState`, so a host that only sees the projection can render them |
361
361
  | `scripts/run-retired-vocabulary-census-test.mjs` | Whether a retirement really happened. When upstream removes a family, a downstream package can cut it out or keep a courteous alias — and the alias is the worse outcome: three clients keep writing branches for something nobody emits, and a status line advertises a state it can never reach. Choosing the clean cut only means something if a guard holds it, since a comment saying *retired* is not an exit code. Each registered entry is held two ways: the name must be gone from **code positions** in this package (comments stripped first, because the explanation is supposed to stay) and off the published surface, and — the half that keeps this from being self-congratulation — it must really be gone **upstream**, since that is the entire reason it was removed here; if it comes back, the disposition deserves reconsideration rather than silence. The scanner proves it can speak by finding a symbol that is genuinely present before any absence is believed, and distinguishes a mention inside a comment from one in a string literal, which is exactly the form being cleared. A closing check runs the other way: the retirement **story** must remain in the comments, including a promise this package made earlier and has now had to withdraw — deleting the history alongside the code is a bad way to satisfy *zero hits*, and leaves the next reader with code that has no reason |
362
362
  | `scripts/run-classifier-status-test.mjs` | What state the auto-mode classifier is in **on this session** — the question a doctor line, a model settings page and a permission card’s status row all ask, and a different question from the one the approval card asks (*why am I being asked right now*), so the sentences are pinned mutually distinct from that face’s as well as from each other. The session-level half of this reading — a breaker record the engine used to keep — was **retired upstream**, and the guard now holds that retirement from **both** sides: the engine's own declarations must really no longer carry it (a fact coming back would mean the removal here was the wrong disposition, and that deserves a conversation rather than silence), and this package must carry no alias, no state word and no leftover narrowing for it — a reading kept alive for something nobody emits any more is a promise the interface cannot keep, and it left the doctor line advertising a state it can never reach. What remains is ordered by the quantity that actually decides whether the classifier is running: the fact from **this round** first, then whether this leg is armed — a decider is minted per run, so a later leg can be armed again. Not armed, and a section that never arrived, both answer **undefined** rather than *available*; that arming question has its own field and answering it twice grows a second ledger. Arming and availability are also **two words, not one**: the engine says a decider was minted *for this leg*, which is an assembly-time fact, while whether that decider answers any given round is a **per-call** one — so an armed leg reads `armed` and only a positive per-call fact (an ask whose origin is the classifier's own denial-bound fallback, which by construction stands *after* the classifier ran) reads `available`. Every other ask origin is refused as evidence and for a stated reason rather than out of caution: several are ones the classifier is structurally forbidden to answer, and for the rest a surviving ask is precisely the case where it did **not** resolve one — so reading availability off them would be a guess. The projection is a **whitelist**, so an older engine still sending the retired member loses it at the boundary while the two live facts beside it ride through untouched. Rendering never throws and never impersonates: a state word this client does not know — including the retired one, which a restored view can still carry — reaches an honest fallback that names it verbatim, carries no invented explanation of a mechanism that no longer exists, and is proven distinct from all three real sentences; prototype keys reach that same fallback rather than a function body, checked against a real out-of-table word so the comparison cannot hold vacuously |
363
363
  | `scripts/run-compaction-boundary-projection-test.mjs` | The compaction divider and the one frame that makes its anchor resolvable. The trigger word is passed through as an **open set** instead of being folded to two: the engine deliberately stopped flattening its third value (a compaction that was not optional — a prompt-too-long recovery or trim pressure) and carries what the hook layer saw, so folding it again at the package boundary re-introduces exactly what upstream had just removed, while a consumer branching on *is it manual* keeps its behaviour byte for byte. Only an unreadable word (absent, empty, non-string) falls back — that is *could not read it*, not *read it and did not recognise it*. Two superset keys ride the metadata and neither fabricates: the preserved-segment anchor is minted only when its id really reads out, because half an anchor sends the host looking up an empty string in its map, and the clamp ratio is a **disclosure** whose real zero is a fact rather than an absence. The clamp ratio also carries a registered exit condition — the service really sends it while the SDK arm has no seat for it yet, so the read is defensive and this guard reds the day that seat appears, forcing a re-check instead of leaving a cast to rot. The committed-message frame moves out of *deliberately not projected*: that classification was true about transcript rows and false about **positioning**, since the engine states that consumers build their own id-to-message map from this frame to place the divider — projecting the anchor without it hands the host something it cannot resolve. It becomes a neutral internal arm and an optional chrome ledger event, never a transcript row (the frame carries no body, so minting one would put words in the engine's mouth), with both required ids narrowed and a malformed frame recorded rather than half-minted |
@@ -366,7 +366,7 @@ public-surface guard checks that last one).
366
366
  | `scripts/run-cost-reconcile-projection-test.mjs` | The **end-of-run cost reconciliation** reaching consumers at all. The engine splits a run's spend on the wire — the task's own cost, which deliberately excludes delegated sub-agents, the delegated total itself, and the within-task compaction subtotal that sits inside the own figure — and states two reconciliation identities for them. The package used to project none of it, so a cost view could only ever see one number and under-reported both delegated and compaction spend. Both structures are now projected onto the result as superset fields in the wire's integer micro-currency unit, read key by key, with unreadable keys dropped individually, an entirely unreadable structure omitted rather than emitted empty, and unknown categories passed through since the vocabulary belongs upstream. The delegated cost stays **absent when it was never priced**, never a fabricated zero. The same reader also feeds a terminal chrome arm carrying the three parts plus the reconciled total, so the two faces can never compute different answers; the reconciled total is minted only when both sides are known, and otherwise a discriminator bit says which side is unknown. **The reference field for total cost keeps its meaning** — it remains the task's own spend and the delegated total is not folded into it — because that is a shape the wider ecosystem reads; the reconciled figure is offered beside it, not in place of it. A frame that carries no stats emits no arm at all, and the existing rule that in-stream per-turn usage is not published for sub-flows is pinned unchanged, since delegated spend arrives once, at the end. The bit that says those figures are a lower bound is **per stream**, not per context: the emit context belongs to the caller and may be reused across streams, so a gap observed on one run is no evidence at all about the next one — the observation is held for the duration of one stream and handed to both projection faces by value, and the guard drives a reused context both sequentially and concurrently to prove neither direction leaks |
367
367
  | `scripts/run-task-progress-terminal-projection-test.mjs` | The one tick that says a delegated child **finished**. The engine fires exactly one final beat carrying a terminal face, and says in the same breath why it exists — so a consumer sees the row finish instead of watching it vanish after the last running beat — but the package's projection whitelist had no seat for that field and its adapter still carried the older premise in a comment, so the terminal beat arrived byte-identical to another running one: the panel row stayed up waiting for a defensive sweep (which only ever settles rows bound to a card still open this turn) or for a separate notification frame. The status now rides through as an **open set** with the vocabulary left upstream, while the question *which words are terminal* is answered by a closed pair on the adapter side — an unrecognised new word takes the running path, because guessing it terminal ends a row that is still working whereas one extra running beat merely renders late. A terminal beat settles the row directly under the lane proof its binding gives it (not the main lane a notification would use, and not by card id, since the engine is naming a child rather than closing a card), freezes the inline group-row twin in the same beat so a later sweep cannot reset the real tool count, clears the session-resident ledger, and fires the stop hook only for a child whose start really fired. It does not mark the row live or emit a second progress beat, and it shares the settled-row ledger with the other two settle legs so a replay or a double-delivery cannot produce a second end. Three things are pinned **unchanged**: a running beat, an absent status (older engines never send the field, and reading absence as terminal would make every child row disappear on its first beat), and the workflow lane gate, which still runs before any of this |
368
368
  | `scripts/run-assistant-arm-identity-test.mjs` | The identity keys on an assistant row, and an explicit account of the two that are **deliberately not** there. What the renderer received was a bare role-and-content object, so a dozen consumer sites downstream were each estimating what the message envelope should have told them. The id is taken from the engine's own event id rather than minted locally, because it has to be **the same value** on the live leg and on a durable replay — a freshly minted one would make a replayed message look new to a host's dedup and to rewind — and when the wire carries none the key is simply absent rather than filled with a random stand-in wearing an identity it does not have; it is also kept distinct from the envelope's own local render key, which is a different identity. The model name comes from what the host pinned when it opened the stream (the request was the host's to build) and is never guessed, since a wrong model name is worse than none once a billing or capability face looks it up. Usage and stop reason are **not** minted on this arm, and the reason is frame order rather than effort: content arms arrive before the turn's closing frame, so at the moment the arm is emitted the engine has not yet said what the round cost — anything put there would be an estimate, which is the very thing this work exists to remove — and synthesising a follow-up assistant update when the real figure lands is also refused, because that shape does not exist upstream and would place a message in the transcript the engine never sent. Their real values leave through the turn's own neutral arm as two superset keys, the usage one reusing the **same single mint point** the footer rollup already folds so the two faces cannot diverge, and the stop reason passed through verbatim as an open set — the machine signal for *was this turn cut short*, previously blind on both the stream and the trace. The existing behaviours beside them are pinned too: no arm at all when usage is wholly absent, and the sub-flow cut-out that keeps a child's turn from driving the leader's face |
369
- | `scripts/run-text-segment-authority-test.mjs` | The **authoritative segment replacement** on `text_end` (L-310, server >=7.75.3). `text_end.content` now goes through the same redactor as `result` and the ledger while `text_delta` stays verbatim, so the two **may differ** — an answer that quoted a credential used to be committed to the local transcript in its unredacted form, because the arm only forwarded the boundary signal. Six timing shapes are pinned, two of which an adversarial review reproduced against the installed engine's real bytes and which the first design got wrong in both directions: a second boundary in the same turn (the per-block case on one provider lane) used to make the first segment's prose vanish, and a boundary that arrives *after* the tool card (the other lane emits it at finalize) used to be read as "this package never handled that segment" and reported nothing at all. Three additive keys, all never-false; the two shapes that look alike are told apart by the second one, because the host's action in them is the opposite. The end-to-end legs drive the real pipeline without hand-inserting a segment commit — doing so is exactly what hid the first defect. A second review round then found two combination timings on top of the first fix — a tool card followed by *more* deltas in the same segment, and a byte count that had been documented as a message count — and both are pinned here too. A third round caught a length that the prose called bytes while the code returned UTF-16 units — harmless in ASCII, and on CJK text enough to leave the credential on screen — plus a backfill ledger that had to be kept in step, so the terminal frame does not re-render the segment a second time — kept in step only where the whole stretch sits in one message, because those ledgers are per-message and a fourth round showed that writing across them charges one message's prose to another. A fifth round settled the whole class into one invariant the guard now checks against the previous release's behaviour: this package only rewrites bytes it is still holding in the current message — once a segment has crossed a package-side boundary it emits the three keys and changes nothing else **0.68.2 (CC-01):** the segment identity is now minted here, not by the host: every committed assistant text row carries a top-level `_sema_segment_id` (stamped once at the `adapt()` exit, so the durable whole-message leg and the streamed-segment leg are covered alike; thinking blocks, tool_use-tailed rows and chrome events are left byte-for-byte), `text_segment_end` carries the same value as `segmentId` before rotating, subagent boundaries never rotate, and a replayed stream yields the same identities. Three mutations (no rotation / no stamping / stamping tool_use rows) each turn the guard red **0.69.0 (CC-02):** the same authority replacement now covers the reasoning face (`reasoning_end`, server >=7.77.0): a thinking block still buffered is swapped whole and its live tail recomputed; one already committed at a boundary (the usual timing, since the first text delta commits it) is left untouched and the host is told the row to replace by its uuid, never re-emitted. Subagent boundaries are ignored and the text-segment identity does not rotate |
369
+ | `scripts/run-text-segment-authority-test.mjs` | The **authoritative segment replacement** on `text_end` (L-310, server >=7.75.3). `text_end.content` now goes through the same redactor as `result` and the ledger while `text_delta` stays verbatim, so the two **may differ** — an answer that quoted a credential used to be committed to the local transcript in its unredacted form, because the arm only forwarded the boundary signal. Six timing shapes are pinned, two of which an adversarial review reproduced against the installed engine's real bytes and which the first design got wrong in both directions: a second boundary in the same turn (the per-block case on one provider lane) used to make the first segment's prose vanish, and a boundary that arrives *after* the tool card (the other lane emits it at finalize) used to be read as "this package never handled that segment" and reported nothing at all. Three additive keys, all never-false; the two shapes that look alike are told apart by the second one, because the host's action in them is the opposite. The end-to-end legs drive the real pipeline without hand-inserting a segment commit — doing so is exactly what hid the first defect. A second review round then found two combination timings on top of the first fix — a tool card followed by *more* deltas in the same segment, and a byte count that had been documented as a message count — and both are pinned here too. A third round caught a length that the prose called bytes while the code returned UTF-16 units — harmless in ASCII, and on CJK text enough to leave the credential on screen — plus a backfill ledger that had to be kept in step, so the terminal frame does not re-render the segment a second time — kept in step only where the whole stretch sits in one message, because those ledgers are per-message and a fourth round showed that writing across them charges one message's prose to another. A fifth round settled the whole class into one invariant the guard now checks against the previous release's behaviour: this package only rewrites bytes it is still holding in the current message — once a segment has crossed a package-side boundary it emits the three keys and changes nothing else **0.68.2 (CC-01):** the segment identity is now minted here, not by the host: every committed assistant text row carries a top-level `_sema_segment_id` (stamped once at the `adapt()` exit, so the durable whole-message leg and the streamed-segment leg are covered alike; thinking blocks, tool_use-tailed rows and chrome events are left byte-for-byte), `text_segment_end` carries the same value as `segmentId` before rotating, subagent boundaries never rotate, and a replayed stream yields the same identities. Three mutations (no rotation / no stamping / stamping tool_use rows) each turn the guard red **0.69.0 (CC-02):** the same authority replacement now covers the reasoning face (`reasoning_end`, server >=7.77.0): a thinking block still buffered is swapped whole and its live tail recomputed; one already committed at a boundary (the usual timing, since the first text delta commits it) is left untouched and the host is told the row to replace by its uuid, never re-emitted. Subagent boundaries are ignored and the text-segment identity does not rotate **0.69.1 (CC-09):** the run-stream replay guard still drops a frame whose event id was already seen, but it now reports the drop through the host's dropped-frame sink as `duplicate_seq` instead of vanishing silently (server 7.77.0 reuses the first reasoning delta's id for `reasoning_end`, so that authoritative segment is lost on the print lane until 7.78.1); the interactive adapter has no such guard and keeps receiving it **0.69.1 (CC-10/CC-11):** subagent segment-end frames are fenced on all three identity keys (a frame carrying only `sourceTaskId` no longer masquerades as the leader's), and a reasoning segment that spans tool cards now hands the host every committed row it covers (`committedUuids`) so nothing unredacted is left behind |
370
370
  | `scripts/run-gate-negative-controls-test.mjs` | Whether the registry-shaped guards among the 74 suites above actually turn red when the material they check really breaks — a census had found 16 of them clean enough to rehearse safely (closed sets, mirrors, baselines, floors, a type-shape ratchet) without touching any judgement code. Each is exercised by tampering a disk copy of the real material, spawning the guard's own unmodified script, asserting it exits non-zero and names the disease, then restoring the file byte-for-byte. Seven guards of the same shape and 51 behaviour/projection suites are catalogued rather than rehearsed this round — see `docs/GATE-NEGATIVE-CONTROLS.md` for the full table, the reasons, and a one-minute manual replay recipe for each blind one. The suite cross-checks its own case count against that document's row counts in both directions, so a case quietly dropped from the array without the document following is itself an undeclared blind guard. The backup that makes the restore possible is taken by **exclusive create**: checking for it and then copying are otherwise two steps, and two instances can pass the check together — the later one overwrites the only clean copy with material the earlier one has already tampered, and the rehearsal that promises to leave no trace leaves a permanently corrupted file instead. That interleaving is rehearsed too, in a throwaway directory of its own |
371
371
 
372
372
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
@@ -446,6 +446,8 @@ const approvalFrameArm = (kind) => function* (m) {
446
446
  * · 第二道 `content` 在场判(投影层已判 malformed/empty):同 `engine_notice` 的 carrier 二道判,
447
447
  * 防的是**非投影口喂进来的帧**(宿主自建管线 / 重放存量转录),不是重复判据。
448
448
  */
449
+ /** CC-10:子流身份三键(sdk `EventIdentity`)任一在场 = 这条段边界不是 leader 的;按「键在不在」判(坏值已在投影口 malformed)。 */
450
+ const isSubFlowSegmentEnd = (m) => m.parentToolCallId !== undefined || m.sourceTaskId !== undefined || m.bgAgentId !== undefined;
449
451
  const textSegmentEndArm = function* (m, { text }) {
450
452
  // 🔴 断闸按「**键在不在**」判,不按「是不是串」判(异源对抗复审第三轮 [medium] 采纳)。
451
453
  // 投影层已对坏 lane 位整帧 fail-closed;这一道是给**非投影口**喂进来的帧(宿主自建管线 /
@@ -453,7 +455,8 @@ const textSegmentEndArm = function* (m, { text }) {
453
455
  // 子代的段边界**擦成 leader 的**上到宿主面。同族臂的 `typeof` 写法在它们那里只影响一行装饰,
454
456
  // 在本臂上决定的是「这条边界算谁的」,方向必须更严。
455
457
  // ⚠️ `null` 也算在场(不给它开口子):wire schema 只允许缺席或 string,`null` 是坏值不是缺席。
456
- if (m.parentToolCallId !== undefined)
458
+ // CC-10(0.69.1):三键任一在场即断(只带 `sourceTaskId` 的子代帧是合法形,修前漏断 = 子代覆盖 leader)。
459
+ if (isSubFlowSegmentEnd(m))
457
460
  return;
458
461
  const content = typeof m.content === 'string' ? m.content : '';
459
462
  if (content.length === 0)
@@ -483,7 +486,7 @@ const textSegmentEndArm = function* (m, { text }) {
483
486
  * 🔴 **不轮换段身份**:段身份是文本行的(CC-01),思考块的收口与它无关。
484
487
  */
485
488
  const thinkingSegmentEndArm = function* (m, { text }) {
486
- if (m.parentToolCallId !== undefined)
489
+ if (isSubFlowSegmentEnd(m))
487
490
  return;
488
491
  const content = typeof m.content === 'string' ? m.content : '';
489
492
  if (content.length === 0)
@@ -497,6 +500,7 @@ const thinkingSegmentEndArm = function* (m, { text }) {
497
500
  ...(r.committedPrefixDiverged ? { committedPrefixDiverged: true } : {}),
498
501
  ...(r.committedPrefixLen > 0 ? { committedPrefixLen: r.committedPrefixLen } : {}),
499
502
  ...(r.committedUuid !== undefined ? { committedUuid: r.committedUuid } : {}),
503
+ ...(r.committedUuids !== undefined && r.committedUuids.length > 0 ? { committedUuids: r.committedUuids } : {}),
500
504
  ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
501
505
  });
502
506
  };
@@ -105,8 +105,10 @@ export interface ThinkingSegmentReplacement {
105
105
  committedPrefixLen: number;
106
106
  /** 已 committed 的那条思考行与权威全文不相等 ⇒ 本包不再交字节,宿主整块换。 */
107
107
  committedPrefixDiverged: boolean;
108
- /** `committedPrefixDiverged` 为真时在场:那条行的 `uuid`。 */
108
+ /** 有已提交行时在场:**末条**行的 `uuid`(0.69.0 起;兼容位)。 */
109
109
  committedUuid?: string;
110
+ /** CC-11(0.69.1):有已提交行时在场:段内**全部**已提交思考行的 `uuid`(按提交序;跨工具卡时多条)。 */
111
+ committedUuids?: readonly string[];
110
112
  }
111
113
  /** M1 对外的七个动作 + 两个读位(矩阵 §3.2 的 #2/#3 两条跨模块接口就是最后那三件)。 */
112
114
  export interface TextStream {
@@ -250,8 +252,9 @@ export interface TextStream {
250
252
  * (b) 思考已在边界**整块**提交(思考→回答边界最常见:`text_delta` 先到、server 的 `reasoning_end`
251
253
  * 后到)⇒ 撤不回,一个字节都不再交;立 `committedPrefixDiverged` + 那条行的 `uuid`,由宿主整块换。
252
254
  * (f) 本包一个字节都没经手(durable 整块思考臂 / 宿主只喂 end 不喂 delta)⇒ 什么都不做。
253
- * 没有 (b1)/(c)/(e):思考块无 idle-flush、无封存、无跨工具卡累加 —— 提交是整块的,边界一到就清账。
254
- * 🔴 `committedUuid` 只在 (b) 形在场;它是本包在 {@link TextStream.takeThinking} 铸的那个 uuid。
255
+ * CC-11(0.69.1)补上 (b1):一段推理**跨工具卡**时已提交的是多条行,前缀 = 全部行按序拼接 —— 对得上就只换还押着的
256
+ * 后段,对不上就整段归宿主换;两形都把全部行的 uuid 交出去(`committedUuids`,`committedUuid` = 末条兼容位)。
257
+ * 无 idle-flush、无封存(思考块只在正常边界整块提交)。
255
258
  */
256
259
  replaceThinkingSegment(content: string, anchor?: Frame): ThinkingSegmentReplacement;
257
260
  /**
@@ -142,7 +142,7 @@ export function createTextStream(ctx, idOf) {
142
142
  * 写于 `takeThinking` 提交那一拍;清于 `replaceThinkingSegment` 用过之后、以及下一块思考开始
143
143
  * (`feedThinking` 首条)—— 上一块的提交不是这一块的对照物。
144
144
  */
145
- let committedThinking = null;
145
+ let committedThinking = [];
146
146
  /**
147
147
  * 段身份窗口(见 {@link SEMA_SEGMENT_ID_KEY} 头注):窗口的第一帧锚 + 序号 + 派生结果缓存。
148
148
  * 🔴 缓存不是优化:锚缺席那一形派生落到 `ctx.uuid()`,不缓存的话同一窗口两次读会得到两个身份。
@@ -169,7 +169,9 @@ export function createTextStream(ctx, idOf) {
169
169
  session_id: ctx.sessionId,
170
170
  parent_tool_use_id: null,
171
171
  };
172
- committedThinking = { text: thinking, uuid: msg.uuid };
172
+ // CC-11(0.69.1):**按序累积**,不覆盖 —— 一段推理可以跨工具卡(openai 车道 finalize 时序),每一块提交都是
173
+ // 这一段的一部分;`reasoning_end` 到达时把它们全部交出去(`committedUuids`),宿主才有整段的归属。
174
+ committedThinking.push({ text: thinking, uuid: msg.uuid });
173
175
  thinking = '';
174
176
  thinkingAnchor = null;
175
177
  thinkingBlockOpen = false;
@@ -283,8 +285,8 @@ export function createTextStream(ctx, idOf) {
283
285
  segmentWindowAnchor = frame;
284
286
  if (thinking.length === 0) {
285
287
  thinkingAnchor = frame;
286
- // CC-02:新一块思考开始 ⇒ 上一块的提交不再是对照物(迟到的 reasoning_end 只认当前块)。
287
- committedThinking = null;
288
+ // CC-11:新一块开始**不**清对照物(修前这里清了 —— 跨工具卡的推理段只剩末条归属,cli 接入回执 ② 实抓);
289
+ // 对照物只在 `reasoning_end` 用过之后清(段界由引擎说了算),turn 结束整只丢,天然有界。
288
290
  // P2d:elapsed 锚在**首条** leader 推理增量(token 累计由 stream_delta.estimatedTokens
289
291
  // 承载,宿主自加;>30s 无增量的 stall 提示同样由宿主按增量时间戳判)。
290
292
  yield chrome({ kind: 'thinking_activity', laneProof: MAIN, active: true });
@@ -431,27 +433,42 @@ export function createTextStream(ctx, idOf) {
431
433
  committedText += committed;
432
434
  },
433
435
  replaceThinkingSegment: (content) => {
434
- // (b) 已整块提交:撤不回 ⇒ 只算分歧、指认行,一个字节都不再交。
435
- if (thinking.length === 0) {
436
- const c = committedThinking;
437
- committedThinking = null;
438
- if (c === null || c.text.length === 0) {
439
- // (f):本包没经手过这一块(durable 整块臂铸过 transcript 行 / 宿主只喂 end)。
440
- return { diverged: false, committedPrefixLen: 0, committedPrefixDiverged: false };
436
+ // CC-11:段 = 段内**全部**已提交思考行(按序)+ 还押着的缓冲。分歧按整段拼文比;归属一次交出全部 uuid。
437
+ const rows = committedThinking;
438
+ committedThinking = [];
439
+ const prefix = rows.map((r) => r.text).join('');
440
+ const uuids = rows.map((r) => r.uuid);
441
+ const last = uuids.length > 0 ? uuids[uuids.length - 1] : undefined;
442
+ // (f) 本包没经手过这一段(durable 整块臂铸过 transcript 行 / 宿主只喂 end)。
443
+ if (prefix.length === 0 && thinking.length === 0) {
444
+ return { diverged: false, committedPrefixLen: 0, committedPrefixDiverged: false };
445
+ }
446
+ const live = prefix + thinking;
447
+ const diverged = live !== content;
448
+ if (prefix.length === 0) {
449
+ // (a) 全部还押在缓冲里:整块换,活体尾巴按已泄前缀重算。
450
+ const emitted = thinking.slice(0, thinking.length - thinkingPending.length);
451
+ thinkingPending = content.startsWith(emitted) ? content.slice(emitted.length) : '';
452
+ thinking = content;
453
+ return { diverged, committedPrefixLen: 0, committedPrefixDiverged: false };
454
+ }
455
+ // (b) 有已提交行(一条或跨卡多条):已提交那截撤不回。
456
+ const committedPrefixDiverged = !content.startsWith(prefix);
457
+ if (!committedPrefixDiverged) {
458
+ // (b1) 已提交前缀逐字对得上 ⇒ 只换还押着的尾段(跨卡后段);`thinking` 为空时零字节可换,只报归属。
459
+ if (thinking.length > 0) {
460
+ const tail = content.slice(prefix.length);
461
+ const emitted = thinking.slice(0, thinking.length - thinkingPending.length);
462
+ thinkingPending = tail.startsWith(emitted) ? tail.slice(emitted.length) : '';
463
+ thinking = tail;
441
464
  }
442
- const diverged = c.text !== content;
443
- return diverged
444
- ? { diverged: true, committedPrefixLen: c.text.length, committedPrefixDiverged: true, committedUuid: c.uuid }
445
- : { diverged: false, committedPrefixLen: c.text.length, committedPrefixDiverged: false };
465
+ return { diverged, committedPrefixLen: prefix.length, committedPrefixDiverged: false, ...(last !== undefined ? { committedUuid: last } : {}), committedUuids: uuids };
446
466
  }
447
- // (a) 还押在缓冲里:整块换,活体尾巴按已泄前缀重算(与 replaceAnswerSegment 的活体面同律)。
448
- const prev = thinking;
449
- const diverged = prev !== content;
450
- const emitted = prev.slice(0, prev.length - thinkingPending.length);
451
- thinkingPending = content.startsWith(emitted) ? content.slice(emitted.length) : '';
452
- thinking = content;
453
- committedThinking = null;
454
- return { diverged, committedPrefixLen: 0, committedPrefixDiverged: false };
467
+ // (b2) 已提交那截自己也过期 ⇒ 一个字节都不再交(还押着的尾段清掉,别让它落成第二份),整段归宿主换。
468
+ thinking = '';
469
+ thinkingPending = '';
470
+ thinkingAnchor = null;
471
+ return { diverged: true, committedPrefixLen: prefix.length, committedPrefixDiverged: true, ...(last !== undefined ? { committedUuid: last } : {}), committedUuids: uuids };
455
472
  },
456
473
  segmentId: segmentIdNow,
457
474
  rotateSegmentIdentity: (anchor) => {
@@ -960,14 +960,22 @@ function segmentEndProjection(ev, ctx, arm) {
960
960
  // 正是本臂头注点名要防的跨 lane 状态破坏,而且是 fail-**open** 方向。
961
961
  // 三态与 B3-DIRTY 同:键缺席/`undefined` ⇒ 本来就是 leader 帧(照旧);键在场却非串(含 `null`,
962
962
  // wire schema 只允许缺席或 string)⇒ `malformed` 丢弃并留痕,坏值不许买路。
963
- if (ev.parentToolCallId !== undefined && typeof ev.parentToolCallId !== 'string') {
964
- return dropped('malformed', arm);
963
+ // CC-10(0.69.1;cli 接入回执 ① 实抓的 fail-open):子流身份是**三键**(sdk `EventIdentity`:`parentToolCallId` /
964
+ // `sourceTaskId` / `bgAgentId`),core 顶注逐字「stamped ONLY on the content events of a task running AS A SUB-AGENT」——
965
+ // 只带 `sourceTaskId`(无 `parentToolCallId`)是合法的子代帧形。修前只透 `parentToolCallId`,臂只按它断闸 ⇒ 那一形的
966
+ // 子代段末帧被当 leader 投上来,端据它换 leader 的思考/文本行 = 子代推理覆盖 leader 推理面。⇒ 三键**原样穿上内部臂**,
967
+ // 任一键在场却非串 ⇒ 整帧 malformed(与 `parentToolCallId` 同一条 B3-DIRTY 律:坏值不许买路)。
968
+ for (const k of ['parentToolCallId', 'sourceTaskId', 'bgAgentId']) {
969
+ if (ev[k] !== undefined && typeof ev[k] !== 'string')
970
+ return dropped('malformed', arm);
965
971
  }
966
972
  if (content.length === 0)
967
973
  return nothing('empty_payload');
968
974
  const identity = {
969
975
  ...(typeof ev.eventId === 'string' ? { eventId: ev.eventId } : {}),
970
976
  ...(typeof ev.parentToolCallId === 'string' ? { parentToolCallId: ev.parentToolCallId } : {}),
977
+ ...(typeof ev.sourceTaskId === 'string' ? { sourceTaskId: ev.sourceTaskId } : {}),
978
+ ...(typeof ev.bgAgentId === 'string' ? { bgAgentId: ev.bgAgentId } : {}),
971
979
  };
972
980
  // 两处字面量铸点(不是一处参数化):内部臂词表门按 `armBody({ type: '<字面量>'` 抽铸点,参数化会让两臂在账上「多登记」。
973
981
  return arm === 'text_end'
@@ -255,6 +255,8 @@ function sanitizeFrameType(type) {
255
255
  * 去看引擎那一侧。
256
256
  */
257
257
  const DROPPED_WHY_SENTENCE = Object.freeze({
258
+ duplicate_seq: 'a frame with this event id was already consumed on this run stream, so it was dropped as a replay (durable idempotency, contract 02 §1.1). ' +
259
+ 'If the engine reuses an id across two DIFFERENT frames (server 7.77.0 does this for `reasoning_end`, fixed upstream in 7.78.1), the second one is lost here — this line is the only trace.',
258
260
  turn_end_usage_absent: "the engine declares `turn_end.usage` as always present (core >= 7.17.0), and this frame has none. " +
259
261
  'The turn is counted as UNKNOWN spend (the run total is reported as a lower bound), and the frame itself renders NOWHERE.',
260
262
  });
@@ -354,8 +356,14 @@ async function* runStreamInner(events, ctx, handle = {}) {
354
356
  // event-id idempotency — drop a re-seen durable seq (contract 02 §1.1).
355
357
  const seq = eventSeq(ev);
356
358
  if (seq !== undefined) {
357
- if (seen.has(seq))
359
+ if (seen.has(seq)) {
360
+ // CC-09(0.69.1;cli L-319 `-p` 真根因的包侧半场):**丢也要留痕**。server 7.77.0 给 `reasoning_end` 的
361
+ // SSE `id:` 复用了该段首枚 `reasoning_delta` 的 id ⇒ 这条去重把**权威段**当重放帧丢了,而修前这里是一条裸
362
+ // `continue` —— 比 0.68.2 的 `unknown_arm`(至少有痕)更静默。归口 = server 改铸唯一 id(7.78.1);本层
363
+ // 不猜「这是真重放还是 id 复用」(去重律不变,契约 02 §1.1),只把这次丢弃交给宿主的丢帧留痕口。
364
+ reportDroppedFrame('duplicate_seq', String(ev.type ?? 'unknown'), ctx);
358
365
  continue;
366
+ }
359
367
  seen.add(seq);
360
368
  }
361
369
  // C1 — SUBAGENT content divert (service 1.89 forwardSubagentEvents): a CONTENT event stamped with
@@ -93,7 +93,7 @@ export declare function fetchMcpPanel(client: Pick<AgentClient, 'sessions'>, ses
93
93
  /**
94
94
  * `lastLegMcp` 那一行措辞的**唯一铸点**(三端共用;别在各端的行装配里另写一遍)。
95
95
  *
96
- * 🔴 四句刻意逐字互异(黑盒锚),且**没有一句**提到 `servers[]`:
96
+ * 🔴 五句刻意逐字互异(黑盒锚;0.69.1 订正:此前写「四句」漏了「面板体读不出」那一句),且**没有一句**提到 `servers[]`:
97
97
  * ① 在场 —— 名册 + runId + at;
98
98
  * ② `opts.reachable === false` —— 「未观测」:这次进程没读到面板体;
99
99
  * ③ 面板读到了但 `lastLegMcp` 整键缺席 —— 「不报」:三形同形 + 老引擎,**不武断咎为版本**;
package/dist/mcpPanel.js CHANGED
@@ -97,7 +97,7 @@ export async function fetchMcpPanel(client, sessionId, opts) {
97
97
  /**
98
98
  * `lastLegMcp` 那一行措辞的**唯一铸点**(三端共用;别在各端的行装配里另写一遍)。
99
99
  *
100
- * 🔴 四句刻意逐字互异(黑盒锚),且**没有一句**提到 `servers[]`:
100
+ * 🔴 五句刻意逐字互异(黑盒锚;0.69.1 订正:此前写「四句」漏了「面板体读不出」那一句),且**没有一句**提到 `servers[]`:
101
101
  * ① 在场 —— 名册 + runId + at;
102
102
  * ② `opts.reachable === false` —— 「未观测」:这次进程没读到面板体;
103
103
  * ③ 面板读到了但 `lastLegMcp` 整键缺席 —— 「不报」:三形同形 + 老引擎,**不武断咎为版本**;
@@ -38,4 +38,4 @@ export { isApprovalRequestFrameV1, isApprovalRevokeFrameV1 } from '@sema-agent/s
38
38
  * 🔴 **谁是权威**:权威恒是 `@sema-agent/sdk` 本身。本文件是**别名**,上游改名 ⇒ 本文件当场编译红,
39
39
  * 端跟着红 —— 这正是型面转口相对「端各自直连」唯一多出来的那点好处(红在一处,不是散在九处)。
40
40
  */
41
- export type { TaskRequest, SemaSettings, SkillSpec, AgentEvent, RunReceipt, CancelAck, RunRecord, ToolApprovalRespondAck, FleetTaskRow, FleetWorkflowRow, ApprovalCard, ApprovalRequestFrame, ApprovalRiskAxes, AskDecisionAck, AskDecisionBody, } from '@sema-agent/sdk';
41
+ export type { TaskRequest, SessionPermissionRules, SemaSettings, SkillSpec, AgentEvent, RunReceipt, CancelAck, RunRecord, ToolApprovalRespondAck, FleetTaskRow, FleetWorkflowRow, ApprovalCard, ApprovalRequestFrame, ApprovalRiskAxes, AskDecisionAck, AskDecisionBody, } from '@sema-agent/sdk';
package/dist/seam.d.ts CHANGED
@@ -742,8 +742,14 @@ export interface ThinkingSegmentEndChromeEvent {
742
742
  committedPrefixDiverged?: true;
743
743
  /** 已 committed 思考行的全文长度(UTF-16 代码单元;never 0)—— 思考面上恒 = 整块。 */
744
744
  committedPrefixLen?: number;
745
- /** `committedPrefixDiverged` 在场时恒在场:要换掉的那条思考行的 `uuid`(本包铸)。 */
745
+ /** 有已提交思考行时在场:**末条**行的 `uuid`(0.69.0 兼容位;整段归属看 {@link committedUuids})。 */
746
746
  committedUuid?: string;
747
+ /**
748
+ * CC-11(0.69.1):有已提交思考行时在场 —— 段内**全部**已提交行的 `uuid`,按提交序(一段推理跨工具卡时多条)。
749
+ * 🔴 `committedPrefixDiverged` 在场 ⇒ 宿主把这些行**全部删掉**、在首行位置放一条 `content`(整段换,不切片);
750
+ * 缺席(`committedPrefixDiverged` 缺席)⇒ 这些行照旧,本包只换了还押着的后段(或零字节)。
751
+ */
752
+ committedUuids?: readonly string[];
747
753
  /** core 铸的事件身份(uuidv7 形);wire 未必带 ⇒ 缺席时本键不在场。 */
748
754
  eventId?: string;
749
755
  }
package/dist/seam.js CHANGED
@@ -83,7 +83,7 @@ const CHROME_ARM_TABLE = {
83
83
  // 本地转录里(b2 形上那条明文思考行已经渲过 = 已发生的行为)⇒ `required: true`,理由同上臂。
84
84
  required: true,
85
85
  duty: '整段替换:①`diverged`(never false)在场 ⇒ 自己拼 thinking stream_delta 上屏的端按 content 重渲该块;' +
86
- '②`committedPrefixDiverged`(never false)在场 ⇒ 所有端把 `uuid === committedUuid` 的那条已 committed 思考行**整块**换成 content(思考面无 idle-flush,前缀全有或全无,`committedPrefixLen` 恒 = 该行全文长度,单位 UTF-16 代码单元);' +
86
+ '②`committedPrefixDiverged`(never false)在场 ⇒ 所有端把 `committedUuids` 里的**全部**已 committed 思考行删掉、在首行位置放一条 content(整段换,不切片;`committedUuid` = 末条兼容位;`committedPrefixLen` = 全部行全文长度之和,单位 UTF-16 代码单元);' +
87
87
  '本包在这一形上一个字节都不再交;③三键全缺席 ⇒ 照旧只当对账/定界;④缺席 ≠ 段没结束,按整条流判。',
88
88
  },
89
89
  wiring_manifest: {
@@ -357,6 +357,7 @@ function synthPhaseStatus(runStatus, agents) {
357
357
  * tolerates absent/permissive fields and never throws (the graceful-degrade contract lives above, in the
358
358
  * source loop; this just maps a well-formed run). Exported for the mock-parity unit cross-check. */
359
359
  export function projectWorkflowRun(run) {
360
+ const parks = readWorkflowParks(run);
360
361
  const agents = (run.agents ?? []).map((r, i) => projectAgent(r, i));
361
362
  const phases = projectPhases(run, agents);
362
363
  const ownTokens = run.stats?.tokens ?? 0;
@@ -367,6 +368,9 @@ export function projectWorkflowRun(run) {
367
368
  const state = {
368
369
  workflowRunId: run.id,
369
370
  status: coerceRunStatus(run.status),
371
+ // CC-12(0.69.1):park 两新键**进视图**(修前只出读器,端拿不到裸体 ⇒ 渲染面结构性造不出);铸法与缺席律逐字同两读器。
372
+ ...(parks !== undefined ? { parks } : {}),
373
+ ...(readWorkflowResumeAdmissionIncomplete(run) ? { resumeAdmissionIncomplete: true } : {}),
370
374
  // The authored script TEXT is not projected to the wire (types.ts:375 — service does not emit it), so leave
371
375
  // it empty: the monitor's "save script" affordance stays off (canSave = script.length > 0). Never fabricate
372
376
  // a script we do not have. scriptPath is likewise absent until the service projects it.
@@ -14,6 +14,7 @@
14
14
  * 留在 TUI 的:`computeVisibleWindow`(滚动窗口数学 = 视口概念,端专属)、glyph/color 映射、
15
15
  * 以及整棵 JSX。壳侧 shim = 那个 tsx 改成从本包 re-export 这两样,自己不再定义。
16
16
  */
17
+ import type { WorkflowParkRowView } from './workflowClient.js';
17
18
  export type AgentState = 'start' | 'progress' | 'queued' | 'done' | 'error' | 'inactive'
18
19
  /** sema 超集:这条腿耐久挂在一张审批卡上(engine core 7.10.0 #642)。见上方头注。 */
19
20
  | 'parked';
@@ -91,6 +92,15 @@ export interface WorkflowRunState {
91
92
  totalTokens: number;
92
93
  /** phases as authored; the component derives the active/clamped windows itself. */
93
94
  phases: WorkflowPhase[];
95
+ /**
96
+ * CC-12(0.69.1;core 7.18.0 #755 / server ≥7.78.0):这条 run **负责**的 park 行(自己的 + 从被 resume 的 run 继承的),
97
+ * 由 `readWorkflowParks` 铸(三键行 + never-false 第四键 `originUnconfirmed`)。🔴 缺席两义(与读器同):整键缺席 =
98
+ * 旧引擎记录 / 读不出(证不出有没有 park);`[]` = 本引擎记录且一个 park 都没有。端渲 resume 候选时按这一位,
99
+ * 不再各自 `run.parks.map(...)`(修前本视图丢了它,端结构性造不出 —— cli 接入回执 ④)。
100
+ */
101
+ parks?: readonly WorkflowParkRowView[];
102
+ /** CC-12:在场 = 这条 resume run 的准入没完成 = **不是 resume 基**(core 逐字);缺席 = 常态。never false。 */
103
+ resumeAdmissionIncomplete?: true;
94
104
  /** the 187 reads e.workflowProgress to recompute phases; the mock pre-builds them. */
95
105
  workflowProgress?: unknown;
96
106
  }
@@ -1,19 +1,3 @@
1
- /**
2
- * ⇄ B6 批搬迁(2026-07-27,设计稿 §3 B6):workflow 监视器的**状态契约**(MF-W ·
3
- * contract/MF-W-workflow-monitor.md)从 cli `src/sema/overrides/workflow-detail-dialog.tsx:118-200`
4
- * 搬入 —— 逐字,一个字段都没动。
5
- *
6
- * 🔴 **为什么类型必须先过来**:`workflowClient.projectWorkflowRun()`(同批搬入)的**产物**就是这套
7
- * 形状。类型留在一个 `.tsx` 渲染文件里,等于「库产出的东西,它的契约住在 TUI 里」——
8
- * web/桌面要消费同一份投影就得手抄一遍 shape,而手抄的那份**没有任何东西**会在它漂移时报红。
9
- * 定位公理([1832])说的「CC 皮肤形状全收本包」,指的正是这种东西。
10
- *
11
- * 🔴 **拆缝**:`workflow-detail-dialog.tsx` 本身是 Ink 渲染件(设计稿 §2 分类表「Ink 渲染」),
12
- * **留 TUI**;搬过来的只有 ① 六个契约类型 ② `agentDisplayStatus`(WPe —— 从 wire 输入词表
13
- * 派生显示态的那条**纯**规则,是本契约的消费侧半场,端端都要用)。
14
- * 留在 TUI 的:`computeVisibleWindow`(滚动窗口数学 = 视口概念,端专属)、glyph/color 映射、
15
- * 以及整棵 JSX。壳侧 shim = 那个 tsx 改成从本包 re-export 这两样,自己不再定义。
16
- */
17
1
  /**
18
2
  * WPe — agentDisplayStatus: agent.state (+ workflowActive) → display status.
19
3
  *
@@ -19,7 +19,7 @@
19
19
 
20
20
  | 项 | 值 | 真源 |
21
21
  |---|---|---|
22
- | 本包 | `@sema-agent/client-core` **0.69.0**(本批发布版 = **minor**:型面 BREAKING 仅 peer `@sema-agent/sdk` 地板 `>=8.8.0` → `>=9.4.0`,运行期零 BREAKING;提货批 = sdk 9.4.0 / core 7.18.0 / settings-schema 2.0.0 换钉 + CC-02 `thinking_segment_end`(`reasoning_end` 推理面权威替换)+ CC-05 core 7.18.0 三件(通告码 +4 / park 两新键 / `gate.disposition.classifier` 视图)+ CC-07 core 值级十件端口注入 `installCoreValuePorts` + CC-03 补件 `fetchMcpPanel`;投影口 `text_end` 进 switch、`tool_roster_delta` 有痕 dropped;§36 / 36y 三端升级必读四条)|
22
+ | 本包 | `@sema-agent/client-core` **0.69.1**(本批发布版 = patch,additive 零 BREAKING:安全面收尾 —— CC-10 段末帧子流断闸改按三键(`text_end`/`reasoning_end` 内部臂新带 `sourceTaskId?`/`bgAgentId?`)/ CC-11 `thinking_segment_end` 新带 `committedUuids[]`(宿主义务改口:删全部行、首行位置放 content)/ CC-12 `WorkflowRunState.parks?`+`resumeAdmissionIncomplete?` / CC-09 runStream 去重丢帧留痕 `duplicate_seq`;文档批 ③⑤⑥;§37 / 37y 三条;CC-04 改随 server 7.78.1 进 0.69.2)|
23
23
  | peer:wire 契约 | `@sema-agent/sdk` **>=8.4.0**(value-level,非 type-only;**0.60.0 抬版**,四条硬理由见 §24a 与 `scripts/run-sdk-floor-test.mjs` 的 `FLOOR` 注;上一次是 0.59.0 的 `>=8.3.0`)。🔴 支持窗同批收到 **engine ≥7.64.0**:sdk 8.4.0 与 7.63.0 及以前的 wire **不同窗** | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
@@ -7882,7 +7882,7 @@ import { resolveAutonomousLoopPrompt, loosenReasons, combinePolicies, createAllo
7882
7882
  installCoreValuePorts({ resolveAutonomousLoopPrompt, loosenReasons, combinePolicies, createAllowDenyPolicy, MCP_NAMESPACE, RETIRED_TOOL_NAMES,
7883
7883
  protocolOf, ruleToolGrammarOf, validatePermissionRules, DISCUSSION_WORKFLOW_NAME })
7884
7884
  ```
7885
- `coreValuePortMisses()` 给 `/doctor` 一行(全装 ⇒ `[]`);`hasCoreValuePorts()` 只答「装没装」,**不判部署形态**。
7885
+ `coreValuePortMisses()` 给 `/doctor` 一行(全装 ⇒ `[]`);`hasCoreValuePorts()` 只答「装没装」,**不判部署形态**。🔴 **部分装载时两口分歧是刻意的**:`hasCoreValuePorts()` 答「整袋在不在」(装了任意一件即 `true`),`coreValuePortMisses()` 答「哪几件缺」(逐件);要判某一件用 `hasCoreValuePort(name)`,别拿整袋位推断单件。**签名口径**:十件签名按 core **7.18.0** `dist/**.d.ts` 写,7.17.x 上同形(这十件在 7.17→7.18 零签名变更);装配根装的是端自己那一版 core 的实现。
7886
7886
 
7887
7887
  **判据**:`run-core-value-ports-test.mjs`(52 格;源码剥注释零 core import 直证)。
7888
7888
 
@@ -7917,3 +7917,38 @@ installCoreValuePorts({ resolveAutonomousLoopPrompt, loosenReasons, combinePolic
7917
7917
  4. **park 两新键必须逐字带**(渲 resume 候选 / park 行的端):`originUnconfirmed` 在场禁当已确认读;`resumeAdmissionIncomplete` 在场 = 不是 resume 基 —— 丢任一 = 已拒记录变回可 resume。
7918
7918
 
7919
7919
  **包侧缺口:** ① `tool_progress` 投影候 DV-741(36z #8);② `tool_disclosure` / `sections` / `hooks`·`lsp` 与 L-315 同批(36z #9);③ `WiringManifestMcpEntry` 同名影子收敛(36z #11,0.70);④ CC-04 技能帽退役随 server 7.78.1 进 0.69.1(36z #14);⑤ 中途翻转 verb 处置表 / 跨消息分段根治(§35 包侧缺口 ③④ 不变;① 面板体传输口已由 36z #18 收口)。
7920
+
7921
+ ---
7922
+
7923
+ ## §37 🆕 0.69.1 收尾批(CC-09 runStream 去重留痕 / CC-04 技能帽退役候 server 7.78.1)
7924
+
7925
+ ### 37a. 本节速览
7926
+
7927
+ | 件 | 一句话 | 端要做什么 |
7928
+ |---|---|---|
7929
+ | S-1 🔴 **已知边界** | **`-p` / runStream 链在 server <7.78.1 上收不到 `reasoning_end`**:server 7.77.0 给它的 SSE `id:` 复用该段首枚 `reasoning_delta` 的 id,`runStream` 的 durable seq 去重把它当重放丢;0.69.1 起**丢了必留痕**(`ctx.onDroppedFrame({ type:'reasoning_end', why:'duplicate_seq' })`)。**交互车道 `adapt()` 不经这只去重**,`thinking_segment_end` 照到 | 装 `onDroppedFrame` 的端把 `duplicate_seq` 当运维面留痕渲(不是用户面);`-p` 端在 server <7.78.1 上**如实披露**「推理面权威段不到」,不许壳侧绕过去重(去重律是契约) |
7930
+ | S-2 🔴 **安全面** | **段末帧子流断闸改按身份三键**(CC-10):`text_end` / `reasoning_end` 内部臂新带 `sourceTaskId?` / `bgAgentId?`(与 `parentToolCallId` 同律原样透传),两臂任一在场即断;只带 `sourceTaskId` 的子代段末帧不再被当 leader 投上来 | 自建管线的端(print 车道 / 座位层)断子流按**三键**判,别只看 `parentToolCallId` |
7931
+ | S-3 🔴 **安全面** | **跨工具卡的推理段归属交全**(CC-11):`thinking_segment_end` 新带 `committedUuids[]`(段内全部已提交思考行,按序;`committedUuid` = 末条兼容位);`committedPrefixLen` = 全部之和;新增 (b1) 形(前缀对得上 ⇒ 只换还押着的后段) | **宿主义务改口**:`committedPrefixDiverged` ⇒ 删 `committedUuids` 全部行、在首行位置放 `content`(不再只换 `committedUuid` 那一条);缺席 ⇒ 行照旧 |
7932
+ | S-4 | **park 两新键进 `WorkflowRunState`**(CC-12):`parks?` / `resumeAdmissionIncomplete?`(additive,由两读器铸) | 渲 resume 候选 / park 行的端改读视图上这两位;cli 的「到货锚」可翻 consumed |
7933
+ | S-5 | 文档批(回执 ③⑤⑥):`mcpPanelLastLegDetail` 是**五句**(源码 jsdoc 已订正);`SessionPermissionRules` 进 `sdkWireTransit` 型面转口;§36d 两口分歧句 + 签名口径句已补;§35z #15「候 sdk 9.5」**作废** —— 已由 §36z #18(`fetchMcpPanel`)收口 | 零动作 |
7934
+ | S-6 | CC-04 `SKILL_CAPS.items` 退役改预算式 | **不在本版**:改随 server 7.78.1 + core 7.18.1 进 **0.69.2**(§38) |
7935
+
7936
+ ### 37z. 逐键处置表(0.69.1)
7937
+
7938
+ | # | 键 / 面 | 处置 | 坐标 / 理由 |
7939
+ |---|---|---|---|
7940
+ | 1 | runStream 对 re-seen durable seq 的裸 `continue` | **consumed(留痕)** | `src/adapter/runStream.ts` 的 `#reportDroppedFrame('duplicate_seq', …)` + `#DROPPED_WHY_SENTENCE` 加行;门 TSA U 段 |
7941
+ | 2 | server 7.77.0 `reasoning_end` id 复用 | **declined(归口 server 7.78.1)** | 本层不猜「真重放 vs id 复用」,去重律不变 |
7942
+ | 3 | 段末帧身份键 `sourceTaskId` / `bgAgentId`(sdk `EventIdentity`) | **consumed** | `src/adapter/downstream/eventToSdkMessage.ts` 的 `#segmentEndProjection`(三键透传 + 非串 malformed);`src/adapt/arms.ts` 的 `#isSubFlowSegmentEnd`;门 TSA V1–V3 |
7943
+ | 4 | `thinking_segment_end.committedUuids[]` + (b1) 形 | **consumed** | `src/adapt/textStream.ts` 的 `#replaceThinkingSegment`(段内行按序累积);`src/seam.ts` 义务文案改口;门 TSA V4–V10 |
7944
+ | 5 | `WorkflowRunState.parks?` / `resumeAdmissionIncomplete?` | **consumed** | `src/workflowClient.ts` 的 `#projectWorkflowRun`;`src/workflowMonitor.ts` 的 `#WorkflowRunState`;门 park H 段 |
7945
+ | 6 | `SessionPermissionRules` 型名 | **consumed(型面转口)** | `src/sdkWireTransit.ts` |
7946
+ | 7 | §35z #15「候 sdk 9.5」 | **作废** | 已由 §36z #18 `fetchMcpPanel` 收口(sdk `sessions.mcp` 自 0.0.75 在场) |
7947
+
7948
+ ### 37y. 🔴 三端升级必读(固定段式,[C295];本批**三条**)
7949
+
7950
+ 1. 🔴 **`thinking_segment_end` 宿主义务改口**:`committedPrefixDiverged` 在场 ⇒ 删 `committedUuids` **全部**行、在首行位置放一条 `content`(0.69.0 写的「换 `committedUuid` 那一条」在跨工具卡的段上会漏掉前面几条未脱敏行);`committedUuid` 仍在(末条),只作兼容。
7951
+ 2. 🔴 **自建管线的端断子流按三键**:`text_end` / `reasoning_end` 内部臂现带 `sourceTaskId?` / `bgAgentId?`,任一在场 = 子代帧;只看 `parentToolCallId` 会把只带 `sourceTaskId` 的子代段末帧当 leader(0.69.0 包侧同病已修)。
7952
+ 3. `onDroppedFrame` 新 why 词 `duplicate_seq`(开集,宿主不必改型):渲进运维面;`-p` 端在 server <7.78.1 上如实披露推理面权威段不到,不绕过去重。`WorkflowRunState` 新带 `parks?` / `resumeAdmissionIncomplete?`,渲 park/resume 面的端改读视图。
7953
+
7954
+ **包侧缺口:** ① CC-04 技能帽退役随 7.78.1 进 0.69.2;② server <7.78.1 上 `-p` 链收不到 `reasoning_end`(归口 server,本版只留痕);③ 其余同 §36 包侧缺口。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.69.0",
3
+ "version": "0.69.1",
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. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",