@songsid/agend 2.3.0-beta.23 → 2.3.0-beta.24

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 (77) hide show
  1. package/dist/auth-api.js +46 -19
  2. package/dist/auth-api.js.map +1 -1
  3. package/dist/backend/grok.js +3 -1
  4. package/dist/backend/grok.js.map +1 -1
  5. package/dist/bug-report-cli.js +10 -9
  6. package/dist/bug-report-cli.js.map +1 -1
  7. package/dist/bug-report.js +11 -10
  8. package/dist/bug-report.js.map +1 -1
  9. package/dist/cli.js +36 -37
  10. package/dist/cli.js.map +1 -1
  11. package/dist/export-import.js +4 -3
  12. package/dist/export-import.js.map +1 -1
  13. package/dist/fleet-manager.js +23 -15
  14. package/dist/fleet-manager.js.map +1 -1
  15. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +48 -10
  16. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +1 -1
  17. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +1 -1
  18. package/dist/locale.js +178 -0
  19. package/dist/locale.js.map +1 -1
  20. package/dist/quickstart.js +3 -3
  21. package/dist/quickstart.js.map +1 -1
  22. package/dist/scheduler/task-board.js +2 -1
  23. package/dist/scheduler/task-board.js.map +1 -1
  24. package/dist/service-installer.js +9 -8
  25. package/dist/service-installer.js.map +1 -1
  26. package/dist/service-plan.d.ts +8 -0
  27. package/dist/service-plan.js +13 -0
  28. package/dist/service-plan.js.map +1 -1
  29. package/dist/settings-change.js +9 -8
  30. package/dist/settings-change.js.map +1 -1
  31. package/dist/settings-confirmation.d.ts +2 -0
  32. package/dist/settings-confirmation.js +14 -8
  33. package/dist/settings-confirmation.js.map +1 -1
  34. package/dist/settings-control.js +2 -1
  35. package/dist/settings-control.js.map +1 -1
  36. package/dist/setup-auth.d.ts +1 -0
  37. package/dist/setup-auth.js +2 -1
  38. package/dist/setup-auth.js.map +1 -1
  39. package/dist/setup-host.d.ts +1 -0
  40. package/dist/setup-host.js +6 -2
  41. package/dist/setup-host.js.map +1 -1
  42. package/dist/tmux-exec-helper.d.ts +8 -2
  43. package/dist/tmux-exec-helper.js +21 -8
  44. package/dist/tmux-exec-helper.js.map +1 -1
  45. package/dist/ui/fleet-task-common.js +2 -2
  46. package/dist/ui/settings-appearance.js +11 -2
  47. package/dist/ui/settings-dialogs.js +2 -2
  48. package/dist/ui/settings-mods.js +2 -0
  49. package/dist/ui/settings-strings.js +30 -0
  50. package/dist/ui/shared/app.css +13 -23
  51. package/dist/ui/shared/effect-stock-trust.js +4 -4
  52. package/dist/ui/shared/panel-view.js +14 -29
  53. package/dist/ui/shared/usage-dialog.js +29 -8
  54. package/dist/ui/shared/view-strings.js +2 -4
  55. package/dist/ui/web-terminal/terminal.html +10 -10
  56. package/dist/ui/web-terminal/terminal.js +58 -12
  57. package/dist/update-preflight.js +1 -1
  58. package/dist/web-api.js +11 -7
  59. package/dist/web-api.js.map +1 -1
  60. package/dist/web-app-icon-worker.js +2 -1
  61. package/dist/web-app-icon-worker.js.map +1 -1
  62. package/dist/web-app-icon.d.ts +10 -8
  63. package/dist/web-app-icon.js +14 -13
  64. package/dist/web-app-icon.js.map +1 -1
  65. package/dist/web-auth.d.ts +8 -4
  66. package/dist/web-auth.js +28 -17
  67. package/dist/web-auth.js.map +1 -1
  68. package/dist/web-cookie-names.d.ts +5 -0
  69. package/dist/web-cookie-names.js +12 -0
  70. package/dist/web-cookie-names.js.map +1 -0
  71. package/dist/web-png.d.ts +4 -0
  72. package/dist/web-png.js +10 -2
  73. package/dist/web-png.js.map +1 -1
  74. package/dist/web-returning-device.d.ts +4 -2
  75. package/dist/web-returning-device.js +12 -5
  76. package/dist/web-returning-device.js.map +1 -1
  77. package/package.json +11 -11
@@ -1062,26 +1062,17 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1062
1062
  .v-pre .ansi[data-dim] { opacity: .5; }
1063
1063
  .v-term-note { position: absolute; left: var(--s-3); right: var(--s-3); top: var(--s-3); padding: var(--s-2) var(--s-3); border-radius: var(--r-sm); background: var(--warn-bg); border: 1px solid var(--warn-line); color: #f0f0f0; font-size: var(--fs-sm); }
1064
1064
  .v-probe { position: absolute; visibility: hidden; white-space: pre; left: -9999px; top: 0; font-size: 100px; }
1065
- .v-card { flex-shrink: 0; display: flex; align-items: center; gap: var(--s-3); padding: var(--s-2) var(--s-4); background: var(--bg); padding-bottom: max(var(--s-2), env(safe-area-inset-bottom)); }
1066
- .v-card.open { align-items: flex-start; padding-top: var(--s-3); padding-bottom: var(--s-3); }
1067
- .v-avatar { width: 28px; height: 28px; flex-shrink: 0; border-radius: 50%; overflow: hidden; background: var(--surface-2); display: flex; align-items: center; justify-content: center; }
1068
- .v-card.open .v-avatar { width: 48px; height: 48px; }
1069
- .v-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
1070
- .v-initial { color: var(--text-2); font-weight: var(--fw-semibold); text-transform: uppercase; }
1071
- .v-card-body { flex: 1; min-width: 0; }
1072
- .v-card-line { display: flex; align-items: baseline; gap: var(--s-2); font-size: var(--fs-sm); min-width: 0; }
1073
- .v-card-line strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1074
- .v-role { color: var(--accent); font-size: var(--fs-xs); white-space: nowrap; }
1075
- .v-card-meta { color: var(--text-3); font-size: var(--fs-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1076
- .v-card.open .v-card-meta { white-space: normal; }
1077
- .v-card-desc { margin-top: var(--s-2); color: var(--text-2); font-size: var(--fs-sm); white-space: pre-wrap; }
1078
1065
  .help-list { list-style: disc; padding-left: var(--s-5); display: flex; flex-direction: column; gap: var(--s-2); font-size: var(--fs-sm); }
1079
1066
  .err { color: var(--danger); font-size: var(--fs-sm); }
1080
1067
  .u-provider { padding: var(--s-3) var(--s-4); border-radius: var(--r-md); background: var(--surface); display: flex; flex-direction: column; gap: var(--s-2); }
1081
1068
  .u-head { display: flex; align-items: center; gap: var(--s-2); min-width: 0; }
1082
- /* #1749: one line beside ↑↓ — the name gives way first, then the plan badge (each with an ellipsis); ↑↓ never wrap. */
1083
- .u-head strong { min-width: 0; flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1084
- .u-plan { min-width: 0; flex: none; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1069
+ /* #1749 / #2412: one line beside ↑↓, by flexing alone (no widths assumed: ↑↓ are larger on a phone). The plan badge
1070
+ starts at its minimum (40 px) and grows up to its own width into the room the name and ↑↓ leave, so a long badge ends
1071
+ in an ellipsis (the full name its tooltip) while the name keeps every pixel (#2415 review: a shared shrink, however
1072
+ weighted, still took a fraction of one and painted "Meta Muse Co…"). Only when the name, that minimum badge and ↑↓
1073
+ cannot all fit (a 320 px phone) does the name shrink and end in an ellipsis — nothing leaves the card. */
1074
+ .u-head strong { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1075
+ .u-plan { flex: 1 0 40px; max-width: max-content; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1085
1076
  /* #2014: a credential profile's row — the provider name beside the plan badge, and the profile on its own full-width
1086
1077
  muted line under the head (one line in the rail, whole in the dialog). */
1087
1078
  .u-profile { margin-top: calc(-1 * var(--s-2) + 2px); color: var(--text-3); font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1116,8 +1107,8 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1116
1107
  @media (max-width: 1199px) { .u-rail { display: none; } }
1117
1108
  @media (max-width: 899px) {
1118
1109
  .hide-narrow { display: none; }
1119
- .v-card { padding-left: var(--s-3); padding-right: var(--s-3); }
1120
- .v-card .btn { min-height: var(--tap); }
1110
+ .v-id { padding-left: var(--s-3); padding-right: var(--s-3); }
1111
+ .v-id-actions .btn { min-height: var(--tap); }
1121
1112
  }
1122
1113
 
1123
1114
  /* ── Settings (#1408 step 3): lists, forms, the pending bar, the Apply in hand ── */
@@ -1209,8 +1200,6 @@ textarea.s-yaml { white-space: pre; overflow-x: auto; }
1209
1200
  .s-dev-bar .lbl { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
1210
1201
  /* An agent's row: name and model on the first line, its tags and buttons from the second, on every row alike. */
1211
1202
  .s-br { display: block; flex-basis: 100%; height: 0; }
1212
- /* View: the profile line wraps to two lines instead of hiding the model and context. */
1213
- .v-card-meta { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
1214
1203
  /* A text button in Needs you lines up with the text above it, first in its row or wrapped onto a line of its own;
1215
1204
  a row's trailing Delete sits at the right. */
1216
1205
  .n-actions > .btn-ghost { padding-left: 0; padding-right: 0; margin-left: -1px; }
@@ -1726,9 +1715,10 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1726
1715
  .id-card.card-c .id-tagline { -webkit-line-clamp: 2; line-clamp: 2; }
1727
1716
  .id-card.card-c .id-bio { -webkit-line-clamp: 3; line-clamp: 3; }
1728
1717
  }
1729
- /* #1629 r4 §1: the card in View (above the profile row) and its part of Edit profile. */
1730
- .v-id { flex-shrink: 0; padding: var(--s-2) var(--s-4) 0; background: var(--bg); }
1718
+ /* #1629 r4 §1: the identity card in View, with Edit profile under it. */
1719
+ .v-id { flex-shrink: 0; padding: var(--s-2) var(--s-4); background: var(--bg); padding-bottom: max(var(--s-2), env(safe-area-inset-bottom)); }
1731
1720
  .v-id .id-card { margin: 0; }
1721
+ .v-id-actions { display: flex; justify-content: flex-end; padding-top: var(--s-2); }
1732
1722
  .form-sub { margin: var(--s-2) 0 0; font-size: var(--fs-sm); font-weight: var(--fw-semibold); }
1733
1723
  /* #1629 r4.1 Q3 (b): Settings → Appearance, the card's upload limits (three to a row; one on a phone). */
1734
1724
  .grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-3) var(--s-4); margin-bottom: var(--s-3); }
@@ -1772,7 +1762,7 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1772
1762
  .p-fleet > .seg, .p-settings > .seg,
1773
1763
  .p-fleet > .panel-body, .p-needs > .panel-body, .p-settings > .panel-body,
1774
1764
  .p-log > .panel-body, .p-details > .panel-body, .p-view > .panel-body,
1775
- .p-log > .log-bar, .p-view > .v-term, .p-view > .v-id, .p-view > .v-card { position: relative; z-index: 1; }
1765
+ .p-log > .log-bar, .p-view > .v-term, .p-view > .v-id { position: relative; z-index: 1; }
1776
1766
  /* Keep the existing Apply bar's sticky behavior, in its own bounded stock band. */
1777
1767
  .p-settings > .s-pending { z-index: 1; }
1778
1768
 
@@ -14,7 +14,7 @@ const SPECS = [
14
14
  ["thread-control", ".shell .chat-main > .amb-note, .shell .p-chat .jump-latest, .shell .thread .msg-tools, .shell .thread .msg > div.msg-actions, .shell .thread .msg > button.msg-quote, .shell .thread .st-more, .shell .thread .cb-head, .shell .thread .cb-more, .shell .thread .tc-head, .shell .thread .tc-more, .shell .thread .pv-head, .shell .thread .pv-note, .shell .thread .pv-banner, .shell .thread .effect-trial-offer, .shell .thread .click-chip"],
15
15
  ["link", ".shell .thread .md a, .shell .thread .st-text > a.st-link, .shell .dock .prompts .txt > a.st-link"], ["attachment", ".shell .thread .atts"], ["media", ".shell .thread .att-media"],
16
16
  ["panel-navigation", ".shell .p-fleet > .seg, .shell .p-settings > .seg"],
17
- ["panel-content", ".shell .p-fleet > .panel-body, .shell .p-needs > .panel-body, .shell .p-settings > .panel-body, .shell .p-log > .panel-body, .shell .p-details > .panel-body, .shell .p-view > .panel-body, .shell .p-view > .v-term, .shell .p-view > .v-id, .shell .p-view > .v-card"],
17
+ ["panel-content", ".shell .p-fleet > .panel-body, .shell .p-needs > .panel-body, .shell .p-settings > .panel-body, .shell .p-log > .panel-body, .shell .p-details > .panel-body, .shell .p-view > .panel-body, .shell .p-view > .v-term, .shell .p-view > .v-id"],
18
18
  ["panel-controls", ".shell .p-log > .log-bar, .shell .p-settings > .s-pending"],
19
19
  ["session-ended", "body > .agend-session-ended[role=alert]"],
20
20
  ];
@@ -28,7 +28,7 @@ const PANELS = Object.freeze({
28
28
  settings: { required: [".panel-head", ".seg", ".panel-body"], optional: [".s-pending"] },
29
29
  log: { required: [".panel-head", ".panel-body"], optional: [".log-bar"] },
30
30
  details: { required: [".panel-head", ".panel-body"], optional: [] },
31
- view: { required: [".panel-head"], optional: [".panel-body", ".v-term", ".v-id", ".v-card"] },
31
+ view: { required: [".panel-head"], optional: [".panel-body", ".v-term", ".v-id"] },
32
32
  });
33
33
  function panelSurface(doc, chat) {
34
34
  if (chat) return { panel: chat, structural: STRUCTURAL, content: CONTENT, kind: "chat" };
@@ -37,8 +37,8 @@ function panelSurface(doc, chat) {
37
37
  const children = [...panel.children], count = selector => children.filter(el => el.matches(selector)).length;
38
38
  if (spec.required.some(selector => count(selector) !== 1) || spec.optional.some(selector => count(selector) > 1)
39
39
  || children.some(el => !el.matches([...spec.required, ...spec.optional, "dialog.dlg", "dialog.lb"].join(",")))) return null;
40
- if (kind === "view" && !(count(".panel-body") === 1 && !count(".v-term") && !count(".v-id") && !count(".v-card")
41
- || !count(".panel-body") && count(".v-term") === 1 && count(".v-card") === 1)) return null;
40
+ if (kind === "view" && !(count(".panel-body") === 1 && !count(".v-term") && !count(".v-id")
41
+ || !count(".panel-body") && count(".v-term") === 1 && count(".v-id") === 1)) return null;
42
42
  const prefix = `.p-${kind} > `;
43
43
  return { panel, kind, structural: [...STRUCTURAL.slice(0, 5), `.main > .p-${kind}`],
44
44
  content: [...CONTENT.filter(selector => !selector.startsWith(".p-chat") && !selector.startsWith(".chat-main")),
@@ -1,4 +1,4 @@
1
- // #1408 step 2: the View panel (/view, /view/<name>) — every agent's live terminal, its profile card, the AI usage
1
+ // #1408 step 2: the View panel (/view, /view/<name>) — every agent's live terminal, its identity card, the AI usage
2
2
  // panel, in the app shell. Public (/assets/): under `web.view_access: open` an anonymous reader opens it in the
3
3
  // View-only shell, so nothing here needs a session to show, and the one write (Edit profile) asks for one.
4
4
  //
@@ -6,7 +6,7 @@
6
6
  // Everything recurring is a passive read (#1374) taken through the navigation's lease: the pane every 800 ms and the
7
7
  // roster every 5 s while the panel is mounted and the tab is visible, usage every 60 s while its dialog is open. They
8
8
  // stop when the panel goes.
9
- import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "./app-html.js";
9
+ import { html, useEffect, useRef, useState } from "./app-html.js";
10
10
  import { t } from "./app-i18n.js";
11
11
  import { appStore, createStore, useStore } from "./app-store.js";
12
12
  import { useLease } from "./app-ctx.js";
@@ -16,7 +16,7 @@ import { HeaderTools, VIEW_SCALE, textSizeStore } from "./header-tools.js";
16
16
  import { readStream } from "./read-stream.js";
17
17
  // The usage dialog's metric row lives with the dialog now (usage-dialog.js); re-exported for what imported it here.
18
18
  export { UsageMetric } from "./usage-dialog.js";
19
- import { backendLabel, modelText, STATUS_KEYS } from "./instance-nav.js";
19
+ import { STATUS_KEYS } from "./instance-nav.js";
20
20
  import { viewStore } from "./view-roster-store.js";
21
21
 
22
22
  /** #1580: the app's stream says the fleet is not answering (a restart): the View's polls skip their turn until it is. */
@@ -189,7 +189,7 @@ export function ViewPanel({ route, navKey }) {
189
189
  else if (!name) body = html`<div data-trust="panel-content" class="panel-body"><${Skeleton} lines=${6} /></div>`;
190
190
  else if (!it) body = html`<div data-trust="panel-content" class="panel-body center"><${Empty} icon="alert" title=${tn("notFound", { name })} hint=${tn("notFoundHint")} /></div>`;
191
191
  else body = html`<${Terminal} key=${name} name=${name} lease=${lease} density=${density} />
192
- <${Card} it=${it} viewOnly=${viewOnly} onEdit=${() => setDialog("edit")} />`;
192
+ <${ViewIdentity} it=${it} viewOnly=${viewOnly} onEdit=${() => setDialog("edit")} />`;
193
193
 
194
194
  return html`<div data-effect-panel="view" class="panel p-view">
195
195
  <${PanelHeader} title=${it ? (it.display_name || it.instance_name) : tn("title")}
@@ -287,33 +287,18 @@ function glyphRatio(pre) {
287
287
  return cellRatio || 0.6;
288
288
  }
289
289
 
290
- /** The profile card under the terminal: one line by default, the whole profile when opened. */
291
- function Card({ it, viewOnly, onEdit }) {
292
- const [open, setOpen] = useState(stored("agend_view_card_expanded") === "1");
293
- const avatar = useRef(null);
294
- // The avatar is fetched once per instance (cache-busted then), not on every 5-second roster refresh.
295
- useLayoutEffect(() => {
296
- if (!avatar.current || !it.has_avatar) return;
297
- avatar.current.src = `/api/avatar/${encodeURIComponent(it.instance_name)}?t=${Date.now()}`;
298
- }, [it.instance_name, it.has_avatar]);
299
- const initial = (it.display_name || it.instance_name || "?").trim().charAt(0) || "?";
300
- const ctx = it.context_pct != null ? ` · ctx ${Math.round(it.context_pct)}%` : "";
301
- const toggle = () => { const next = !open; setOpen(next); store("agend_view_card_expanded", next ? "1" : "0"); };
302
- // #1629 r4 §1: the identity card, above the profile row while it is expanded.
290
+ /** The identity card under the terminal, with Edit profile (or Sign in to edit) below it. Always on: the old one-line
291
+ * strip is gone, and IdentityCard itself is null when the card is empty, so the button lives here rather than inside it. */
292
+ function ViewIdentity({ it, viewOnly, onEdit }) {
303
293
  const card = useCard(it.instance_name, it.card_v);
304
- return html`${open && card ? html`<div data-trust="panel-content" class="v-id"><${IdentityCard} name=${it.instance_name} card=${card} /></div>` : null}<section data-trust="panel-content" class=${`v-card${open ? " open" : ""}`} aria-label=${it.display_name || it.instance_name}>
305
- <div class="v-avatar">${it.has_avatar ? html`<img ref=${avatar} alt="" />` : html`<span class="v-initial" aria-hidden="true">${initial}</span>`}</div>
306
- <div class="v-card-body">
307
- <div class="v-card-line"><strong>${it.display_name || it.instance_name}</strong>${it.role ? html`<span class="v-role">${it.role}</span>` : null}</div>
308
- <div class="v-card-meta">${it.instance_name} · ${backendLabel(it.backend)}${it.model ? ` · ${modelText(it)}` : ""}${ctx}</div>
309
- ${open && it.description ? html`<p class="v-card-desc">${it.description}</p>` : null}
294
+ return html`<div data-trust="panel-content" class="v-id">
295
+ ${card ? html`<${IdentityCard} name=${it.instance_name} card=${card} />` : null}
296
+ <div class="v-id-actions">
297
+ ${viewOnly
298
+ ? html`<a class="btn btn-sm" href=${signInHref()}><${Icon} name="edit" size=${14} />${tn("signInToEdit")}</a>`
299
+ : html`<button type="button" class="btn btn-sm" onClick=${onEdit}><${Icon} name="edit" size=${14} />${tn("editProfile")}</button>`}
310
300
  </div>
311
- <button type="button" class="icon-btn" onClick=${toggle} aria-expanded=${open ? "true" : "false"} aria-label=${tn(open ? "collapseProfile" : "expandProfile")} title=${tn(open ? "collapseProfile" : "expandProfile")}>
312
- <${Icon} name="chevron" size=${16} cls=${open ? "caret" : "caret flipped"} /></button>
313
- ${viewOnly
314
- ? html`<a class="btn btn-sm" href=${signInHref()}><${Icon} name="edit" size=${14} />${tn("signInToEdit")}</a>`
315
- : html`<button type="button" class="btn btn-sm" onClick=${onEdit}><${Icon} name="edit" size=${14} />${tn("editProfile")}</button>`}
316
- </section>`;
301
+ </div>`;
317
302
  }
318
303
 
319
304
  /** Edit profile: display name, role, description, avatar. A write: it needs a session (CSRF via agend-auth.js). */
@@ -107,9 +107,20 @@ export function providerTitle(p) {
107
107
  * tooltip and for a screen reader.
108
108
  */
109
109
  export function planBadge(plan) {
110
- const m = /^(\S+)(?: [^()]+)? \((\d+(?:\.\d+)?)x\)$/i.exec(String(plan || "").trim());
111
- return m ? `${m[1]} ×${m[2]}` : String(plan || "");
110
+ const text = String(plan || "").trim();
111
+ const m = /^(\S+)(?: [^()]+)? \((\d+(?:\.\d+)?)x\)$/i.exec(text);
112
+ if (m) return `${m[1]} ×${m[2]}`;
113
+ // #2412: a long name ("Self Serve Business Usage Based", Codex's plan_type spelled out) says only its kind, so the badge
114
+ // never takes the row from the provider's name; the full name is the badge's tooltip.
115
+ if (text.length > PLAN_BADGE_MAX) {
116
+ for (const [word, short] of PLAN_KINDS) if (word.test(text)) return short;
117
+ }
118
+ return text;
112
119
  }
120
+ const PLAN_BADGE_MAX = 14;
121
+ /** The kinds a long plan name is shortened to, the most specific first. */
122
+ const PLAN_KINDS = [[/\benterprise\b/i, "Enterprise"], [/\bbusiness\b/i, "Business"], [/\bteam\b/i, "Team"], [/\bedu(?:cation)?\b/i, "Edu"],
123
+ [/\bpro\+/i, "Pro+"], [/\bpro\b/i, "Pro"], [/\bplus\b/i, "Plus"], [/\bmax\b/i, "Max"], [/\bfree\b/i, "Free"]];
113
124
  /** "Updated 3 min ago" (#1585): how old the numbers are — the panel may show the last snapshot while a fresh one comes. */
114
125
  export function updatedAgo(fetchedAt, now = Date.now()) {
115
126
  const ms = now - new Date(fetchedAt).getTime();
@@ -124,7 +135,10 @@ export function updatedAgo(fetchedAt, now = Date.now()) {
124
135
  */
125
136
  export function UsageBody({ owner }) {
126
137
  const lease = useLease(owner);
127
- const [data, setData] = useState(undefined); // undefined: loading; null: failed
138
+ const [data, setData] = useState(undefined); // undefined: loading; null: failed, with nothing read before
139
+ // The last read failed while numbers were already shown: they stay, with a quiet note (#2236/#2289 for the server's
140
+ // rows; here for the read itself — a dropped or failed read must not flash the error over numbers already on screen).
141
+ const [failed, setFailed] = useState(false);
128
142
  const [order, setOrder] = useState(() => { try { const a = JSON.parse(stored(USAGE_ORDER_KEY) || "[]"); return Array.isArray(a) ? a.filter((x) => typeof x === "string") : []; } catch { return []; } });
129
143
  const reads = useMemo(() => readStream(lease), [lease]);
130
144
  // Refresh (force) supersedes a read on its way; the minute's tick waits for it.
@@ -134,19 +148,26 @@ export function UsageBody({ owner }) {
134
148
  try {
135
149
  const r = await lease.fetch(`/api/ai-usage${force ? "?force=1" : ""}`);
136
150
  if (!reads.live(token)) return;
151
+ // A refused or failed answer (a 500, a proxy's 502 page, a session that lapsed) is a failed read, not numbers.
152
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
137
153
  const d = await r.json();
138
- if (reads.live(token)) setData(d);
139
- } catch { if (reads.live(token)) setData(null); }
154
+ if (!d || !Array.isArray(d.providers)) throw new Error("no providers");
155
+ if (reads.live(token)) { setData(d); setFailed(false); }
156
+ } catch {
157
+ if (!reads.live(token)) return;
158
+ setData((had) => (had && Array.isArray(had.providers) ? had : null)); // the last numbers stay; none: the error
159
+ setFailed(true);
160
+ }
140
161
  finally { reads.end(token); }
141
162
  };
142
163
  useEffect(() => { load(false); lease.interval(() => load(false), 60_000); }, [lease]);
143
164
  // #1585: the server answered with the last snapshot while it fetches a fresh one — read again shortly (the same
144
165
  // cache: no vendor call of its own) so the fresh numbers replace it without waiting for the minute's tick.
145
166
  useEffect(() => {
146
- if (!data || !data.refreshing) return undefined;
167
+ if (!data || !data.refreshing || failed) return undefined;
147
168
  const timer = setTimeout(() => load(false), 4_000);
148
169
  return () => clearTimeout(timer);
149
- }, [data]);
170
+ }, [data, failed]);
150
171
  const key = (p) => String(p.id || p.name || "");
151
172
  const ordered = (providers) => {
152
173
  const rank = new Map(order.map((id, i) => [id, i]));
@@ -182,7 +203,7 @@ export function UsageBody({ owner }) {
182
203
  : html`${(p.metrics || []).map((m, j) => html`<${UsageMetric} key=${j} m=${m} />`)}
183
204
  ${!p.stale && p.hint ? html`<p class="note">${usageText(p.hint, p.hintI18n)}</p>` : !(p.metrics || []).length ? html`<p class="note">${tn("usage.no_data")}</p>` : null}`}
184
205
  </section>`)}
185
- <div class="u-foot"><span class="note" title=${new Date(data.fetchedAt).toLocaleString()}>${updatedAgo(data.fetchedAt)}${data.refreshing ? html` · <span class="u-refreshing">${tn("usageRefreshing")}</span>` : null}</span>
206
+ <div class="u-foot"><span class="note" title=${new Date(data.fetchedAt).toLocaleString()}>${updatedAgo(data.fetchedAt)}${failed ? html` · <span class="u-refresh-failed" role="status">${tn("usageRefreshFailed")}</span>` : data.refreshing ? html` · <span class="u-refreshing">${tn("usageRefreshing")}</span>` : null}</span>
186
207
  ${byAgent() ? html`<a class="btn btn-ghost btn-sm u-by-agent" href="/ui/fleet/usage" title=${tn("usageByAgentTitle")}>${tn("usageByAgent")}</a>` : null}
187
208
  <button type="button" class="btn btn-sm" onClick=${() => load(true)}>${tn("usageRefresh")}</button></div>`;
188
209
  }
@@ -37,6 +37,7 @@ register("view", {
37
37
  "usageUpdatedAgo": "Updated {age} ago",
38
38
  "usageUpdatedJustNow": "Updated just now",
39
39
  "usageRefreshing": "Refreshing…",
40
+ "usageRefreshFailed": "Couldn't refresh — showing the last numbers",
40
41
  "usageMinutes": "{n} min",
41
42
  "usageHours": "{n} h",
42
43
  "usageRefresh": "Refresh",
@@ -164,7 +165,6 @@ register("view", {
164
165
  "usageButton": "Usage",
165
166
  "helpButton": "Help",
166
167
  "close": "Close",
167
- "expandProfile": "Show the profile",
168
168
  "rosterFailed": "Failed to load roster: {error}\n(Is the fleet running?)",
169
169
  "paneFailed": "⚠ Could not load pane for {name} (HTTP {status}).",
170
170
  "signInToSave": "Sign in to save — your session may have ended.",
@@ -207,7 +207,6 @@ register("view", {
207
207
  "textSize": "Text size: {size}",
208
208
  "profileTitle": "Edit {name}'s profile",
209
209
  "avatar": "Avatar",
210
- "collapseProfile": "Hide the profile",
211
210
  "instancesLabel": "Instances",
212
211
  "favorites": "Favorites",
213
212
  "favAdd": "Add {name} to favorites",
@@ -270,6 +269,7 @@ register("view", {
270
269
  "usageUpdatedAgo": "{age}前更新",
271
270
  "usageUpdatedJustNow": "剛剛更新",
272
271
  "usageRefreshing": "更新中…",
272
+ "usageRefreshFailed": "無法更新,顯示上次的數據",
273
273
  "usageMinutes": "{n} 分鐘",
274
274
  "usageHours": "{n} 小時",
275
275
  "usageRefresh": "重新整理",
@@ -397,7 +397,6 @@ register("view", {
397
397
  "usageButton": "用量",
398
398
  "helpButton": "說明",
399
399
  "close": "關閉",
400
- "expandProfile": "顯示個人資料",
401
400
  "rosterFailed": "載入 instance 清單失敗:{error}\n(Fleet 是否正在執行?)",
402
401
  "paneFailed": "⚠ 無法載入 {name} 的 pane(HTTP {status})。",
403
402
  "signInToSave": "請先登入才能儲存:你的 session 可能已結束。",
@@ -440,7 +439,6 @@ register("view", {
440
439
  "textSize": "字級:{size}",
441
440
  "profileTitle": "編輯 {name} 的個人資料",
442
441
  "avatar": "頭像",
443
- "collapseProfile": "隱藏個人資料",
444
442
  "instancesLabel": "Instances",
445
443
  "favorites": "最愛",
446
444
  "favAdd": "把 {name} 加入最愛",
@@ -13,25 +13,25 @@
13
13
  <header id="bar">
14
14
  <span id="title">AgEnD terminal</span>
15
15
  <span id="ttl" hidden></span>
16
- <button id="btn-close" type="button" hidden title="End this session now (the command is stopped and the terminal closes)">Stop</button>
16
+ <button id="btn-close" type="button" hidden title="End this session now (the command is stopped and the terminal closes)" data-i18n="stop" data-i18n-title="stopTitle">Stop</button>
17
17
  </header>
18
18
  <section id="gate">
19
- <p>This terminal is connected to <strong>one command only</strong> and closes when it exits or when the time limit lapses. There is no shell behind it.</p>
20
- <p>Paste the <strong>access token</strong> you were given: privately in chat, or in the dashboard's sign-in dialog. It is separate from this link; the link alone grants nothing.</p>
19
+ <p data-i18n-html="gateOne">This terminal is connected to <strong>one command only</strong> and closes when it exits or when the time limit lapses. There is no shell behind it.</p>
20
+ <p data-i18n-html="gateToken">Paste the <strong>access token</strong> you were given: privately in chat, or in the dashboard's sign-in dialog. It is separate from this link; the link alone grants nothing.</p>
21
21
  <form id="gate-form" autocomplete="off">
22
- <input id="token" type="text" inputmode="text" autocapitalize="characters" spellcheck="false" placeholder="access token" maxlength="40" required>
23
- <button type="submit">Open</button>
22
+ <input id="token" type="text" inputmode="text" autocapitalize="characters" spellcheck="false" placeholder="access token" data-i18n-placeholder="tokenPlaceholder" maxlength="40" required>
23
+ <button type="submit" data-i18n="open">Open</button>
24
24
  </form>
25
25
  <p id="gate-msg" role="alert"></p>
26
- <noscript><p role="alert">JavaScript is required: the token is sent with a script, never as part of a page URL.</p></noscript>
26
+ <noscript><p role="alert">JavaScript is required: the token is sent with a script, never as part of a page URL. 需要啟用 JavaScript:存取權杖由程式送出,絕不會放進網址。</p></noscript>
27
27
  </section>
28
28
  <main id="term" hidden></main>
29
29
  <form id="code-row" hidden autocomplete="off">
30
- <label for="code">Sign-in code</label>
31
- <input id="code" type="password" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="1100" placeholder="paste the code here">
32
- <button type="submit">Send</button>
30
+ <label for="code" data-i18n="codeLabel">Sign-in code</label>
31
+ <input id="code" type="password" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="1100" placeholder="paste the code here" data-i18n-placeholder="codePlaceholder">
32
+ <button type="submit" data-i18n="send">Send</button>
33
33
  <span id="code-msg" role="status"></span>
34
- <p id="code-hint">The terminal does not show what you paste. Paste the code once, then press Enter or Send. Ctrl+C is off on this page — use Stop to cancel.</p>
34
+ <p id="code-hint" data-i18n="codeHint">The terminal does not show what you paste. Paste the code once, then press Enter or Send. Ctrl+C is off on this page — use Stop to cancel.</p>
35
35
  </form>
36
36
  <footer id="keys" hidden>
37
37
  <button data-seq="\u001b" type="button">Esc</button>
@@ -20,6 +20,52 @@
20
20
  var ws = null, term = null, fit = null, ttlTimer = null, expiresAt = 0;
21
21
  var finished = false, reconnectAttempts = 0;
22
22
 
23
+ // #2390: the page in the device's language (as the dashboard when none is chosen): English as written in
24
+ // terminal.html, else zh-TW over it. Static words only — never anything the session sends.
25
+ var ZH = typeof navigator !== "undefined" && /^zh/i.test(navigator.language || "");
26
+ var WORDS = {
27
+ stop: ["Stop", "停止"],
28
+ stopTitle: ["End this session now (the command is stopped and the terminal closes)", "立即結束這個工作階段(停止指令並關閉終端機)"],
29
+ gateOne: ["", "這個終端機只連到<strong>一個指令</strong>,指令結束或時間到就會關閉;後面沒有 shell。"],
30
+ gateToken: ["", "請貼上你拿到的<strong>存取權杖</strong>:私訊給你的,或在儀表板的登入對話框中。它和這個連結是分開的;只有連結沒有任何權限。"],
31
+ tokenPlaceholder: ["access token", "存取權杖"],
32
+ open: ["Open", "開啟"],
33
+ codeLabel: ["Sign-in code", "登入代碼"],
34
+ codePlaceholder: ["paste the code here", "在這裡貼上代碼"],
35
+ send: ["Send", "送出"],
36
+ codeHint: ["The terminal does not show what you paste. Paste the code once, then press Enter or Send. Ctrl+C is off on this page — use Stop to cancel.",
37
+ "終端機不會顯示你貼上的內容。貼上代碼一次,再按 Enter 或「送出」。這個頁面停用了 Ctrl+C,要取消請按「停止」。"],
38
+ invalid: ["Invalid token. {0} attempt(s) left before this session is destroyed.", "權杖不正確。再錯 {0} 次這個工作階段就會被銷毀。"],
39
+ used: ["This token was already used. If that was not you, tell the fleet admin: the link may have leaked.", "這個權杖已經用過了。如果不是你,請告訴 fleet 管理員:連結可能已外流。"],
40
+ ended: ["This session has ended.", "這個工作階段已結束。"],
41
+ couldNotOpen: ["Could not open: {0}", "無法開啟:{0}"],
42
+ network: ["Network error.", "網路錯誤。"],
43
+ truncated: ["[earlier output truncated]", "[較早的輸出已截斷]"],
44
+ finished: ["Finished: {0}", "已完成:{0}"],
45
+ endedWith: ["Ended: {0}", "已結束:{0}"],
46
+ exitCode: [" (exit {0})", "(結束代碼 {0})"],
47
+ replaced: ["Replaced by a newer connection.", "已被較新的連線取代。"],
48
+ closed: ["Connection closed ({0}).", "連線已關閉({0})。"],
49
+ lost: ["Connection lost.", "連線中斷。"],
50
+ closesIn: ["closes in {0}", "{0} 後關閉"],
51
+ "notice.empty": ["", "請先從瀏覽器貼上代碼。"],
52
+ "notice.too-long": ["", "這比登入代碼長太多了,請只複製代碼。"],
53
+ "notice.not-sent": ["", "沒有送出:連線中斷了。代碼還在框裡,等重新連上後再按一次「送出」。"],
54
+ "notice.sent": ["", "已送出。終端機不會顯示代碼,請在上方等待結果。"]
55
+ };
56
+ function T(key) {
57
+ var pair = WORDS[key], s = pair ? (ZH && pair[1] ? pair[1] : pair[0]) : key;
58
+ for (var i = 1; i < arguments.length; i++) s = s.split("{" + (i - 1) + "}").join(String(arguments[i]));
59
+ return s;
60
+ }
61
+ if (ZH) {
62
+ document.documentElement.lang = "zh-TW";
63
+ Array.prototype.forEach.call(document.querySelectorAll("[data-i18n]"), function (el) { el.textContent = T(el.getAttribute("data-i18n")); });
64
+ Array.prototype.forEach.call(document.querySelectorAll("[data-i18n-html]"), function (el) { el.innerHTML = T(el.getAttribute("data-i18n-html")); });
65
+ Array.prototype.forEach.call(document.querySelectorAll("[data-i18n-title]"), function (el) { el.title = T(el.getAttribute("data-i18n-title")); });
66
+ Array.prototype.forEach.call(document.querySelectorAll("[data-i18n-placeholder]"), function (el) { el.placeholder = T(el.getAttribute("data-i18n-placeholder")); });
67
+ }
68
+
23
69
  // A reload or a second tab within the TTL still holds the HttpOnly cookie:
24
70
  // try the WebSocket first and only fall back to the token gate on refusal.
25
71
  // This GET-free probe keeps the page itself side-effect free.
@@ -47,16 +93,16 @@
47
93
  }
48
94
  return res.json().catch(function () { return {}; }).then(function (body) {
49
95
  if (res.status === 403 && typeof body.remaining === "number") {
50
- gateMsg.textContent = "Invalid token. " + body.remaining + " attempt" + (body.remaining === 1 ? "" : "s") + " left before this session is destroyed.";
96
+ gateMsg.textContent = ZH ? T("invalid", body.remaining) : "Invalid token. " + body.remaining + " attempt" + (body.remaining === 1 ? "" : "s") + " left before this session is destroyed.";
51
97
  } else if (res.status === 409) {
52
- gateMsg.textContent = "This token was already used. If that was not you, tell the fleet admin: the link may have leaked.";
98
+ gateMsg.textContent = T("used");
53
99
  } else if (res.status === 410) {
54
- gateMsg.textContent = "This session has ended.";
100
+ gateMsg.textContent = T("ended");
55
101
  } else {
56
- gateMsg.textContent = "Could not open: " + (body.error || res.status);
102
+ gateMsg.textContent = T("couldNotOpen", body.error || res.status);
57
103
  }
58
104
  });
59
- }).catch(function () { gateMsg.textContent = "Network error."; });
105
+ }).catch(function () { gateMsg.textContent = T("network"); });
60
106
  });
61
107
 
62
108
  function connect(probe) {
@@ -109,9 +155,9 @@
109
155
  expiresAt = Date.now() + msg.ttlRemainingMs;
110
156
  if (ttlTimer) clearInterval(ttlTimer); // exactly one countdown, however many reconnects
111
157
  ttlEl.hidden = false; tick(); ttlTimer = setInterval(tick, 1000);
112
- if (msg.truncated && term) term.writeln("\x1b[33m[earlier output truncated]\x1b[0m");
158
+ if (msg.truncated && term) term.writeln("\x1b[33m" + T("truncated") + "\x1b[0m");
113
159
  } else if (msg.t === "exit") {
114
- finish(msg.ok, (msg.ok ? "Finished" : "Ended") + ": " + msg.detail + (typeof msg.exitCode === "number" ? " (exit " + msg.exitCode + ")" : ""));
160
+ finish(msg.ok, T(msg.ok ? "finished" : "endedWith", msg.detail) + (typeof msg.exitCode === "number" ? T("exitCode", msg.exitCode) : ""));
115
161
  }
116
162
  return;
117
163
  }
@@ -120,8 +166,8 @@
120
166
  conn.onclose = function (ev) {
121
167
  if (ws !== conn || finished) return;
122
168
  if (!opened && probe) { gate.hidden = false; return; } // initial cookie probe refused → token gate
123
- if (opened && ev.code === 4000) { finish(false, "Replaced by a newer connection."); return; }
124
- if (opened && (ev.code === 1000 || ev.code === 1001 || ev.code === 1008)) { finish(false, "Connection closed (" + (ev.reason || ev.code) + ")."); return; }
169
+ if (opened && ev.code === 4000) { finish(false, T("replaced")); return; }
170
+ if (opened && (ev.code === 1000 || ev.code === 1001 || ev.code === 1008)) { finish(false, T("closed", ev.reason || ev.code)); return; }
125
171
  // Transient drop, or a reconnect attempt that failed before opening: the
126
172
  // process is still running; keep trying within the TTL and the budget.
127
173
  scheduleReconnect();
@@ -134,7 +180,7 @@
134
180
  setTimeout(function () { if (!finished) connect(false); }, 500 * reconnectAttempts);
135
181
  return;
136
182
  }
137
- finish(false, "Connection lost.");
183
+ finish(false, T("lost"));
138
184
  }
139
185
 
140
186
  /** True only when the bytes were handed to an open socket. */
@@ -146,7 +192,7 @@
146
192
  function tick() {
147
193
  var left = Math.max(0, expiresAt - Date.now());
148
194
  var m = Math.floor(left / 60000), s = Math.floor((left % 60000) / 1000);
149
- ttlEl.textContent = "closes in " + m + ":" + (s < 10 ? "0" : "") + s;
195
+ ttlEl.textContent = T("closesIn", m + ":" + (s < 10 ? "0" : "") + s);
150
196
  }
151
197
 
152
198
  function finish(ok, text) {
@@ -166,7 +212,7 @@
166
212
  ev.preventDefault();
167
213
  var result = input.submitCode(codeInput.value, function (s) { return send(new TextEncoder().encode(s)); });
168
214
  if (result.clear) codeInput.value = "";
169
- codeMsg.textContent = input.NOTICES[result.notice] || "";
215
+ codeMsg.textContent = (ZH && result.notice && T("notice." + result.notice) !== "notice." + result.notice ? T("notice." + result.notice) : input.NOTICES[result.notice]) || "";
170
216
  if (term) term.focus();
171
217
  });
172
218
 
@@ -71,7 +71,7 @@ export function evaluateUpdateTarget(input) {
71
71
  const pin = Object.prototype.hasOwnProperty.call(m.runtimePins, input.host.id) ? m.runtimePins[input.host.id] : null;
72
72
  const support = p.runtimeSupport(input.host);
73
73
  if (pin && support.supported) {
74
- // The pin keeps its identity (22.23.3-agend.1); the Node it installs is the one judged (postinstall: 22.23.3).
74
+ // The pin keeps its identity (22.23.4-agend.1); the Node it installs is the one judged (postinstall: 22.23.4).
75
75
  const node = RUNTIME_PIN.exec(pin)?.[1];
76
76
  if (!node)
77
77
  return { kind: "unknown", note: ` Note: the release pins its bundled Node as ${JSON.stringify(pin)}, which this check cannot read; its own install check still applies.` };
package/dist/web-api.js CHANGED
@@ -346,10 +346,14 @@ function stillAuthorized(req, res, url, ctx) {
346
346
  * 5 MiB) and DELETE (back to the bundled icon): a signed-in admin's session only — never the CLI's token, a read-only
347
347
  * session, a guest or the public link; checked again once the body has arrived.
348
348
  */
349
+ /** An app icon refusal as the page reads it: its code, the English detail and, when named, the detail's key (#2390). */
350
+ function appIconRefusal(r) {
351
+ return { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.detail_key ? { detail_key: r.detail_key } : {}), ...(r.detail_args ? { detail_args: r.detail_args } : {}) };
352
+ }
349
353
  function handleAppIcon(req, res, url, ctx, method) {
350
354
  const store = ctx.appIcon;
351
355
  if (!store) {
352
- json(res, 404, { error: "The app icon is not kept on this fleet" });
356
+ json(res, 404, { error: "app_icon_not_kept", detail: "The app icon is not kept on this fleet" });
353
357
  return true;
354
358
  }
355
359
  const reading = method === "GET" || method === "HEAD";
@@ -385,7 +389,7 @@ function handleAppIcon(req, res, url, ctx, method) {
385
389
  if (method === "DELETE") {
386
390
  const reset = store.reset();
387
391
  if (!reset.ok) {
388
- json(res, reset.status, { error: reset.error, ...(reset.detail ? { detail: reset.detail } : {}) });
392
+ json(res, reset.status, appIconRefusal(reset));
389
393
  return true;
390
394
  }
391
395
  json(res, 200, state());
@@ -410,7 +414,7 @@ function handleAppIcon(req, res, url, ctx, method) {
410
414
  bytes = await readBoundedWebBody(req, APP_ICON_MAX_BYTES);
411
415
  }
412
416
  catch {
413
- json(res, 413, { error: "app_icon_too_large", detail: "at most 5 MiB" });
417
+ json(res, 413, { error: "app_icon_too_large", detail: "at most 5 MiB", detail_key: "max_size", detail_args: [5] });
414
418
  return;
415
419
  }
416
420
  if (!again())
@@ -418,7 +422,7 @@ function handleAppIcon(req, res, url, ctx, method) {
418
422
  // #2308 review: decoding and making the sizes is seconds of CPU at worst — a worker thread's, not the fleet loop's.
419
423
  const prepared = await store.prepare(bytes);
420
424
  if (!prepared.ok) {
421
- json(res, prepared.status, { error: prepared.error, ...(prepared.detail ? { detail: prepared.detail } : {}) });
425
+ json(res, prepared.status, appIconRefusal(prepared));
422
426
  return;
423
427
  }
424
428
  const person = again();
@@ -426,14 +430,14 @@ function handleAppIcon(req, res, url, ctx, method) {
426
430
  return;
427
431
  const saved = store.commit(prepared.prepared, person);
428
432
  if (!saved.ok) {
429
- json(res, saved.status, { error: saved.error, ...(saved.detail ? { detail: saved.detail } : {}) });
433
+ json(res, saved.status, appIconRefusal(saved));
430
434
  return;
431
435
  }
432
436
  json(res, 200, state());
433
437
  })().catch((err) => {
434
438
  ctx.logger.error({ err: err.message }, "App icon request failed");
435
439
  try {
436
- json(res, 500, { error: "App icon request failed" });
440
+ json(res, 500, { error: "app_icon_failed", detail: "App icon request failed" });
437
441
  }
438
442
  catch { /* already answered */ }
439
443
  });
@@ -2743,7 +2747,7 @@ export function handleWebRequest(req, res, url, ctx) {
2743
2747
  if (path === "/ui/tasks/board" || path === "/ui/tasks/search" || path === "/ui/tasks/work" || path.startsWith("/ui/tasks/cards") || path.startsWith("/ui/tasks/projects")) {
2744
2748
  const board = ctx.scheduler?.db.board;
2745
2749
  if (!board) {
2746
- json(res, 503, { error: "The task board is not available" });
2750
+ json(res, 503, { error: "The task board is not available", code: "unavailable", key: "unavailable" });
2747
2751
  return true;
2748
2752
  }
2749
2753
  // The temporary public link reads the board and changes nothing on it: confirming, sending back, cancelling and