dsh-connect-agnes-token-plan 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  本文件只记**公开行为变化**(新增能力、破坏性改动、重要修复)。实现细节、重构与测试加固请直接看 `git log`。
4
4
 
5
+ ## [0.11.0] — 2026-10-16
6
+
7
+ 本版两条主线:新能力**对话框用量胶囊**(输入框右侧的第二条读数,按 provider 门控),与**面板操作体感**批次(ADR-014)落地。
8
+
9
+ ### 对话框用量胶囊:输入框右侧的第二条读数(两个上游、一个座席,provider 门控)
10
+
11
+ - **新:注册 `conversation.input.right` composer 座席并挂载 `ComposerUsagePill`**——读数按当前会话模型的 provider 门控:`agnes-token-plan` 模型画控制台 5 小时 / 每周请求窗口的使用百分比,`agnescode` 模型画桌面端积分余额(画存量、不造百分比:网关只发积分不给上限,把余额换算成百分比需要发明一个它占谁的份额);**其它 provider 的会话一律不挂载**(无读取、无定时器、无读数)——别的插件的会话绝不该带这些数字,`agnescode` 的额度宇宙也是桌面网关自己的,不能给它画控制台的窗口。
12
+ - **门控坐在组件边界而非 hook 之后**:外层组件只决定挂哪个内层组件,轮询 hook 全在内层里——先调 hook 再早退会违反 Rules of Hooks,React 会把组件丢弃到会话结束(姊妹插件有专门钉住这条的回归 scenario)。
13
+ - **读数与面板同源、不造第二把尺**:百分比走 `windowPercent`(平台自己的 `usagePct` 优先、`used/limit` 只作兜底、**从不钳制**——超额窗口报 >100 是事实不是渲染 bug),严重度点走 `usageTier`(90 / 70,与面板进度条同源);AgnesCode 侧 `0` 是「耗尽」读数、字段缺席是「读不到」(破折号),不是零。
14
+ - **软失败(§5 不变量②)**:读取失败保留最后读数并标记过期,不清空、不抛错;注册失败面板照常工作、该模块缺席。
15
+ - 影响面:`src/client/{composer-usage-pill,const,apply,cards,agnescode-tab,i18n,styles,index}.ts` + `client.js`(随 src 同 commit 重建);`test/composer-slot-gate.mjs`(新增:钉住座席 / 渲染器 / projection / 物化契约与两个 provider id 的双侧拼写)、`test/dom-render.test.mjs`(胶囊真 DOM scenario)、`test/render.test.mjs` / `test/contract.test.mjs`(§11c);文档与 CI 门禁同步。
16
+
17
+ ### 面板操作体感:快照取数并行 + 保存回显不再拖全量快照(ADR-014)
18
+
19
+ - **可感变化(账号失效 / 控制台降级场景)**:① 快照五源取数全部并行(原 `overview` 先行再批量;失效账号下两条 15 s 超时链叠加,单次快照最坏 30 s 起,现压到 ~15 s;死令牌锁号不重新引入——`getToken` 单飞 + `rejected` 集 + 逐请求 401 处置,测试 B 组按新形状重钉:每源至多一次出示 / 每 poll 一次登录);② 接入 API tab 的 provider 开关 / 模型清单 / 出图·视频工具卡的「保存后全量快照 reload」改为把 POST 响应体就地并入快照 `llm` 块——关模型、切开关的反馈不再等下一轮慢轮询;③ AgnesCode 三动作(开关 / 检测本机登录态 / 保存模型清单)直接应用 POST 的完整响应,harvest 成功即时翻出已关联卡片(此前要再排一次最长 30 s 的 GET);④ `/agnescode` 路由的 balance / catalog 两个 30 s 上游读改并行(最坏 60 s → 30 s)。工具卡「实际生效模型」徽标与额度数字仍随 30 s 轮询更新(Host 计算值,本地不造数)。
20
+ - **归位说明**:此批次(①–④)在 0.10.2 发布(2026-10-10)之后的补记提交里被记在 `[0.10.2]` 节下,但已发布的 0.10.2 tarball 里**没有其中任何一条**(0.10.2 的 `overview` 仍先行单独取数、客户端半边尚无 `LlmEchoPatch`);`[0.10.2]` 下的「面板操作体感」节移入本节订正。
21
+ - 影响面:`src/host/{snapshot-aggregate,routes/agnescode}.ts`、`src/client/{panel-page,provider-controls,model-picker,wire,agnescode-tab}.ts`、`test/routes.test.mjs`、`docs/`(ADR-014、ARCHITECTURE §3、PROVIDER-HOT-RELOAD、AGNES-API、PITFALLS)、`lib/` + `client.js`(随 src 同 commit 重建)。
22
+
5
23
  ## [0.10.2] — 2026-10-10
6
24
 
7
25
  ### 修复:视频状态轮询断点续查、额度渲染与模型花名册的几处真实缺陷 + 凭据隔离加固
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # dsh-connect-agnes-token-plan
2
2
 
3
- 把 Agnes 接进 DSH 的 **Plugins 页**插件卡。**DSH** 即 **DeepSeek Harness**;**Agnes**(爱思)是全模态大模型平台,本插件对接其**控制台 API** 与**推理 API**。
3
+ 把 Agnes 接进 DSH:**Plugins 页**的插件卡(三个 tab),外加**会话输入框右侧的一枚用量胶囊**。**DSH** 即 **DeepSeek Harness**;**Agnes**(爱思)是全模态大模型平台,本插件对接其**控制台 API** 与**推理 API**。
4
4
 
5
5
 
6
6
  > **第一次读这个仓库?先看这三句。**
7
7
  >
8
- > 1. **这是给 DeepSeek Harness 用的插件**,不是独立应用;所有入口都在 DSH 的 Plugins 页那张卡上(三个 tab,见下)。
8
+ > 1. **这是给 DeepSeek Harness 用的插件**,不是独立应用;面板入口在 DSH 的 **Plugins 页**那张卡上(三个 tab,见下),另有一枚会话内的用量胶囊(见「对话框用量胶囊」)。
9
9
  > 2. **三种「红」是正常的,不是回归**:① `test/wiring.test.mjs` 等 peer 套件报 `cannot resolve the peer dependency` —— 那是 DSH runtime 自带的包不在本仓库,属环境问题,先查 `test/peer-roots.mjs`;② 没装 `dsh` CLI 时 e2e 会 SKIP,属预期;③ 若在 **23:00–00:00** 之间单独跑 `render.test.mjs` 报红,那是历史上的午夜窗口缺陷(已在 0.10.0 修复,见 [docs/PITFALLS.md](docs/PITFALLS.md) §48),当前安装版本已含,无需升级。
10
10
  > 3. **见到「奇怪」的写法先别定罪**——本仓多数刻意的取舍都在 [docs/PITFALLS.md](docs/PITFALLS.md) / [docs/ADR.md](docs/ADR.md) 有明文出处。**动手前先 grep 一遍 docs/**。
11
11
  >
@@ -58,6 +58,14 @@
58
58
 
59
59
  注意:检测不到登录态时,面板会逐条列出探测过哪些文件、各自为什么没成(「没装 App」「解不开密」「会话里没有令牌」是三种不同的处理);
60
60
 
61
+ ## 对话框用量胶囊(自动出现)
62
+
63
+ 输入框右侧会出现一枚用量胶囊,**画哪条上游的额度由当前会话选中的模型决定**,前缀写明是谁的:选中 `agnes-token-plan` 的模型显示控制台的 **5 小时 / 本周**已用百分比(前缀 `Token Plan`);选中 `agnescode`(桌面端)的模型显示那套**积分余额**(前缀 `AgnesCode`)。圆点分级(绿 / 黄 / 红 / 灰),余下的细节——重置时刻、账号昵称与时效 / 永久拆分、以及「为什么没有数字」——都在悬停提示里。
64
+
65
+ - 读数与面板**同源**:控制台百分比优先用平台自己报的 `usage_pct`、缺失才回落 `已用 / 上限`,**不做钳制**(超额窗口就显示 >100%,那是事实);桌面端画的是**存量**,所以**不造百分比**——网关只公布积分,没有「上限」可以当分母;
66
+ - 两条上游**互不借用**:`agnescode` 的会话只读它自己的路由、绝不画控制台窗口,反之亦然;切到**别的 provider**(其它插件)的模型时胶囊直接消失,也不再发任何请求;
67
+ - 读不到时**保留上一次数字并标「上次数据」**,不会清零或伪装成 0%;余额里的 `0` 是「烧完了」的读数(红色告警),`—` 才是「这次没读到」——这个区别是刻意的。
68
+
61
69
  ## 边界
62
70
 
63
71
  - 面板**不代你操作账务**——不改套餐、不代扣额度、不碰 Key 明文;数据来自 Agnes 控制台自己的 API,与网页控制台口径一致。
@@ -104,7 +112,7 @@ npm run doctor:json # 机器读:JSON(可进你的巡检 / 工单脚本
104
112
  - [docs/API.md](docs/API.md) — 路由与控制台端点
105
113
  - [docs/AGNES-API.md](docs/AGNES-API.md) — Agnes 接口全集(控制台额度侧 + 推理侧)
106
114
  - [docs/TESTING.md](docs/TESTING.md) — 测试体系
107
- - [docs/PITFALLS.md](docs/PITFALLS.md) — 真实踩坑(55 条)
115
+ - [docs/PITFALLS.md](docs/PITFALLS.md) — 真实踩坑(56 条)
108
116
  - [CHANGELOG.md](CHANGELOG.md) — 版本变化
109
117
 
110
118
  AI 协作会话请先读 [AGENTS.md](AGENTS.md)。人类贡献者先读 [SETUP.md](docs/SETUP.md) 与 [ARCHITECTURE.md](docs/ARCHITECTURE.md) §2 理解结构与改动代价,改代码前再读 [AGENTS.md](AGENTS.md) 的红线与 [TESTING.md](docs/TESTING.md) 的 `npm test` 门禁。
package/client.js CHANGED
@@ -13,10 +13,12 @@ var dsh_connect_agnes_token_plan_client = (function() {
13
13
 
14
14
  //#endregion
15
15
  //#region src/client/const.ts
16
- var PANEL_ID, NS, SNAPSHOT_PATH, ACCOUNT_PATH, API_KEY_PATH, PROVIDER_PATH, MODELS_PATH, DRAW_PATH, VIDEO_PATH, AGNESCODE_PATH, AGNES_SIGNUP_URL, AGNESCODE_SITE_URL;
16
+ var PANEL_ID, NS, PROVIDER_ID, AGNESCODE_PROVIDER_ID, SNAPSHOT_PATH, ACCOUNT_PATH, API_KEY_PATH, PROVIDER_PATH, MODELS_PATH, DRAW_PATH, VIDEO_PATH, AGNESCODE_PATH, AGNES_SIGNUP_URL, AGNESCODE_SITE_URL;
17
17
  var init_const = __esmMin((() => {
18
18
  PANEL_ID = "dsh-connect-agnes-token-plan";
19
19
  NS = PANEL_ID;
20
+ PROVIDER_ID = "agnes-token-plan";
21
+ AGNESCODE_PROVIDER_ID = "agnescode";
20
22
  SNAPSHOT_PATH = `/api/${PANEL_ID}/snapshot`;
21
23
  ACCOUNT_PATH = `/api/${PANEL_ID}/account`;
22
24
  API_KEY_PATH = `/api/${PANEL_ID}/api-key`;
@@ -108,6 +110,17 @@ var dsh_connect_agnes_token_plan_client = (function() {
108
110
  "quota.total.video": "视频",
109
111
  "quota.total.activeDays": "活跃天数",
110
112
  "quota.usageMissing": "{label}:暂未读到。",
113
+ "composer.planPrefix": "Token Plan",
114
+ "composer.window5h": "5 小时",
115
+ "composer.windowWeekly": "本周",
116
+ "composer.pillTitle": "Token Plan 额度",
117
+ "composer.notConnected": "控制台未连接",
118
+ "composer.noWindows": "控制台未返回额度窗口",
119
+ "composer.balancePrefix": "AgnesCode",
120
+ "composer.balanceTitle": "AgnesCode 积分余额",
121
+ "composer.balanceUnit": "积分",
122
+ "composer.balanceUnread": "本次没能读到余额",
123
+ "composer.stale": "上次数据",
111
124
  "usage.none": "该区间内没有用量记录。",
112
125
  "usage.period": "近 {days} 天",
113
126
  "usage.periodUnknown": "用量区间",
@@ -330,6 +343,17 @@ var dsh_connect_agnes_token_plan_client = (function() {
330
343
  "quota.total.video": "Video",
331
344
  "quota.total.activeDays": "Active days",
332
345
  "quota.usageMissing": "{label}: not read yet.",
346
+ "composer.planPrefix": "Token Plan",
347
+ "composer.window5h": "5h",
348
+ "composer.windowWeekly": "this week",
349
+ "composer.pillTitle": "Token Plan quota",
350
+ "composer.notConnected": "console not connected",
351
+ "composer.noWindows": "the console returned no quota windows",
352
+ "composer.balancePrefix": "AgnesCode",
353
+ "composer.balanceTitle": "AgnesCode credit balance",
354
+ "composer.balanceUnit": "credits",
355
+ "composer.balanceUnread": "the balance could not be read this time",
356
+ "composer.stale": "last reading",
333
357
  "usage.none": "No usage recorded in this range.",
334
358
  "usage.period": "Last {days} days",
335
359
  "usage.periodUnknown": "Usage range",
@@ -1344,7 +1368,42 @@ var dsh_connect_agnes_token_plan_client = (function() {
1344
1368
  fontSize: 11,
1345
1369
  lineHeight: "16px",
1346
1370
  marginBottom: 2
1347
- }
1371
+ },
1372
+ pillTrigger: {
1373
+ appearance: "none",
1374
+ display: "inline-flex",
1375
+ alignItems: "center",
1376
+ gap: 5,
1377
+ height: 28,
1378
+ padding: "0 6px",
1379
+ flexShrink: 0,
1380
+ background: "none",
1381
+ border: "none",
1382
+ color: "inherit",
1383
+ fontFamily: "inherit",
1384
+ fontSize: 12,
1385
+ lineHeight: "20px",
1386
+ whiteSpace: "nowrap",
1387
+ userSelect: "none",
1388
+ cursor: "default"
1389
+ },
1390
+ pillDot: {
1391
+ width: 7,
1392
+ height: 7,
1393
+ borderRadius: 999,
1394
+ flexShrink: 0
1395
+ },
1396
+ pillPlan: {
1397
+ color: "var(--dsw-alias-label-secondary)",
1398
+ flexShrink: 0
1399
+ },
1400
+ pillReading: { fontVariantNumeric: "tabular-nums" },
1401
+ pillStale: { color: "var(--dsw-alias-label-secondary)" },
1402
+ pillErrorText: { color: "var(--dsw-alias-state-error-primary)" },
1403
+ pillTierOk: { background: "var(--dsw-alias-state-success-primary, #22c55e)" },
1404
+ pillTierWarn: { background: "var(--dsw-alias-state-warn-primary, #f59e0b)" },
1405
+ pillTierError: { background: "var(--dsw-alias-state-error-primary, #ef4444)" },
1406
+ pillTierUnknown: { background: "var(--dsw-alias-label-secondary)" }
1348
1407
  };
1349
1408
  }));
1350
1409
 
@@ -1861,14 +1920,16 @@ var dsh_connect_agnes_token_plan_client = (function() {
1861
1920
  * The curated model allow-list: which of this key's models get pushed to
1862
1921
  * DSH's model list.
1863
1922
  *
1864
- * Hook-based like `ApiKeyForm`, so the render suite exercises the secret-
1923
+ * Hook-based like `ApiKeyForm`, so the Node render suite exercises the secret-
1865
1924
  * free half it draws - `ModelRoster` and the counts - instead of this
1866
- * state machine. The draft/dirty/saved machinery now lives in
1867
- * `roster-draft.ts`, shared with `AgnescodeModelPicker`; what stays here is
1868
- * only what this roster does differently — it POSTs to `MODELS_PATH` itself
1869
- * and words its errors with the `llm.roster*` keys.
1925
+ * state machine. The hook path itself (search filter, tick→dirty footer, save
1926
+ * POST) is mounted for real by `test/dom-render.test.mjs` (jsdom + real
1927
+ * react-dom over the shipped artifact). The draft/dirty/saved machinery now
1928
+ * lives in `roster-draft.ts`, shared with `AgnescodeModelPicker`; what stays
1929
+ * here is only what this roster does differently — it POSTs to `MODELS_PATH`
1930
+ * itself and words its errors with the `llm.roster*` keys.
1870
1931
  */
1871
- function ModelPicker({ llm, onDone, tt }) {
1932
+ function ModelPicker({ llm, onDone, onEcho, tt }) {
1872
1933
  const models = Array.isArray(llm?.models) ? llm.models : [];
1873
1934
  const hostIds = Array.isArray(llm?.enabledModelIds) ? llm.enabledModelIds : [];
1874
1935
  const { ids, busy, setBusy, query, setQuery, setSavedKey, notice, setNotice, dirty, justSaved, visible, tickedCount, bulk, toggle, discard } = useRosterDraft(models, hostIds);
@@ -1878,9 +1939,15 @@ var dsh_connect_agnes_token_plan_client = (function() {
1878
1939
  setNotice(null);
1879
1940
  const posted = JSON.stringify(ids);
1880
1941
  try {
1881
- await postJsonOrThrow(MODELS_PATH, { enabledModelIds: ids });
1942
+ const body = await postJsonOrThrow(MODELS_PATH, { enabledModelIds: ids });
1882
1943
  setSavedKey(posted);
1883
- onDone?.();
1944
+ if (onEcho) onEcho({
1945
+ enabledModelIds: Array.isArray(body.enabledModelIds) ? body.enabledModelIds : ids,
1946
+ registerProvider: body.registerProvider === true,
1947
+ providerRegistered: body.providerRegistered === true,
1948
+ providerError: typeof body.providerError === "string" ? body.providerError : ""
1949
+ });
1950
+ else onDone?.();
1884
1951
  } catch (error) {
1885
1952
  setNotice(format(tt("llm.rosterError"), { error: error instanceof Error ? error.message : String(error) }));
1886
1953
  } finally {
@@ -1890,6 +1957,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
1890
1957
  busy,
1891
1958
  ids,
1892
1959
  onDone,
1960
+ onEcho,
1893
1961
  tt,
1894
1962
  setBusy,
1895
1963
  setNotice,
@@ -2213,10 +2281,12 @@ var dsh_connect_agnes_token_plan_client = (function() {
2213
2281
  * restart. Hook-based like `ApiKeyForm`, so the render suite (which
2214
2282
  * cannot mount hooks) exercises the secret-free status lines instead;
2215
2283
  * the route itself is covered by `routes.test.mjs`. The state shown is
2216
- * the SNAPSHOT's effective value, never local optimism — the poll after
2217
- * `onDone` repaints whatever the Host actually reports.
2284
+ * the SNAPSHOT's effective value, never local optimism: the flip's OWN
2285
+ * POST answer (the Host's post-write state) is merged into that snapshot
2286
+ * by `onEcho`, and the plain `onDone` full reload is only the fallback
2287
+ * for mounts without an echo hook.
2218
2288
  */
2219
- function ProviderSwitch({ llm, onDone, tt }) {
2289
+ function ProviderSwitch({ llm, onDone, onEcho, tt }) {
2220
2290
  const [busy, setBusy] = useState(false);
2221
2291
  const [switchError, setSwitchError] = useState(null);
2222
2292
  const enabled = llm?.registerProvider === true;
@@ -2224,8 +2294,14 @@ var dsh_connect_agnes_token_plan_client = (function() {
2224
2294
  setBusy(true);
2225
2295
  setSwitchError(null);
2226
2296
  try {
2227
- await postJsonOrThrow(PROVIDER_PATH, { enabled: !enabled });
2228
- onDone?.();
2297
+ const body = await postJsonOrThrow(PROVIDER_PATH, { enabled: !enabled });
2298
+ if (onEcho) onEcho({
2299
+ registerProvider: body.registerProvider === true,
2300
+ registerSource: typeof body.registerSource === "string" ? body.registerSource : "",
2301
+ providerRegistered: body.providerRegistered === true,
2302
+ providerError: typeof body.providerError === "string" ? body.providerError : ""
2303
+ });
2304
+ else onDone?.();
2229
2305
  } catch (error) {
2230
2306
  setSwitchError(format(tt("llm.switchError"), { error: error instanceof Error ? error.message : String(error) }));
2231
2307
  } finally {
@@ -2234,6 +2310,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2234
2310
  }, [
2235
2311
  enabled,
2236
2312
  onDone,
2313
+ onEcho,
2237
2314
  tt
2238
2315
  ]);
2239
2316
  return h("div", null, h(Switch, {
@@ -2267,7 +2344,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2267
2344
  * `useCallback` through, so `render.test.mjs` pins the picker's row markup —
2268
2345
  * the `modelRowHead` wrap below is that contract.
2269
2346
  */
2270
- function ToolSwitch({ path, prefix, modelKey, enabled, hasKey, candidates, preferred, effective, extraNote, onDone, tt }) {
2347
+ function ToolSwitch({ path, prefix, modelKey, enabledKey, sourceKey, preferredKey, enabled, hasKey, candidates, preferred, effective, extraNote, onDone, onEcho, tt }) {
2271
2348
  const [busy, setBusy] = useState(false);
2272
2349
  const [switchError, setSwitchError] = useState(null);
2273
2350
  const k = (suffix) => tt(`${prefix}.${suffix}`);
@@ -2275,8 +2352,15 @@ var dsh_connect_agnes_token_plan_client = (function() {
2275
2352
  setBusy(true);
2276
2353
  setSwitchError(null);
2277
2354
  try {
2278
- await postJsonOrThrow(path, { enabled: !enabled });
2279
- onDone?.();
2355
+ const body = await postJsonOrThrow(path, { enabled: !enabled });
2356
+ if (onEcho) {
2357
+ const patch = {};
2358
+ if (enabledKey === "drawEnabled") patch.drawEnabled = body[enabledKey] === true;
2359
+ else patch.videoEnabled = body[enabledKey] === true;
2360
+ if (sourceKey === "drawSource") patch.drawSource = typeof body[sourceKey] === "string" ? body[sourceKey] : "";
2361
+ else patch.videoSource = typeof body[sourceKey] === "string" ? body[sourceKey] : "";
2362
+ onEcho(patch);
2363
+ } else onDone?.();
2280
2364
  } catch (error) {
2281
2365
  setSwitchError(format(k("switchError"), { error: error instanceof Error ? error.message : String(error) }));
2282
2366
  } finally {
@@ -2285,16 +2369,25 @@ var dsh_connect_agnes_token_plan_client = (function() {
2285
2369
  }, [
2286
2370
  path,
2287
2371
  prefix,
2372
+ enabledKey,
2373
+ sourceKey,
2288
2374
  enabled,
2289
2375
  onDone,
2376
+ onEcho,
2290
2377
  tt
2291
2378
  ]);
2292
2379
  const saveModel = useCallback(async (id) => {
2293
2380
  setBusy(true);
2294
2381
  setSwitchError(null);
2295
2382
  try {
2296
- await postJsonOrThrow(path, { [modelKey]: id });
2297
- onDone?.();
2383
+ const body = await postJsonOrThrow(path, { [modelKey]: id });
2384
+ if (onEcho) {
2385
+ const patch = {};
2386
+ const picked = typeof body[modelKey] === "string" ? body[modelKey] : "";
2387
+ if (preferredKey === "drawPreferredModel") patch.drawPreferredModel = picked === "" ? void 0 : picked;
2388
+ else patch.videoPreferredModel = picked === "" ? void 0 : picked;
2389
+ onEcho(patch);
2390
+ } else onDone?.();
2298
2391
  } catch (error) {
2299
2392
  setSwitchError(format(k("switchError"), { error: error instanceof Error ? error.message : String(error) }));
2300
2393
  } finally {
@@ -2304,7 +2397,9 @@ var dsh_connect_agnes_token_plan_client = (function() {
2304
2397
  path,
2305
2398
  modelKey,
2306
2399
  prefix,
2400
+ preferredKey,
2307
2401
  onDone,
2402
+ onEcho,
2308
2403
  tt
2309
2404
  ]);
2310
2405
  const statusText = enabled ? hasKey ? k("onList") : k("needsKey") : k("off");
@@ -2384,17 +2479,21 @@ var dsh_connect_agnes_token_plan_client = (function() {
2384
2479
  * shows the effective value immediately. The route is covered by
2385
2480
  * `routes.test.mjs`.
2386
2481
  */
2387
- function DrawSwitch({ llm, onDone, tt }) {
2482
+ function DrawSwitch({ llm, onDone, onEcho, tt }) {
2388
2483
  return h(ToolSwitch, {
2389
2484
  path: DRAW_PATH,
2390
2485
  prefix: "draw",
2391
2486
  modelKey: "drawModelId",
2487
+ enabledKey: "drawEnabled",
2488
+ sourceKey: "drawSource",
2489
+ preferredKey: "drawPreferredModel",
2392
2490
  enabled: llm?.drawEnabled === true,
2393
2491
  hasKey: llm?.hasApiKey === true,
2394
2492
  candidates: rosterIds(llm?.drawCandidateIds),
2395
2493
  preferred: llm?.drawPreferredModel != null ? String(llm.drawPreferredModel) : null,
2396
2494
  effective: String(llm?.drawModel ?? ""),
2397
2495
  onDone,
2496
+ onEcho,
2398
2497
  tt
2399
2498
  });
2400
2499
  }
@@ -2411,7 +2510,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2411
2510
  * nearest whole second. The card's note line says so, rather than leaving a
2412
2511
  * reader wondering why a seconds-based model sits next to a frame-based one.
2413
2512
  */
2414
- function VideoSwitch({ llm, onDone, tt }) {
2513
+ function VideoSwitch({ llm, onDone, onEcho, tt }) {
2415
2514
  const excluded = rosterIds(llm?.video25ModelIds);
2416
2515
  const extraNote = excluded.length > 0 ? h("div", { style: {
2417
2516
  ...S.muted,
@@ -2425,6 +2524,9 @@ var dsh_connect_agnes_token_plan_client = (function() {
2425
2524
  path: VIDEO_PATH,
2426
2525
  prefix: "video",
2427
2526
  modelKey: "videoModelId",
2527
+ enabledKey: "videoEnabled",
2528
+ sourceKey: "videoSource",
2529
+ preferredKey: "videoPreferredModel",
2428
2530
  enabled: llm?.videoEnabled === true,
2429
2531
  hasKey: llm?.hasApiKey === true,
2430
2532
  candidates: rosterIds(llm?.videoCandidateIds),
@@ -2432,6 +2534,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2432
2534
  effective: String(llm?.videoModel ?? ""),
2433
2535
  extraNote,
2434
2536
  onDone,
2537
+ onEcho,
2435
2538
  tt
2436
2539
  });
2437
2540
  }
@@ -2443,14 +2546,16 @@ var dsh_connect_agnes_token_plan_client = (function() {
2443
2546
  * constituents (`ProviderSwitch`, `ProviderRegStatus`, and the `ModelPicker`
2444
2547
  * it composes) rather than in the key editor's file.
2445
2548
  */
2446
- function ProviderForm({ llm, onDone, tt }) {
2549
+ function ProviderForm({ llm, onDone, onEcho, tt }) {
2447
2550
  return h("div", null, h(ProviderSwitch, {
2448
2551
  llm,
2449
2552
  onDone,
2553
+ onEcho,
2450
2554
  tt
2451
2555
  }), h(ModelPicker, {
2452
2556
  llm,
2453
2557
  onDone,
2558
+ onEcho,
2454
2559
  tt
2455
2560
  }), h(ProviderRegStatus, {
2456
2561
  llm,
@@ -2607,20 +2712,63 @@ var dsh_connect_agnes_token_plan_client = (function() {
2607
2712
 
2608
2713
  //#endregion
2609
2714
  //#region src/client/cards.ts
2610
- /** The bar fill and figure tone for a usage percentage: 70 warn / 90 danger. */
2715
+ /**
2716
+ * One percentage's severity: 90 danger / 70 warn, else ok.
2717
+ *
2718
+ * Its own function because a SECOND surface reads the same thresholds — the
2719
+ * composer usage pill paints a status dot with them (`composer-usage-pill.ts`).
2720
+ * Two copies of "90 / 70" would be two answers to "when is a window in
2721
+ * trouble", and the panel's tonal vocabulary is not something to re-invent per
2722
+ * surface.
2723
+ */
2724
+ function usageTier(pct) {
2725
+ if (pct >= 90) return "error";
2726
+ if (pct >= 70) return "warn";
2727
+ return "ok";
2728
+ }
2729
+ /**
2730
+ * The bar fill for a usage percentage, by {@link usageTier}.
2731
+ *
2732
+ * The fill is the whole answer: the bar is the only surface that colours by
2733
+ * tier, and the figure keeps the panel's one text colour. (This used to also
2734
+ * return a `color` "for the figure", which no caller ever read — a second
2735
+ * answer to a question nobody asked.)
2736
+ */
2611
2737
  function usageTone(pct) {
2612
- if (pct >= 90) return {
2613
- fill: S.barFillError,
2614
- color: "var(--dsw-alias-state-error-primary)"
2615
- };
2616
- if (pct >= 70) return {
2617
- fill: S.barFillWarn,
2618
- color: "var(--dsw-alias-state-warn-primary)"
2619
- };
2620
- return {
2621
- fill: S.barFill,
2622
- color: "var(--dsw-alias-label-secondary)"
2623
- };
2738
+ const tier = usageTier(pct);
2739
+ if (tier === "error") return S.barFillError;
2740
+ if (tier === "warn") return S.barFillWarn;
2741
+ return S.barFill;
2742
+ }
2743
+ /**
2744
+ * One window's consumption percentage — the panel's single reading of it.
2745
+ *
2746
+ * The percentage is the platform's own `usagePct`, quoted verbatim — never a
2747
+ * re-derivation. The Host parses it from the subscription response and ships it
2748
+ * down for exactly this purpose (docs/API.md §快照, AGNES-API.md: the bar is a
2749
+ * transcription, not a computation). Two reasons it is NOT `used/limit` first:
2750
+ *
2751
+ * - the platform's denominator need not be `limit` (it may be a separate
2752
+ * quota ceiling for the same window), so the arithmetic silently shows a
2753
+ * different number than the one the console shows;
2754
+ * - clamping to 100 would rewrite the platform's own value. A window in
2755
+ * overage reports >100, and that is a fact, not a rendering bug.
2756
+ *
2757
+ * `used / limit` remains only as the fallback for a Host that predates the
2758
+ * field or omits it for this window — a shape mismatch must still render.
2759
+ * `null` means "no reading", which is a different claim from `0`.
2760
+ *
2761
+ * Exported because the composer pill quotes the same two windows and must not
2762
+ * grow a second opinion about what a window's percentage is.
2763
+ */
2764
+ function windowPercent(window) {
2765
+ if (window === null || typeof window !== "object") return null;
2766
+ const source = window;
2767
+ const platformPct = typeof source.usagePct === "number" && Number.isFinite(source.usagePct) ? source.usagePct : null;
2768
+ if (platformPct !== null) return platformPct;
2769
+ const limit = Number(source.limit) || 0;
2770
+ const used = typeof source.used === "number" && Number.isFinite(source.used) ? source.used : null;
2771
+ return used !== null && limit > 0 ? used / limit * 100 : null;
2624
2772
  }
2625
2773
  /**
2626
2774
  * The window's own name, from the key the Host emits.
@@ -2643,18 +2791,26 @@ var dsh_connect_agnes_token_plan_client = (function() {
2643
2791
  return format(tt("quota.perHours"), { hours: value });
2644
2792
  }
2645
2793
  /**
2646
- * The unit a dimension is counted in — `""` for video.
2794
+ * The unit a dimension is counted in — `""` only where naming one would be an
2795
+ * invention.
2647
2796
  *
2648
2797
  * Video is deliberately unitless. The platform's limit field is
2649
2798
  * `video_daily_limit` while its usage field is `total_video_seconds`, and it
2650
2799
  * never says which one the limit is expressed in, so printing "500 秒" would
2651
2800
  * assert something nobody verified.
2801
+ *
2802
+ * An UNKNOWN unit falls back to the raw name, the same rule `windowLabel`
2803
+ * follows for an unknown key: a Host that grew a dimension this bundle has
2804
+ * never heard of must show up as itself. Returning `""` there would drop the
2805
+ * noun and print a naked "1500" — the exact "reads as available capacity"
2806
+ * shape this card exists to avoid, and silent where an unknown KEY is loud.
2652
2807
  */
2653
2808
  function unitOf(unit, tt) {
2654
2809
  const name = String(unit ?? "");
2655
2810
  if (name === "requests") return tt("quota.unit.requests");
2656
2811
  if (name === "images") return tt("quota.unit.images");
2657
- return "";
2812
+ if (name === "video") return "";
2813
+ return name;
2658
2814
  }
2659
2815
  /** `monthly` / `yearly` as the panel phrases them. */
2660
2816
  function cycleLabel(cycle, tt) {
@@ -2687,9 +2843,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2687
2843
  const limit = Number(source.limit) || 0;
2688
2844
  const unit = unitOf(source.unit, tt);
2689
2845
  const used = typeof source.used === "number" && Number.isFinite(source.used) ? source.used : null;
2690
- const platformPct = typeof source.usagePct === "number" && Number.isFinite(source.usagePct) ? source.usagePct : null;
2691
- const pct = platformPct !== null ? platformPct : used !== null && limit > 0 ? used / limit * 100 : null;
2692
- const tone = usageTone(pct ?? 0);
2846
+ const pct = windowPercent(source);
2693
2847
  const resetAt = typeof source.resetAt === "number" && Number.isFinite(source.resetAt) ? source.resetAt : null;
2694
2848
  const resetInSeconds = typeof source.resetInSeconds === "number" && Number.isFinite(source.resetInSeconds) ? source.resetInSeconds : null;
2695
2849
  const resetLine = resetAt !== null ? format(tt("quota.resetAt"), { time: clockSameDay(resetAt) }) : resetInSeconds !== null ? format(tt("quota.resetCountdown"), { minutes: Math.max(1, Math.round(resetInSeconds / 60)) }) : null;
@@ -2699,9 +2853,10 @@ var dsh_connect_agnes_token_plan_client = (function() {
2699
2853
  "aria-label": `${label} ${tt("quota.used")} ${pct.toFixed(1)}%`,
2700
2854
  "aria-valuenow": pct.toFixed(1),
2701
2855
  "aria-valuemin": 0,
2702
- "aria-valuemax": 100
2856
+ "aria-valuemax": Math.max(100, Math.ceil(pct)),
2857
+ "aria-valuetext": `${pct.toFixed(1)}%`
2703
2858
  }, h("div", { style: {
2704
- ...tone.fill,
2859
+ ...usageTone(pct),
2705
2860
  width: `${pct}%`
2706
2861
  } })), (pct === null || used === null) && resetLine === null ? null : h("div", { style: S.quotaFoot }, pct === null || used === null ? null : h("span", { style: S.quotaUsed }, `${tt("quota.used")} ${count(used)} / ${count(limit)}`), resetLine === null ? null : h("span", { style: {
2707
2862
  ...S.muted,
@@ -3084,11 +3239,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
3084
3239
  action: "switch",
3085
3240
  enabled
3086
3241
  });
3087
- if (alive.current) setState((current) => current ? {
3088
- ...current,
3089
- enabled: body.enabled === true,
3090
- providerRegistered: body.providerRegistered === true
3091
- } : current);
3242
+ if (alive.current) setState(body);
3092
3243
  } catch (why) {
3093
3244
  if (alive.current) setNote(format(tt("agnescode.switchError"), { error: why instanceof Error ? why.message : String(why) }));
3094
3245
  }
@@ -3099,32 +3250,23 @@ var dsh_connect_agnes_token_plan_client = (function() {
3099
3250
  try {
3100
3251
  const body = await postJson(AGNESCODE_PATH, { action: "harvest" });
3101
3252
  if (alive.current) {
3102
- const harvestBlock = body?.harvest;
3103
- const attempts = Array.isArray(harvestBlock?.attempts) ? harvestBlock.attempts : [];
3104
- setState((current) => current ? {
3105
- ...current,
3106
- harvest: {
3107
- ok: body?.ok === true,
3108
- attempts
3109
- }
3110
- } : current);
3253
+ if (body !== null) setState(body);
3111
3254
  if (body?.ok === true) setNote(tt("agnescode.harvestOk"));
3112
3255
  else setNote(tt("agnescode.harvestFail"));
3113
- load();
3114
3256
  }
3115
3257
  } catch (why) {
3116
3258
  if (alive.current) setNote(format(tt("agnescode.error"), { error: why instanceof Error ? why.message : String(why) }));
3117
3259
  } finally {
3118
3260
  if (alive.current) setHarvestBusy(false);
3119
3261
  }
3120
- }, [load, tt]);
3262
+ }, [tt]);
3121
3263
  const saveModels = useCallback(async (ids) => {
3122
- await postJsonOrThrow(AGNESCODE_PATH, {
3264
+ const body = await postJsonOrThrow(AGNESCODE_PATH, {
3123
3265
  action: "saveModels",
3124
3266
  enabledModelIds: ids
3125
3267
  });
3126
- if (alive.current) load();
3127
- }, [load]);
3268
+ if (alive.current) setState(body);
3269
+ }, []);
3128
3270
  const enabled = state?.enabled === true;
3129
3271
  const view = agnescodeView(state, error);
3130
3272
  const loggedIn = view.linked;
@@ -3560,6 +3702,21 @@ var dsh_connect_agnes_token_plan_client = (function() {
3560
3702
  if (inFlight.current === controller) inFlight.current = null;
3561
3703
  }
3562
3704
  }, []);
3705
+ const dataRef = useRef(null);
3706
+ dataRef.current = data;
3707
+ const echoLlm = useCallback((patch) => {
3708
+ if (dataRef.current === null) {
3709
+ load();
3710
+ return;
3711
+ }
3712
+ setData((current) => current === null ? current : {
3713
+ ...current,
3714
+ llm: {
3715
+ ...current.llm ?? {},
3716
+ ...patch
3717
+ }
3718
+ });
3719
+ }, [load]);
3563
3720
  const toggleSection = useCallback((key) => {
3564
3721
  setOpenSections((current) => ({
3565
3722
  ...current,
@@ -3749,6 +3906,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
3749
3906
  }, h(ProviderForm, {
3750
3907
  llm: data?.llm ?? null,
3751
3908
  onDone: () => void load(),
3909
+ onEcho: echoLlm,
3752
3910
  tt
3753
3911
  })), h(SectionCard, {
3754
3912
  title: tt("draw.title"),
@@ -3758,6 +3916,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
3758
3916
  }, h(DrawSwitch, {
3759
3917
  llm: data?.llm ?? null,
3760
3918
  onDone: () => void load(),
3919
+ onEcho: echoLlm,
3761
3920
  tt
3762
3921
  })), h(SectionCard, {
3763
3922
  title: tt("video.title"),
@@ -3767,6 +3926,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
3767
3926
  }, h(VideoSwitch, {
3768
3927
  llm: data?.llm ?? null,
3769
3928
  onDone: () => void load(),
3929
+ onEcho: echoLlm,
3770
3930
  tt
3771
3931
  })), h(SectionCard, {
3772
3932
  title: tt("llm.title"),
@@ -3816,6 +3976,404 @@ var dsh_connect_agnes_token_plan_client = (function() {
3816
3976
  init_agnescode_tab();
3817
3977
  }));
3818
3978
 
3979
+ //#endregion
3980
+ //#region src/client/composer-usage-pill.ts
3981
+ /**
3982
+ * The provider the session is currently pointed at, `next` winning over
3983
+ * `lastUsed` — with one repair: an EMPTY `next` (the transient frame right after
3984
+ * a picker switch, before it carries the new selection) must not shadow a
3985
+ * still-usable `lastUsed`. A bare `next ?? lastUsed` reads a provider off `{}`,
3986
+ * finds none, and hides the pill for OWNED models on exactly the frames a switch
3987
+ * produces.
3988
+ */
3989
+ function selectedProviderOf(projection) {
3990
+ const usable = (candidate) => candidate !== null && candidate !== void 0 && (typeof candidate.provider === "string" || typeof candidate.model === "string");
3991
+ const p = projection ?? {};
3992
+ const provider = (usable(p.next) ? p.next : usable(p.lastUsed) ? p.lastUsed : void 0)?.provider;
3993
+ return typeof provider === "string" && provider !== "" ? provider : void 0;
3994
+ }
3995
+ /**
3996
+ * One window by key, skipping rows that are not objects at all.
3997
+ *
3998
+ * A shape-drifted row must not throw inside the composer: the panel already
3999
+ * reports shape warnings, and a pill that crashes would take the toolbar down
4000
+ * with it.
4001
+ */
4002
+ function windowByKey(windows, key) {
4003
+ if (!Array.isArray(windows)) return void 0;
4004
+ for (const row of windows) {
4005
+ if (row === null || typeof row !== "object") continue;
4006
+ if (row.key === key) return row;
4007
+ }
4008
+ }
4009
+ /**
4010
+ * The worse of several readings, with "unknown" only when NONE is readable.
4011
+ *
4012
+ * `null` (no reading) is skipped rather than treated as zero: a window the Host
4013
+ * could not measure must not drag the dot down to a healthy-looking grade.
4014
+ */
4015
+ function worstTier(percentages) {
4016
+ const rank = {
4017
+ error: 3,
4018
+ warn: 2,
4019
+ ok: 1,
4020
+ unknown: 0
4021
+ };
4022
+ let worst = "unknown";
4023
+ for (const pct of percentages) {
4024
+ if (pct === null) continue;
4025
+ const tier = usageTier(pct);
4026
+ if (rank[tier] > rank[worst]) worst = tier;
4027
+ }
4028
+ return worst;
4029
+ }
4030
+ /**
4031
+ * A percentage as the pill writes it — the platform's own figure at one decimal,
4032
+ * the same precision the window card leads with. `—` is reserved for "no reading",
4033
+ * which is a different claim from `0.0%`.
4034
+ */
4035
+ function percentText(pct) {
4036
+ return pct === null ? "—" : `${pct.toFixed(1)}%`;
4037
+ }
4038
+ /**
4039
+ * The pill's own route reader.
4040
+ *
4041
+ * It exists because the pill and the panel are mounted independently: the card
4042
+ * is a Plugins-page surface that may never be opened in a given session, while
4043
+ * the pill sits in the composer of every conversation whose model is one of this
4044
+ * plugin's. Sharing one poller would mean either polling for a card nobody
4045
+ * opened or rendering a card from a read the pill happened to pay for.
4046
+ *
4047
+ * ONE core, TWO routes. The generation guard, the abort, the stated cadence and
4048
+ * the hidden-tab pause are identical for both upstreams; only the route, the
4049
+ * body reading and the default rate differ. A second copy of this body is
4050
+ * exactly what `dup-gate` exists to refuse, and two hand-kept copies would drift
4051
+ * the first time one of them is repaired.
4052
+ *
4053
+ * What it does NOT carry is the panel's policy: the failure taxonomy (guidance
4054
+ * lines, the sign-in form gate), the refresh spinner, and the `llm` echo merge
4055
+ * all belong to a screen with forms on it. Here a failure is one bit — "this
4056
+ * reading is stale" — and the numbers stay.
4057
+ *
4058
+ * @param route - module-scope stable: a fresh literal per render would rebuild
4059
+ * `load` and restart the poll interval with it.
4060
+ * @param initialCadenceMs - the rate to follow before any answer arrives;
4061
+ * `null` means "no timer until something states one".
4062
+ */
4063
+ function useRemoteRead(route, initialCadenceMs) {
4064
+ const [reading, setReading] = useState({
4065
+ data: null,
4066
+ updatedAt: 0,
4067
+ cadenceMs: initialCadenceMs,
4068
+ loadedOnce: false,
4069
+ failed: false
4070
+ });
4071
+ const cadenceRef = useRef(initialCadenceMs);
4072
+ cadenceRef.current = reading.cadenceMs;
4073
+ const generation = useRef(0);
4074
+ const inFlight = useRef(null);
4075
+ const load = useCallback(async () => {
4076
+ generation.current += 1;
4077
+ const mine = generation.current;
4078
+ const isCurrent = () => generation.current === mine;
4079
+ inFlight.current?.abort?.();
4080
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
4081
+ inFlight.current = controller;
4082
+ const fail = () => {
4083
+ if (isCurrent()) setReading((current) => ({
4084
+ ...current,
4085
+ failed: true,
4086
+ loadedOnce: true
4087
+ }));
4088
+ };
4089
+ try {
4090
+ const response = await fetch(route.path, {
4091
+ headers: { accept: "application/json" },
4092
+ cache: "no-store",
4093
+ ...controller ? { signal: controller.signal } : {}
4094
+ });
4095
+ if (!isCurrent()) return;
4096
+ if (!response.ok) {
4097
+ fail();
4098
+ return;
4099
+ }
4100
+ const body = await response.json().catch(() => null);
4101
+ if (!isCurrent()) return;
4102
+ const read = route.parse(body, response.status);
4103
+ if (read.data === null) {
4104
+ fail();
4105
+ return;
4106
+ }
4107
+ setReading({
4108
+ data: read.data,
4109
+ updatedAt: Date.now(),
4110
+ cadenceMs: read.cadenceMs !== void 0 ? read.cadenceMs : cadenceRef.current,
4111
+ loadedOnce: true,
4112
+ failed: false
4113
+ });
4114
+ } catch {
4115
+ fail();
4116
+ } finally {
4117
+ if (inFlight.current === controller) inFlight.current = null;
4118
+ }
4119
+ }, [route]);
4120
+ useEffect(() => {
4121
+ load();
4122
+ }, [load]);
4123
+ useEffect(() => {
4124
+ const cadenceMs = reading.cadenceMs;
4125
+ if (cadenceMs === null) return void 0;
4126
+ let timer = null;
4127
+ const start = () => {
4128
+ if (timer === null) timer = setInterval(() => {
4129
+ load();
4130
+ }, cadenceMs);
4131
+ };
4132
+ const stop = () => {
4133
+ if (timer !== null) {
4134
+ clearInterval(timer);
4135
+ timer = null;
4136
+ }
4137
+ };
4138
+ start();
4139
+ const onVisibility = () => {
4140
+ if (typeof document === "undefined") return;
4141
+ if (document.visibilityState === "hidden") stop();
4142
+ else {
4143
+ load();
4144
+ start();
4145
+ }
4146
+ };
4147
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") document.addEventListener("visibilitychange", onVisibility);
4148
+ return () => {
4149
+ stop();
4150
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") document.removeEventListener("visibilitychange", onVisibility);
4151
+ };
4152
+ }, [load, reading.cadenceMs]);
4153
+ useEffect(() => () => {
4154
+ generation.current += 1;
4155
+ inFlight.current?.abort?.();
4156
+ }, []);
4157
+ return reading;
4158
+ }
4159
+ /**
4160
+ * "These are the last numbers we managed to get."
4161
+ *
4162
+ * Two causes: the last read failed, or the reading has aged past two cadences —
4163
+ * expressed as a multiple of the rate being followed, so this file holds no
4164
+ * second spelling of any poll interval. Without a cadence there is no expected
4165
+ * freshness to judge against, so only a failure can mark it.
4166
+ */
4167
+ function isStale(reading, now) {
4168
+ const aged = reading.cadenceMs !== null && reading.updatedAt > 0 && now - reading.updatedAt > reading.cadenceMs * 2;
4169
+ return reading.loadedOnce && (reading.failed || aged);
4170
+ }
4171
+ /** The console reader (the pill's `/snapshot` loop). */
4172
+ function useConsoleQuota() {
4173
+ return useRemoteRead(SNAPSHOT_ROUTE, null);
4174
+ }
4175
+ /** The desktop-app reader (the pill's `/agnescode` loop). */
4176
+ function useAgnescodeState() {
4177
+ return useRemoteRead(AGNESCODE_ROUTE, AGNESCODE_POLL_MS);
4178
+ }
4179
+ /** The shared shell: the tier dot, the upstream prefix, the reading. */
4180
+ function PillBody(props) {
4181
+ const { tt, planLabel, tier, stale, title, children } = props;
4182
+ return h("button", {
4183
+ type: "button",
4184
+ className: PILL_CLASS,
4185
+ title,
4186
+ "aria-label": title,
4187
+ style: S.pillTrigger
4188
+ }, h("span", {
4189
+ className: `${PILL_CLASS}-dot`,
4190
+ "aria-hidden": "true",
4191
+ style: {
4192
+ ...S.pillDot,
4193
+ ...TIER_INK[tier]
4194
+ }
4195
+ }), h("span", {
4196
+ className: `${PILL_CLASS}-plan`,
4197
+ style: S.pillPlan
4198
+ }, `${planLabel} · `), h("span", {
4199
+ className: `${PILL_CLASS}-text`,
4200
+ style: tier === "error" ? S.pillErrorText : void 0
4201
+ }, children, stale ? h("span", { style: S.pillStale }, ` · ${tt("composer.stale")}`) : null));
4202
+ }
4203
+ /** The first of several epoch-second instants that is a usable number, else 0. */
4204
+ function firstInstant(...values) {
4205
+ for (const value of values) if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
4206
+ return 0;
4207
+ }
4208
+ /**
4209
+ * The console reading: the two request windows and the tooltip that carries what
4210
+ * the body has no room for (which plan, which quota, when the window resets, and
4211
+ * whether this reading is the last known one).
4212
+ */
4213
+ function ConsoleQuotaPill({ tt }) {
4214
+ const reading = useConsoleQuota();
4215
+ const quota = reading.data?.quota ?? null;
4216
+ const windows = Array.isArray(quota?.windows) ? quota.windows : [];
4217
+ const fiveHour = windowByKey(windows, WINDOW_5H);
4218
+ const weekly = windowByKey(windows, WINDOW_WEEKLY);
4219
+ const p5 = windowPercent(fiveHour);
4220
+ const pWeekly = windowPercent(weekly);
4221
+ const tier = worstTier([p5, pWeekly]);
4222
+ const stale = isStale(reading, Date.now());
4223
+ const disconnected = quota !== null && quota.consoleConnected === false;
4224
+ const planName = String(quota?.plan?.displayName || quota?.plan?.name || "");
4225
+ const resetAt = firstInstant(fiveHour?.resetAt, weekly?.resetAt);
4226
+ const title = [
4227
+ tt("composer.pillTitle"),
4228
+ planName === "" ? null : planName,
4229
+ !reading.loadedOnce ? tt("panel.loading") : disconnected ? tt("composer.notConnected") : null,
4230
+ reading.loadedOnce && !disconnected && windows.length === 0 ? tt("composer.noWindows") : null,
4231
+ resetAt > 0 ? format(tt("quota.resetAt"), { time: clockSameDay(resetAt) }) : null,
4232
+ stale ? tt("composer.stale") : null
4233
+ ].filter(Boolean).join(" · ");
4234
+ const windowText = (label, pct) => `${label} ${percentText(pct)}`;
4235
+ return h(PillBody, {
4236
+ tt,
4237
+ planLabel: tt("composer.planPrefix"),
4238
+ tier,
4239
+ stale,
4240
+ title,
4241
+ children: [
4242
+ h("span", {
4243
+ key: WINDOW_5H,
4244
+ style: S.pillReading
4245
+ }, windowText(tt("composer.window5h"), p5)),
4246
+ " · ",
4247
+ h("span", {
4248
+ key: WINDOW_WEEKLY,
4249
+ style: S.pillReading
4250
+ }, windowText(tt("composer.windowWeekly"), pWeekly))
4251
+ ]
4252
+ });
4253
+ }
4254
+ /**
4255
+ * One credit balance as a number, or `null` when the state declared none.
4256
+ *
4257
+ * The AgnesCode gateway publishes STOCKS: `totalBalance` plus its time-limited
4258
+ * and permanent parts. There is no cap in that universe, so the pill draws the
4259
+ * balance — never a percentage, which would require inventing the limit it is a
4260
+ * share of.
4261
+ *
4262
+ * `0` is a READING (a drained balance is the alarm, and it must read `0` in the
4263
+ * error tier), while a missing or non-finite field is ABSENCE — the grey dash.
4264
+ * The panel's own rule for this tab ("a rendered zero would present an unread
4265
+ * figure as a measurement") is the same one, applied to a field-level gap the
4266
+ * panel never has to face because it renders the block only when it is there.
4267
+ */
4268
+ function balanceOf(state) {
4269
+ const total = state?.balance?.totalBalance;
4270
+ return typeof total === "number" && Number.isFinite(total) ? total : null;
4271
+ }
4272
+ /** One part of the balance (time-limited / permanent), or `null` when unreadable. */
4273
+ function partOf(state, key) {
4274
+ const value = state?.balance?.[key];
4275
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
4276
+ }
4277
+ /**
4278
+ * The desktop-app reading: the gateway's own credit balance.
4279
+ *
4280
+ * It is a STOCK, not a meter — see {@link balanceOf} — so the body carries the
4281
+ * figure and the tooltip carries the three facts that have no room there: which
4282
+ * account (`nickname`), how the total splits (time-limited / permanent), and —
4283
+ * when there is no figure at all — WHICH fact is missing. "The desktop App is
4284
+ * not signed in", "the provider switch is off" and "the gateway did not answer
4285
+ * this time" are three different problems, and a bare dash beside them left the
4286
+ * reader guessing (the same repair the sibling's capsule made for its gateway).
4287
+ */
4288
+ function AgnescodeBalancePill({ tt }) {
4289
+ const reading = useAgnescodeState();
4290
+ const state = reading.data;
4291
+ const balance = balanceOf(state);
4292
+ const tier = balance === null ? "unknown" : balance <= 0 ? "error" : "ok";
4293
+ const stale = isStale(reading, Date.now());
4294
+ const pending = !reading.loadedOnce && balance === null;
4295
+ const why = !reading.loadedOnce || balance !== null ? null : state === null ? tt("composer.balanceUnread") : state.enabled !== true ? tt("agnescode.unregistered") : state.loggedIn !== true ? tt("agnescode.notLogged") : tt("composer.balanceUnread");
4296
+ const timeLimited = partOf(state, "timeSensitiveBalance");
4297
+ const permanent = partOf(state, "permanentBalance");
4298
+ const split = timeLimited === null || permanent === null ? null : `${tt("agnescode.balanceTimeLabel")} ${count(timeLimited)} · ${tt("agnescode.balancePermanentLabel")} ${count(permanent)}`;
4299
+ const title = [
4300
+ tt("composer.balanceTitle"),
4301
+ state?.loggedIn === true && String(state.nickname ?? "") !== "" ? format(tt("agnescode.loggedIn"), { nick: String(state.nickname) }) : null,
4302
+ split,
4303
+ why,
4304
+ stale ? tt("composer.stale") : null
4305
+ ].filter(Boolean).join(" · ");
4306
+ return h(PillBody, {
4307
+ tt,
4308
+ planLabel: tt("composer.balancePrefix"),
4309
+ tier,
4310
+ stale,
4311
+ title,
4312
+ children: h("span", { style: S.pillReading }, pending ? "…" : balance === null ? "—" : `${count(balance)} ${tt("composer.balanceUnit")}`)
4313
+ });
4314
+ }
4315
+ /**
4316
+ * The composer pill.
4317
+ *
4318
+ * Renders nothing unless the session's model belongs to this plugin — so no other
4319
+ * provider's conversation carries a foreign readout, and no fetch loop runs while
4320
+ * it is absent (the inner reader is not mounted). WHICH upstream it belongs to is
4321
+ * the same decision as whether one mounts: the console provider reads the
4322
+ * snapshot, the desktop-app provider reads its own route, and neither is mounted
4323
+ * for the other's models.
4324
+ */
4325
+ function ComposerUsagePill(props) {
4326
+ const { tt, useProjection } = props;
4327
+ const provider = selectedProviderOf((useProjection ?? (() => void 0))("modelSelection"));
4328
+ if (provider === "agnes-token-plan") return h(ConsoleQuotaPill, { tt });
4329
+ if (provider === "agnescode") return h(AgnescodeBalancePill, { tt });
4330
+ return null;
4331
+ }
4332
+ var PILL_CLASS, WINDOW_5H, WINDOW_WEEKLY, TIER_INK, SNAPSHOT_ROUTE, AGNESCODE_ROUTE;
4333
+ var init_composer_usage_pill = __esmMin((() => {
4334
+ init_runtime();
4335
+ init_styles();
4336
+ init_format();
4337
+ init_const();
4338
+ init_snapshot();
4339
+ init_cards();
4340
+ init_agnescode_tab();
4341
+ PILL_CLASS = "dsh-agnes-composer-pill";
4342
+ WINDOW_5H = "requests5h";
4343
+ WINDOW_WEEKLY = "requestsWeekly";
4344
+ TIER_INK = {
4345
+ error: S.pillTierError,
4346
+ warn: S.pillTierWarn,
4347
+ ok: S.pillTierOk,
4348
+ unknown: S.pillTierUnknown
4349
+ };
4350
+ SNAPSHOT_ROUTE = {
4351
+ path: SNAPSHOT_PATH,
4352
+ parse: (body, status) => {
4353
+ if (status < 200 || status >= 300) return { data: null };
4354
+ const read = interpretSnapshot(body);
4355
+ if (read.data === null) return { data: null };
4356
+ const stated = read.data.pollSeconds;
4357
+ return {
4358
+ data: read.data,
4359
+ cadenceMs: typeof stated === "number" && Number.isFinite(stated) && stated > 0 ? Math.floor(stated) * 1e3 : null
4360
+ };
4361
+ }
4362
+ };
4363
+ AGNESCODE_ROUTE = {
4364
+ path: AGNESCODE_PATH,
4365
+ parse: (body, status) => {
4366
+ if (status < 200 || status >= 300) return { data: null };
4367
+ const state = body;
4368
+ if (state === null || typeof state !== "object" || state.ok !== true) return { data: null };
4369
+ return {
4370
+ data: state,
4371
+ cadenceMs: AGNESCODE_POLL_MS
4372
+ };
4373
+ }
4374
+ };
4375
+ }));
4376
+
3819
4377
  //#endregion
3820
4378
  //#region src/client/apply.ts
3821
4379
  /**
@@ -3861,6 +4419,17 @@ var dsh_connect_agnes_token_plan_client = (function() {
3861
4419
  } catch (error) {
3862
4420
  console.warn(`[${NS}] config card registration failed:`, error);
3863
4421
  }
4422
+ try {
4423
+ disposers.push(ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
4424
+ name: "conversation.input.right",
4425
+ id: `${NS}-composer-pill`,
4426
+ locale: NS,
4427
+ order: 4,
4428
+ inject: () => ({ tt })
4429
+ }, ComposerUsagePill)));
4430
+ } catch (error) {
4431
+ console.warn(`[${NS}] composer pill registration failed:`, error);
4432
+ }
3864
4433
  ctx.effect(() => () => {
3865
4434
  for (const dispose of disposers.splice(0)) try {
3866
4435
  dispose();
@@ -3872,6 +4441,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
3872
4441
  init_const();
3873
4442
  init_i18n();
3874
4443
  init_panel_page();
4444
+ init_composer_usage_pill();
3875
4445
  inject = ["slots", "locale"];
3876
4446
  }));
3877
4447
 
@@ -3892,6 +4462,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
3892
4462
  init_api_key_form();
3893
4463
  init_model_picker();
3894
4464
  init_panel_page();
4465
+ init_composer_usage_pill();
3895
4466
  init_agnescode_tab();
3896
4467
  function clientFactory(loaderRequire) {
3897
4468
  provideClientReact(loaderRequire("react"));
@@ -3935,7 +4506,14 @@ var dsh_connect_agnes_token_plan_client = (function() {
3935
4506
  allowListFor,
3936
4507
  toggleModelIn,
3937
4508
  setAllModelsIn,
3938
- bulkModelsIn
4509
+ bulkModelsIn,
4510
+ selectedProviderOf,
4511
+ windowByKey,
4512
+ worstTier,
4513
+ percentText,
4514
+ windowPercent,
4515
+ usageTier,
4516
+ balanceOf
3939
4517
  }),
3940
4518
  components: Object.freeze({
3941
4519
  PlanCard,
@@ -3956,6 +4534,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
3956
4534
  ModelRoster,
3957
4535
  ModelPicker,
3958
4536
  PanelPage,
4537
+ ComposerUsagePill,
3959
4538
  AgnescodeTab,
3960
4539
  AgnescodeRoster,
3961
4540
  AgnescodeModelPicker
package/lib/index.js CHANGED
@@ -4638,8 +4638,8 @@ async function buildSnapshotBody({ settings, cache, inflight, tokenStore, apiKey
4638
4638
  const providerState = publisher.state;
4639
4639
  const resolveApiKey = async () => (await apiKeyStore.resolve()).value;
4640
4640
  const { startDate, endDate } = usageWindow(settings.usageDays);
4641
- const overview = await soft(() => fetchConsole(settings, USAGE_OVERVIEW_PATH, void 0, settings.cacheSeconds * 1e3, cache, inflight, tokenStore));
4642
- const [series, subscription, plans, catalog] = await Promise.all([
4641
+ const [overview, series, subscription, plans, catalog] = await Promise.all([
4642
+ soft(() => fetchConsole(settings, USAGE_OVERVIEW_PATH, void 0, settings.cacheSeconds * 1e3, cache, inflight, tokenStore)),
4643
4643
  soft(() => fetchConsole(settings, USAGE_SERIES_PATH, {
4644
4644
  range: "custom",
4645
4645
  start_date: startDate,
@@ -5801,16 +5801,15 @@ function registerAgnescodeRoute(ctx, wiring) {
5801
5801
  bffBase = state?.bffBase ?? "";
5802
5802
  expiresAtMs = state?.expiresAtMs ?? null;
5803
5803
  credential = (await agnescodeStore.resolve().catch(() => ({ credential: null }))).credential ?? null;
5804
- if (loggedIn && credential?.accessToken) balance = await fetchAgnescodeBalance(credential).catch(() => null);
5805
5804
  }
5806
5805
  } catch (why) {
5807
5806
  error = redactSecrets(why instanceof Error ? why.message : String(why));
5808
5807
  }
5809
5808
  let models = null;
5810
- try {
5811
- if (credential?.accessToken) models = await fetchAgnescodeCatalog(credential).catch(() => null);
5812
- } catch {
5813
- models = null;
5809
+ if (credential?.accessToken) {
5810
+ const [b, m] = await Promise.all([loggedIn ? fetchAgnescodeBalance(credential).catch(() => null) : Promise.resolve(null), fetchAgnescodeCatalog(credential).catch(() => null)]);
5811
+ balance = b;
5812
+ models = m;
5814
5813
  }
5815
5814
  const roster = models !== null && Array.isArray(models) && models.length > 0 ? models : AGNESCODE_FALLBACK_MODELS;
5816
5815
  const publisherState = agnescodePublisher?.state ?? null;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-agnes-token-plan",
3
3
  "displayName": "DSH Connect Agnes Token Plan",
4
4
  "icon": "./icon.svg",
5
- "version": "0.10.2",
5
+ "version": "0.11.0",
6
6
  "description": "Agnes connect bundle for the Harness Web UI: Token Plan quota panel (account-wide quota windows, cumulative usage, console sign-in) plus optional provider registration that brings Agnes models into DSH conversation, with host-side rate-limit self-healing.",
7
7
  "keywords": [
8
8
  "dsh-plugin",
@@ -95,6 +95,9 @@
95
95
  "@commitlint/config-conventional": "^21.2.3",
96
96
  "@types/node": "^26.6.3",
97
97
  "jscpd": "^5.4.0",
98
+ "jsdom": "^30.1.3",
99
+ "react": "^18.3.1",
100
+ "react-dom": "^18.3.1",
98
101
  "tsdown": "^0.23.0",
99
102
  "typescript": "^5.9.3"
100
103
  }