@songsid/agend 2.3.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/backend/muse.d.ts +20 -2
  4. package/dist/backend/muse.js +154 -16
  5. package/dist/backend/muse.js.map +1 -1
  6. package/dist/channel/adapters/telegram.d.ts +1 -1
  7. package/dist/channel/adapters/telegram.js +3 -35
  8. package/dist/channel/adapters/telegram.js.map +1 -1
  9. package/dist/cli.js +6 -3
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config-validator.js +2 -2
  12. package/dist/config-validator.js.map +1 -1
  13. package/dist/daemon.d.ts +13 -0
  14. package/dist/daemon.js +155 -16
  15. package/dist/daemon.js.map +1 -1
  16. package/dist/delivery-outbox.d.ts +11 -0
  17. package/dist/delivery-outbox.js +47 -2
  18. package/dist/delivery-outbox.js.map +1 -1
  19. package/dist/fleet-context.d.ts +1 -1
  20. package/dist/fleet-lock.d.ts +6 -0
  21. package/dist/fleet-lock.js +19 -0
  22. package/dist/fleet-lock.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +34 -2
  24. package/dist/fleet-manager.js +264 -39
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +11 -0
  27. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +3 -2
  28. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +3 -2
  29. package/dist/instance-lifecycle.d.ts +1 -0
  30. package/dist/instance-lifecycle.js +11 -0
  31. package/dist/instance-lifecycle.js.map +1 -1
  32. package/dist/locale.js +24 -2
  33. package/dist/locale.js.map +1 -1
  34. package/dist/log-service.js +1 -0
  35. package/dist/log-service.js.map +1 -1
  36. package/dist/needs-you.js +1 -0
  37. package/dist/needs-you.js.map +1 -1
  38. package/dist/outbound-handlers.d.ts +2 -2
  39. package/dist/outbound-handlers.js +5 -5
  40. package/dist/outbound-handlers.js.map +1 -1
  41. package/dist/search/search-index.d.ts +10 -2
  42. package/dist/search/search-index.js +17 -4
  43. package/dist/search/search-index.js.map +1 -1
  44. package/dist/service-installer.d.ts +23 -1
  45. package/dist/service-installer.js +40 -11
  46. package/dist/service-installer.js.map +1 -1
  47. package/dist/session-oom-stop.d.ts +45 -0
  48. package/dist/session-oom-stop.js +259 -0
  49. package/dist/session-oom-stop.js.map +1 -0
  50. package/dist/session-oom.d.ts +13 -0
  51. package/dist/session-oom.js +119 -7
  52. package/dist/session-oom.js.map +1 -1
  53. package/dist/settings-api.d.ts +1 -1
  54. package/dist/settings-api.js +1 -1
  55. package/dist/settings-api.js.map +1 -1
  56. package/dist/spawn-gate.d.ts +2 -0
  57. package/dist/spawn-gate.js +17 -4
  58. package/dist/spawn-gate.js.map +1 -1
  59. package/dist/start-attempt.d.ts +11 -1
  60. package/dist/start-attempt.js +16 -5
  61. package/dist/start-attempt.js.map +1 -1
  62. package/dist/start-failure-chat.d.ts +37 -0
  63. package/dist/start-failure-chat.js +142 -0
  64. package/dist/start-failure-chat.js.map +1 -0
  65. package/dist/start-failure.d.ts +24 -0
  66. package/dist/start-failure.js +68 -1
  67. package/dist/start-failure.js.map +1 -1
  68. package/dist/telegram-api-root.d.ts +1 -0
  69. package/dist/telegram-api-root.js +40 -0
  70. package/dist/telegram-api-root.js.map +1 -0
  71. package/dist/types.d.ts +3 -2
  72. package/dist/ui/panel-chat.js +19 -6
  73. package/dist/ui/panel-log.js +3 -3
  74. package/dist/ui/panel-settings.js +8 -1
  75. package/dist/ui/settings-strings.js +6 -0
  76. package/dist/ui/shared/app-shell.js +21 -4
  77. package/dist/ui/shared/app.css +9 -3
  78. package/dist/ui/shared/usage-dialog.js +21 -11
  79. package/dist/ui/shared/view-strings.js +2 -0
  80. package/dist/web-api.d.ts +1 -1
  81. package/dist/web-api.js +1 -1
  82. package/dist/web-api.js.map +1 -1
  83. package/dist/working-state-journal.d.ts +68 -0
  84. package/dist/working-state-journal.js +93 -0
  85. package/dist/working-state-journal.js.map +1 -0
  86. package/package.json +1 -1
@@ -648,7 +648,13 @@ function Composer({ name, label = name, lease, busy }) {
648
648
  const [, tick] = useState(0);
649
649
  const [sel, setSel] = useState(0);
650
650
  const [closedFor, setClosedFor] = useState(null); // the draft the palette was closed on (Esc); it opens again on change
651
+ // An IME composing (Zhuyin, Pinyin, kana…): between compositionstart and compositionend the box is the IME's. Nothing
652
+ // here touches it — no draft update, no re-render, no auto-grow (each one moved the box, and iOS redrew the candidate
653
+ // bar on every keystroke: the user's flicker). What it holds is taken once, at compositionend.
654
+ const composing = useRef(false);
651
655
  const value = s.drafts[name] || "";
656
+ // A render while composing (another part of the chat changed) gives the box what it holds, so it never writes it.
657
+ const boxValue = composing.current && input.current ? input.current.value : value;
652
658
  const files = s.pendingFiles[name] || [];
653
659
  const reply = s.replies[name]; // #2272: the message this one will reply to
654
660
  const content = !!value.trim() || files.length > 0;
@@ -660,7 +666,7 @@ function Composer({ name, label = name, lease, busy }) {
660
666
  // Grow with the text, up to a limit: set through the CSSOM, never a style attribute (#1300). Empty, it is one line
661
667
  // (#2478 — a long name's placeholder made the box two lines tall).
662
668
  useLayoutEffect(() => {
663
- const el = input.current; if (!el) return;
669
+ const el = input.current; if (!el || composing.current) return;
664
670
  el.style.height = "auto";
665
671
  if (value) el.style.height = `${Math.min(el.scrollHeight, 240)}px`;
666
672
  }, [value]);
@@ -700,7 +706,11 @@ function Composer({ name, label = name, lease, busy }) {
700
706
  if (reply && e.key === "Escape" && !e.isComposing) { e.preventDefault(); store.clearReply(name); return; } // #2272
701
707
  if (R().composerKey(e, { touch: touchScreen() }) === "send") { e.preventDefault(); submit(); }
702
708
  };
703
- const onInput = (e) => { setValue(e.target.value); };
709
+ const onInput = (e) => { if (composing.current || e.isComposing) return; setValue(e.target.value); };
710
+ const onCompositionStart = () => { composing.current = true; };
711
+ // The composed text is the draft now (some engines send no input after it); a blur ends one that never ended.
712
+ // Lower-case props below: an element has no oncomposition… property, so Preact would listen for "CompositionStart".
713
+ const onCompositionEnd = (e) => { if (!composing.current) return; composing.current = false; setValue(e.target.value); };
704
714
  // Files in a paste are attached; a very long text paste goes as a text file — only once it is really attached.
705
715
  const onPaste = (e) => {
706
716
  const list = e.clipboardData && e.clipboardData.files;
@@ -727,11 +737,14 @@ function Composer({ name, label = name, lease, busy }) {
727
737
  <input ref=${fileIn} type="file" multiple hidden
728
738
  onChange=${(e) => { store.addFiles(name, e.target.files); e.target.value = ""; }} />
729
739
  ${/* #2478: the placeholder is drawn beside the box, one line ending in "…" — a textarea's own placeholder is clipped,
730
- never ellipsized, and a long name wrapped it to two lines. The box keeps the words as its accessible name. */ ""}
731
- <span class="composer-field"><textarea id="msgIn" ref=${input} rows="1" value=${value} aria-label=${t("chat.composerPlaceholder", label)}
740
+ never ellipsized, and a long name wrapped it to two lines. The box keeps the words as its accessible name. Its own
741
+ placeholder is one space: :placeholder-shown hides the drawn one (app.css) the moment the box holds text, an
742
+ IME's composing text included, with no change to the page. */ ""}
743
+ <span class="composer-field"><textarea id="msgIn" ref=${input} rows="1" value=${boxValue} aria-label=${t("chat.composerPlaceholder", label)}
732
744
  enterkeyhint=${touchScreen() ? "enter" : undefined} autocomplete="off" aria-controls=${palette ? "cmdPalette" : undefined} aria-expanded=${palette ? "true" : "false"}
733
- aria-activedescendant=${pick ? optId(pick) : undefined} onInput=${onInput} onKeyDown=${onKeyDown} onPaste=${onPaste}></textarea>${value ? null
734
- : html`<span class="composer-ph" aria-hidden="true">${t("chat.composerPlaceholder", label)}</span>`}</span>
745
+ aria-activedescendant=${pick ? optId(pick) : undefined} onInput=${onInput} onKeyDown=${onKeyDown} onPaste=${onPaste}
746
+ oncompositionstart=${onCompositionStart} oncompositionend=${onCompositionEnd} onBlur=${onCompositionEnd}
747
+ placeholder=" "></textarea><span class="composer-ph" aria-hidden="true">${t("chat.composerPlaceholder", label)}</span></span>
735
748
  <button id="stopBtn" type="button" class="btn btn-stop" hidden=${!busy} title=${t("chat.stopReplyTitle")}
736
749
  disabled=${!!s.cancelling[name]} onClick=${() => store.cancelReply(name)}><${Icon} name="stop" size=${14} /><span class="stop-label">${t("chat.stopReply")}</span></button>
737
750
  <button id="sendBtn" type="button" class="btn btn-primary btn-send" hidden=${busy && !content} disabled=${!content}
@@ -62,7 +62,7 @@ register("log", {
62
62
  ev_backend_unreachable: "CLI unreachable", ev_startup_backend_unreachable: "CLI unreachable at start", ev_backend_launch_warning: "Launch warning",
63
63
  ev_clear_action: "Conversation cleared", ev_normal_exit_action: "Exited", ev_reply_drop_detected: "A reply went missing",
64
64
  ev_reply_drop_recovered: "Missing reply recovered", ev_reply_drop_unrecovered: "Missing reply not recovered",
65
- ev_supervision_ended: "Supervision ended", ev_tmux_server_crash: "tmux crashed", ev_malformed_tool_call: "Malformed tool call", ev_health_check_error: "Health check error",
65
+ ev_supervision_ended: "Supervision ended", ev_tmux_server_crash: "tmux crashed", ev_malformed_tool_call: "Malformed tool call", ev_health_check_error: "Health check error", ev_working_state: "Working-state journal",
66
66
  },
67
67
  "zh-TW": {
68
68
  offTitle: "紀錄未開啟",
@@ -100,7 +100,7 @@ register("log", {
100
100
  ev_backend_unreachable: "連不到 CLI", ev_startup_backend_unreachable: "啟動時連不到 CLI", ev_backend_launch_warning: "啟動警告",
101
101
  ev_clear_action: "對話已清除", ev_normal_exit_action: "已結束", ev_reply_drop_detected: "有回覆遺失",
102
102
  ev_reply_drop_recovered: "遺失的回覆已補回", ev_reply_drop_unrecovered: "遺失的回覆沒有補回",
103
- ev_supervision_ended: "監督結束", ev_tmux_server_crash: "tmux 當掉", ev_malformed_tool_call: "格式錯誤的工具呼叫", ev_health_check_error: "健康檢查錯誤",
103
+ ev_supervision_ended: "監督結束", ev_tmux_server_crash: "tmux 當掉", ev_malformed_tool_call: "格式錯誤的工具呼叫", ev_health_check_error: "健康檢查錯誤", ev_working_state: "工作狀態紀錄",
104
104
  },
105
105
  });
106
106
  const tl = (key, ...a) => t(`log.${key}`, ...a);
@@ -179,7 +179,7 @@ export function timeline(steps, events) {
179
179
  return out;
180
180
  }
181
181
 
182
- const isError = (x) => x.kind === "error" || x.kind === "event" || x.status === "failed" || x.status === "interrupted";
182
+ const isError = (x) => x.kind === "error" || (x.kind === "event" && x.type !== "working_state") || x.status === "failed" || x.status === "interrupted";
183
183
  /** Consecutive identical finished tool steps (same tool, subject and status) as one row with a count: a loop shows. */
184
184
  export function collapse(items) {
185
185
  const out = [];
@@ -811,7 +811,7 @@ function General({ ctx }) {
811
811
  */
812
812
  export const WEB_READ_ONLY = [
813
813
  "web.allowed_hosts", "web.view_access", "web.notify_login", "web.preview", "web.preview_port", "web.preview_origin", "web.preview_on_public_link",
814
- "web.usage_panel", "web.provider_secrets", "web.upload_max_file_mb", "web.upload_max_total_mb", "web_terminal.bind", "web_terminal.tunnel",
814
+ "web.usage_panel", "web.search", "web.provider_secrets", "web.upload_max_file_mb", "web.upload_max_total_mb", "web_terminal.bind", "web_terminal.tunnel",
815
815
  ];
816
816
  function WebSection({ ctx, staged }) {
817
817
  const { schema } = ctx;
@@ -907,6 +907,12 @@ function WebSection({ ctx, staged }) {
907
907
  useEffect(() => { if (!formMounted.current) { formMounted.current = true; return; } review(true); }, [f]);
908
908
  const shown = (path) => {
909
909
  const [top, key] = path.split(".");
910
+ if (top === "web" && key === "search") {
911
+ const days = web.search && typeof web.search === "object" ? web.search.retention_days : undefined;
912
+ if (days === undefined || days === null) return tn("searchRetentionUnset");
913
+ if (days === 0) return tn("searchRetentionZero");
914
+ return String(days);
915
+ }
910
916
  const v = (top === "web" ? web : term)[key];
911
917
  return v === undefined ? tn("notSet") : typeof v === "object" ? JSON.stringify(v) : String(v);
912
918
  };
@@ -958,6 +964,7 @@ function WebSection({ ctx, staged }) {
958
964
  <section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
959
965
  <p class="note">${tn("webReadOnlyLead")}</p>
960
966
  <dl class="s-web-ro">${WEB_READ_ONLY.map((k) => html`<div key=${k}><dt><code>${k}</code></dt><dd>${shown(k)}</dd></div>`)}</dl>
967
+ <p class="note">${tn("webSearchHint")}</p>
961
968
  </section>
962
969
  <div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${() => review()}>${tn("reviewChanges")}</button>
963
970
  ${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
@@ -530,6 +530,9 @@ register("settings", {
530
530
  vaRevoke: "Revoke web authority", vaRevokeTitle: "Revoke all web authority?", vaRevokeConfirm: "Every authority given from the web so far reads as none from now on, and every session's authorization ends. Nobody is signed out: a session carries authority again once it is authorized again.",
531
531
  vaRevoked: "Web authority given so far is revoked.", vaRevokedNotDurable: "Revoked for now, but it could not be saved: web authority stays off until it can be, and a restart before then undoes the revocation.", vaRevokeFailed: "That did not work — try again.",
532
532
  webReadOnlyLead: "Read-only here: these decide what the dashboard listens on and exposes. Edit fleet.yaml, then run agend reload (web.preview_port and web.preview_origin take a restart of AgEnD).",
533
+ searchRetentionUnset: "not set (no age limit; 300,000-row cap)",
534
+ searchRetentionZero: "0 (no age limit; 300,000-row cap)",
535
+ webSearchHint: "Global search keeps entries with no age limit by default. The daily prune still caps the index at 300,000 rows (oldest first). Set web.search.retention_days in fleet.yaml (1–3650 days, or 0 for no age limit); it takes effect at the next fleet start.",
533
536
  notSet: "not set (default)",
534
537
  defaultsTitle: "Defaults",
535
538
  classicBackend: "ClassicBot default backend",
@@ -1139,6 +1142,9 @@ register("settings", {
1139
1142
  vaRevoke: "撤銷網頁授權", vaRevokeTitle: "撤銷所有網頁授權?", vaRevokeConfirm: "到目前為止從網頁給出的授權,從現在起都視為無,所有 session 的授權也會結束。不會登出任何人:session 再授權一次後,送出的訊息會再帶有授權。",
1140
1143
  vaRevoked: "已撤銷到目前為止的網頁授權。", vaRevokedNotDurable: "已暫時撤銷,但無法保存:在可以保存之前網頁授權會保持關閉,而在那之前重新啟動會讓這次撤銷失效。", vaRevokeFailed: "沒有成功,請再試一次。",
1141
1144
  webReadOnlyLead: "這裡僅供檢視:這些設定決定儀表板監聽和公開的內容。請編輯 fleet.yaml 後執行 agend reload(web.preview_port 與 web.preview_origin 需要重新啟動 AgEnD)。",
1145
+ searchRetentionUnset: "未設定(不限天數;上限 30 萬筆)",
1146
+ searchRetentionZero: "0(不限天數;上限 30 萬筆)",
1147
+ webSearchHint: "全域搜尋預設不限天數。每日清理仍會把索引上限維持在 30 萬筆(先刪最舊的)。在 fleet.yaml 設定 web.search.retention_days(1–3650 天,或 0 代表不限天數),下次啟動 fleet 後生效。",
1142
1148
  notSet: "未設定(預設)",
1143
1149
  defaultsTitle: "預設值",
1144
1150
  classicBackend: "ClassicBot 預設 backend",
@@ -679,9 +679,16 @@ export function Shell({ panels, onNewInstance, viewOnly = false }) {
679
679
  // Only a touch screen (a coarse pointer) has an on-screen keyboard: a narrow desk window made shorter is not one.
680
680
  // Turned with the keyboard up and the field still focused, the new width has no height without it yet: the keyboard
681
681
  // stays up until the field is left, or the view grows from its lowest by more than a keyboard (put away).
682
- let width = -1, tallest = 0, held = false, lowest = 0;
682
+ // Once up, the keyboard stays up while the field keeps focus: an IME's candidate bar (or the predictive row) coming and
683
+ // going moves the view by tens of px on every keystroke, and must never take the tabs out and back. Only leaving the
684
+ // field, or the view growing from its lowest by more than a keyboard (put away), takes it down. While an IME composes
685
+ // nothing changes at all; what the view says then is applied at compositionend — or as soon as the field it composes
686
+ // in has lost focus or left the page (a composition can end without its compositionend: #2517 review).
687
+ let width = -1, tallest = 0, held = false, lowest = 0, upLowest = 0, composing = null;
683
688
  const coarse = () => typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;
684
689
  const check = () => {
690
+ if (composing && composing.isConnected !== false && document.activeElement === composing) return;
691
+ composing = null;
685
692
  const w = Math.round(vv.width), h = vv.height;
686
693
  const typing = coarse() && isTextEntry(document.activeElement);
687
694
  if (w !== width) { // turned, or a new width: measured again
@@ -693,14 +700,24 @@ export function Shell({ panels, onNewInstance, viewOnly = false }) {
693
700
  if (!typing || h - lowest > KEYBOARD_MIN_PX) held = false; // left the field, or the keyboard was put away
694
701
  }
695
702
  if (!held) tallest = Math.max(tallest, window.innerHeight || 0, typing ? 0 : h); // the view itself only when not typing
696
- const up = held || keyboardUp(h, tallest, typing);
697
- if (up !== shellStore.get().keyboard) shellStore.set({ keyboard: up });
703
+ const was = shellStore.get().keyboard;
704
+ let up = held || keyboardUp(h, tallest, typing);
705
+ if (!up && was && typing && h - upLowest <= KEYBOARD_MIN_PX) up = true; // a bar's height, not the keyboard
706
+ upLowest = up ? (was ? Math.min(upLowest, h) : h) : 0;
707
+ if (up !== was) shellStore.set({ keyboard: up });
698
708
  };
709
+ const start = (e) => { composing = (e && e.target) || document.activeElement || null; };
710
+ const end = () => { composing = null; check(); };
699
711
  check();
700
712
  vv.addEventListener("resize", check);
701
713
  document.addEventListener("focusin", check);
702
714
  document.addEventListener("focusout", check);
703
- return () => { vv.removeEventListener("resize", check); document.removeEventListener("focusin", check); document.removeEventListener("focusout", check); };
715
+ document.addEventListener("compositionstart", start, true);
716
+ document.addEventListener("compositionend", end, true);
717
+ return () => {
718
+ vv.removeEventListener("resize", check); document.removeEventListener("focusin", check); document.removeEventListener("focusout", check);
719
+ document.removeEventListener("compositionstart", start, true); document.removeEventListener("compositionend", end, true);
720
+ };
704
721
  }, []);
705
722
  const guest = guestPage(); // #1920: a guest's page — no operations, usage, pending changes or owner CSS
706
723
  const cls = ["shell", shell.collapsed ? "sb-collapsed" : "", shell.drawer ? "sb-open" : "", shell.keyboard ? "kb-open" : "", guest ? "guest" : ""].filter(Boolean).join(" ");
@@ -791,6 +791,7 @@ img.amb-p { object-fit: contain; }
791
791
  .composer-field { position: relative; flex: 1; min-width: 0; display: flex; }
792
792
  .composer-ph { position: absolute; left: var(--s-1); right: var(--s-1); top: 7px; pointer-events: none; color: var(--text-3);
793
793
  font-size: var(--fs-md); line-height: var(--lh-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
794
+ .composer-field textarea:not(:placeholder-shown) + .composer-ph { display: none; } /* text in the box, composing text too */
794
795
  .btn-send { width: 36px; padding: 0; }
795
796
  .btn-send .send-label { display: none; }
796
797
  /* A narrow phone: Stop reply beside Send leaves the message box no room — the label is for screen readers there. */
@@ -1191,11 +1192,14 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1191
1192
  .u-crow-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: var(--fw-semibold); color: var(--text); }
1192
1193
  .u-crow .u-plan { flex: 0 100 auto; min-width: 22px; max-width: max-content; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1193
1194
  padding-block: 0; line-height: 16px; font-size: 10.5px; }
1194
- .u-crow-reset { margin-left: auto; flex: none; padding-left: 6px; font-size: var(--fs-xs); color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
1195
- .u-cm { display: grid; grid-template-columns: 2.6em minmax(0, 1fr) 3.4em; align-items: center; gap: 7px; min-width: 0; }
1195
+ /* Last known numbers: a small muted "cached at …" at the name line's end (why, in its tooltip). */
1196
+ .u-crow-stale { margin-left: auto; flex: none; padding-left: 6px; font-size: var(--fs-xs); color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
1197
+ .u-cm { display: grid; grid-template-columns: 2.2em minmax(0, 1fr) 2.9em 3.7em; align-items: center; gap: 5px; min-width: 0; }
1198
+ /* Each window's own reset, after its percent: muted, tabular, right-aligned (an empty cell keeps the columns lined up). */
1199
+ .u-cm-reset { text-align: right; font-size: var(--fs-xs); color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums; overflow: hidden; }
1196
1200
  .u-cm-tag { font-size: var(--fs-xs); color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1197
1201
  /* #2240: an account of several pools (agy) names each pool's line: a wider label, ending in an ellipsis if it must. */
1198
- .u-crow.is-pooled .u-cm { grid-template-columns: minmax(0, 7.5em) minmax(0, 1fr) 3.4em; }
1202
+ .u-crow.is-pooled .u-cm { grid-template-columns: minmax(0, 7.0em) minmax(0, 1fr) 2.9em 3.7em; column-gap: 7px; }
1199
1203
  .u-cm-bar { height: 6px; border-radius: var(--r-full); background: var(--surface-2); overflow: hidden; }
1200
1204
  .u-cm-fill { display: block; height: 100%; width: 0; min-width: 2px; border-radius: var(--r-full); background: var(--accent); }
1201
1205
  .u-cm-fill.warn { background: var(--warn); } .u-cm-fill.crit { background: var(--danger); }
@@ -1204,6 +1208,8 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1204
1208
  .u-crow-note { font-size: var(--fs-xs); color: var(--text-3); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1205
1209
  .u-crow-note.is-alert::before { content: "●"; margin-right: 5px; color: var(--danger); }
1206
1210
  .u-crow.is-stale .u-crow-note.is-alert::before { color: var(--warn); }
1211
+ /* Last known numbers in compact: as the full card shows them (#2386), muted — not current. */
1212
+ .u-crow.is-stale .u-cm-pct { color: var(--text-3); } .u-crow.is-stale .u-cm-fill { background: var(--text-3); }
1207
1213
  @media (prefers-reduced-motion: no-preference) { .u-crow { transition: background-color 0.15s var(--ease); } }
1208
1214
  .u-rail-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 var(--s-3) var(--s-4); display: flex; flex-direction: column; gap: var(--s-3); }
1209
1215
  @media (max-width: 1199px) { .u-rail { display: none; } }
@@ -214,18 +214,27 @@ export function compactPools(p) {
214
214
  return [...pools.values()].map((x) => ({ ...x, worst: x.metrics.reduce((a, b) => ((b.used ?? 0) > (a.used ?? 0) ? b : a)) }));
215
215
  }
216
216
  const pctOf = (m) => Math.min(100, Math.max(0, m.used ?? 0));
217
+ /** When a window resets, at the clock ("14:05", "10/19 14:05"), for a tooltip; "" when unknown or already past. */
218
+ function resetClock(m, now = Date.now()) {
219
+ const at = new Date((m && m.resetsAt) || "").getTime();
220
+ return Number.isFinite(at) && at > now ? `↻ ${usageClock(m.resetsAt, now)}` : "";
221
+ }
217
222
  /**
218
- * One window on its own line: its few characters ("5h"), a full-width bar and the percent, coloured as the full card.
219
- * A pool's line (#2240): the pool's name in place of the window, its every window in the tooltip.
223
+ * One window on its own line: its few characters ("5h"), a full-width bar, the percent coloured as the full card, and
224
+ * when this window resets ("↻ 8m"; the user's ask, 2026-10-23: a week's reset was hidden behind the 5 h one's). A pool's
225
+ * line (#2240): the pool's name in place of the window, the reset of the window its percent is, every window (with its
226
+ * reset time) in the tooltip.
220
227
  */
221
228
  function CompactMeter({ m, tag, title }) {
222
229
  const bar = useRef(null);
223
230
  const pct = Math.min(100, Math.max(0, m.used ?? 0)), cls = level(pct);
224
231
  useLayoutEffect(() => { if (bar.current) bar.current.style.width = `${pct}%`; }, [pct]); // CSSOM (#1300)
225
232
  const word = cls === "crit" ? tn("usageNear") : cls === "warn" ? tn("usageHigh") : "";
226
- return html`<span class="u-cm" title=${title || `${usageText(m.label, m.labelI18n)}: ${pct.toFixed(0)}%${word ? ` (${word})` : ""}`}>
233
+ const reset = soonestReset([m]), clock = resetClock(m);
234
+ return html`<span class="u-cm" title=${title || `${usageText(m.label, m.labelI18n)}: ${pct.toFixed(0)}%${word ? ` (${word})` : ""}${clock ? ` · ${clock}` : ""}`}>
227
235
  <span class="u-cm-tag">${tag || windowTag(m)}</span><span class="u-cm-bar"><span ref=${bar} class=${`u-cm-fill ${cls}`}></span></span>
228
- <span class=${`u-cm-pct ${cls}`}>${pct.toFixed(0)}%</span></span>`;
236
+ <span class=${`u-cm-pct ${cls}`}>${pct.toFixed(0)}%</span>
237
+ <span class="u-cm-reset">${reset ? html`${reset.short}<span class="sr-only"> (${reset.full})</span>` : null}</span></span>`;
229
238
  }
230
239
  /** What a login says instead of numbers (not logged in, an error), or "" when it has numbers. */
231
240
  function rowNote(p) {
@@ -234,26 +243,27 @@ function rowNote(p) {
234
243
  return "";
235
244
  }
236
245
  /**
237
- * A login in three short lines, under its provider's name (the user's pick, 2026-10-22): its name and plan with the
238
- * soonest reset on the right; then its short window and its long one, each a labelled full-width bar and the percent.
246
+ * A login in three short lines, under its provider's name (the user's pick, 2026-10-22): its name and plan; then its
247
+ * short window and its long one, each a labelled full-width bar, the percent and that window's own reset.
239
248
  * Numbers not current, a failed read or no login: one muted line in place of the bars, what is wrong as its tooltip.
240
249
  * Pressed, it opens its full card.
241
250
  */
242
251
  export function CompactRow({ p, onExpand }) {
243
252
  const title = providerTitle(p), failed = p.status === "error" || p.status === "no-credentials";
244
- const pools = failed || p.stale ? null : compactPools(p);
245
- const metrics = failed || p.stale ? [] : pools ? pools.flatMap((x) => x.metrics) : compactMetrics(p);
253
+ // Last known numbers (stale, e.g. an expired token) show as the others do (the user, 2026-10-23; the full card has since
254
+ // #2386), with a small "cached at 07:47" on the name line; a window that reset since is left out (compactSlots).
255
+ const pools = failed ? null : compactPools(p);
256
+ const metrics = failed ? [] : pools ? pools.flatMap((x) => x.metrics) : compactMetrics(p);
246
257
  const note = rowNote(p), stale = p.stale ? staleNote(p) : null;
247
258
  const say = stale ? stale.mark : note || tn("usage.no_data");
248
259
  const detail = stale ? [stale.mark, stale.detail].filter(Boolean).join(" — ") : say;
249
- const reset = soonestReset(metrics);
250
260
  const name = title.profile || title.base;
251
261
  return html`<button type="button" class=${`u-crow${p.stale ? " is-stale" : ""}${pools ? " is-pooled" : ""}`} aria-expanded="false" title=${`${p.name} — ${tn("usageExpand")}`} onClick=${onExpand}>
252
262
  <span class="u-crow-head"><span class="u-crow-name">${name}</span>
253
263
  ${p.plan ? html`<span class="pill u-plan" title=${p.plan}><span aria-hidden="true">${planBadge(p.plan)}</span><span class="sr-only">${p.plan}</span></span>` : null}
254
- ${reset ? html`<span class="u-crow-reset" title=${reset.full}>${reset.short}<span class="sr-only"> (${reset.full})</span></span>` : null}</span>
264
+ ${stale && metrics.length ? html`<span class="u-crow-stale" title=${detail}>${p.cachedAt ? tn("usageCachedAt", { at: usageClock(p.cachedAt) }) : stale.mark}<span class="sr-only"> (${detail})</span></span>` : null}</span>
255
265
  ${pools ? pools.map((x) => html`<${CompactMeter} key=${x.pool.label} m=${x.worst} tag=${usageText(x.pool.label, x.pool.labelI18n)}
256
- title=${x.metrics.map((m) => `${usageText(m.label, m.labelI18n)}: ${pctOf(m).toFixed(0)}%`).join(" · ")} />`)
266
+ title=${x.metrics.map((m) => { const c = resetClock(m); return `${usageText(m.label, m.labelI18n)}: ${pctOf(m).toFixed(0)}%${c ? ` (${c})` : ""}`; }).join(" · ")} />`)
257
267
  : metrics.length ? metrics.map((m, j) => html`<${CompactMeter} key=${j} m=${m} />`)
258
268
  : html`<span class=${`u-crow-note${(stale || (p.status === "error" && !p.transient)) ? " is-alert" : ""}`} title=${detail}>${say}</span>`}
259
269
  </button>`;
@@ -52,6 +52,7 @@ register("view", {
52
52
  "usageFull": "Full",
53
53
  "usageFullTitle": "The full card for each login",
54
54
  "usageExpand": "Show the full card",
55
+ "usageCachedAt": "cached at {at}",
55
56
  "usageCollapse": "Back to one line",
56
57
  "usageWindowHours": "{n}h",
57
58
  "usageWindowDays": "{n}d",
@@ -294,6 +295,7 @@ register("view", {
294
295
  "usageFull": "完整",
295
296
  "usageFullTitle": "每個登入顯示完整卡片",
296
297
  "usageExpand": "顯示完整卡片",
298
+ "usageCachedAt": "快取於 {at}",
297
299
  "usageCollapse": "收回成一行",
298
300
  "usageWindowHours": "{n}小時",
299
301
  "usageWindowDays": "{n}天",
package/dist/web-api.d.ts CHANGED
@@ -212,7 +212,7 @@ export interface WebApiContext {
212
212
  } | null;
213
213
  /** A fleet.yaml instance (own key) or a ClassicBot room, now. */
214
214
  isKnownInstance?(name: string): boolean;
215
- startInstance(name: string, config: unknown, topicMode: boolean): Promise<void>;
215
+ startInstance(name: string, config: unknown, topicMode: boolean, kind?: "fleet-topic" | "classic", resumePaused?: boolean): Promise<void>;
216
216
  stopInstance(name: string): Promise<{
217
217
  backgroundWorkEnded: boolean;
218
218
  }>;
package/dist/web-api.js CHANGED
@@ -2625,7 +2625,7 @@ export function handleWebRequest(req, res, url, ctx) {
2625
2625
  (async () => {
2626
2626
  let held = false;
2627
2627
  try {
2628
- await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false));
2628
+ await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
2629
2629
  ctx.emitSseEvent("status", ctx.getUiStatus());
2630
2630
  if (!held)
2631
2631
  json(res, 200, { started: name });