@sema-agent/client-core 0.82.1 → 0.82.2

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,17 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.82.2(2026-09-24)
53
+
54
+ ### Added
55
+ - **云控制面有效配置回体的降级告警、预算与能力上限**(CC-164):`projectEffectiveBody` 的结果新增 `effectiveWarnings` / `budget` / `runtimeCaps` 三个读数,给人看的句子统一出自新增的 `cloudEffectiveNotices(projection)`。告警按开集读,认不出的种类也出一句提示;一行告警坏了只算坏一行,不连累别的行。预算与能力上限分四态:配了 / 这个主体没解析出(`null`)/ 预览别人时被抹掉(`null` 且带 `previewOf`)/ 判不出。只有「这个主体没解析出」那一态才说没有预算;被抹与判不出都不会被读成 0 或「没有预算」;读不懂的预算字段逐个点名为「没显示」,「没配 / 被抹 / 判不出」三类句子里不含数字。接入文档 **§94 S-1**。
56
+ - **计划复核结局带机读位**(CC-160 ①):投给宿主队列口的结局项新增 `_sema_planReviewOutcome: { taskId, dispatchNo, decision, effect }`,宿主不必再从结局文字里找任务号(仍锁 / 推进 / 没送出 / 拒绝生效这几种的文字本来就不带任务号)。`dispatchNo` 是本包铸的投递序号,只在同一份包实例内单调,进程重启从 1 起,引擎不认识它;被在飞闸拒掉的那次不铸号,也不投结局。结局文字逐字不变;`enqueuePlanReviewOutcome` 多了一个可选第二参,不给则队列项上没有这一键。接入文档 **§94 S-3**。
57
+ - **审批决断操作的起止回调**(CC-160 ③):`HitlBridge` 构造可以带第三参 `{ onDecideOperation }`,`AskGateWireDeps` / `FsApprovalWireDeps` 有同名可选位,透传到两条停泊决断腿。一次决断(含瞬断重试)在第一发请求之前同步报 `start`;最后一发结算、不会再有下一发时恰好报一次 `end`(带 `ok` / `attempts` / `retryExhausted`);重试之间不报。回调抛错或返回被拒的 promise 都被吞掉,不改变这次决断。包内对同一次决断的再投递(老 server 不认会话级放行时回退纯 approve、归因键不在签名集里时去键重发)算同一个操作,只报一对 `start` / `end`;宿主自己要把多发并成一次时,用 `bridge.openDecideOperation()` 开句柄放进 `decideTool` 的 `opts.operation`;句柄只并同一会话、同一 `boundCallId` 的发,拿去发另一道门的那一发单独成一个操作;关句柄时还有一发在飞,`end` 等它结算后才报。计划审阅、续跑与计划复核投递不经这个回调。宿主不必再照着包的重发判据自写一份「这一发还会不会再发」。接入文档 **§94 S-4**。
58
+
59
+ ### Fixed
60
+ - **本机引擎的模型目录补上默认模型与档位组**(CC-165):写给本机引擎的 models 文档此前丢掉 `default` / `tierGroups` / `activeTierGroup`,面板上选的默认模型与档位组在本机不生效。现在宿主在 `cloudModelsToDoc` / `projectEffectiveBody` 注入本机条目判定 `entryAccepted`(用宿主手上 settings-schema 的 `ModelEntry` 判定实现)后,三键都带上;不注入则三键照旧不投 —— 本机引擎会先剔掉格式不合的条目再判引用,本包判不出哪些条目会被剔,投了就可能让整份目录作废。注入时,判定口不收的条目本身不写进文档 —— 本机引擎启动时会逐条剔掉它,运行中刷新时却会因它拒收整份配置;一条都没过判定口时目录原样保留并出一句提示(写成空目录,刷新时会被当成合法配置收下、改用环境目录,上一份就丢了)。指向被拒条目的引用一并剔掉(记 `reason:'rejected'`);档位词只带本机格式认得的那几个,表外词剔掉并记 `reason:'unsupported'`。本机引擎会整个拒收的目录引用(指向不在目录里的模型或档位组、坏组名、重名组)逐条剔掉,并记进新字段 `modelRefIssues` —— 留着其中任何一条,本机引擎都会弃用整份云端目录:启动时改用自己的环境目录,运行中刷新时拒收这份新配置、保留上一份。修前就会触发的「@ 名单里有被授权排除的模型」一并修正;@ 名单整张都不在目录里时原样保留并给一句提示(剔空会被读成「谁都能 @」)。接入文档 **§94 S-2**。
61
+ - **云端改了模型配置,本机引擎的刷新不再因条目上的来源注记整份被拒**:registry 的个人层会给每一条模型条目打上来源注记(`origin`,覆盖团队条目时另带 `overridesTeam`),本机引擎的模型条目格式不认这两个键,刷新时会因此拒收整份配置、继续用上一份。现在写 models 文档前把这两个注记键去掉,条目其余内容逐字不变。接入文档 **§94 S-2**。
62
+
52
63
  ## 0.82.1(2026-09-24)
53
64
 
54
65
  ### Added
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.82.1
38
+ **Version:** 0.82.2
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
@@ -348,9 +348,9 @@ guard still cross-checks the table by name).
348
348
  | `scripts/run-background-view-test.mjs` | `createBackgroundView` lifecycle: polling/notify pairing, per-source degrade (`501 → not-configured` vs `unavailable`), the capabilities `scheduler` probe, and dispose really aborting the in-flight fleet snapshot (pure projection lives in the pure suite's W-A segment) |
349
349
  | `scripts/run-fleet-view-keys-test.mjs` | The fleet projection views, **both directions**: `FleetTaskView`/`FleetWorkflowView` ⇄ their key lists (compile-pinned) ⇄ what a maximal/minimal row really projects, plus a wire-key coverage ledger (every `FleetTaskRow` key is either projected or carries a written reason why not) and a drift ledger against the shell's render contract. A one-directional assignability check is blind to optional keys — which is how `startedAt` was silently dropped |
350
350
  | `scripts/run-usage-verbatim-channel-test.mjs` | The two complementary usage disciplines (core 3.0.0 metering semantics): the CC `ModelUsage` mirror stays pure (five pinned keys, `totalInputTokens` has no seat), while the sema-owned channel forwards the engine `turn_end.usage` object **verbatim** (six keys, incl. `totalInputTokens`) via `last_turn_usage.engineUsage` / `handle.latestEngineUsage` — honest absence on pre-3.0.0 engines, no fabricated zeros |
351
- | `scripts/run-plan-review-decide-verify-test.mjs` | `decidePlanReview`'s post-decide honesty ([2315]/[2316], engine RB-471 family): a 2xx from the decide endpoint is **not** a terminal — the wire re-pulls the task status and words the outcome by the real shape (still-locked / legal new gate / genuinely left park / unverified), never claiming success it hasn't earned; when the engine answers that the session's stored resume context cannot be read, the outcome names the unreadable row and says the decision was not applied. Driven against a real fake-engine HTTP server through the shipped dist |
351
+ | `scripts/run-plan-review-decide-verify-test.mjs` | `decidePlanReview`'s post-decide honesty ([2315]/[2316], engine RB-471 family): a 2xx from the decide endpoint is **not** a terminal — the wire re-pulls the task status and words the outcome by the real shape (still-locked / legal new gate / genuinely left park / unverified), never claiming success it hasn't earned; when the engine answers that the session's stored resume context cannot be read, the outcome names the unreadable row and says the decision was not applied. Driven against a real fake-engine HTTP server through the shipped dist. The outcome queue item also carries a machine-readable `_sema_planReviewOutcome` (task id, a package-minted dispatch number, decision, effect) so a host can tell which in-flight decision an outcome belongs to without searching the prose; the prose is byte-identical, the number is minted only for a decision the in-flight latch admits, and a caller that passes no metadata gets no key. |
352
352
  | `scripts/run-shell-gate-durable-allow-test.mjs` | #110: the durable approval leg for **shell** gates. The tool_end HOLD/REJECT predicate must cover Bash the same way park detection already does (otherwise the park poison frame `Operation aborted` hits the transcript, `endedCalls` swallows the real replayed result, and the user who pressed Yes watches a command that really ran be reported as aborted); a replayed, already-decided park must resume reading the stream instead of being reported as a failed turn; `lastEventId` must track numeric `seq` too. Mutation-proven: each of the three fixes reverted turns the gate red |
353
- | `scripts/run-hitl-gate-honesty-test.mjs` | [2393] the four HITL disciplines that a passing type-check cannot see. (1) The park predicate and the `tool_end` predicate must cover the **same** set — the park side admits a first-class `kind:'tool_approval'` gate for *any* tool name, and a `tool_end` frame carries no `kind`, so the frame-level judge falls back to the engine's exact abort marker; otherwise the poison frame hits the transcript and `markEnded` swallows the real replayed result (the #110 disease, reopened on kind-only gates). (2) The already-decided identity criterion is **one-shot**: its two inputs are monotonic, so without consumption one successful decide makes every later park failure — including a real `approvals.list` outage — read as "already resolved" until the 24-hop budget runs out and reports a cause that has nothing to do with what happened. (3) A `plan_review` card dismissed without an answer must be re-presentable: the idempotent re-arm short-circuit re-publishes the still-armed card, and a stale armed id (responder gone) re-arms from scratch rather than presenting a card nobody can answer. (4) `HitlSafetyError` is a safety signal — the `remember` fallback arm must re-raise it instead of auto-retrying the decide, while a plain unknown-key 400 still falls back. (5) The polling leg reschedules after an escaping throw and flips `mode()` to `idle` once it consistently fails, so the honesty surface stops reporting a dead feed as live. (6) The live-frame leg carries the fact behind "you are being asked because the auto-mode classifier could not run" all the way to the card port. Transit narrows on SHAPE only — a non-empty cause string is taken verbatim, an open set, because the word table's owner is the engine and re-checking a closed table at the package boundary would drop a legal value the day a new cause word appears, which is exactly the information worth keeping. A malformed carrier degrades to absence rather than half-minting, and absence stays absence: it covers "the classifier answered", "this ask never qualified" and "this deployment has no classifier" at once, so nothing may render it as reassurance. The guard also pins the division of labour that makes the open set safe — the same word that transits is judged again by the public display reader, which narrows to the availability axis, so a word the engine says it never stamps on this fact renders no sentence while still being visible on the card for triage A later section pins the split this release introduced on the deny close-out frame. Until now every denied tool call was stamped with the same sentence — the one that says *the user* does not want to proceed — including the calls denied automatically on a lane that has no approval surface at all, where nobody was ever asked. The guard drives all three shapes (a person pressed No, a rule settled it, nobody said which) through both close-out arms and the durable park leg, and pins that the third shape is byte-identical to the previous release: an attribution nobody supplied is not evidence for either answer. The rule-settled shape carries the shell's own reason on a second line when there is one and stands alone when there is not, because a blank line where a reason should be reads worse than no line at all. The attribution is read from own data properties only, so neither a polluted prototype nor a getter can make an automatic denial claim a person made it — and the getter case is pinned to never run at all. The transcript classification word is minted only on the two paths where the upstream transcript format really carries one; the three classifier words and the two abort words are left absent, with the abort words pinned against the strings this package actually normalises interruptions to, which are different strings |
353
+ | `scripts/run-hitl-gate-honesty-test.mjs` | [2393] the four HITL disciplines that a passing type-check cannot see. (1) The park predicate and the `tool_end` predicate must cover the **same** set — the park side admits a first-class `kind:'tool_approval'` gate for *any* tool name, and a `tool_end` frame carries no `kind`, so the frame-level judge falls back to the engine's exact abort marker; otherwise the poison frame hits the transcript and `markEnded` swallows the real replayed result (the #110 disease, reopened on kind-only gates). (2) The already-decided identity criterion is **one-shot**: its two inputs are monotonic, so without consumption one successful decide makes every later park failure — including a real `approvals.list` outage — read as "already resolved" until the 24-hop budget runs out and reports a cause that has nothing to do with what happened. (3) A `plan_review` card dismissed without an answer must be re-presentable: the idempotent re-arm short-circuit re-publishes the still-armed card, and a stale armed id (responder gone) re-arms from scratch rather than presenting a card nobody can answer. (4) `HitlSafetyError` is a safety signal — the `remember` fallback arm must re-raise it instead of auto-retrying the decide, while a plain unknown-key 400 still falls back. (5) The polling leg reschedules after an escaping throw and flips `mode()` to `idle` once it consistently fails, so the honesty surface stops reporting a dead feed as live. (6) The live-frame leg carries the fact behind "you are being asked because the auto-mode classifier could not run" all the way to the card port. Transit narrows on SHAPE only — a non-empty cause string is taken verbatim, an open set, because the word table's owner is the engine and re-checking a closed table at the package boundary would drop a legal value the day a new cause word appears, which is exactly the information worth keeping. A malformed carrier degrades to absence rather than half-minting, and absence stays absence: it covers "the classifier answered", "this ask never qualified" and "this deployment has no classifier" at once, so nothing may render it as reassurance. The guard also pins the division of labour that makes the open set safe — the same word that transits is judged again by the public display reader, which narrows to the availability axis, so a word the engine says it never stamps on this fact renders no sentence while still being visible on the card for triage A later section pins the split this release introduced on the deny close-out frame. Until now every denied tool call was stamped with the same sentence — the one that says *the user* does not want to proceed — including the calls denied automatically on a lane that has no approval surface at all, where nobody was ever asked. The guard drives all three shapes (a person pressed No, a rule settled it, nobody said which) through both close-out arms and the durable park leg, and pins that the third shape is byte-identical to the previous release: an attribution nobody supplied is not evidence for either answer. The rule-settled shape carries the shell's own reason on a second line when there is one and stands alone when there is not, because a blank line where a reason should be reads worse than no line at all. The attribution is read from own data properties only, so neither a polluted prototype nor a getter can make an automatic denial claim a person made it — and the getter case is pinned to never run at all. The transcript classification word is minted only on the two paths where the upstream transcript format really carries one; the three classifier words and the two abort words are left absent, with the abort words pinned against the strings this package actually normalises interruptions to, which are different strings. A final section pins the decide-operation observer: one `start` in the same tick as the first request and exactly one `end` after the last attempt has settled, across success, retried timeouts, exhausted transient failures, semantic refusal, binding mismatch and both kinds of caller abort, with nothing between retries; an observer that throws or rejects — even when logging that fault fails — never changes what is sent or returned, and the stream-level dependency reaches both durable park legs. A package-internal re-delivery of the same decision (plain approve after an older server rejects the session-scope flag, or a re-send without the attribution key) is reported as one operation with a single start and end. An operation handle only groups sends for the same session and bound call — a send for another gate through the same handle is its own operation — and closing a handle while a send is still in flight defers the end until that send settles. |
354
354
  | `scripts/run-park-hop-progress-test.mjs` | L-80: the park re-attach loop budgets **stalled** rounds, not parks. A turn where the model keeps hitting gates and every one of them is really decided (a card was answered, the engine really moved on) must never be cut off by the hop budget — the budget counts consecutive rounds that produced no progress, and "the engine revived and immediately parked again on the same coordinates" is not progress. The three non-progress arms (already-resolved, decide-transport-exhausted, and a re-scan that was adopted but led nowhere) share one same-cause limit instead of one arm having a limit and the others having none, and every non-progress re-attach is announced once through the host callback rather than only to the debug log. When the limit is spent the resolver reads the approval queue once more and puts whatever is decidable in front of the user before it gives up; only when there is genuinely nothing to show does it fail soft, and the terminal message then carries the real cause and a real way out instead of a sentence about a budget. On the self-heal side, a reopen verdict that reports `decidedWithoutCard` — the chain settled the gate by rule, so there was no card to present — is progress, not a reopen failure, and the user is not told their message was NOT sent. Negative control: a genuinely empty queue with a run that never moves still fails soft |
355
355
  | `scripts/run-notif-fleet-honesty-test.mjs` | [2393] the five notification/fleet disciplines a green type-check cannot see, each proven by reverting the fix. (1) The workflow-side dedup `return` keeps a count and a trace — without it "suppressed by design" and "a real completion swallowed because the runId minting changed" are the same observation. (2) `seq` normalisation has exactly one mint point, so a 0-based or fractional wire `seq` cannot make the watcher lane and the frame lane key the same completion differently (which would feed the model twice). (3) The TTL sweep defers to a probe arm that is still inside its own deadline — an entry recorded as "abandoned" must not be delivered a moment later — while an arm that has outlived its deadline never blocks the sweep, so the headless exit gate keeps its liveness. (4) The reset hook really clears every ledger it claims to (the sticky `prompt` ledger leaked across cases). (5) The fleet ledger counts all three drop paths (malformed / unknown frame type / isolation drop), and the panel projection's settled recycling is anchored on the settle instant and skips still-present rows, so the dedup token is never carried off with the entry (which would re-emit `end`) |
356
356
  | `scripts/run-public-surface-test.mjs` | The outward promises: the npm export surface baseline (an **exact set**, both directions — a new export that never entered the baseline is one nobody watched leave, and deleting it later would not be red), the peer floor witness, and this README's claims |
@@ -419,6 +419,7 @@ guard still cross-checks the table by name).
419
419
  | `scripts/run-registry-quota-usage-test.mjs` | The projection of the cloud control plane's quota reading, and the three different things a missing number can mean there. This response says `null` in two places and means something different each time: no token quota is configured for this principal on this instance, and this window has no cap at all. Both are **facts the server is asserting**, not gaps in the reading — while a key that is absent or carries the wrong type is a genuine gap. All three have to survive to the screen separately, because folding them is how a user ends up staring at a confident `0`: an uncapped window rendered as if nothing were left, or a deployment that simply never configured quotas rendered as if the quota were exhausted. The complaint that started this was the opposite direction — a centrally configured quota that the command line could not see at all — so the reading also refuses to let an unreadable response masquerade as "no quota configured". Two fields deliberately do not share one signal: whether a window is exhausted and whether there is a recovery time, since the recovery time is only ever populated in the exhausted case and reading its absence as "not exhausted" would answer a question the response never answered. Counts that the server always provides are narrowed no further than the mint: a used counter has no uncapped state, so a null there is unreadable rather than zero. The wording helper carries the only human-facing phrasing, and the sentences for "no cap" and "unknown" are checked to contain no digits at all |
420
420
  | `scripts/run-file-history-capture-capability-test.mjs` | The engine's file-history-capture self-description (`capabilities.fileHistoryCapture`), read the same four-state way as its sibling capability readers: an absent key is reported as not reported (never folded into `off`), words are taken as an open set so a newer mode is not mistaken for a malformed answer, `fileHistoryCaptureMode` recognises only `off` and `on-always`, and the wording for `off` speaks about capture only — whether code can be rewound is left to the rewind readings. |
421
421
  | `scripts/run-model-identity-resolvability-test.mjs` | The model-identity judgement a client makes before letting anyone in: can the engine it is about to use start with a model name? Each end reports what it read from each place that can feed a model name to a local engine (complete, partial — a gateway address or a credential but no model name —, absent, or unreadable), or, for an engine that runs elsewhere, whether that engine has been seen answering; `modelIdentityResolvability` answers resolvable, unresolvable or unknown. Having part of an upstream configuration is not having enough of one, so partial lanes never add up to resolvable; a lane that was not reported or could not be read makes the answer unknown rather than unresolvable; an engine that runs elsewhere is never judged unresolvable and local lanes are never consulted for it (it does not start without a model name, so seeing it answer is enough to call it resolvable). `modelSetupDecision` combines that answer with whether this end can configure a model at all: setup is offered only for unresolvable on an end that can configure one, an end that cannot says so and points at whoever runs the engine, and unknown never opens setup. The detail and notice sentences are checked to be pairwise distinct, unknown sentences neither claim a model is configured nor that it is not, and the module is checked to import no platform I/O |
422
+ | `scripts/run-cloud-effective-projection-test.mjs` | The cloud control plane's effective-configuration response beyond its four configuration domains, and what a locally started engine does with the models document derived from it. Three top-level keys are read with the meaning their producer gives them: `warnings` (degradation warnings from the build that produced the served view — an empty list is a clean build, an absent key is an older server that cannot tell), and `budget` / `runtimeCaps`, where `null` means two different things: nothing resolves for this principal when you view yourself, and values withheld when the response previews another principal. A missing key or a wrong type is a third state, unknown, and none of the three is ever folded into a zero, a `false` or "no budget". A malformed warning row, budget field or cap costs only itself, and a known budget field of the wrong type is named as not shown rather than silently read as "no limit on this axis"; warning kinds are an open set, so a kind this client does not recognise still produces a warning line. The budget and cap readers are reconciled against the installed settings schema. The single wording source puts degradation warnings first and keeps every "not set / withheld / unknown" sentence free of digits, while a zero the server really sent is shown as a zero. On the models side, when the host injects an entry check the models document carries the default model, the tier groups and the active tier group (without the check none of the three is written), and every catalog reference the local engine's schema would reject — a default, role, @-mention entry, tier binding or active group that names something outside the catalog served to this principal — is dropped and recorded, because one dangling reference makes the local engine discard the whole models domain and fall back to its environment catalog; this is proven by reading the produced document with the installed file store. An @-mention allowlist that would be pruned to empty is kept as sent, since an empty list means "everything may be mentioned"; that case is recorded, produces its own warning that a locally started engine will reject the cloud model settings and use its environment catalog instead, and the gate reads the document with the installed file store to confirm exactly that outcome, so the sentence turns red the day the local reader becomes lenient. A per-model budget in which no field could be read is never described as having no limits. Registry annotation keys on model entries (`origin`, `overridesTeam`) are removed before the models document is written: the local engine's schema does not accept them, and a configuration refresh would otherwise be rejected as a whole. A model entry the host-injected entry check rejects is left out of the document and references to it are dropped: the local engine drops such an entry at startup, but a refresh rejects the whole configuration over it, so the gate requires a clean read of the produced document; when no entry passes the check, the catalog is kept as sent and gets its own warning, which the gate proves by reading the document back. The check receives a copy, so it cannot alter what is written. Tier words outside the local schema's closed set are dropped and recorded as unsupported, and the package's tier word list is reconciled against the installed schema in both directions; an active tier group is judged against group names, never model names. |
422
423
 
423
424
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
424
425
  assertions is a failure, not a quieter pass. Guards anchor on the **installed artefact's content**
@@ -16,7 +16,17 @@ export interface CloudMcpProjection {
16
16
  }
17
17
  export declare function cloudMcpToSpecs(servers: unknown, env: EnvLike): CloudMcpProjection;
18
18
  export declare function cloudSkillsToSpecs(manifestSkills: unknown, resolveBody: (contentHash: string) => string | null): SkillSpec[];
19
- export declare function cloudModelsToDoc(models: unknown): Rec | null;
19
+ export interface CloudModelRefIssue {
20
+ readonly key: 'models' | 'default' | 'roles' | 'atModelAllowlist' | 'tierGroups' | 'activeTierGroup';
21
+ readonly at?: string;
22
+ readonly target?: string;
23
+ readonly reason: 'dangling' | 'rejected' | 'unreadable' | 'unsupported' | 'duplicate';
24
+ readonly outcome: 'dropped' | 'kept';
25
+ }
26
+ export interface CloudModelsDocOptions {
27
+ readonly entryAccepted?: (entry: object) => boolean;
28
+ }
29
+ export declare function cloudModelsToDoc(models: unknown, opts?: CloudModelsDocOptions): Rec | null;
20
30
  export interface CloudDroppedModel {
21
31
  id?: string;
22
32
  name?: string;
@@ -35,6 +45,62 @@ export interface CloudExecutionProjection {
35
45
  }
36
46
  export declare function cloudExecutionToPolicy(execution: unknown): CloudExecutionProjection;
37
47
  export declare function cloudRosterNames(rostersValue: unknown): string[];
48
+ export interface CloudEffectiveWarning {
49
+ readonly kind: string;
50
+ readonly domain?: string;
51
+ readonly droppedNames?: readonly string[];
52
+ readonly keys?: readonly string[];
53
+ readonly path?: string;
54
+ readonly file?: string;
55
+ readonly why?: string;
56
+ readonly truncatedFrom?: number;
57
+ }
58
+ export type CloudEffectiveWarningsReading = {
59
+ readonly kind: 'reported';
60
+ readonly warnings: readonly CloudEffectiveWarning[];
61
+ readonly unreadable: number;
62
+ } | {
63
+ readonly kind: 'unknown';
64
+ };
65
+ export interface CloudBudgetModelLimits {
66
+ readonly maxBudgetUsd?: number;
67
+ readonly tpmLimit?: number;
68
+ readonly rpmLimit?: number;
69
+ }
70
+ export interface CloudBudgetLimits {
71
+ readonly mode?: string;
72
+ readonly maxBudgetUsd?: number;
73
+ readonly budgetDuration?: string;
74
+ readonly tpmLimit?: number;
75
+ readonly rpmLimit?: number;
76
+ readonly maxParallelRequests?: number;
77
+ readonly perModel?: Readonly<Record<string, CloudBudgetModelLimits>>;
78
+ readonly maxRunCostUsd?: number;
79
+ readonly maxIterations?: number;
80
+ readonly enforcement?: string;
81
+ }
82
+ export type CloudBudgetReading = {
83
+ readonly kind: 'configured';
84
+ readonly limits: CloudBudgetLimits;
85
+ readonly uninterpretedKeys: readonly string[];
86
+ } | {
87
+ readonly kind: 'not-configured';
88
+ } | {
89
+ readonly kind: 'redacted';
90
+ } | {
91
+ readonly kind: 'unknown';
92
+ };
93
+ export type CloudRuntimeCapsReading = {
94
+ readonly kind: 'configured';
95
+ readonly caps: Readonly<Record<string, boolean>>;
96
+ readonly uninterpretedKeys: readonly string[];
97
+ } | {
98
+ readonly kind: 'not-configured';
99
+ } | {
100
+ readonly kind: 'redacted';
101
+ } | {
102
+ readonly kind: 'unknown';
103
+ };
38
104
  export interface CloudProjection {
39
105
  mcp: McpServerSpec[];
40
106
  skills: SkillSpec[];
@@ -47,5 +113,14 @@ export interface CloudProjection {
47
113
  droppedMcpServers: CloudMcpDroppedServer[];
48
114
  droppedModels: CloudDroppedModel[];
49
115
  execution: CloudExecutionProjection;
116
+ effectiveWarnings: CloudEffectiveWarningsReading;
117
+ budget: CloudBudgetReading;
118
+ runtimeCaps: CloudRuntimeCapsReading;
119
+ modelRefIssues: CloudModelRefIssue[];
120
+ }
121
+ export declare function projectEffectiveBody(body: unknown, skillBodies: Record<string, string>, env: EnvLike, rostersValue?: unknown, opts?: CloudModelsDocOptions): CloudProjection;
122
+ export interface CloudEffectiveNotice {
123
+ readonly level: 'warn' | 'info';
124
+ readonly text: string;
50
125
  }
51
- export declare function projectEffectiveBody(body: unknown, skillBodies: Record<string, string>, env: EnvLike, rostersValue?: unknown): CloudProjection;
126
+ export declare function cloudEffectiveNotices(p: Pick<CloudProjection, 'effectiveWarnings' | 'budget' | 'runtimeCaps' | 'modelRefIssues'>): CloudEffectiveNotice[];
@@ -114,19 +114,153 @@ export function cloudSkillsToSpecs(manifestSkills, resolveBody) {
114
114
  }
115
115
  return specs;
116
116
  }
117
- export function cloudModelsToDoc(models) {
117
+ const REGISTRY_ENTRY_ANNOTATION_KEYS = ['origin', 'overridesTeam'];
118
+ function withoutRegistryAnnotations(e) {
119
+ const r = asRec(e);
120
+ if (!r || !REGISTRY_ENTRY_ANNOTATION_KEYS.some((k) => Object.hasOwn(r, k)))
121
+ return e;
122
+ const out = {};
123
+ for (const [k, v] of Object.entries(r)) {
124
+ if (REGISTRY_ENTRY_ANNOTATION_KEYS.includes(k))
125
+ continue;
126
+ setOwn(out, k, v);
127
+ }
128
+ return out;
129
+ }
130
+ const TIER_WORDS = Object.freeze(['ultra', 'max', 'pro', 'flash', 'lite']);
131
+ const TIER_GROUP_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
132
+ const setOwn = (o, k, v) => {
133
+ Object.defineProperty(o, k, { value: v, enumerable: true, writable: true, configurable: true });
134
+ };
135
+ function modelsDocOf(models, opts) {
118
136
  const m = asRec(models);
119
137
  if (!m || !Array.isArray(m.models))
120
- return null;
138
+ return { doc: null, issues: [] };
121
139
  const entries = m.models.filter((e) => {
122
140
  const r = asRec(e);
123
141
  return !!r && !!asStr(r.name) && !!asStr(r.id);
124
142
  });
125
- return {
126
- models: entries,
127
- ...(asRec(m.roles) ? { roles: m.roles } : {}),
128
- ...(Array.isArray(m.atModelAllowlist) ? { atModelAllowlist: m.atModelAllowlist } : {}),
143
+ const stripped = entries.map(withoutRegistryAnnotations);
144
+ const allNames = new Set(entries.map((e) => e.name));
145
+ const rawNames = new Set(m.models.map((e) => asStr(asRec(e)?.name)).filter((n) => !!n));
146
+ const accept = typeof opts?.entryAccepted === 'function' ? opts.entryAccepted : undefined;
147
+ const acceptedName = (e) => {
148
+ try {
149
+ const copy = JSON.parse(JSON.stringify(e));
150
+ return typeof copy === 'object' && copy !== null && accept(copy) === true;
151
+ }
152
+ catch {
153
+ return false;
154
+ }
129
155
  };
156
+ const acceptedFlags = accept ? stripped.map(acceptedName) : undefined;
157
+ const names = acceptedFlags ? new Set(stripped.filter((_, i) => acceptedFlags[i]).map((e) => e.name)) : allNames;
158
+ const issues = [];
159
+ const drop = (i) => {
160
+ issues.push({ ...i, outcome: 'dropped' });
161
+ };
162
+ const refOf = (v) => (typeof v === 'string' && v !== '' ? { target: v } : {});
163
+ const reasonOf = (v) => typeof v === 'string' && v !== '' ? (rawNames.has(v) ? 'rejected' : 'dangling') : 'unreadable';
164
+ let written = stripped;
165
+ if (acceptedFlags && stripped.length > 0) {
166
+ const anyAccepted = acceptedFlags.some((f) => f);
167
+ stripped.forEach((e, i) => {
168
+ if (!acceptedFlags[i])
169
+ issues.push({ key: 'models', target: e.name, reason: 'rejected', outcome: anyAccepted ? 'dropped' : 'kept' });
170
+ });
171
+ if (anyAccepted)
172
+ written = stripped.filter((_, i) => acceptedFlags[i]);
173
+ }
174
+ const doc = { models: written };
175
+ if (accept && Object.hasOwn(m, 'default')) {
176
+ const d = m.default;
177
+ if (typeof d === 'string' && names.has(d))
178
+ doc.default = d;
179
+ else
180
+ drop({ key: 'default', ...refOf(d), reason: reasonOf(d) });
181
+ }
182
+ const roles = asRec(m.roles);
183
+ if (roles) {
184
+ const kept = {};
185
+ for (const [role, t] of Object.entries(roles)) {
186
+ const tr = asRec(t);
187
+ const model = tr && Object.hasOwn(tr, 'model') ? tr.model : undefined;
188
+ if (typeof model === 'string' && !names.has(model)) {
189
+ drop({ key: 'roles', at: role, ...refOf(model), reason: reasonOf(model) });
190
+ continue;
191
+ }
192
+ setOwn(kept, role, t);
193
+ }
194
+ doc.roles = kept;
195
+ }
196
+ if (Array.isArray(m.atModelAllowlist)) {
197
+ const src = m.atModelAllowlist;
198
+ const keep = src.filter((n) => typeof n === 'string' && names.has(n));
199
+ if (keep.length === 0 && src.length > 0) {
200
+ doc.atModelAllowlist = src;
201
+ for (const n of src)
202
+ issues.push({ key: 'atModelAllowlist', ...refOf(n), reason: reasonOf(n), outcome: 'kept' });
203
+ }
204
+ else {
205
+ for (const n of src)
206
+ if (!(typeof n === 'string' && names.has(n)))
207
+ drop({ key: 'atModelAllowlist', ...refOf(n), reason: reasonOf(n) });
208
+ doc.atModelAllowlist = keep;
209
+ }
210
+ }
211
+ let groupNames;
212
+ const rawGroupNames = new Set((Array.isArray(m.tierGroups) ? m.tierGroups : []).map((g) => asStr(asRec(g)?.name)).filter((n) => !!n));
213
+ if (accept && Object.hasOwn(m, 'tierGroups')) {
214
+ if (!Array.isArray(m.tierGroups)) {
215
+ drop({ key: 'tierGroups', reason: 'unreadable' });
216
+ }
217
+ else {
218
+ const groups = [];
219
+ groupNames = new Set();
220
+ for (const raw of m.tierGroups) {
221
+ const g = asRec(raw);
222
+ const name = g ? asStr(g.name) : undefined;
223
+ if (!g || !name || !TIER_GROUP_NAME.test(name)) {
224
+ drop({ key: 'tierGroups', ...(name ? { at: name } : {}), reason: 'unreadable' });
225
+ continue;
226
+ }
227
+ if (groupNames.has(name)) {
228
+ drop({ key: 'tierGroups', at: name, reason: 'duplicate' });
229
+ continue;
230
+ }
231
+ groupNames.add(name);
232
+ const tiers = {};
233
+ if (Object.hasOwn(g, 'tiers')) {
234
+ const t = asRec(g.tiers);
235
+ if (!t)
236
+ drop({ key: 'tierGroups', at: `${name}.tiers`, reason: 'unreadable' });
237
+ else {
238
+ for (const [tier, model] of Object.entries(t)) {
239
+ if (!TIER_WORDS.includes(tier))
240
+ drop({ key: 'tierGroups', at: `${name}.${tier}`, ...refOf(model), reason: 'unsupported' });
241
+ else if (typeof model === 'string' && names.has(model))
242
+ setOwn(tiers, tier, model);
243
+ else
244
+ drop({ key: 'tierGroups', at: `${name}.${tier}`, ...refOf(model), reason: reasonOf(model) });
245
+ }
246
+ }
247
+ }
248
+ groups.push({ name, tiers, ...(typeof g.notes === 'string' ? { notes: g.notes } : {}) });
249
+ }
250
+ doc.tierGroups = groups;
251
+ }
252
+ }
253
+ if (accept && Object.hasOwn(m, 'activeTierGroup')) {
254
+ const a = m.activeTierGroup;
255
+ if (typeof a === 'string' && groupNames?.has(a))
256
+ doc.activeTierGroup = a;
257
+ else
258
+ drop({ key: 'activeTierGroup', ...refOf(a), reason: typeof a === 'string' && a !== '' ? (rawGroupNames.has(a) ? 'rejected' : 'dangling') : 'unreadable' });
259
+ }
260
+ return { doc, issues };
261
+ }
262
+ export function cloudModelsToDoc(models, opts) {
263
+ return modelsDocOf(models, opts).doc;
130
264
  }
131
265
  export function cloudModelsDropped(models) {
132
266
  const m = asRec(models);
@@ -184,7 +318,136 @@ export function cloudRosterNames(rostersValue) {
184
318
  .map((e) => asStr(asRec(e)?.name))
185
319
  .filter((n) => !!n);
186
320
  }
187
- export function projectEffectiveBody(body, skillBodies, env, rostersValue) {
321
+ const BUDGET_NUMBER_KEYS = ['maxBudgetUsd', 'tpmLimit', 'rpmLimit', 'maxParallelRequests', 'maxRunCostUsd', 'maxIterations'];
322
+ const BUDGET_STRING_KEYS = ['mode', 'budgetDuration', 'enforcement'];
323
+ const PER_MODEL_NUMBER_KEYS = ['maxBudgetUsd', 'tpmLimit', 'rpmLimit'];
324
+ const _budgetKeysCovered = true;
325
+ const _budgetKeysReal = true;
326
+ const _perModelKeysCovered = true;
327
+ void _budgetKeysCovered;
328
+ void _budgetKeysReal;
329
+ void _perModelKeysCovered;
330
+ const isFiniteNum = (v) => typeof v === 'number' && Number.isFinite(v);
331
+ const ownOf = (o, k) => (Object.hasOwn(o, k) ? o[k] : undefined);
332
+ const inList = (list, k) => list.includes(k);
333
+ const viewModeOf = (b) => (!Object.hasOwn(b, 'previewOf') ? 'self' : asStr(b.previewOf) ? 'preview' : 'unknown');
334
+ const nullReadingOf = (mode) => mode === 'self' ? { kind: 'not-configured' } : mode === 'preview' ? { kind: 'redacted' } : { kind: 'unknown' };
335
+ function effectiveWarningsOf(b) {
336
+ const raw = ownOf(b, 'warnings');
337
+ if (!Array.isArray(raw))
338
+ return { kind: 'unknown' };
339
+ const warnings = [];
340
+ let unreadable = 0;
341
+ for (const row of raw) {
342
+ const w = asRec(row);
343
+ const kind = w ? asStr(ownOf(w, 'kind')) : undefined;
344
+ if (!w || !kind) {
345
+ unreadable += 1;
346
+ continue;
347
+ }
348
+ const out = { kind };
349
+ const domain = asStr(ownOf(w, 'domain'));
350
+ if (domain)
351
+ out.domain = domain;
352
+ for (const k of ['droppedNames', 'keys']) {
353
+ const xs = ownOf(w, k);
354
+ if (Array.isArray(xs)) {
355
+ const names = xs.filter((x) => typeof x === 'string' && x.length > 0);
356
+ if (names.length > 0)
357
+ out[k] = names;
358
+ }
359
+ }
360
+ for (const k of ['path', 'file', 'why']) {
361
+ const s = asStr(ownOf(w, k));
362
+ if (s)
363
+ out[k] = s;
364
+ }
365
+ const tf = ownOf(w, 'truncatedFrom');
366
+ if (isFiniteNum(tf))
367
+ out.truncatedFrom = tf;
368
+ warnings.push(out);
369
+ }
370
+ return { kind: 'reported', warnings, unreadable };
371
+ }
372
+ function budgetOf(b, mode) {
373
+ if (!Object.hasOwn(b, 'budget'))
374
+ return { kind: 'unknown' };
375
+ const v = b.budget;
376
+ if (v === null)
377
+ return nullReadingOf(mode);
378
+ const r = asRec(v);
379
+ if (!r)
380
+ return { kind: 'unknown' };
381
+ const limits = {};
382
+ const uninterpreted = [];
383
+ for (const [k, val] of Object.entries(r)) {
384
+ if (inList(BUDGET_NUMBER_KEYS, k)) {
385
+ if (isFiniteNum(val))
386
+ limits[k] = val;
387
+ else
388
+ uninterpreted.push(k);
389
+ }
390
+ else if (inList(BUDGET_STRING_KEYS, k)) {
391
+ const s = asStr(val);
392
+ if (s)
393
+ limits[k] = s;
394
+ else
395
+ uninterpreted.push(k);
396
+ }
397
+ else if (k === 'perModel') {
398
+ const pm = asRec(val);
399
+ if (!pm) {
400
+ uninterpreted.push(k);
401
+ continue;
402
+ }
403
+ const perModel = {};
404
+ for (const [model, entry] of Object.entries(pm)) {
405
+ const e = asRec(entry);
406
+ if (!e) {
407
+ uninterpreted.push(`perModel.${model}`);
408
+ continue;
409
+ }
410
+ const one = {};
411
+ let unread = 0;
412
+ for (const [k2, v2] of Object.entries(e)) {
413
+ if (inList(PER_MODEL_NUMBER_KEYS, k2) && isFiniteNum(v2))
414
+ one[k2] = v2;
415
+ else {
416
+ uninterpreted.push(`perModel.${model}.${k2}`);
417
+ unread += 1;
418
+ }
419
+ }
420
+ if (Object.keys(one).length > 0 || unread === 0)
421
+ setOwn(perModel, model, one);
422
+ }
423
+ limits.perModel = perModel;
424
+ }
425
+ else {
426
+ uninterpreted.push(k);
427
+ }
428
+ }
429
+ return { kind: 'configured', limits, uninterpretedKeys: uninterpreted };
430
+ }
431
+ function runtimeCapsOf(b, mode) {
432
+ if (!Object.hasOwn(b, 'runtimeCaps'))
433
+ return { kind: 'unknown' };
434
+ const v = b.runtimeCaps;
435
+ if (v === null)
436
+ return nullReadingOf(mode);
437
+ const r = asRec(v);
438
+ if (!r)
439
+ return { kind: 'unknown' };
440
+ const caps = {};
441
+ const uninterpreted = [];
442
+ for (const [k, val] of Object.entries(r)) {
443
+ if (typeof val === 'boolean')
444
+ setOwn(caps, k, val);
445
+ else
446
+ uninterpreted.push(k);
447
+ }
448
+ return { kind: 'configured', caps, uninterpretedKeys: uninterpreted };
449
+ }
450
+ export function projectEffectiveBody(body, skillBodies, env, rostersValue, opts) {
188
451
  const b = asRec(body) ?? {};
189
452
  const config = asRec(b.config) ?? {};
190
453
  const mcpDomain = asRec(config.mcp);
@@ -202,7 +465,8 @@ export function projectEffectiveBody(body, skillBodies, env, rostersValue) {
202
465
  return name && hash && skillBodies[hash] === undefined ? name : undefined;
203
466
  })
204
467
  .filter((n) => !!n);
205
- const modelsDoc = cloudModelsToDoc(config.models);
468
+ const { doc: modelsDoc, issues: modelRefIssues } = modelsDocOf(config.models, opts);
469
+ const view = viewModeOf(b);
206
470
  return {
207
471
  mcp,
208
472
  skills,
@@ -215,5 +479,138 @@ export function projectEffectiveBody(body, skillBodies, env, rostersValue) {
215
479
  droppedMcpServers,
216
480
  droppedModels: cloudModelsDropped(config.models),
217
481
  execution: cloudExecutionToPolicy(config.execution),
482
+ effectiveWarnings: effectiveWarningsOf(b),
483
+ budget: budgetOf(b, view),
484
+ runtimeCaps: runtimeCapsOf(b, view),
485
+ modelRefIssues,
218
486
  };
219
487
  }
488
+ const quoted = (s, fallback) => (s === undefined ? fallback : `the "${s}" settings`);
489
+ const listOf = (xs, truncatedFrom) => xs === undefined ? '' : `: ${xs.join(', ')}${truncatedFrom === undefined ? '' : ` (${truncatedFrom} in total)`}`;
490
+ function warningNotice(w) {
491
+ const where = quoted(w.domain, 'the configuration');
492
+ switch (w.kind) {
493
+ case 'hosts-grandfathered':
494
+ return { level: 'warn', text: `cloud config warning: host entries exceeding the current limits were skipped by the registry${listOf(w.droppedNames, w.truncatedFrom)}` };
495
+ case 'domain-defaulted':
496
+ return { level: 'warn', text: `cloud config warning: ${where} could not be read by the registry and were served as defaults — what is configured there is not in effect` };
497
+ case 'entry-dropped':
498
+ return { level: 'warn', text: `cloud config warning: an entry in ${where} could not be read by the registry and was left out${w.path === undefined ? '' : ` (${w.path})`}` };
499
+ case 'unknown-keys-dropped':
500
+ return { level: 'warn', text: `cloud config warning: the registry dropped unrecognised keys from ${where}${listOf(w.keys, w.truncatedFrom)}` };
501
+ case 'unknown-keys-carried':
502
+ return { level: 'warn', text: `cloud config warning: ${where} carry keys the registry does not recognise, passed on as written (check the spelling)${listOf(w.keys, w.truncatedFrom)}` };
503
+ case 'legacy-governance-lifted':
504
+ return { level: 'info', text: `cloud config note: ${where} were taken from their older location${listOf(w.keys, undefined)}` };
505
+ case 'unread-config-file':
506
+ return { level: 'warn', text: `cloud config warning: a configuration file was not read by the registry${w.file === undefined ? '' : ` (${w.file}${w.why === undefined ? '' : `: ${w.why}`})`}` };
507
+ default:
508
+ return { level: 'warn', text: `cloud config warning: the registry reported "${w.kind}"${w.domain === undefined ? '' : ` for ${where}`} — this client does not recognise that warning, so the served configuration may be degraded` };
509
+ }
510
+ }
511
+ function droppedRefNotice(d) {
512
+ const t = d.target;
513
+ const notServed = d.reason === 'rejected'
514
+ ? 'is in the catalog, but its entry is one the local engine cannot use (incomplete or invalid settings)'
515
+ : 'is not in the model catalog served to you';
516
+ switch (d.key) {
517
+ case 'models':
518
+ return { level: 'warn', text: `cloud config: ${t === undefined ? 'a model entry' : `model "${t}"`} is in the catalog, but its entry is one the local engine cannot use (incomplete or invalid settings) — it was left out of the local catalog` };
519
+ case 'default':
520
+ return { level: 'warn', text: `cloud config: ${t === undefined ? 'the default model setting could not be read' : `the default model "${t}" ${notServed}`}, so it was left out — the local engine picks its default from roles and tiers instead` };
521
+ case 'roles':
522
+ return { level: 'warn', text: `cloud config: role "${d.at ?? ''}" ${t === undefined ? 'names no readable model' : `points at model "${t}", which ${notServed}`} — the role was left out and falls back along the engine's role chain` };
523
+ case 'atModelAllowlist':
524
+ return { level: 'warn', text: `cloud config: ${t === undefined ? 'an unreadable @-mention allowlist entry' : `@-mention allowlist entry "${t}" ${notServed} and`} was left out` };
525
+ case 'tierGroups':
526
+ if (d.reason === 'duplicate')
527
+ return { level: 'warn', text: `cloud config: tier group "${d.at ?? ''}" appears more than once — only the first one is kept` };
528
+ if (d.reason === 'dangling' || d.reason === 'rejected')
529
+ return { level: 'warn', text: `cloud config: tier "${d.at ?? ''}" is bound to model "${t ?? ''}", which ${notServed} — that tier was left unbound` };
530
+ if (d.reason === 'unsupported')
531
+ return { level: 'warn', text: `cloud config: tier "${d.at ?? ''}" is not a tier the local engine reads (${TIER_WORDS.join(', ')}) — that binding was left out` };
532
+ return { level: 'warn', text: `cloud config: a tier group setting${d.at === undefined ? '' : ` ("${d.at}")`} could not be read and was left out` };
533
+ case 'activeTierGroup':
534
+ return { level: 'warn', text: `cloud config: ${t === undefined ? 'the active tier group setting could not be read' : `the active tier group "${t}" is not among the readable tier groups`} — no tier table is active on the local engine` };
535
+ }
536
+ }
537
+ function budgetNotice(r) {
538
+ switch (r.kind) {
539
+ case 'configured': {
540
+ const l = r.limits;
541
+ const parts = [];
542
+ for (const k of ['mode', 'maxBudgetUsd', 'budgetDuration', 'tpmLimit', 'rpmLimit', 'maxParallelRequests', 'maxRunCostUsd', 'maxIterations', 'enforcement']) {
543
+ if (l[k] !== undefined)
544
+ parts.push(`${k} ${String(l[k])}`);
545
+ }
546
+ for (const [model, one] of Object.entries(l.perModel ?? {})) {
547
+ const inner = PER_MODEL_NUMBER_KEYS.filter((k) => one[k] !== undefined).map((k) => `${k} ${String(one[k])}`);
548
+ parts.push(`perModel ${model}: ${inner.length > 0 ? inner.join(', ') : 'no limits set'}`);
549
+ }
550
+ const extra = r.uninterpretedKeys.length > 0 ? `also carries ${r.uninterpretedKeys.join(', ')} (not shown by this client)` : '';
551
+ const body = parts.length > 0 ? parts.join(' · ') + (extra ? ` · ${extra}` : '') : extra ? `configured — ${extra}` : 'configured, with no limits set';
552
+ return { level: 'info', text: `cloud budget: ${body}` };
553
+ }
554
+ case 'not-configured':
555
+ return { level: 'info', text: "cloud budget: none set for you in the organisation's entitlement settings on this registry (pooled budgets and token quotas are separate readings)" };
556
+ case 'redacted':
557
+ return { level: 'info', text: 'cloud budget: withheld — this response previews another principal, and budget values are not shown in previews' };
558
+ case 'unknown':
559
+ return { level: 'info', text: 'cloud budget: not reported in a readable form by this registry — unknown, which is not the same as having no budget' };
560
+ }
561
+ }
562
+ function runtimeCapsNotice(r) {
563
+ switch (r.kind) {
564
+ case 'configured': {
565
+ const parts = Object.entries(r.caps).map(([k, v]) => `${k}=${String(v)}`);
566
+ const extra = r.uninterpretedKeys.length > 0 ? `also carries ${r.uninterpretedKeys.join(', ')} (not shown by this client)` : '';
567
+ const body = parts.length > 0 ? parts.join(', ') + (extra ? ` · ${extra}` : '') : extra ? `configured — ${extra}` : 'configured, with no caps set';
568
+ return { level: 'info', text: `cloud runtime caps: ${body}` };
569
+ }
570
+ case 'not-configured':
571
+ return { level: 'info', text: 'cloud runtime caps: none set for you on this registry — local controls decide' };
572
+ case 'redacted':
573
+ return { level: 'info', text: 'cloud runtime caps: withheld — this response previews another principal, and cap values are not shown in previews' };
574
+ case 'unknown':
575
+ return { level: 'info', text: 'cloud runtime caps: not reported in a readable form by this registry — unknown, which is not the same as having no caps' };
576
+ }
577
+ }
578
+ export function cloudEffectiveNotices(p) {
579
+ const out = [];
580
+ const w = p.effectiveWarnings;
581
+ if (w.kind === 'unknown') {
582
+ out.push({ level: 'info', text: 'cloud config: this registry does not report configuration degradation warnings — whether the served configuration was degraded is unknown' });
583
+ }
584
+ else {
585
+ for (const one of w.warnings)
586
+ out.push(warningNotice(one));
587
+ if (w.unreadable > 0) {
588
+ out.push({ level: 'warn', text: `cloud config warning: ${w.unreadable} warning ${w.unreadable === 1 ? 'entry' : 'entries'} from the registry could not be read by this client — the served configuration may be degraded` });
589
+ }
590
+ }
591
+ for (const d of p.modelRefIssues)
592
+ if (d.outcome === 'dropped')
593
+ out.push(droppedRefNotice(d));
594
+ const keptModels = p.modelRefIssues.filter((d) => d.outcome === 'kept' && d.key === 'models');
595
+ if (keptModels.length > 0) {
596
+ const named = keptModels.flatMap((d) => (d.target === undefined ? [] : [`"${d.target}"`]));
597
+ const shown = named.slice(0, 5).join(', ') + (named.length > 5 ? `, +${named.length - 5} more` : '');
598
+ out.push({
599
+ level: 'warn',
600
+ text: `cloud config: none of the model entries served to you${shown !== '' ? ` (${shown})` : ''} is one the local engine can use (incomplete or invalid settings) — ` +
601
+ 'the catalog is kept as sent, and a locally started engine rejects the cloud model settings: at startup it uses its own environment catalog instead, and on a later refresh it keeps the previous configuration',
602
+ });
603
+ }
604
+ const keptAllowlist = p.modelRefIssues.filter((d) => d.outcome === 'kept' && d.key === 'atModelAllowlist');
605
+ if (keptAllowlist.length > 0) {
606
+ const names = keptAllowlist.flatMap((d) => (d.target === undefined ? [] : [`"${d.target}"`]));
607
+ out.push({
608
+ level: 'warn',
609
+ text: `cloud config: no @-mention allowlist entry${names.length > 0 ? ` (${names.join(', ')})` : ''} names a usable model in the catalog served to you — ` +
610
+ 'the list is kept as sent, because an empty list would let every model be mentioned; with it, a locally started engine rejects the cloud model settings — at startup it uses its own environment catalog instead, and on a later refresh it keeps the previous configuration',
611
+ });
612
+ }
613
+ out.push(budgetNotice(p.budget));
614
+ out.push(runtimeCapsNotice(p.runtimeCaps));
615
+ return out;
616
+ }