@songsid/agend 2.3.4-beta.8 → 2.3.4

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.
Files changed (167) hide show
  1. package/THIRD_PARTY_NOTICES.md +37 -0
  2. package/dist/agent-cli-instructions.md +1 -1
  3. package/dist/agent-credentials.d.ts +24 -0
  4. package/dist/agent-credentials.js +66 -0
  5. package/dist/agent-credentials.js.map +1 -0
  6. package/dist/agent-endpoint.d.ts +3 -1
  7. package/dist/agent-endpoint.js +13 -0
  8. package/dist/agent-endpoint.js.map +1 -1
  9. package/dist/auth-api.js +5 -0
  10. package/dist/auth-api.js.map +1 -1
  11. package/dist/authority/session-authorizations.d.ts +28 -4
  12. package/dist/authority/session-authorizations.js +63 -17
  13. package/dist/authority/session-authorizations.js.map +1 -1
  14. package/dist/backend/antigravity.js +3 -1
  15. package/dist/backend/antigravity.js.map +1 -1
  16. package/dist/backend/codex-footer-hints.d.ts +4 -0
  17. package/dist/backend/codex-footer-hints.js +27 -0
  18. package/dist/backend/codex-footer-hints.js.map +1 -0
  19. package/dist/backend/codex-metadata.d.ts +14 -0
  20. package/dist/backend/codex-metadata.js +45 -0
  21. package/dist/backend/codex-metadata.js.map +1 -1
  22. package/dist/backend/codex-speed.d.ts +14 -0
  23. package/dist/backend/codex-speed.js +35 -0
  24. package/dist/backend/codex-speed.js.map +1 -0
  25. package/dist/backend/codex.js +17 -5
  26. package/dist/backend/codex.js.map +1 -1
  27. package/dist/backend/kiro.js +11 -1
  28. package/dist/backend/kiro.js.map +1 -1
  29. package/dist/backend/muse.js +5 -3
  30. package/dist/backend/muse.js.map +1 -1
  31. package/dist/cache-ledger.d.ts +95 -0
  32. package/dist/cache-ledger.js +276 -4
  33. package/dist/cache-ledger.js.map +1 -1
  34. package/dist/cache-service.d.ts +5 -0
  35. package/dist/cache-service.js +16 -4
  36. package/dist/cache-service.js.map +1 -1
  37. package/dist/channel/adapters/discord.js +11 -3
  38. package/dist/channel/adapters/discord.js.map +1 -1
  39. package/dist/channel/adapters/telegram.d.ts +51 -0
  40. package/dist/channel/adapters/telegram.js +235 -17
  41. package/dist/channel/adapters/telegram.js.map +1 -1
  42. package/dist/channel/mcp-tools.js +1 -1
  43. package/dist/channel/mcp-tools.js.map +1 -1
  44. package/dist/channel/types.d.ts +2 -0
  45. package/dist/config-validator.js +5 -0
  46. package/dist/config-validator.js.map +1 -1
  47. package/dist/cross-instance-notice.d.ts +10 -0
  48. package/dist/cross-instance-notice.js +67 -5
  49. package/dist/cross-instance-notice.js.map +1 -1
  50. package/dist/daemon.d.ts +3 -0
  51. package/dist/daemon.js +11 -4
  52. package/dist/daemon.js.map +1 -1
  53. package/dist/export-import.d.ts +7 -0
  54. package/dist/export-import.js +44 -10
  55. package/dist/export-import.js.map +1 -1
  56. package/dist/fleet-context.d.ts +2 -0
  57. package/dist/fleet-manager.d.ts +93 -4
  58. package/dist/fleet-manager.js +451 -39
  59. package/dist/fleet-manager.js.map +1 -1
  60. package/dist/general-knowledge/skills/concepts/SKILL.md +3 -0
  61. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -0
  62. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +1 -1
  63. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +7 -2
  64. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  65. package/dist/instance-config-impact.js +2 -0
  66. package/dist/instance-config-impact.js.map +1 -1
  67. package/dist/instance-lifecycle.d.ts +6 -2
  68. package/dist/instance-lifecycle.js +11 -6
  69. package/dist/instance-lifecycle.js.map +1 -1
  70. package/dist/instance-topic-delete.d.ts +77 -0
  71. package/dist/instance-topic-delete.js +123 -0
  72. package/dist/instance-topic-delete.js.map +1 -0
  73. package/dist/instructions.d.ts +1 -1
  74. package/dist/instructions.js +1 -1
  75. package/dist/instructions.js.map +1 -1
  76. package/dist/locale.js +33 -1
  77. package/dist/locale.js.map +1 -1
  78. package/dist/log-service.d.ts +28 -0
  79. package/dist/log-service.js +29 -0
  80. package/dist/log-service.js.map +1 -1
  81. package/dist/log-steps.d.ts +4 -3
  82. package/dist/log-steps.js +32 -7
  83. package/dist/log-steps.js.map +1 -1
  84. package/dist/logger.d.ts +12 -0
  85. package/dist/logger.js +43 -1
  86. package/dist/logger.js.map +1 -1
  87. package/dist/missed-offline.d.ts +25 -0
  88. package/dist/missed-offline.js +79 -0
  89. package/dist/missed-offline.js.map +1 -0
  90. package/dist/needs-you-hub.d.ts +6 -1
  91. package/dist/needs-you-hub.js +30 -7
  92. package/dist/needs-you-hub.js.map +1 -1
  93. package/dist/needs-you.d.ts +16 -2
  94. package/dist/needs-you.js +9 -1
  95. package/dist/needs-you.js.map +1 -1
  96. package/dist/outbound-handlers.js +3 -3
  97. package/dist/outbound-handlers.js.map +1 -1
  98. package/dist/outbound-schemas.js +5 -2
  99. package/dist/outbound-schemas.js.map +1 -1
  100. package/dist/scheduler/db.d.ts +2 -1
  101. package/dist/scheduler/db.js +23 -9
  102. package/dist/scheduler/db.js.map +1 -1
  103. package/dist/settings-api.d.ts +13 -0
  104. package/dist/settings-api.js +22 -1
  105. package/dist/settings-api.js.map +1 -1
  106. package/dist/settings-change.js +16 -1
  107. package/dist/settings-change.js.map +1 -1
  108. package/dist/settings-instance-patch.js +27 -0
  109. package/dist/settings-instance-patch.js.map +1 -1
  110. package/dist/tool-progress.d.ts +2 -0
  111. package/dist/tool-progress.js +19 -3
  112. package/dist/tool-progress.js.map +1 -1
  113. package/dist/topic-commands.js +4 -0
  114. package/dist/topic-commands.js.map +1 -1
  115. package/dist/ui/chat-md-strings.js +9 -0
  116. package/dist/ui/chat-strings.js +2 -2
  117. package/dist/ui/chat-thread.js +53 -3
  118. package/dist/ui/fleet-usage.js +90 -27
  119. package/dist/ui/panel-chat.js +5 -2
  120. package/dist/ui/panel-needs.js +13 -5
  121. package/dist/ui/preview-panel.js +78 -2
  122. package/dist/ui/settings-codex-copy.js +19 -0
  123. package/dist/ui/settings-dialogs.js +8 -0
  124. package/dist/ui/settings-model.js +16 -2
  125. package/dist/ui/settings-strings.js +2 -2
  126. package/dist/ui/shared/app-i18n.js +2 -2
  127. package/dist/ui/shared/app.css +19 -0
  128. package/dist/ui/shared/panel-view.js +77 -4
  129. package/dist/ui/shared/usage-dialog.js +18 -1
  130. package/dist/ui/shared/view-history.css +17 -0
  131. package/dist/ui/shared/view-history.js +438 -0
  132. package/dist/ui/shared/view-strings.js +16 -0
  133. package/dist/usage/capacity.d.ts +69 -0
  134. package/dist/usage/capacity.js +119 -0
  135. package/dist/usage/capacity.js.map +1 -0
  136. package/dist/usage/i18n-keys.d.ts +1 -1
  137. package/dist/usage/i18n-keys.js +3 -0
  138. package/dist/usage/i18n-keys.js.map +1 -1
  139. package/dist/usage/profile-timeline.d.ts +16 -0
  140. package/dist/usage/profile-timeline.js +58 -0
  141. package/dist/usage/profile-timeline.js.map +1 -0
  142. package/dist/usage/quota-history.d.ts +43 -0
  143. package/dist/usage/quota-history.js +168 -0
  144. package/dist/usage/quota-history.js.map +1 -0
  145. package/dist/usage/usage-api.d.ts +31 -0
  146. package/dist/usage/usage-api.js +80 -1
  147. package/dist/usage/usage-api.js.map +1 -1
  148. package/dist/usage-report.d.ts +25 -1
  149. package/dist/usage-report.js +55 -4
  150. package/dist/usage-report.js.map +1 -1
  151. package/dist/usage-rollup.d.ts +6 -1
  152. package/dist/usage-rollup.js +16 -4
  153. package/dist/usage-rollup.js.map +1 -1
  154. package/dist/view-api.d.ts +16 -0
  155. package/dist/view-api.js +100 -16
  156. package/dist/view-api.js.map +1 -1
  157. package/dist/view-history.d.ts +84 -0
  158. package/dist/view-history.js +116 -0
  159. package/dist/view-history.js.map +1 -0
  160. package/dist/web-api.d.ts +6 -0
  161. package/dist/web-api.js +8 -2
  162. package/dist/web-api.js.map +1 -1
  163. package/dist/web-session-authorization.d.ts +13 -2
  164. package/dist/web-session-authorization.js +10 -3
  165. package/dist/web-session-authorization.js.map +1 -1
  166. package/launcher/private-home.cjs +1 -1
  167. package/package.json +1 -1
@@ -14,11 +14,20 @@ register("fusage", {
14
14
  en: {
15
15
  range: "Range", today: "Today", r7d: "7 days", month: "This month", all: "All", custom: "Custom", from: "From", to: "To", apply: "Apply",
16
16
  group: "Group by", g_instance: "Agent", g_backend: "Backend", g_model: "Model", g_profile: "Credential profile", g_team: "Team (as configured now)",
17
- csv: "Download CSV", basis: "API-equivalent at list price (checked {0}) — not money spent. Subscriptions are not billed per token.",
17
+ csv: "Download CSV", basis: "API-equivalent at list price ({0}) — not money spent.", basisMore: "Subscriptions are not billed per token.",
18
18
  amount: "API-equivalent", input: "Input", output: "Output", reasoning: "Reasoning", read: "Cache read", write: "Cache write", total: "Total tokens", requests: "Requests", messages: "Messages",
19
19
  notPriced: "not priced", partial: "partial", noTeam: "(no team)", unknown: "unknown",
20
- f_cache_write_not_reported: "cache writes not reported (Codex, Muse) — an estimate", f_not_priced: "not priced: {0}", f_source_unknown: "read before backend and profile were recorded",
21
- f_estimated: "estimated from text size — Kiro records no token counts", f_includes_estimates: "includes estimates (Kiro)", f_cost_reported: "amount as the CLI reported it (no list price for this model)", estimatedPart: "of which estimated: {0}",
20
+ // #3066 §3: what a row's numbers are, said once each as a small mark with its tooltip (never a pill per row).
21
+ t_not_priced: "Not priced: {0}", t_partial: "Part of this row's models have no price", t_write_unreported: "Codex/Muse don't report cache writes",
22
+ t_cost_reported: "Amount as the CLI reported it", t_estimated: "Estimated from text size (Kiro records no token counts)", t_reasoning_none: "Not reported by this CLI",
23
+ sourceNote: "Some hours were read before AgEnD recorded their backend and profile.", profileUnrecorded: "profile not recorded",
24
+ l_partial: "◐ partly priced", l_cost_reported: "† amount from the CLI", l_estimated: "≈ estimated",
25
+ // #3066 §2: output speed (Claude after the first token, Codex over the response window).
26
+ speed: "Speed", speedUnit: "{0} tok/s", speedCoverage: "{0} of {1} requests timed", speedSpread: "p10–p90: {0}–{1} tok/s",
27
+ speedNone: "no timing in its transcripts", speedByModel: "Speed by model",
28
+ speedBasis: "Output tokens ÷ the model's own response time (tool time excluded); method after tokdash.",
29
+
30
+ f_includes_estimates: "includes estimates (Kiro)", estimatedPart: "of which estimated: {0}",
22
31
  scanning: "Scanning — totals may still grow.", updated: "Updated {0} · read every 10 min", since: "Collected since {0}", notYet: "Not collected yet",
23
32
  refresh: "Refresh",
24
33
  notMeasured: "Not measured", nm_credit_billed: "Kiro is credit-billed and records no tokens", nm_unsupported: "AgEnD can't read this CLI's usage yet", nm_no_data: "nothing read yet",
@@ -42,11 +51,18 @@ register("fusage", {
42
51
  "zh-TW": {
43
52
  range: "期間", today: "今天", r7d: "7 天", month: "本月", all: "全部", custom: "自訂", from: "從", to: "到", apply: "套用",
44
53
  group: "分組", g_instance: "Agent", g_backend: "Backend", g_model: "模型", g_profile: "憑證 profile", g_team: "Team(依目前設定)",
45
- csv: "下載 CSV", basis: "依牌價換算的 API 等值(查證日期 {0})——不是實際花費。訂閱方案不按 token 計費。",
54
+ csv: "下載 CSV", basis: "依牌價換算的 API 等值({0})——不是實際花費。", basisMore: "訂閱方案不按 token 計費。",
46
55
  amount: "API 等值", input: "輸入", output: "輸出", reasoning: "推理", read: "快取讀取", write: "快取寫入", total: "token 總計", requests: "請求數", messages: "訊息數",
47
56
  notPriced: "未定價", partial: "部分", noTeam: "(不屬於任何 team)", unknown: "未知",
48
- f_cache_write_not_reported: "未回報快取寫入(Codex、Muse)——是估算", f_not_priced: "未定價:{0}", f_source_unknown: "讀取時尚未記錄 backend 與 profile",
49
- f_estimated: "依文字長度估算——Kiro 不記錄 token 數", f_includes_estimates: "含估算值(Kiro)", f_cost_reported: "金額為 CLI 自行回報(價目表沒有此模型)", estimatedPart: "其中估算:{0}",
57
+ t_not_priced: "未定價:{0}", t_partial: "這一列有部分模型沒有價格", t_write_unreported: "Codex/Muse 不回報快取寫入",
58
+ t_cost_reported: "金額為 CLI 自行回報", t_estimated: "依文字長度估算(Kiro 不記錄 token 數)", t_reasoning_none: "這個 CLI 不回報",
59
+ sourceNote: "部分時段在 AgEnD 記錄 backend 與 profile 之前就已讀取。", profileUnrecorded: "未記錄 profile",
60
+ l_partial: "◐ 部分定價", l_cost_reported: "† 金額由 CLI 回報", l_estimated: "≈ 估算值",
61
+ speed: "速度", speedUnit: "{0} token/秒", speedCoverage: "{1} 個請求中有 {0} 個可計時", speedSpread: "p10–p90:{0}–{1} token/秒",
62
+ speedNone: "transcript 沒有計時資訊", speedByModel: "各模型速度",
63
+ speedBasis: "輸出 token ÷ 模型本身的回應時間(不含工具執行時間);方法參考 tokdash。",
64
+
65
+ f_includes_estimates: "含估算值(Kiro)", estimatedPart: "其中估算:{0}",
50
66
  scanning: "掃描中——總數可能還會增加。", updated: "更新於 {0} · 每 10 分鐘讀取一次", since: "自 {0} 起收集", notYet: "尚未收集",
51
67
  refresh: "重新整理",
52
68
  notMeasured: "未量測", nm_credit_billed: "Kiro 以 credit 計費,不記錄 token", nm_unsupported: "AgEnD 目前還讀不到這個 CLI 的用量", nm_no_data: "尚未讀到任何資料",
@@ -95,10 +111,6 @@ export function keyText(group, key) {
95
111
  return key;
96
112
  }
97
113
  const writes = (r) => (r.cacheWrite5m || 0) + (r.cacheWrite1h || 0) + (r.cacheWrite || 0);
98
- function flagText(f, r) {
99
- if (f === "not_priced") return tu("f_not_priced", (r.unpriced || []).join(", "));
100
- return ["cache_write_not_reported", "source_unknown", "estimated", "includes_estimates", "cost_reported"].includes(f) ? tu(`f_${f}`) : f;
101
- }
102
114
 
103
115
  // ── CSV (design §4): the daily rows of this same answer; messages on their own rows (they are per agent, not per model).
104
116
  const CSV_COLUMNS = ["date", "zone", "instance", "backend", "model", "tier", "profile", "input", "output", "reasoning", "cache_read", "cache_write_5m", "cache_write_1h", "cache_write", "requests", "messages", "amount", "basis", "flags", "estimated"];
@@ -318,25 +330,73 @@ function Heatmap({ report, dates }) {
318
330
  </section>`;
319
331
  }
320
332
 
321
- function Row({ r, group, max, measure }) {
333
+ /**
334
+ * A mark with what it means: shown as its sign, said (title, and aria-label for readers) as words. role="img" makes it
335
+ * nameable (#3067 review, Prism P3: aria-label is not allowed on a generic span).
336
+ */
337
+ const mark = (sign, words, cls = "us-mark") => html`<span class=${cls} role="img" title=${words} aria-label=${words}>${sign}</span>`;
338
+ /** "—" that says why there is no number (nameable the same way). */
339
+ const dash = (words) => html`<span class="us-none" role="img" title=${words} aria-label=${words}>—</span>`;
340
+ /** The marks a row's numbers carry (#3066 §3): ◐ partly priced, † the CLI's own amount, ≈ estimated. */
341
+ export function rowMarks(r) {
342
+ const flags = r.flags || [];
343
+ return { partial: r.priced === "partial", cost: flags.includes("cost_reported"), est: flags.includes("estimated") || r.estimated === true,
344
+ writeUnreported: flags.includes("cache_write_not_reported") && writes(r) === 0, unpriced: r.amount === null || r.amount === undefined };
345
+ }
346
+ /** A profile as shown: Codex's older hours were never tagged with one. */
347
+ const profileText = (p) => (p === "unrecorded" ? tu("profileUnrecorded") : p);
348
+
349
+ function Row({ r, group, max, measure, cols }) {
322
350
  const share = max > 0 ? Math.max(0.01, measure(r) / max) : 0;
323
- const meta = [r.backends.join(", "), r.models.join(", "), r.profiles.join(", ")].filter(Boolean).join(" · ");
351
+ const meta = [r.backends.join(", "), r.models.join(", "), r.profiles.map(profileText).join(", ")].filter(Boolean).join(" · ");
352
+ const m = rowMarks(r);
353
+ // Estimated rows (Kiro): every number is an estimate — "≈" before each, the reason in its tooltip.
354
+ const num = (text) => (m.est ? html`${mark("≈", tu("t_estimated"), "us-mark us-approx")}${text}` : text);
355
+ const key = group === "profile" ? profileText(keyText(group, r.key)) : keyText(group, r.key);
324
356
  return html`<tr class="us-row">
325
- <th scope="row" class="us-name"><span class="mono">${keyText(group, r.key)}</span>
357
+ <th scope="row" class="us-name"><span class="mono">${key}</span>
326
358
  <svg class="us-bar" aria-hidden="true"><rect class="us-bar-bg" width="100%" height="6" rx="3" /><rect class="us-bar-fg" width=${`${Math.round(share * 100)}%`} height="6" rx="3" /></svg>
327
- ${group !== "instance" || meta ? html`<span class="us-meta">${meta}</span>` : null}
328
- ${r.flags.length ? html`<span class="us-flags">${r.flags.map((f) => html`<span key=${f} class="ch-tag">${flagText(f, r)}</span>`)}</span>` : null}</th>
329
- <td data-label=${tu("amount")} class=${`num${r.amount === null ? " muted" : ""}`}>${amountText(r)}</td>
330
- <td data-label=${tu("input")} class="num">${tokens(r.input)}</td>
331
- <td data-label=${tu("output")} class="num">${tokens(r.output)}</td>
332
- <td data-label=${tu("reasoning")} class="num">${r.reasoningReported === false ? "—" : tokens(r.reasoning || 0)}</td>
333
- <td data-label=${tu("read")} class="num">${tokens(r.cacheRead)}</td>
334
- <td data-label=${tu("write")} class="num">${r.flags.includes("cache_write_not_reported") && writes(r) === 0 ? "—" : tokens(writes(r))}</td>
335
- <td data-label=${tu("total")} class="num">${tokens(r.total)}</td>
359
+ ${group !== "instance" || meta ? html`<span class="us-meta">${meta}</span>` : null}</th>
360
+ <td data-label=${tu("amount")} class=${`num${m.unpriced ? " muted" : ""}`}>${m.unpriced ? dash(tu("t_not_priced", (r.unpriced || []).join(", "))) : html`${num(money(r.amount))}${m.partial ? mark("◐", tu("t_partial")) : null}${m.cost ? mark("†", tu("t_cost_reported")) : null}`}</td>
361
+ <td data-label=${tu("input")} class="num">${num(tokens(r.input))}</td>
362
+ <td data-label=${tu("output")} class="num">${num(tokens(r.output))}</td>
363
+ ${cols.reasoning ? html`<td data-label=${tu("reasoning")} class="num">${r.reasoningReported === false ? dash(tu("t_reasoning_none")) : num(tokens(r.reasoning || 0))}</td>` : null}
364
+ <td data-label=${tu("read")} class="num">${num(tokens(r.cacheRead))}</td>
365
+ <td data-label=${tu("write")} class="num">${m.writeUnreported ? dash(tu("t_write_unreported")) : num(tokens(writes(r)))}</td>
366
+ <td data-label=${tu("total")} class="num">${num(tokens(r.total))}</td>
336
367
  <td data-label=${tu("requests")} class="num">${n(r.requests)}</td>
337
- <td data-label=${tu("messages")} class="num">${r.messages === null ? "—" : n(r.messages)}</td>
368
+ ${cols.messages ? html`<td data-label=${tu("messages")} class="num">${r.messages === null ? "—" : n(r.messages)}</td>` : null}
369
+ ${cols.speed ? html`<td data-label=${tu("speed")} class="num">${r.speed ? speedText(r.speed) : dash(tu("speedNone"))}</td>` : null}
338
370
  </tr>`;
339
371
  }
372
+ /** #3066 §2: a rate as shown — whole tok/s, one decimal below 10. */
373
+ export const speedText = (s) => tu("speedUnit", s.tokPerSec >= 10 ? Math.round(s.tokPerSec).toLocaleString("en-US") : s.tokPerSec.toFixed(1));
374
+ /** Columns worth a place (#3066 §3): Reasoning only when some row reports it; Messages only when some row has them; Speed (§2) only when some response was timed. */
375
+ export function usageColumns(rows) {
376
+ return { reasoning: rows.some((r) => r.reasoningReported !== false), messages: rows.some((r) => r.messages !== null && r.messages !== undefined),
377
+ speed: rows.some((r) => !!r.speed) };
378
+ }
379
+ /** #3066 §2: each model's speed — rate · requests timed (coverage) · p10–p90 from 30 timed. */
380
+ function SpeedCard({ report }) {
381
+ const list = report.speedByModel || [];
382
+ if (!list.length) return null;
383
+ return html`<section class="us-speed card" aria-labelledby="us-speed-h">
384
+ <h3 id="us-speed-h">${tu("speedByModel")}</h3>
385
+ <ul class="rows">${list.map((m) => html`<li key=${m.model} class="row-item"><span class="grow strong mono">${m.model}</span>
386
+ <span class="num">${speedText(m.speed)}</span>
387
+ <span class="muted">${[m.requests > 0 ? tu("speedCoverage", n(m.speed.timedRequests), n(m.requests)) : null,
388
+ m.speed.p10 !== null && m.speed.p90 !== null ? tu("speedSpread", Math.round(m.speed.p10), Math.round(m.speed.p90)) : null].filter(Boolean).join(" · ")}</span></li>`)}</ul>
389
+ <p class="note">${tu("speedBasis")}</p>
390
+ </section>`;
391
+ }
392
+ /** The legend under the table: only the marks that appear, and the one footnote when an hour's source was not recorded. */
393
+ function Legend({ rows }) {
394
+ const marks = rows.map(rowMarks);
395
+ const parts = [marks.some((m) => m.partial) && tu("l_partial"), marks.some((m) => m.cost) && tu("l_cost_reported"), marks.some((m) => m.est) && tu("l_estimated")].filter(Boolean);
396
+ const source = rows.some((r) => (r.flags || []).includes("source_unknown"));
397
+ if (!parts.length && !source) return null;
398
+ return html`<p class="note us-key">${parts.join(" · ")}${parts.length && source ? html`<br />` : null}${source ? tu("sourceNote") : null}</p>`;
399
+ }
340
400
 
341
401
  /** The tab's body for one answer of GET /ui/usage-report. */
342
402
  export function UsageReport({ report, choice, setChoice, refresh, fetchedAt }) {
@@ -355,6 +415,7 @@ export function UsageReport({ report, choice, setChoice, refresh, fetchedAt }) {
355
415
  const zones = zonesChanged(report);
356
416
  const csvFrom = report.dailyTruncated ? report.dailyFrom : report.range.kind === "all" ? "all" : report.range.from;
357
417
  const rows = report.rows.filter((r) => r.total > 0 || (r.messages || 0) > 0);
418
+ const cols = usageColumns(rows);
358
419
  const anyAmount = rows.some((r) => r.amount !== null && r.amount > 0);
359
420
  const measure = (r) => (anyAmount ? r.amount || 0 : r.total);
360
421
  const max = Math.max(0, ...rows.map(measure));
@@ -375,7 +436,7 @@ export function UsageReport({ report, choice, setChoice, refresh, fetchedAt }) {
375
436
  <label>${tu("from")} <input type="date" value=${draft.from} max=${draft.to} onInput=${(e) => setDraft({ ...draft, from: e.target.value })} /></label>
376
437
  <label>${tu("to")} <input type="date" value=${draft.to} min=${draft.from} onInput=${(e) => setDraft({ ...draft, to: e.target.value })} /></label>
377
438
  <button type="submit" class="btn btn-sm">${tu("apply")}</button></form>` : null}
378
- <p class="us-basis" role="note">${tu("basis", report.pricesChecked)}</p>
439
+ <p class="us-basis" role="note" title=${tu("basisMore")}>${tu("basis", report.pricesChecked)}</p>
379
440
  ${noDaily ? html`<p class="us-partial" role="status">${tu("noDaily")}</p>` : report.dailyTruncated ? html`<p class="us-partial" role="status">${tu("partialDays", report.dailyFrom)}</p>` : null}
380
441
  ${zones ? html`<p class="us-partial" role="status">${tu("zoneChanged", zones.join(", "), report.zone)}</p>` : null}
381
442
  <p class="note us-fresh">${tu("updated", new Date(fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))} · ${Number.isFinite(since) ? tu("since", new Date(since).toLocaleDateString()) : tu("notYet")} · ${tu("zoneNote", report.zone)}${group === "team" ? ` ${tu("teamsNote")}` : ""}
@@ -389,10 +450,12 @@ export function UsageReport({ report, choice, setChoice, refresh, fetchedAt }) {
389
450
  <div class="ch-metric"><dt>${tu("messages")}</dt><dd>${report.totals.messages === null ? "—" : n(report.totals.messages)}</dd></div>
390
451
  </dl>
391
452
  ${rows.length ? html`<table class="us-table"><thead><tr><th scope="col">${tu(`g_${group}`)}</th><th scope="col" class="num">${tu("amount")}</th>
392
- <th scope="col" class="num">${tu("input")}</th><th scope="col" class="num">${tu("output")}</th><th scope="col" class="num">${tu("reasoning")}</th><th scope="col" class="num">${tu("read")}</th><th scope="col" class="num">${tu("write")}</th>
393
- <th scope="col" class="num">${tu("total")}</th><th scope="col" class="num">${tu("requests")}</th><th scope="col" class="num">${tu("messages")}</th></tr></thead>
394
- <tbody>${rows.map((r) => html`<${Row} key=${r.key} r=${r} group=${group} max=${max} measure=${measure} />`)}</tbody></table>`
453
+ <th scope="col" class="num">${tu("input")}</th><th scope="col" class="num">${tu("output")}</th>${cols.reasoning ? html`<th scope="col" class="num">${tu("reasoning")}</th>` : null}<th scope="col" class="num">${tu("read")}</th><th scope="col" class="num">${tu("write")}</th>
454
+ <th scope="col" class="num">${tu("total")}</th><th scope="col" class="num">${tu("requests")}</th>${cols.messages ? html`<th scope="col" class="num">${tu("messages")}</th>` : null}${cols.speed ? html`<th scope="col" class="num">${tu("speed")}</th>` : null}</tr></thead>
455
+ <tbody>${rows.map((r) => html`<${Row} key=${r.key} r=${r} group=${group} max=${max} measure=${measure} cols=${cols} />`)}</tbody></table>
456
+ <${Legend} rows=${rows} />`
395
457
  : html`<p class="note">${tu("noRows")}</p>`}
458
+ <${SpeedCard} report=${report} />
396
459
  <${Trend} report=${report} dates=${dates} />
397
460
  <${Heatmap} report=${report} dates=${dates} />`}
398
461
  ${report.notMeasured.length || report.shared.length ? html`<section class="us-rest" aria-labelledby="us-rest-h"><h3 id="us-rest-h">${tu("notMeasured")}</h3>
@@ -77,7 +77,7 @@ import { createEmojiIndex } from "./chat-emojis.js";
77
77
  import { pollPublicPreview } from "./preview-status.js";
78
78
  import { createMentionIndex } from "./chat-mentions.js";
79
79
  import { setPanelFeedback } from "./preview-panel-actions.js";
80
- import { PreviewPanel, claimPanel, openPanel, panelClaims, panelStore, panelVersion, shownKey } from "./preview-panel.js";
80
+ import { PreviewPanel, claimPanel, openMarkdown, openPanel, panelClaims, panelStore, panelVersion, shownKey } from "./preview-panel.js";
81
81
  import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
82
82
  import { installTour, refreshTourSpot } from "./chat-tour.js";
83
83
  import { LightboxHost, openLightbox } from "./image-lightbox.js";
@@ -438,7 +438,8 @@ function threadEntry(name, doc) {
438
438
  replyButtonBusy: (id) => store.state.rbBusy.has(id),
439
439
  pollMine: (id) => store.state.pollMine[id], // #1823: this device's own vote
440
440
  serverNow: () => Date.now() + store.state.clockSkew, // #1823: the deadline is the server's
441
- panel: { shown: shownKey, open: (spec, o) => openPanel(spec, o), version: panelVersion, claim: claimPanel, claims: panelClaims }, // #1481, #1704
441
+ panel: { shown: shownKey, open: (spec, o) => openPanel(spec, o), version: panelVersion, claim: claimPanel, claims: panelClaims, // #1481, #1704
442
+ openMarkdown: (spec) => openMarkdown(spec) }, // M-2448: a Markdown file in the side panel
442
443
  openImage: openLightbox, // an image, full size, in the page
443
444
  emojis: (id) => !!emojiIndex && emojiIndex.known(id), // #1630: the fleet's server emoji
444
445
  mentions: (kind, id) => (mentionIndex ? mentionIndex.name(kind, id) : null), // #1682: mention names
@@ -797,6 +798,8 @@ function DeleteDialog({ name, onClose }) {
797
798
  } catch (err) { r = { error: err && err.message ? err.message : t("chat.disconnected") }; }
798
799
  if (r && r.error) { toast(r.error, false); if (!handedOver && lease.current()) setBusy(false); return; }
799
800
  toast(t("chat.instanceDeleted", name));
801
+ // The agent's own topic went too, or stayed with the reason (the server's own words: no new string here).
802
+ if (r.topic_note) toast(r.topic_note, r.topic_deleted !== false);
800
803
  if (handedOver || !lease.current()) return;
801
804
  setBusy(false);
802
805
  onClose();
@@ -34,7 +34,7 @@ register("needs", {
34
34
  notifyMobile: "On a phone, rely on your chat app's notification: a new item is a new post in General.", notifyMobileWeb: "On a phone, open this page to see what needs you.",
35
35
  notifyInsecure: "Notifications need a secure address (localhost or the HTTPS public link), not a plain-HTTP LAN address.",
36
36
  notifyUnsupported: "This browser cannot show notifications.", turnOn: "Turn on", turnOff: "Turn off",
37
- typePrompt: "Fleet prompt", typeAwaiting: "At its terminal", typeInstance: "Instance", typeDelivery: "Delivery",
37
+ typePrompt: "Fleet prompt", typeAwaiting: "At its terminal", typeInstance: "Instance", typeDelivery: "Delivery", typeMissed: "Missed while offline",
38
38
  },
39
39
  "zh-TW": {
40
40
  title: "等你處理", empty: "目前沒有需要你處理的事", emptyHint: "agent 在等你時,會出現在這裡——也會出現在聊天 app 的 General。", emptyHintWeb: "agent 在等你時,會出現在這裡。",
@@ -47,10 +47,18 @@ register("needs", {
47
47
  notifyMobile: "在手機上請以聊天 app 的通知為主:每個新項目都會在 General 發一則新訊息。", notifyMobileWeb: "在手機上請打開這個頁面查看等你處理的事。",
48
48
  notifyInsecure: "通知需要安全的網址(localhost 或 HTTPS 公開連結),區網的一般 HTTP 網址不行。",
49
49
  notifyUnsupported: "這個瀏覽器無法顯示通知。", turnOn: "開啟", turnOff: "關閉",
50
- typePrompt: "Fleet 提示", typeAwaiting: "在終端機上", typeInstance: "Instance", typeDelivery: "傳送",
50
+ typePrompt: "Fleet 提示", typeAwaiting: "在終端機上", typeInstance: "Instance", typeDelivery: "傳送", typeMissed: "離線時錯過",
51
51
  },
52
52
  });
53
53
  const tn = (k, ...v) => t(`needs.${k}`, ...v);
54
+ /** Stale shells keep the old app-i18n.js; a missing key would otherwise show as app.needs_reason_*. */
55
+ function itemReason(item) {
56
+ const text = reasonText(item);
57
+ if (typeof text === "string" && text.startsWith("app.needs_reason_") && (item.type === "missed" || item.reason === "missed_offline")) {
58
+ return tn("typeMissed");
59
+ }
60
+ return text;
61
+ }
54
62
 
55
63
  /**
56
64
  * The page's claims on what Needs you writes, kept for the life of the page — not of a panel or a render (#1463
@@ -86,7 +94,7 @@ const setClaim = (kind, key, value) => claims.set((c) => {
86
94
  return { ...c, [kind]: next };
87
95
  });
88
96
 
89
- const ICON = { prompt: "chat", awaiting_input: "clock", instance: "alert", delivery: "send", task: "tasks" };
97
+ const ICON = { prompt: "chat", awaiting_input: "clock", instance: "alert", delivery: "send", task: "tasks", missed: "clock" };
90
98
  const BAD = new Set(["crashed", "delivery_failed", "hang", "exited"]);
91
99
 
92
100
  /**
@@ -195,12 +203,12 @@ function NeedsItem({ item, nowMs, owner }) {
195
203
  return html`<article data-trust="permission" class=${`n-item${BAD.has(item.reason) ? " bad" : ""}`}>
196
204
  <span class="n-icon"><${Icon} name=${ICON[item.type] || "alert"} size=${18} /></span>
197
205
  <div class="n-body">
198
- <div class="n-title"><strong>${reasonText(item)}</strong><span class="n-age">${age(item.since, nowMs)}</span></div>
206
+ <div class="n-title"><strong>${itemReason(item)}</strong><span class="n-age">${age(item.since, nowMs)}</span></div>
199
207
  ${detailOf(item) ? html`<p class="n-detail">${detailOf(item)}</p>` : null}
200
208
  <div class="n-actions">
201
209
  ${item.type === "prompt" && Array.isArray(item.actions) ? item.actions.filter((a) => !a.input).map((a) => html`<button key=${a.id} type="button" class="btn btn-sm"
202
210
  disabled=${promptBusy} onClick=${() => answer(a.id)}>${(p ? p.busy : fallback === "busy") ? tn("answering") : a.label}</button>`) : null}
203
- ${item.type === "delivery" ? html`<button type="button" class="btn btn-sm" disabled=${!!ack} onClick=${acknowledge}>
211
+ ${item.type === "delivery" || item.type === "missed" ? html`<button type="button" class="btn btn-sm" disabled=${!!ack} onClick=${acknowledge}>
204
212
  <${Icon} name="check" size=${14} />${ack === "busy" ? tn("acknowledging") : tn("acknowledge")}</button>` : null}
205
213
  ${(item.reason === "auth_paused" || item.reason === "backend_signin") && item.signIn && signInOffered() ? html`<button type="button" class="btn btn-sm btn-primary" onClick=${() => openSignIn(item.signIn)}>${tn("signIn")}</button>` : null}
206
214
  ${item.type === "task" && item.card ? html`<a class="btn btn-sm" href=${`/ui/fleet?card=${encodeURIComponent(item.card.code)}`}>${tn("openCard")}</a>` : null}
@@ -20,6 +20,8 @@ import { chatTime, onDayChange } from "./chat-time.js";
20
20
  import { PanelActions } from "./preview-panel-actions.js";
21
21
  import { htmlTitle, versionsOf, lineDiff, diffHunks, DIFF_LIMITS } from "./preview-versions.js";
22
22
  import { pagesOf, pageIdOf } from "./preview-pages.js";
23
+ import { drawMarkdown, TEXT_CARD_MAX } from "./chat-thread.js";
24
+ import "./chat-md-strings.js"; // M-2448: the Markdown panel's own words (an older cached chat-strings.js lacks them)
23
25
 
24
26
  export { htmlTitle };
25
27
 
@@ -57,6 +59,16 @@ export function openPanel(spec, { run = false } = {}) {
57
59
  set({ open: { ...spec, kind: "html" }, run: run ? ++runs : 0 });
58
60
  retire(old); // State changes first: cleanup reentry cannot close a newer panel.
59
61
  }
62
+ /**
63
+ * M-2448: show a Markdown file in the panel — `spec`: { key, instance, msgKey, att: { id, name, size }, sender, ts,
64
+ * text? } (text: what the card's Show already read). Nothing runs: it is rendered by the chat's own Markdown renderer.
65
+ * The block shown before stops, as for any open.
66
+ */
67
+ export function openMarkdown(spec) {
68
+ const old = state.open;
69
+ set({ open: { ...spec, kind: "md" }, run: 0 });
70
+ retire(old);
71
+ }
60
72
  /** Close publishes the empty slot before old-owner callbacks can reenter. */
61
73
  export function closePanel() {
62
74
  const old = state.open; if (!old) return;
@@ -363,7 +375,35 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
363
375
  }
364
376
  return () => { live = false; };
365
377
  }, [shown, open && open.key, prev && prev.key]);
378
+ // M-2448: a Markdown file's text (read once per open: the same capped, same-origin read as the chat's card) and
379
+ // which view shows it — rendered (the chat's Markdown renderer, which escapes first) or its source.
380
+ const [md, setMd] = useState(null); // { state: "loading" | "ready" | "failed", text?, reason? }
381
+ const [mdView, setMdView] = useState("rendered");
382
+ const [mdPlain, setMdPlain] = useState(false);
383
+ const mdBody = useRef(null);
384
+ const isMd = !!open && open.kind === "md";
385
+ useEffect(() => {
386
+ if (!isMd) { setMd(null); return undefined; }
387
+ setMdView("rendered");
388
+ if (typeof open.text === "string") { setMd({ state: "ready", text: open.text }); return undefined; }
389
+ let live = true;
390
+ setMd({ state: "loading" });
391
+ loadHtmlAttachment(open.att, undefined, { ask: TEXT_CARD_MAX, max: TEXT_CARD_MAX }).then((r) => {
392
+ if (!live) return;
393
+ setMd(r.ok ? { state: "ready", text: r.code } : { state: "failed", reason: r.reason === "over" ? t("chat.tcOver") : attReason(r) });
394
+ }, () => { if (live) setMd({ state: "failed", reason: t("chat.pvAttFailed") }); });
395
+ return () => { live = false; };
396
+ }, [isMd && open.key]);
397
+ useEffect(() => {
398
+ if (!isMd || !md || md.state !== "ready" || mdView !== "rendered" || !mdBody.current) return;
399
+ setMdPlain(drawMarkdown(mdBody.current, md.text) === "plain");
400
+ }, [isMd && open.key, md, mdView]);
366
401
  const fileName = open && open.att ? open.att.name : undefined;
402
+ // The divider between the thread and the panel (both kinds): drag, or the arrow keys, Home and End.
403
+ function divider0() {
404
+ return html`<div data-trust="preview" class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
405
+ aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>`;
406
+ }
367
407
  // Esc in the panel closes it (and is not the chat's "stop the reply").
368
408
  const onPanelKey = (e) => {
369
409
  if (e.key !== "Escape" || e.isComposing) return;
@@ -372,6 +412,8 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
372
412
  };
373
413
 
374
414
  if (!open) return null;
415
+ if (isMd) return html`${divider0()}<${MarkdownPanel} open=${open} name=${name} md=${md} view=${mdView} setView=${setMdView} plain=${mdPlain}
416
+ body=${mdBody} panel=${panel} fs=${fs} enterFs=${enterFs} leaveFs=${leaveFs} onKey=${onPanelKey} reveal=${reveal} />`;
375
417
  const going = run.state === "starting" || run.state === "running";
376
418
  const a = P().availability();
377
419
  // #1554: a device that has not allowed previews is offered the same "Allow previews" here (the same confirm). Allowing
@@ -390,8 +432,7 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
390
432
  if (want && pending.current === want && panelStore.get().run === want) start();
391
433
  };
392
434
  const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
393
- return html`<div data-trust="preview" class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
394
- aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
435
+ return html`${divider0()}
395
436
  <aside data-trust="preview" class=${`pv-panel${fs ? ` pv-fs pv-fs-${fs}` : ""}`} ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
396
437
  <div class="pv-panel-head">
397
438
  <button type="button" class="icon-btn only-narrow pv-panel-close" title=${t("chat.pvBack")} aria-label=${t("chat.pvBack")} onClick=${closePanel}><${Icon} name="back" size=${18} /></button>
@@ -440,6 +481,41 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
440
481
  </aside>`;
441
482
  }
442
483
 
484
+ /**
485
+ * M-2448: a Markdown file in the panel — the HTML preview's frame (head, source, close, full screen; a full-screen
486
+ * sheet on a phone), with Rendered / Source and Download. Nothing in the file runs: Rendered is the chat's Markdown
487
+ * renderer (escaped first, no raw HTML), Source is text.
488
+ */
489
+ function MarkdownPanel({ open, name, md, view, setView, plain, body, panel, fs, enterFs, leaveFs, onKey, reveal }) {
490
+ const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
491
+ const ready = md && md.state === "ready";
492
+ return html`<aside data-trust="preview" class=${`pv-panel pv-md-panel${fs ? ` pv-fs pv-fs-${fs}` : ""}`} ref=${panel} aria-label=${t("chat.pvMdPanel")} onKeyDown=${onKey}>
493
+ <div class="pv-panel-head">
494
+ <button type="button" class="icon-btn only-narrow pv-panel-close" title=${t("chat.pvBack")} aria-label=${t("chat.pvBack")} onClick=${closePanel}><${Icon} name="back" size=${18} /></button>
495
+ <div class="pv-panel-title"><strong>${open.att ? open.att.name : t("chat.pvMdPanel")}</strong>
496
+ <button type="button" class="pv-source" title=${source} onClick=${() => { if (window.matchMedia && window.matchMedia("(max-width: 899px)").matches) closePanel(); reveal(open.msgKey); }}>${source}</button></div>
497
+ <button type="button" class="icon-btn only-wide pv-panel-close" title=${t("chat.pvClose")} aria-label=${t("chat.pvClose")} onClick=${closePanel}><${Icon} name="close" size=${18} /></button>
498
+ </div>
499
+ <div class="pv-panel-bar">
500
+ <div class="seg-inline" role="group" aria-label=${t("chat.pvMdPanel")}>
501
+ <button type="button" class=${`btn btn-sm${view === "rendered" ? " on" : ""}`} aria-pressed=${view === "rendered" ? "true" : "false"} title=${t("chat.pvMdRendered")} onClick=${() => setView("rendered")}><${Icon} name="eye" size=${14} /><span class="lbl">${t("chat.pvMdRendered")}</span></button>
502
+ <button type="button" class=${`btn btn-sm${view === "source" ? " on" : ""}`} aria-pressed=${view === "source" ? "true" : "false"} title=${t("chat.pvMdSource")} onClick=${() => setView("source")}><${Icon} name="code" size=${14} /><span class="lbl">${t("chat.pvMdSource")}</span></button>
503
+ </div>
504
+ <span class="grow"></span>
505
+ <button type="button" class=${`btn btn-sm only-wide pv-panel-fs${fs ? " on" : ""}`} aria-pressed=${fs ? "true" : "false"} title=${t(fs ? "chat.pvFsExit" : "chat.pvFs")} aria-label=${t(fs ? "chat.pvFsExit" : "chat.pvFs")}
506
+ onClick=${() => (fs ? leaveFs() : enterFs())}><${Icon} name=${fs ? "fullscreenexit" : "fullscreen"} size=${14} /></button>
507
+ ${open.att ? html`<a class="btn btn-sm pv-panel-dl" href=${`/ui/file/${open.att.id}`} download=${open.att.name} title=${t("chat.pvDownload")}><${Icon} name="download" size=${14} /><span class="lbl">${t("chat.pvDownload")}</span></a>` : null}
508
+ </div>
509
+ ${md && md.state === "loading" ? html`<div class="pv-note" role="status">${t("chat.pvLoading")}</div>` : null}
510
+ ${md && md.state === "failed" ? html`<div class="pv-note" role="alert">${md.reason}</div>` : null}
511
+ ${ready && view === "rendered" && plain ? html`<div class="pv-note" role="status">${t("chat.tcPlain")}</div>` : null}
512
+ <div class="pv-panel-body show-code">
513
+ ${ready && view === "rendered" ? html`<div class="pv-code pv-md" ref=${body}></div>` : null}
514
+ ${ready && view === "source" ? html`<pre class="pv-code"><code>${md.text}</code></pre>` : null}
515
+ </div>
516
+ </aside>`;
517
+ }
518
+
443
519
  /** The Changes view: hunks of the line diff as a unified diff shows them, drawn as text (never as HTML). */
444
520
  function Changes({ changes }) {
445
521
  if (!changes || changes.state === "loading") return html`<div class="pv-note pv-diff-note">${t("chat.pvLoading")}</div>`;
@@ -0,0 +1,19 @@
1
+ // M-2446: the agent dialog's Codex speed words, lazy and fixed. A tab opened before an upgrade may keep the old
2
+ // settings-strings module while the dialog module is fresh: these words live here, registered with the dialog.
3
+ import { register } from "/assets/app-i18n.js";
4
+ register("settings", {
5
+ en: {
6
+ codexSpeed: "Speed",
7
+ codexSpeedDefault: "Codex default",
8
+ codexSpeedStandard: "Standard",
9
+ codexSpeedFast: "Fast",
10
+ codexSpeedHint: "Fast: uses plan limits at 2.5× (OpenAI). Only for models that offer Fast; otherwise the agent starts without it and the log says why. Changing it restarts the agent.",
11
+ },
12
+ "zh-TW": {
13
+ codexSpeed: "速度",
14
+ codexSpeedDefault: "Codex 預設",
15
+ codexSpeedStandard: "標準",
16
+ codexSpeedFast: "快速",
17
+ codexSpeedHint: "快速:方案額度以 2.5 倍消耗(OpenAI)。只適用於提供 Fast 的模型;不支援時 Agent 會照常啟動,並在記錄中說明原因。變更後會重新啟動 Agent。",
18
+ },
19
+ });
@@ -25,6 +25,10 @@ import {
25
25
  chLabel, dashboardDefault, hasOwn, impactOf, newConnectionImpact, nonNegative, nonNegativeInteger, positive, replyGuardSupported, sameValue, visibilityDefault,
26
26
  classicWebEchoEffective, toolSetDefault, worstImpact,
27
27
  } from "./settings-model.js";
28
+ import "./settings-codex-copy.js"; // M-2446: the Speed field's words (a retained old settings-strings lacks them)
29
+ /** M-2446: Codex speed choices (backend_options.codex.speed); "" = unset. Local, not imported: a stale tab may keep an
30
+ * older settings-model.js, and a new named import would fail this module's load. */
31
+ const CODEX_SPEED_CHOICES = ["", "standard", "fast"];
28
32
  /** #2342: a field by id, for recordEdit before a pick or a reset writes it. */
29
33
  const byId = (id) => (typeof document !== "undefined" ? document.getElementById(id) : null);
30
34
 
@@ -298,6 +302,10 @@ export function AgentDialog({ name, inst, ctx, onClose }) {
298
302
  ${inheritedBackend ? html`<${Inherited} ...${inh("backend", f.backend, "#g-be", "backend")} />` : null}</div>
299
303
  <div class="field">${L(tn("model"), "instance.model", "ag-model")}<${ModelSelect} id="ag-model" value=${f.model} onChange=${set("model")}
300
304
  backend=${f.backend || defaults.backend || "claude-code"} instance=${name} inherit=${tn("inheritValue", defaults.model || tn("modelCliDefault"))} /></div>
305
+ ${(f.backend || defaults.backend || "claude-code") === "codex" ? html`<div class="field codex-speed">${L(tn("codexSpeed"), "instance.backend_options", "ag-speed")}
306
+ <${Select} id="ag-speed" value=${f.codex_speed} onChange=${set("codex_speed")}
307
+ options=${CODEX_SPEED_CHOICES.map((v) => ({ value: v, label: tn(v === "fast" ? "codexSpeedFast" : v === "standard" ? "codexSpeedStandard" : "codexSpeedDefault") }))} />
308
+ <p class="note">${tn("codexSpeedHint")}</p></div>` : null}
301
309
  </div>
302
310
  <div class="field">${L(tn("workingDir"), "instance.working_directory", "ag-wd")}<input id="ag-wd" type="text" value=${f.working_directory} onInput=${(e) => set("working_directory")(e.target.value)} /></div>
303
311
  <div class="field">${L(`${tn("autoPause")} (${tn("minutes")})`, "instance.auto_pause_after", "ag-auto")}
@@ -167,6 +167,11 @@ export function groupAgents(instances, live, filter, groupFor = (n) => groupOf(i
167
167
  * (the key is removed); the visibility baseline is what the picker showed, so a value it cannot show (a typo, read as
168
168
  * unset) is never rewritten by an unrelated edit (#1302).
169
169
  */
170
+ /** The agent's own Codex speed, as the picker shows it ("" when unset or not one AgEnD knows). */
171
+ export function codexSpeedOf(inst) {
172
+ const v = inst && inst.backend_options && inst.backend_options.codex && inst.backend_options.codex.speed;
173
+ return v === "fast" || v === "standard" ? v : "";
174
+ }
170
175
  export function agentPatch(form, inst, defaults, visibilityBaseline, statusEmojisBaseline) {
171
176
  const hang = inst.hang_detector || {};
172
177
  const next = {
@@ -190,7 +195,7 @@ export function agentPatch(form, inst, defaults, visibilityBaseline, statusEmoji
190
195
  office_access: separateChoice(form.dashboard.office),
191
196
  office_pack_access: separateChoice(form.dashboard.office_packs),
192
197
  };
193
- return changedFields(next, {
198
+ return Object.assign(changedFields(next, {
194
199
  backend: inst.backend || defaults.backend || "claude-code",
195
200
  model: (inst.model && inst.model.trim()) || null,
196
201
  working_directory: inst.working_directory || "",
@@ -214,7 +219,15 @@ export function agentPatch(form, inst, defaults, visibilityBaseline, statusEmoji
214
219
  dashboard: dashboardPatchValue(dashboardForm(inst)),
215
220
  office_access: separateChoice(dashboardForm(inst).office),
216
221
  office_pack_access: separateChoice(dashboardForm(inst).office_packs),
217
- });
222
+ }), codexSpeedPatch(form, inst));
223
+ }
224
+ /**
225
+ * M-2446: only the Codex speed key, merged per backend by the server (settings-instance-patch.ts), so the agent's other
226
+ * backend options are kept; back to "" removes it (null). Nothing when it did not change.
227
+ */
228
+ function codexSpeedPatch(form, inst) {
229
+ const next = form.codex_speed || "";
230
+ return next === codexSpeedOf(inst) ? {} : { backend_options: { codex: { speed: next || null } } };
218
231
  }
219
232
 
220
233
  /** The agent form's starting values (an override shows the inherited value while "inherit" is on). */
@@ -245,6 +258,7 @@ export function agentForm(inst, defaults, name = "") {
245
258
  display_name: inst.display_name ?? defaults.display_name ?? "",
246
259
  status_emojis: inst.status_emojis || null,
247
260
  dashboard: dashboardForm(inst),
261
+ codex_speed: codexSpeedOf(inst),
248
262
  inherit: {
249
263
  auto_pause_after: inherit("auto_pause_after"), hang: !hasOwn(hang, "timeout_minutes"), agent_mode: inherit("agent_mode"),
250
264
  tool_set: inherit("tool_set"), tool_progress: inherit("tool_progress"), cross_instance_visibility: inherit("cross_instance_visibility"),
@@ -166,7 +166,7 @@ register("settings", {
166
166
  failed: "Failed",
167
167
  actionFailed: "{0} failed",
168
168
  noAgentMatch: "No agents match \"{0}\".",
169
- deleteAgent: "Delete agent \"{0}\"? This will be applied only after you confirm the pending changes.",
169
+ deleteAgent: "Delete agent \"{0}\" and its chat topic (on Discord, its channel and history)? This will be applied only after you confirm the pending changes.",
170
170
  nameRequired: "name required",
171
171
  tokenEnvRequired: "token env required",
172
172
  removeBot: "Remove bot \"{0}\"? (the token in .env is left untouched)",
@@ -823,7 +823,7 @@ register("settings", {
823
823
  failed: "失敗",
824
824
  actionFailed: "{0}失敗",
825
825
  noAgentMatch: "沒有符合「{0}」的 Agent。",
826
- deleteAgent: "確定刪除 Agent「{0}」嗎?只有在確認套用待處理變更後才會生效。",
826
+ deleteAgent: "確定刪除 Agent「{0}」及它的聊天 topic(在 Discord 上是它的頻道與訊息記錄)嗎?只有在確認套用待處理變更後才會生效。",
827
827
  nameRequired: "必須填寫名稱",
828
828
  tokenEnvRequired: "必須填寫 token 環境變數",
829
829
  removeBot: "確定移除機器人「{0}」嗎?(.env 中的 token 會保留)",
@@ -111,7 +111,7 @@ register("app", {
111
111
  confirmTitle: "Please confirm", confirmOk: "Continue", cancel: "Cancel",
112
112
  sidePanelLeave: "Leave {0} without saving? Your changes there will be lost.", sidePanelDiscard: "Discard changes",
113
113
  // The server's own wording (src/locale.ts needs.reason.*), so the web says what Discord says.
114
- needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_question: "Asking you a question", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_kiro_choice: "Kiro needs your decision", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_backend_signin: "Backend sign-in needed", needs_reason_config_invalid: "Configuration file needs attention", needs_reason_config_fallback: "Running on stored configuration", config_history_edit: "{0}, line {1}, column {2}: {3}. AgEnD is still running on the previous settings. The next restart would use the stored valid copy from {4}. Correct the YAML error; the file has not been changed by AgEnD.", config_history_no_history: "{0}, line {1}, column {2}: {3}. AgEnD is still running on the previous settings. No stored valid copy is available, so the next restart cannot recover automatically. Correct the YAML error before restarting; the file has not been changed by AgEnD.", config_history_fallback: "{0}, line {1}, column {2}: {3}. AgEnD started using the stored valid copy from {4}. Correct the YAML error and reload the settings. The file on disk has not been changed by AgEnD.", needs_reason_crashed: "Crashed", needs_reason_usage_limit: "Usage limit reached · reset time unknown", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed", needs_reason_task_review: "Waiting for your confirmation", needs_reason_task_blocked: "Blocked: asks you",
114
+ needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_question: "Asking you a question", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_kiro_choice: "Kiro needs your decision", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_backend_signin: "Backend sign-in needed", needs_reason_config_invalid: "Configuration file needs attention", needs_reason_config_fallback: "Running on stored configuration", config_history_edit: "{0}, line {1}, column {2}: {3}. AgEnD is still running on the previous settings. The next restart would use the stored valid copy from {4}. Correct the YAML error; the file has not been changed by AgEnD.", config_history_no_history: "{0}, line {1}, column {2}: {3}. AgEnD is still running on the previous settings. No stored valid copy is available, so the next restart cannot recover automatically. Correct the YAML error before restarting; the file has not been changed by AgEnD.", config_history_fallback: "{0}, line {1}, column {2}: {3}. AgEnD started using the stored valid copy from {4}. Correct the YAML error and reload the settings. The file on disk has not been changed by AgEnD.", needs_reason_crashed: "Crashed", needs_reason_usage_limit: "Usage limit reached · reset time unknown", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed", needs_reason_missed_offline: "Missed while AgEnD was offline", needs_reason_task_review: "Waiting for your confirmation", needs_reason_task_blocked: "Blocked: asks you",
115
115
  },
116
116
  "zh-TW": {
117
117
  brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet 概覽", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance", newAgent: "新增 Agent",
@@ -156,6 +156,6 @@ register("app", {
156
156
  search: "搜尋", searchTitle: "搜尋聊天、agent 之間的訊息與任務({0})",
157
157
  confirmTitle: "請確認", confirmOk: "繼續", cancel: "取消",
158
158
  sidePanelLeave: "不儲存就離開「{0}」?你在那裡的修改會遺失。", sidePanelDiscard: "捨棄修改",
159
- needs_reason_hang: "沒有回應", needs_reason_exited: "已結束", needs_reason_assist: "在終端機上等待", needs_reason_question: "正在問你問題", needs_reason_permission: "需要權限", needs_reason_dangerous_command: "危險指令待確認", needs_reason_login: "需要登入", needs_reason_dialog: "停在對話框", needs_reason_kiro_choice: "Kiro 需要你決定", needs_reason_terminal_input: "可能在等輸入", needs_reason_auth_paused: "已暫停:需要登入", needs_reason_backend_signin: "後端需要登入", needs_reason_config_invalid: "設定檔需要處理", needs_reason_config_fallback: "正在使用儲存的設定副本", config_history_edit: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 仍使用先前的設定執行。下次重新啟動會改用 {4} 儲存的有效副本。請修正 YAML 錯誤;AgEnD 未更動此檔案。", config_history_no_history: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 仍使用先前的設定執行。沒有可用的有效副本,下次重新啟動將無法自動恢復。請在重新啟動前修正 YAML 錯誤;AgEnD 未更動此檔案。", config_history_fallback: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 已使用 {4} 儲存的有效副本啟動。請修正 YAML 錯誤並重新載入設定。AgEnD 未更動磁碟上的檔案。", needs_reason_crashed: "已崩潰", needs_reason_usage_limit: "用量已達上限 · 恢復時間未知", needs_reason_delivery_uncertain: "無法確認是否送達", needs_reason_delivery_failed: "傳送失敗", needs_reason_task_review: "等你確認", needs_reason_task_blocked: "卡住,在問你",
159
+ needs_reason_hang: "沒有回應", needs_reason_exited: "已結束", needs_reason_assist: "在終端機上等待", needs_reason_question: "正在問你問題", needs_reason_permission: "需要權限", needs_reason_dangerous_command: "危險指令待確認", needs_reason_login: "需要登入", needs_reason_dialog: "停在對話框", needs_reason_kiro_choice: "Kiro 需要你決定", needs_reason_terminal_input: "可能在等輸入", needs_reason_auth_paused: "已暫停:需要登入", needs_reason_backend_signin: "後端需要登入", needs_reason_config_invalid: "設定檔需要處理", needs_reason_config_fallback: "正在使用儲存的設定副本", config_history_edit: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 仍使用先前的設定執行。下次重新啟動會改用 {4} 儲存的有效副本。請修正 YAML 錯誤;AgEnD 未更動此檔案。", config_history_no_history: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 仍使用先前的設定執行。沒有可用的有效副本,下次重新啟動將無法自動恢復。請在重新啟動前修正 YAML 錯誤;AgEnD 未更動此檔案。", config_history_fallback: "{0} 第 {1} 行、第 {2} 欄:{3}。AgEnD 已使用 {4} 儲存的有效副本啟動。請修正 YAML 錯誤並重新載入設定。AgEnD 未更動磁碟上的檔案。", needs_reason_crashed: "已崩潰", needs_reason_usage_limit: "用量已達上限 · 恢復時間未知", needs_reason_delivery_uncertain: "無法確認是否送達", needs_reason_delivery_failed: "傳送失敗", needs_reason_missed_offline: "AgEnD 離線時錯過", needs_reason_task_review: "等你確認", needs_reason_task_blocked: "卡住,在問你",
160
160
  },
161
161
  });
@@ -816,6 +816,10 @@ img.amb-p { object-fit: contain; }
816
816
  .pv-panel-body { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0 var(--s-3) var(--s-3); }
817
817
  .pv-panel-body .pv-holder { flex: 1; min-height: 0; margin: 0; display: flex; flex-direction: column; }
818
818
  .pv-panel-body.show-code .pv-holder { display: none; }
819
+ /* M-2448: a Markdown file in the panel — the chat's Markdown look, scrolling inside the panel (never the page). */
820
+ .pv-code.pv-md { font-family: var(--font-ui); font-size: var(--fs-md); line-height: 1.6; white-space: normal; padding: var(--s-3) var(--s-4); }
821
+ .pv-md .md { max-width: 860px; overflow-wrap: anywhere; }
822
+ .pv-md .tc-pre { white-space: pre-wrap; font-family: var(--font-mono); font-size: var(--fs-xs); }
819
823
  .preview-frame.fill { flex: 1; height: 100%; min-height: 0; }
820
824
  .pv-code { flex: 1; min-height: 0; margin: 0; overflow: auto; font-family: var(--font-mono); font-size: var(--fs-xs); line-height: 1.5;
821
825
  background: var(--surface); border-radius: var(--r-sm); padding: var(--s-3); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); }
@@ -1392,6 +1396,11 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1392
1396
  .u-stale { font-weight: var(--fw-semibold); }
1393
1397
  .u-stale-detail { color: var(--text-3); font-size: var(--fs-xs); }
1394
1398
  .u-sub { color: var(--text-3); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
1399
+ /* #3066 §1: a weekly quota's measured 100% under its bar, and its notes (the fleet-only limit always). */
1400
+ .u-cap { margin-top: 4px; font-size: var(--fs-xs); }
1401
+ .u-cap-line { color: var(--text-2); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
1402
+ .u-cap-wait .u-cap-line { color: var(--text-3); }
1403
+ .u-cap-note { color: var(--text-3); overflow-wrap: anywhere; }
1395
1404
  .u-err { color: var(--warn); font-size: var(--fs-sm); }
1396
1405
  .u-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); }
1397
1406
  /* #1673: the usage rail beside the page on a wide screen (header-tools.js UsageRail; shown only at min-width 1200px). */
@@ -2111,6 +2120,16 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
2111
2120
  .us-meta { display: block; font-size: var(--fs-xs); color: var(--text-3); overflow-wrap: anywhere; }
2112
2121
  .us-est { display: block; font-size: var(--fs-xs); font-weight: var(--fw-regular); color: var(--text-3); }
2113
2122
  .us-flags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
2123
+ /* #3066 §3: a row's numbers carry small marks (◐ partly priced, † the CLI's amount, ≈ estimated) and "—" with a reason,
2124
+ each said in its tooltip; the key under the table names only the marks that appear. */
2125
+ .us-mark { margin-left: 2px; color: var(--text-3); font-size: var(--fs-xs); cursor: help; }
2126
+ .us-mark.us-approx { margin-left: 0; margin-right: 1px; }
2127
+ .us-none { cursor: help; }
2128
+ .us-key { margin-top: 6px; }
2129
+ /* #3066 §2: each model's speed after the first token. */
2130
+ .us-speed { margin-top: 12px; }
2131
+ .us-speed .row-item { gap: 8px; flex-wrap: wrap; }
2132
+ .us-speed .num { font-variant-numeric: tabular-nums; }
2114
2133
  .us-trend, .us-heat { display: flex; flex-direction: column; gap: var(--s-2); min-width: 0; }
2115
2134
  .us-trend h3, .us-heat h3, .us-rest h3 { font-size: var(--fs-sm); font-weight: var(--fw-semibold); }
2116
2135
  .us-scroll { overflow-x: auto; max-width: 100%; }