@songsid/agend 2.3.2-beta.14 → 2.3.2-beta.16

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 (145) hide show
  1. package/dist/backend/codex-session-lookup.d.ts +15 -0
  2. package/dist/backend/codex-session-lookup.js +58 -0
  3. package/dist/backend/codex-session-lookup.js.map +1 -1
  4. package/dist/backend/codex.js +15 -4
  5. package/dist/backend/codex.js.map +1 -1
  6. package/dist/backend/kiro-classic-archive.d.ts +5 -0
  7. package/dist/backend/kiro-classic-archive.js +132 -7
  8. package/dist/backend/kiro-classic-archive.js.map +1 -1
  9. package/dist/backend/kiro-identity.d.ts +2 -0
  10. package/dist/backend/kiro-identity.js +4 -0
  11. package/dist/backend/kiro-identity.js.map +1 -1
  12. package/dist/backend/kiro-reconcile-store.d.ts +27 -0
  13. package/dist/backend/kiro-reconcile-store.js +344 -0
  14. package/dist/backend/kiro-reconcile-store.js.map +1 -0
  15. package/dist/backend/kiro-v2-store-worker.js +12 -3
  16. package/dist/backend/kiro-v2-store-worker.js.map +1 -1
  17. package/dist/backend/kiro-v2-store.d.ts +11 -0
  18. package/dist/backend/kiro-v2-store.js.map +1 -1
  19. package/dist/cache-service.d.ts +13 -1
  20. package/dist/cache-service.js +45 -2
  21. package/dist/cache-service.js.map +1 -1
  22. package/dist/channel/mcp-tools.js +5 -3
  23. package/dist/channel/mcp-tools.js.map +1 -1
  24. package/dist/cli.js +17 -4
  25. package/dist/cli.js.map +1 -1
  26. package/dist/config-validator.js +4 -1
  27. package/dist/config-validator.js.map +1 -1
  28. package/dist/fleet-manager.d.ts +23 -1
  29. package/dist/fleet-manager.js +127 -9
  30. package/dist/fleet-manager.js.map +1 -1
  31. package/dist/general-instance.d.ts +24 -0
  32. package/dist/general-instance.js +53 -0
  33. package/dist/general-instance.js.map +1 -1
  34. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +2 -0
  35. package/dist/general-knowledge/skills/web-appearance/SKILL.md +2 -0
  36. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +16 -9
  37. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +16 -9
  38. package/dist/instance-config-impact.js +1 -0
  39. package/dist/instance-config-impact.js.map +1 -1
  40. package/dist/kiro-reconcile.d.ts +21 -0
  41. package/dist/kiro-reconcile.js +80 -0
  42. package/dist/kiro-reconcile.js.map +1 -0
  43. package/dist/kiro-tui-forensic.d.ts +7 -0
  44. package/dist/kiro-tui-forensic.js +17 -3
  45. package/dist/kiro-tui-forensic.js.map +1 -1
  46. package/dist/locale.js +20 -0
  47. package/dist/locale.js.map +1 -1
  48. package/dist/log-service.js +1 -1
  49. package/dist/log-service.js.map +1 -1
  50. package/dist/office-levels.d.ts +64 -0
  51. package/dist/office-levels.js +66 -0
  52. package/dist/office-levels.js.map +1 -0
  53. package/dist/office-share-page.d.ts +3 -1
  54. package/dist/office-share-page.js +8 -3
  55. package/dist/office-share-page.js.map +1 -1
  56. package/dist/office-share-service.d.ts +4 -3
  57. package/dist/office-share-service.js +6 -6
  58. package/dist/office-share-service.js.map +1 -1
  59. package/dist/office-share.d.ts +35 -8
  60. package/dist/office-share.js +50 -15
  61. package/dist/office-share.js.map +1 -1
  62. package/dist/outbound-handlers.js +20 -1
  63. package/dist/outbound-handlers.js.map +1 -1
  64. package/dist/outbound-schemas.d.ts +6 -0
  65. package/dist/outbound-schemas.js +6 -0
  66. package/dist/outbound-schemas.js.map +1 -1
  67. package/dist/public-web-gateway.js +1 -1
  68. package/dist/public-web-gateway.js.map +1 -1
  69. package/dist/quickstart-api.d.ts +25 -1
  70. package/dist/quickstart-api.js +53 -6
  71. package/dist/quickstart-api.js.map +1 -1
  72. package/dist/scheduler/task-board.d.ts +28 -6
  73. package/dist/scheduler/task-board.js +66 -11
  74. package/dist/scheduler/task-board.js.map +1 -1
  75. package/dist/service-installer.d.ts +13 -0
  76. package/dist/service-installer.js +15 -0
  77. package/dist/service-installer.js.map +1 -1
  78. package/dist/service-owner.d.ts +25 -0
  79. package/dist/service-owner.js +43 -2
  80. package/dist/service-owner.js.map +1 -1
  81. package/dist/settings-api.js +4 -0
  82. package/dist/settings-api.js.map +1 -1
  83. package/dist/settings-change.js +2 -0
  84. package/dist/settings-change.js.map +1 -1
  85. package/dist/settings-effect.js +2 -0
  86. package/dist/settings-effect.js.map +1 -1
  87. package/dist/setup-form.js +17 -3
  88. package/dist/setup-form.js.map +1 -1
  89. package/dist/setup-i18n.js +2 -0
  90. package/dist/setup-i18n.js.map +1 -1
  91. package/dist/task-board-waker.js +7 -2
  92. package/dist/task-board-waker.js.map +1 -1
  93. package/dist/tool-permissions.js +1 -0
  94. package/dist/tool-permissions.js.map +1 -1
  95. package/dist/types.d.ts +5 -0
  96. package/dist/ui/fleet-office-card.js +114 -0
  97. package/dist/ui/fleet-office-editor.js +27 -7
  98. package/dist/ui/fleet-office-layout.js +14 -6
  99. package/dist/ui/fleet-office-levels.js +105 -0
  100. package/dist/ui/fleet-office-model.js +9 -1
  101. package/dist/ui/fleet-office-pack.d.ts +6 -0
  102. package/dist/ui/fleet-office-pack.js +62 -6
  103. package/dist/ui/fleet-office-skin.js +39 -3
  104. package/dist/ui/fleet-office-store.js +31 -3
  105. package/dist/ui/fleet-office-view.js +8 -0
  106. package/dist/ui/fleet-office-zones.js +10 -2
  107. package/dist/ui/fleet-office.js +208 -30
  108. package/dist/ui/fleet-org.js +2 -0
  109. package/dist/ui/fleet-task-card.js +2 -2
  110. package/dist/ui/fleet-task-common.js +30 -7
  111. package/dist/ui/fleet-task-work.js +2 -2
  112. package/dist/ui/fleet-tasks.js +10 -11
  113. package/dist/ui/office-share-dialog.js +31 -7
  114. package/dist/ui/office-share-page.js +1 -0
  115. package/dist/ui/panel-log.js +2 -2
  116. package/dist/ui/panel-settings.js +7 -1
  117. package/dist/ui/settings-registry.js +1 -0
  118. package/dist/ui/settings-strings.js +4 -0
  119. package/dist/ui/settings-wizard.js +15 -4
  120. package/dist/ui/shared/app.css +50 -0
  121. package/dist/ui/shared/setup-host-text.js +2 -0
  122. package/dist/usage-rollup.d.ts +34 -3
  123. package/dist/usage-rollup.js +56 -3
  124. package/dist/usage-rollup.js.map +1 -1
  125. package/dist/web-api.d.ts +5 -0
  126. package/dist/web-api.js +116 -14
  127. package/dist/web-api.js.map +1 -1
  128. package/dist/web-auth.js +1 -1
  129. package/dist/web-auth.js.map +1 -1
  130. package/dist/web-office-acts.d.ts +5 -2
  131. package/dist/web-office-acts.js +7 -2
  132. package/dist/web-office-acts.js.map +1 -1
  133. package/dist/web-office-pack-tools.d.ts +10 -1
  134. package/dist/web-office-pack-tools.js +36 -5
  135. package/dist/web-office-pack-tools.js.map +1 -1
  136. package/dist/web-office-skins.js +6 -0
  137. package/dist/web-office-skins.js.map +1 -1
  138. package/dist/web-office-store.d.ts +28 -4
  139. package/dist/web-office-store.js +49 -8
  140. package/dist/web-office-store.js.map +1 -1
  141. package/dist/web-settings-patch.d.ts +3 -1
  142. package/dist/web-settings-patch.js +4 -1
  143. package/dist/web-settings-patch.js.map +1 -1
  144. package/dist/workflow-templates/default.md +1 -1
  145. package/package.json +1 -1
@@ -15,7 +15,7 @@ import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
15
15
  import { Icon } from "/assets/ui-icons.js";
16
16
  import { Menu } from "/assets/ui-menu.js";
17
17
  import { toast } from "/assets/ui-toast.js";
18
- import { AskHost, COLUMNS, NO_PROJECT, ProgressBar, StatusPill, UNASSIGNED, Who, answerBlocked, boardApi, currentWork, dropNeighbours, errorText, followsSubs, moveCard, onPublicLink, projectKey, projectStats, readStored, recentlyDone, relTime, workersOf, wrapUpText, writeStored } from "./fleet-task-common.js";
18
+ import { AskHost, COLUMNS, NO_PROJECT, ProgressBar, StatusPill, UNASSIGNED, Who, answerBlocked, boardApi, currentWork, dropNeighbours, errorText, followsSubs, moveCard, onPublicLink, projectKey, projectStats, readStored, recentlyDone, relTime, reviewLabel, waitsForYou, workersOf, wrapUpText, writeStored } from "./fleet-task-common.js";
19
19
  import { CardDialog } from "./fleet-task-card.js";
20
20
  import { InstancesDrawer } from "./fleet-task-work.js";
21
21
 
@@ -188,16 +188,16 @@ function ProjectTile({ project, stats, onOpen, onDialog, reload }) {
188
188
  }
189
189
 
190
190
  /**
191
- * Cards waiting for the person: to confirm or send back, and blocked ones to answer. A card's buttons take one answer
192
- * at a time: claimed before the first await, so a double click sends one move.
191
+ * Cards waiting for the person: to confirm or send back, and blocked ones to answer — #2080: only those the person
192
+ * handed out (waitsForYou, Needs you's rule); an agent's card in review is its confirmer's, and a blocked one asks its
193
+ * dispatcher, not the person. A card's buttons take one answer at a time: claimed before the first await, so a double
194
+ * click sends one move.
193
195
  */
194
196
  function Waiting({ cards, writable, onOpen, reload }) {
195
- const { instances } = useStore(appStore);
196
- const fleetNames = new Set(instances.map((i) => i.name));
197
197
  const pending = useRef(new Set());
198
198
  const [busy, setBusy] = useState(() => new Set());
199
- const review = cards.filter((c) => c.status === "review");
200
- const blocked = cards.filter((c) => c.status === "blocked");
199
+ const review = cards.filter((c) => c.status === "review" && waitsForYou(c));
200
+ const blocked = cards.filter((c) => c.status === "blocked" && waitsForYou(c));
201
201
  if (!review.length && !blocked.length) return null;
202
202
  const act = async (fn, done, card) => {
203
203
  if (pending.current.has(card.id)) return;
@@ -213,8 +213,6 @@ function Waiting({ cards, writable, onOpen, reload }) {
213
213
  <${StatusPill} status=${c.status} />
214
214
  <button type="button" class="tb-wait-title" onClick=${() => onOpen(c.id)}><span class="tb-code">${c.id}</span> ${c.title}
215
215
  ${c.status === "blocked" && c.blockedReason ? html`<span class="tb-wait-reason">${c.blockedReason}</span>` : null}
216
- ${c.status === "blocked" && c.dispatcher && c.dispatcher.kind === "instance" && c.dispatcher.name !== c.assignee && fleetNames.has(c.dispatcher.name)
217
- ? html`<span class="tb-wait-asked muted">${t("tasks.askedFirst", c.dispatcher.name)}</span>` : null}
218
216
  ${c.status === "review" && wrapUpText(c) ? html`<span class="tb-wait-reason wrapup">${t("tasks.wrapLine", wrapUpText(c))}</span>` : null}</button>
219
217
  </div>
220
218
  <div class="tb-wait-actions">
@@ -257,7 +255,7 @@ function SearchBox({ onOpen }) {
257
255
  ${results ? html`<ul class="tb-search-results" role="listbox">
258
256
  ${results.length ? results.map((r, i) => html`<li key=${r.card.id} role="option" aria-selected=${i === active ? "true" : "false"} class=${i === active ? "active" : ""}>
259
257
  <button type="button" onClick=${() => pick(r.card.id)}>
260
- <span class="tb-code">${r.card.id}</span><span class="tb-search-title">${r.card.title}</span><${StatusPill} status=${r.card.status} />
258
+ <span class="tb-code">${r.card.id}</span><span class="tb-search-title">${r.card.title}</span><${StatusPill} status=${r.card.status} card=${r.card} />
261
259
  <span class="tb-search-where muted">${[r.project, r.parentTitle].filter(Boolean).join(" › ")}${r.match ? ` · ${t("tasks.inField", t(`tasks.field${r.match.field}`))}: ${r.match.text}` : ""}</span>
262
260
  </button></li>`) : html`<li class="muted tb-search-none">${t("tasks.searchEmpty")}</li>`}
263
261
  </ul>` : null}
@@ -457,9 +455,10 @@ function Tile({ card, dragging, flash, subs = [], expanded, writable, onToggleSu
457
455
  ${now ? html`<div class="tb-tile-now" title=${now.cards.map((c) => `${c.id} ${c.title}`).join("\n")}>
458
456
  <span class=${`tb-now-label st-${now.tone}`}>${t(now.label)}</span><span class="tb-code">${now.cards[0].id}</span>
459
457
  <span class="tb-now-title">${now.cards[0].title}</span>${now.cards.length > 1 ? html`<span class="muted">${t("tasks.moreCards", now.cards.length - 1)}</span>` : null}</div>` : null}
458
+ ${card.status === "review" && !hasSubs ? html`<div class=${`tb-tile-waits${waitsForYou(card) ? " you" : ""}`}>${reviewLabel(card)}</div>` : null}
460
459
  <div class="tb-tile-foot">
461
460
  ${now ? html`<span class="tb-people">${workers.map((w) => html`<${Who} key=${w} name=${w} empty=${t("tasks.nobody")} title=${t("tasks.ownerTip", card.assignee || t("tasks.nobody"))} />`)}</span>`
462
- : hasSubs && card.status === "review" ? html`<span class="tb-who tb-now-label st-review" title=${t("tasks.ownerTip", card.assignee || t("tasks.nobody"))}>${t("tasks.review")}</span>`
461
+ : hasSubs && card.status === "review" ? html`<span class="tb-who tb-now-label st-review" title=${t("tasks.ownerTip", card.assignee || t("tasks.nobody"))}>${reviewLabel(card)}</span>`
463
462
  : html`<${Who} name=${card.assignee} empty=${t("tasks.nobody")} />`}
464
463
  <span class="tb-tile-stats">
465
464
  ${card.acceptance.length ? html`<span class=${accepted === card.acceptance.length ? "all" : ""} title=${t(card.kind === "main" ? "tasks.definition" : "tasks.acceptance")}><${Icon} name="check" size=${13} />${accepted}/${card.acceptance.length}</span>` : null}
@@ -1,6 +1,9 @@
1
1
  // #2636: Office share links on the dashboard — the Office's Share… dialog (make one: which office, how long, whether
2
2
  // agents' own lines show; then its URL to copy) and the list of open ones (Settings → Access & security, and under the
3
3
  // dialog's form): each with its office, when it ends, whether lines show and who made it, Revoke and Revoke all.
4
+ // #2716: the whole-fleet office can be shared too ("auto", the default while it is the one shown); #2715: the link's
5
+ // look — light or dark as this dashboard shows it now (the default, the device's when it follows the device), or the
6
+ // viewer's device's.
4
7
  // GET / POST /ui/office-share, DELETE /ui/office-share/<id|all> (web-api.ts): an admin session on the dashboard reached
5
8
  // directly only; the public link never offers these.
6
9
  import { html, useEffect, useState } from "/assets/app-html.js";
@@ -11,7 +14,8 @@ import { toast } from "/assets/ui-toast.js";
11
14
  register("oshare", {
12
15
  en: {
13
16
  share: "Share…", title: "Share the office", lead: "Anyone with the link can watch this office live — names and states, nothing else — until it ends. No sign-in.",
14
- office: "Office", allOffices: "All offices", how: "For", text: "Include agents' own status lines", create: "Create link", creating: "Starting the link…",
17
+ office: "Office", allOffices: "All offices", wholeFleet: "Whole fleet", how: "For",
18
+ look: "Look", lookAuto: "The viewer's device (light or dark)", lookLight: "Light", lookDark: "Dark", text: "Include agents' own status lines", create: "Create link", creating: "Starting the link…",
15
19
  made: "Anyone with this link can watch until {0}.", copy: "Copy link", copied: "Link copied", open: "Open links", none: "No share links are open.",
16
20
  ends: "until {0}", withText: "with status lines", by: "by {0}", revoke: "Revoke", revokeAll: "Revoke all", close: "Close", failed: "The link could not be made.",
17
21
  m15: "15 minutes", m30: "30 minutes", h1: "1 hour", h2: "2 hours", h4: "4 hours", h8: "8 hours", h24: "24 hours",
@@ -22,7 +26,8 @@ register("oshare", {
22
26
  },
23
27
  "zh-TW": {
24
28
  share: "分享…", title: "分享辦公室", lead: "拿到連結的人都能即時觀看這間辦公室(只有名稱與狀態),直到連結結束,不需要登入。",
25
- office: "辦公室", allOffices: "所有辦公室", how: "時間", text: "包含 agent 自己的狀態文字", create: "建立連結", creating: "正在啟動連結…",
29
+ office: "辦公室", allOffices: "所有辦公室", wholeFleet: "整個 fleet", how: "時間",
30
+ look: "外觀", lookAuto: "依觀看者的裝置(淺色或深色)", lookLight: "淺色", lookDark: "深色", text: "包含 agent 自己的狀態文字", create: "建立連結", creating: "正在啟動連結…",
26
31
  made: "拿到這個連結的人可以觀看到 {0}。", copy: "複製連結", copied: "已複製連結", open: "開啟中的連結", none: "目前沒有開啟中的分享連結。",
27
32
  ends: "到 {0} 為止", withText: "含狀態文字", by: "由 {0} 建立", revoke: "撤銷", revokeAll: "全部撤銷", close: "關閉", failed: "無法建立連結。",
28
33
  m15: "15 分鐘", m30: "30 分鐘", h1: "1 小時", h2: "2 小時", h4: "4 小時", h8: "8 小時", h24: "24 小時",
@@ -44,6 +49,16 @@ export function refusalText(d) {
44
49
  return ["disabled", "office", "duration", "too_many"].includes(e) ? to(`e_${e}`) : to("failed");
45
50
  }
46
51
 
52
+ /**
53
+ * #2715: the look a new link takes by default — what this dashboard shows now: its chosen light or dark, or (following
54
+ * the device) the device's; unknown, the viewer's own.
55
+ */
56
+ export function defaultLook(theme = globalThis.AgendTheme && typeof globalThis.AgendTheme.get === "function" ? globalThis.AgendTheme.get() : null,
57
+ media = typeof globalThis.matchMedia === "function" ? globalThis.matchMedia : null) {
58
+ if (theme === "light" || theme === "dark") return theme;
59
+ try { return media ? (media("(prefers-color-scheme: dark)").matches ? "dark" : "light") : "auto"; } catch { return "auto"; }
60
+ }
61
+
47
62
  /** The open shares, read (and re-read after a change): { enabled, shares } or null while unread / unavailable. */
48
63
  export function useShares(get = globalThis.fetch, rev = 0) {
49
64
  const [d, setD] = useState(null);
@@ -67,7 +82,7 @@ export function ShareList({ shares, names = {}, get = globalThis.fetch, onChange
67
82
  if (!shares || !shares.length) return html`<p class="muted os-none">${to("none")}</p>`;
68
83
  return html`<div class="os-list">
69
84
  <ul class="os-rows">${shares.map((s) => html`<li key=${s.id} class="os-row">
70
- <span class="os-what"><strong>${s.office === "all" ? to("allOffices") : names[s.office] || s.office_name || s.office}</strong>
85
+ <span class="os-what"><strong>${s.office === "all" ? to("allOffices") : s.office === "auto" ? to("wholeFleet") : names[s.office] || s.office_name || s.office}</strong>
71
86
  <span class="muted"> · ${to("ends", when(s.expiresAt))}${s.text ? ` · ${to("withText")}` : ""}</span></span>
72
87
  <button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => revoke(s.id)}>${to("revoke")}</button>
73
88
  </li>`)}</ul>
@@ -75,9 +90,13 @@ export function ShareList({ shares, names = {}, get = globalThis.fetch, onChange
75
90
  </div>`;
76
91
  }
77
92
 
78
- /** Share…: make a link for one office (the one shown, by default) or all of them. `offices`: the stored ones. */
93
+ /**
94
+ * Share…: make a link for one office (the one shown, by default), all of them, or (#2716) the whole-fleet office
95
+ * (`current` null: the one shown). `offices`: the stored ones.
96
+ */
79
97
  export function ShareDialog({ offices = [], current = null, get = globalThis.fetch, onClose }) {
80
- const [office, setOffice] = useState(current && offices.some((o) => o.id === current) ? current : "all");
98
+ const [office, setOffice] = useState(current === null ? "auto" : current === "all" || offices.some((o) => o.id === current) ? current : "all");
99
+ const [theme, setTheme] = useState(() => defaultLook());
81
100
  const [minutes, setMinutes] = useState(60);
82
101
  const [text, setText] = useState(false);
83
102
  const [busy, setBusy] = useState(false);
@@ -89,7 +108,7 @@ export function ShareDialog({ offices = [], current = null, get = globalThis.fet
89
108
  const create = async () => {
90
109
  setBusy(true); setError("");
91
110
  try {
92
- const r = await get("/ui/office-share", { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ office, minutes, text }) });
111
+ const r = await get("/ui/office-share", { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ office, minutes, text, theme }) });
93
112
  const d = await r.json().catch(() => ({}));
94
113
  if (r.ok && d && typeof d.url === "string") setMade(d); else setError(refusalText(d));
95
114
  } catch { setError(to("failed")); }
@@ -110,13 +129,18 @@ export function ShareDialog({ offices = [], current = null, get = globalThis.fet
110
129
  </div>` : html`<div class="os-form">
111
130
  <label class="field"><span>${to("office")}</span>
112
131
  <select class="tb-select" value=${office} onChange=${(e) => setOffice(e.target.value)} disabled=${busy}>
113
- ${offices.length > 1 || !offices.length ? html`<option value="all">${to("allOffices")}</option>` : null}
132
+ <option value="auto">${to("wholeFleet")}</option>
133
+ ${offices.length > 1 || office === "all" ? html`<option value="all">${to("allOffices")}</option>` : null}
114
134
  ${offices.map((o) => html`<option key=${o.id} value=${o.id}>${o.name}</option>`)}
115
135
  </select></label>
116
136
  <label class="field"><span>${to("how")}</span>
117
137
  <select class="tb-select" value=${String(minutes)} onChange=${(e) => setMinutes(Number(e.target.value))} disabled=${busy}>
118
138
  ${DURATIONS.map(([k, m]) => html`<option key=${k} value=${String(m)}>${to(k)}</option>`)}
119
139
  </select></label>
140
+ <label class="field"><span>${to("look")}</span>
141
+ <select class="tb-select os-look" value=${theme} onChange=${(e) => setTheme(e.target.value)} disabled=${busy}>
142
+ ${[["auto", "lookAuto"], ["light", "lookLight"], ["dark", "lookDark"]].map(([v, k]) => html`<option key=${v} value=${v}>${to(k)}</option>`)}
143
+ </select></label>
120
144
  <label class="check"><input type="checkbox" checked=${text} onChange=${(e) => setText(e.target.checked)} disabled=${busy} /> ${to("text")}</label>
121
145
  ${error ? html`<p class="err os-err" role="alert">${error}</p>` : null}
122
146
  </div>`}
@@ -30,6 +30,7 @@ export function shareUrl(base, path) {
30
30
  const p = String(path);
31
31
  if (p === "/ui/offices") return `${base}/offices`;
32
32
  if (p === "/ui/office-acts") return `${base}/office-acts`;
33
+ if (p === "/ui/office-levels") return `${base}/office-levels`;
33
34
  if (p === "/ui/office-skins" || p.startsWith("/ui/office-skins/") || p.startsWith("/ui/office-skins?")) return `${base}${p.slice(3)}`;
34
35
  return null;
35
36
  }
@@ -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", ev_context_lost: "Started fresh: context lost", ev_working_state: "Working-state journal",
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_context_lost: "Started fresh: context lost", ev_working_state: "Working-state journal", ev_kiro_reconcile: "Kiro conversation reconciliation",
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: "健康檢查錯誤", ev_context_lost: "改用全新對話:context 遺失", ev_working_state: "工作狀態紀錄",
103
+ ev_supervision_ended: "監督結束", ev_tmux_server_crash: "tmux 當掉", ev_malformed_tool_call: "格式錯誤的工具呼叫", ev_health_check_error: "健康檢查錯誤", ev_context_lost: "改用全新對話:context 遺失", ev_working_state: "工作狀態紀錄", ev_kiro_reconcile: "Kiro 對話接續處理",
104
104
  },
105
105
  });
106
106
  const tl = (key, ...a) => t(`log.${key}`, ...a);
@@ -1198,13 +1198,14 @@ function WebSection({ ctx, staged, part = "all" }) {
1198
1198
  echo: web.echo_to_channel ?? true,
1199
1199
  log: web.log_tab === true, // #1218: off by default
1200
1200
  usagePub: web.usage_report_on_public_link === true, // #2122: off by default
1201
+ levels: web.office_levels !== false, // #2661: on by default
1201
1202
  pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2", hostname: (web.public_link && web.public_link.hostname) ?? "", tunnel_token_env: (web.public_link && web.public_link.tunnel_token_env) ?? "", origin_port: (web.public_link && web.public_link.origin_port) ?? 19282, auto_open: (web.public_link && web.public_link.auto_open) ?? false },
1202
1203
  session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
1203
1204
  terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
1204
1205
  }), []);
1205
1206
  const text = (v) => (v === null || v === undefined ? "" : String(v));
1206
1207
  /** #2347: a field's row class: `s-dirty` while it differs from what is saved (the draft may come from before). */
1207
- const savedForm = useMemo(() => ({ notify: String(original.notify), echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public,
1208
+ const savedForm = useMemo(() => ({ notify: String(original.notify), echo: original.echo, log: original.log, usagePub: original.usagePub, levels: original.levels, pub: original.pub.allow_public,
1208
1209
  ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
1209
1210
  abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours), termOn: original.terminal.enabled !== false,
1210
1211
  termTtl: text(original.terminal.ttl_minutes), autoOpen: original.pub.auto_open }), []);
@@ -1218,6 +1219,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1218
1219
  return {
1219
1220
  notify: String(pick(pending, "notify_login", original.notify)),
1220
1221
  echo: pick(pending, "echo_to_channel", original.echo), log: pick(pending, "log_tab", original.log), usagePub: pick(pending, "usage_report_on_public_link", original.usagePub),
1222
+ levels: pick(pending, "office_levels", original.levels),
1221
1223
  pub: pick(pub, "allow_public", original.pub.allow_public), ttl: String(pick(pub, "ttl_minutes", original.pub.ttl_minutes)), protocol: pick(pub, "protocol", original.pub.protocol),
1222
1224
  hostname: pick(pub, "hostname", original.pub.hostname), originPort: String(pick(pub, "origin_port", original.pub.origin_port)), autoOpen: pick(pub, "auto_open", original.pub.auto_open),
1223
1225
  abs: text(pick(session, "absolute_hours", original.session.absolute_hours)), idle: text(pick(session, "idle_hours", original.session.idle_hours)),
@@ -1269,6 +1271,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1269
1271
  ...(f.echo !== original.echo ? { echo_to_channel: f.echo } : {}),
1270
1272
  ...(f.log !== original.log ? { log_tab: f.log } : {}),
1271
1273
  ...(f.usagePub !== original.usagePub ? { usage_report_on_public_link: f.usagePub } : {}),
1274
+ ...(f.levels !== original.levels ? { office_levels: f.levels } : {}),
1272
1275
  ...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}),
1273
1276
  ...(Object.keys(sessionPatch).length ? { session: { local: sessionPatch } } : {}),
1274
1277
  ...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
@@ -1278,6 +1281,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1278
1281
  ...(body.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []),
1279
1282
  ...(body.log_tab !== undefined ? ["web.log_tab"] : []),
1280
1283
  ...(body.usage_report_on_public_link !== undefined ? ["web.usage_report_on_public_link"] : []),
1284
+ ...(body.office_levels !== undefined ? ["web.office_levels"] : []),
1281
1285
  ...Object.keys(publicPatch).map((k) => `web.public_link.${k}`),
1282
1286
  ...Object.keys(sessionPatch).map((k) => `web.session.local.${k}`),
1283
1287
  ...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
@@ -1369,6 +1373,8 @@ function WebSection({ ctx, staged, part = "all" }) {
1369
1373
  <p class="note">${tn("webLogTabHint")}</p>
1370
1374
  <label class=${fc("check", "usagePub")}><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
1371
1375
  <p class="note">${tn("webUsagePublicHint")}</p>
1376
+ <label class=${fc("check", "levels")}><input id="webOfficeLevels" type="checkbox" checked=${f.levels} onChange=${(e) => set("levels")(e.target.checked)} /><span>${tn("webOfficeLevels")}</span><${Impact} schema=${schema} field="web.office_levels" /></label>
1377
+ <p class="note">${tn("webOfficeLevelsHint")}</p>
1372
1378
  </section>`) : null}
1373
1379
  ${has("readonly") ? html`<section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
1374
1380
  <p class="note">${tn("webReadOnlyLead")}</p>
@@ -66,6 +66,7 @@ const ROWS = [
66
66
  // Fleet · Web dashboard
67
67
  ["dash.log-tab", "web-dashboard", "#webLogTab", "settings.webLogTab", "web.log_tab", ["log tab", "steps", "log"], ["Log 分頁", "紀錄", "步驟"]],
68
68
  ["dash.usage-public", "web-dashboard", "#webUsagePublic", "settings.webUsagePublic", "web.usage_report_on_public_link", ["usage", "public link", "cost", "tokens"], ["用量", "公開連結", "費用"]],
69
+ ["dash.office-levels", "web-dashboard", "#webOfficeLevels", "settings.webOfficeLevels", "web.office_levels", ["office", "level", "badge", "profile card"], ["辦公室", "等級", "徽章", "名片"]],
69
70
  ["dash.app-icon", "web-dashboard", ".s-app-icon", "settings.appIconTitle", null, ["app icon", "favicon", "home screen", "icon"], ["App 圖示", "圖示", "主畫面"]],
70
71
  ["dash.card-limits", "web-dashboard", ".s-card-limits", "settings.cardLimitsTitle", "web.appearance", ["upload limits", "portrait", "banner", "card limits"], ["上傳限制", "身分卡上傳限制"]],
71
72
  ["dash.canvas-worker", "web-dashboard", ".s-experimental-effects", "experimentalEffects.title", "web.experimental.canvas_worker", ["canvas worker", "experimental", "effects"], ["Canvas Worker", "實驗功能", "特效"]],
@@ -39,6 +39,7 @@ register("settings", {
39
39
  namedTokenSaved: "Token change saved. Reopen the public link.",
40
40
  webEcho: "Web echo",
41
41
  webEchoHint: "Copies web messages to the bound Telegram or Discord topic. ClassicBot rooms follow it unless a room sets its own.",
42
+ webOfficeLevels: "Office levels", webOfficeLevelsHint: "Each agent's level from its lifetime work, on its name tag in the Office and on its profile card. Off removes the badges and the card's level rows. The token counts behind a level follow the usage report's rules. Applies at once.",
42
43
  webUsagePublic: "Usage report over the public link", webUsagePublicHint: "Fleet → Usage (up to 400 days of each agent's tokens, requests, messages and API-equivalent amounts) is offered over the temporary public link too. Off by default; guests never see it. Applies at once.",
43
44
  webLogTab: "Log tab: each agent's steps", webLogTabHint: "Shows each instance's prompts, replies, tool calls and results, and fleet events in its Log tab, read from its own transcript only while the tab is open. Secret values are masked. Also over the public link. Turning it on waits for a fleet admin's confirmation.",
44
45
  general: "General Settings",
@@ -185,6 +186,7 @@ register("settings", {
185
186
  wizardBackendFound: "Found on this host: {0}",
186
187
  wizardBackendNone: "No backend CLI found on this host — install one first.",
187
188
  wizardAgentName: "First agent name",
189
+ wizardGeneralNote: "This agent is the General: it answers in the chat's own General topic and hands work to the agents you add later.",
188
190
  wizardAgentWhich: "Which agent answers on this bot",
189
191
  wizardAgentNew: "A new agent",
190
192
  wizardAgentExisting: "An agent you already have",
@@ -718,6 +720,7 @@ register("settings", {
718
720
  namedTokenSaved: "Token 變更已儲存,請重新開啟公開連結。",
719
721
  webEcho: "Web 同步",
720
722
  webEchoHint: "將網頁訊息複製到綁定的 Telegram 或 Discord 主題。ClassicBot 聊天室也照這個設定,除非該聊天室另外設定。",
723
+ webOfficeLevels: "辦公室等級", webOfficeLevelsHint: "每個 agent 依終身工作量得到的等級,顯示在辦公室的名牌與名片上。關閉會移除徽章與名片上的等級列。等級背後的 token 數字依用量報告的規則顯示。變更立即生效。",
721
724
  webUsagePublic: "透過公開連結提供用量報告", webUsagePublicHint: "Fleet → 用量(最多 400 天、每個 agent 的 token、請求、訊息與依牌價換算的 API 等值)也會透過臨時公開連結提供。預設關閉;訪客一律看不到。變更立即生效。",
722
725
  webLogTab: "紀錄分頁:每個 agent 的步驟", webLogTabHint: "在每個 instance 的「紀錄」分頁顯示提示、回覆、工具呼叫與結果,以及 fleet 事件;只在分頁開著時從它自己的對話紀錄讀取。機密值會遮蔽。公開連結也看得到。開啟需 fleet admin 確認。",
723
726
  general: "一般設定",
@@ -838,6 +841,7 @@ register("settings", {
838
841
  wizardBackendFound: "在這台主機上找到:{0}",
839
842
  wizardBackendNone: "這台主機上找不到任何後端 CLI —— 請先安裝一個。",
840
843
  wizardAgentName: "第一個 agent 名稱",
844
+ wizardGeneralNote: "這個 agent 就是 General:它在聊天本身的 General 主題回覆,並把工作分給你之後加入的 agent。",
841
845
  wizardAgentWhich: "由哪個 agent 在這個機器人上回覆",
842
846
  wizardAgentNew: "新的 agent",
843
847
  wizardAgentExisting: "既有的 agent",
@@ -44,7 +44,11 @@ export function SetupWizard({ ctx, onClose }) {
44
44
  const agents = Array.isArray(e.agents) ? e.agents : [];
45
45
  const names = new Set(agents.map((a) => a.name));
46
46
  let n = 1; while (names.has(`agent-${n}`)) n++;
47
- setW({ step: 1, env: e, backend: (e.backends || [])[0] || "claude-code", working_directory: "", instance_name: `agent-${n}`,
47
+ // M-2069: on a fleet with no chat connection and no General, the new agent is the General — named "general" (unless
48
+ // an agent has that name already: #2744 review).
49
+ const general = !!e.first_general && !names.has("general");
50
+ setW({ step: 1, env: e, backend: (e.backends || [])[0] || "claude-code", working_directory: "", instance_name: general ? "general" : `agent-${n}`,
51
+ general, defaultName: `agent-${n}`, nameTyped: false,
48
52
  agent: agents.length ? "existing" : "new", existing_name: agents.length ? agents[0].name : "",
49
53
  platform: "telegram", token: "", group_id: "", guild_id: "", general_channel_id: "", admin_user_id: "",
50
54
  identity: null, guilds: [], plan: null, offset: 0 });
@@ -54,6 +58,11 @@ export function SetupWizard({ ctx, onClose }) {
54
58
  // Every field the plan or a probe is about moves the revision (#1529 review r2): a plan or Detect that began before an
55
59
  // edit — target, admin, backend, directory, name — is dropped when it lands, never committed or written over the edit.
56
60
  const set = (k) => (v) => { bump(); setW((x) => ({ ...x, [k]: v })); };
61
+ // #2744 review: the General works in its own folder — a typed directory makes the new agent a worker, so a name not
62
+ // typed by hand goes back to agent-N (and "general" again once the directory is cleared).
63
+ const asGeneral = (x) => x.general && !x.working_directory;
64
+ const setDir = (v) => { bump(); setW((x) => { const next = { ...x, working_directory: v };
65
+ return x.nameTyped ? next : { ...next, instance_name: asGeneral(next) ? "general" : x.defaultName }; }); };
57
66
  // No token env: the plan generates one (#1519 P1: AGEND_<PLATFORM>_<ID>_TOKEN, unique) and the commit carries the plan's.
58
67
  // An existing agent (#1519 P7) is named and nothing else: the server keeps its directory and backend.
59
68
  const input = () => ({
@@ -144,9 +153,10 @@ export function SetupWizard({ ctx, onClose }) {
144
153
  <p class="note">${tn("wizardAgentExistingNote")}</p></div>` : html`${which}<div class="field"><label for="wz-be">${tn("wizardBackend")}</label>
145
154
  <${Select} id="wz-be" value=${w.backend} onChange=${set("backend")} options=${w.env.backends && w.env.backends.length ? w.env.backends : BACKENDS} />
146
155
  <p class="note">${w.env.backends && w.env.backends.length ? tn("wizardBackendFound", w.env.backends.join(", ")) : tn("wizardBackendNone")}</p></div>
147
- <div class="field"><label for="wz-wd">${tn("wizardDir")}</label><input id="wz-wd" type="text" placeholder="/home/you/projects/app" aria-describedby="wz-wd-note" value=${dirText} onInput=${(e) => set("working_directory")(onDirText(e.target.value))} />
156
+ <div class="field"><label for="wz-wd">${tn("wizardDir")}</label><input id="wz-wd" type="text" placeholder="/home/you/projects/app" aria-describedby="wz-wd-note" value=${dirText} onInput=${(e) => setDir(onDirText(e.target.value))} />
148
157
  <p class="note" id="wz-wd-note">${w.env.workspaces_dir && w.instance_name ? tn("wizardDirDefault", `${w.env.workspaces_dir}/${w.instance_name}`) : tn("wizardDirDefaultName")}</p></div>
149
- <div class="field"><label for="wz-name">${agents.length ? tn("wizardAgentNewName") : tn("wizardAgentName")}</label><input id="wz-name" type="text" value=${nameText} onInput=${(e) => set("instance_name")(onNameText(e.target.value))} /></div>`;
158
+ <div class="field"><label for="wz-name">${agents.length ? tn("wizardAgentNewName") : tn("wizardAgentName")}</label><input id="wz-name" type="text" value=${nameText} onInput=${(e) => { const v = onNameText(e.target.value); bump(); setW((x) => ({ ...x, instance_name: v, nameTyped: true })); }} />
159
+ ${asGeneral(w) ? html`<p class="note">${tn("wizardGeneralNote")}</p>` : null}</div>`;
150
160
  } else if (w.step === 2) {
151
161
  body = html`<div class="seg-inline" role="group" aria-label=${tn("wizardPlatform")}>
152
162
  ${["telegram", "discord"].map((p) => html`<button key=${p} type="button" class=${`btn${w.platform === p ? " btn-primary" : ""}`} aria-pressed=${w.platform === p ? "true" : "false"}
@@ -169,7 +179,8 @@ export function SetupWizard({ ctx, onClose }) {
169
179
  body = !w.plan ? html`<p class="note">${tn("wizardPlanLoading")}</p>` : html`
170
180
  <p><strong>${tn("wizardWillWrite")}</strong></p>
171
181
  <pre class="s-yaml">${toYaml({ channels: [w.plan.channel], instances: { [w.plan.instance.name]: { working_directory: w.plan.instance.working_directory, backend: w.plan.instance.backend, channel_id: w.plan.instance.channel_id,
172
- ...(w.plan.instance.topic && w.plan.instance.topic.to != null ? { topic_id: w.plan.instance.topic.to } : {}) } } })}</pre>
182
+ ...(w.plan.instance.topic && w.plan.instance.topic.to != null ? { topic_id: w.plan.instance.topic.to } : {}),
183
+ ...(w.plan.instance.general ? { general_topic: true, ...(w.plan.instance.general.topic_id != null ? { topic_id: w.plan.instance.general.topic_id } : {}) } : {}) } } })}</pre>
173
184
  ${w.plan.instance && w.plan.instance.existing ? html`<p class="note">${tn("wizardAgentExistingNote")}</p>` : null}
174
185
  ${w.plan.instance && w.plan.instance.topic ? html`<p class="feedback warning">${w.plan.instance.topic.to == null
175
186
  ? tn("wizardTopicNotCarried", w.plan.instance.topic.from) : tn("wizardTopicGeneral")}</p>` : null}
@@ -1798,6 +1798,8 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1798
1798
  .oo-zoom .oo-fill { font-size: 11px; font-weight: var(--fw-semibold); }
1799
1799
  .oo-zoom .oo-fill.on { background: var(--accent-dim); color: var(--text); }
1800
1800
  .oo-stage.editing .oo-mini { display: none; }
1801
+ /* #2737: a single office all in view (the page sets data-whole): the overview would repeat the panel. */
1802
+ .oo-mini[data-whole] { display: none; }
1801
1803
  .oo-mini-scene { fill: var(--bg); }
1802
1804
  .oo-mini-room { fill: var(--surface); stroke: var(--border-strong); stroke-width: 6; }
1803
1805
  .oo-mini-dot { fill: var(--text-3); }
@@ -1807,6 +1809,47 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1807
1809
  .oo-mini-dot.match { fill: var(--focus); }
1808
1810
  .oo-tip { position: absolute; left: 0; top: 0; z-index: 5; max-width: 240px; pointer-events: none; padding: 6px 10px; font-size: var(--fs-xs); line-height: 1.4;
1809
1811
  color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); box-shadow: var(--shadow-2); overflow-wrap: anywhere; }
1812
+ /* #2661: a person's profile card — a popover beside them (placed by the page: transform through the CSSOM), 320 px fixed;
1813
+ on a phone a bottom sheet. Tone over borders; the status tokens for the level's tier. */
1814
+ .oo-card { position: absolute; left: 0; top: 0; z-index: 8; width: 320px; box-sizing: border-box; padding: var(--s-3); font-size: var(--fs-sm); line-height: var(--lh-sm);
1815
+ color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: var(--shadow-2); }
1816
+ .oo-card.sheet { position: fixed; left: 0; right: 0; top: auto; bottom: 0; width: auto; transform: none; z-index: 40; border-radius: var(--r-lg) var(--r-lg) 0 0;
1817
+ padding-bottom: calc(var(--s-3) + env(safe-area-inset-bottom, 0px)); touch-action: none; }
1818
+ /* On a phone the tab bar is fixed at the window's foot (z 35): the sheet sits above it. */
1819
+ @media (max-width: 899px) {
1820
+ .oo-card.sheet { bottom: calc(56px + env(safe-area-inset-bottom, 0px)); padding-bottom: var(--s-3); }
1821
+ .kb-open .oo-card.sheet { bottom: 0; }
1822
+ }
1823
+ .oo-card-grip { width: 36px; height: 4px; margin: 0 auto var(--s-2); border-radius: var(--r-full); background: var(--surface-2); }
1824
+ .oo-card-head { display: flex; gap: var(--s-3); align-items: flex-start; }
1825
+ .oo-card-avatar { position: relative; flex: none; width: 56px; height: 56px; border-radius: var(--r-md); background: var(--surface-2); }
1826
+ .oo-card-avatar canvas { width: 56px; height: 56px; image-rendering: pixelated; }
1827
+ .oo-card-avatar .oo-badge { position: absolute; left: -6px; top: -6px; }
1828
+ .oo-card-names { flex: 1; min-width: 0; }
1829
+ .oo-card-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1830
+ /* One line, cut; a fixed line height so a CJK fallback in the mono stack doesn't make it taller. */
1831
+ .oo-card-instance { height: 18px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-xs); color: var(--text-2); }
1832
+ .oo-card-state { color: var(--text-2); }
1833
+ .oo-card-close { flex: none; margin: -4px -4px 0 0; }
1834
+ .oo-card-xp { margin-top: var(--s-3); }
1835
+ .oo-card-bar { display: block; width: 100%; height: 6px; margin-top: var(--s-1); appearance: none; border: 0; border-radius: var(--r-full); background: var(--surface-2); overflow: hidden; }
1836
+ .oo-card-bar::-webkit-progress-bar { background: var(--surface-2); border-radius: var(--r-full); }
1837
+ .oo-card-bar::-webkit-progress-value { background: var(--accent); border-radius: var(--r-full); }
1838
+ .oo-card-bar::-moz-progress-bar { background: var(--accent); border-radius: var(--r-full); }
1839
+ .oo-card-rows { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-2) var(--s-3); margin: var(--s-3) 0 0; }
1840
+ .oo-card-rows > div { min-width: 0; }
1841
+ .oo-card-rows dt { font-size: var(--fs-xs); color: var(--text-3); }
1842
+ .oo-card-rows dd { margin: 0; overflow-wrap: anywhere; }
1843
+ .oo-card-now { margin-top: var(--s-3); overflow-wrap: anywhere; }
1844
+ .oo-card-actions { display: flex; justify-content: flex-end; gap: var(--s-2); margin-top: var(--s-3); }
1845
+ .oo-badge { display: inline-block; min-width: 18px; padding: 0 4px; box-sizing: border-box; font-size: 10px; font-weight: var(--fw-semibold); line-height: 16px; text-align: center;
1846
+ border-radius: var(--r-sm); border: 1px solid var(--border); background: var(--surface-2); color: var(--text-2); }
1847
+ .oo-badge.mark { min-width: 10px; width: 10px; height: 10px; padding: 0; transform: rotate(45deg); border-radius: 2px; }
1848
+ .oo-badge.tier-1 { background: var(--ok-bg); color: var(--ok); border-color: var(--ok); }
1849
+ .oo-badge.tier-2 { background: var(--accent-dim); color: var(--accent); border-color: var(--accent); }
1850
+ .oo-badge.tier-3 { background: var(--accent); color: var(--surface); border-color: var(--accent); }
1851
+ .oo-badge.tier-4 { background: var(--warn-bg); color: var(--warn); border-color: var(--warn); }
1852
+ .oo-badge.tier-5 { background: var(--danger-bg); color: var(--danger); border-color: var(--danger); }
1810
1853
  /* #2554: All offices — each tile's label (office name and head count) just above the tile, placed by the page (left, top,
1811
1854
  max-width through the CSSOM); a button that opens the office, the lobby's a plain chip. */
1812
1855
  .oo-tile-label { position: absolute; left: 0; top: 0; z-index: 3; transform: translateY(calc(-100% - 4px)); max-width: 240px; overflow: hidden; text-overflow: ellipsis;
@@ -1851,6 +1894,10 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
1851
1894
  .ol-seat > circle { fill: var(--ok); }
1852
1895
  .ol-furniture > rect { fill: var(--warn); }
1853
1896
  .ol-door > rect { fill: var(--danger); }
1897
+ /* #2736: the mail drop — an envelope; dashed while it is the automatic spot (the pack names none). */
1898
+ .ol-mail > rect { fill: var(--accent-dim); }
1899
+ .ol-mail > .ol-flap { fill: none; stroke: var(--text); stroke-width: 1.5; pointer-events: none; }
1900
+ .ol-mail.auto > rect { stroke-dasharray: 3 2; fill-opacity: 0.6; }
1854
1901
  /* #2665: an item's night place — a moon on a faint disc, a thin dashed line to its day place. */
1855
1902
  .ol-night > circle { fill: var(--surface); fill-opacity: 0.85; }
1856
1903
  .ol-night > .ol-moon { fill: var(--warn); stroke: none; pointer-events: none; }
@@ -2386,6 +2433,9 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
2386
2433
  .tb-tile-note { display: flex; gap: var(--s-1); align-items: flex-start; font-size: var(--fs-xs); color: var(--text-2); overflow-wrap: anywhere; }
2387
2434
  /* A main task's sub task at work now (its worker in the foot), and what a card waiting for you still lacks. */
2388
2435
  .tb-tile-now { display: flex; align-items: baseline; gap: var(--s-1); min-width: 0; font-size: var(--fs-xs); color: var(--text-2); }
2436
+ /* #2080: whom a card in review waits for — the person (accented), or the agent that confirms it. */
2437
+ .tb-tile-waits { font-size: var(--fs-xs); color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2438
+ .tb-tile-waits.you { color: var(--text); font-weight: var(--fw-semibold); }
2389
2439
  .tb-now-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2390
2440
  .tb-now-label { flex-shrink: 0; font-size: 11px; font-weight: var(--fw-semibold); color: var(--text-2); }
2391
2441
  .tb-now-label.st-claimed { color: var(--accent); }
@@ -35,6 +35,8 @@ export const HOST_MESSAGES = [
35
35
  // Conflicts (agentConflict, newConnectionConflict, webOnlyConflict, token-env-name.ts).
36
36
  ["an agent called \"{0}\" already exists — connect it as an existing agent, or choose another name", "已經有名為「{0}」的 agent —— 請把它當作既有的 agent 連接,或換一個名稱"],
37
37
  ["there is no agent called \"{0}\"", "沒有名為「{0}」的 agent"],
38
+ ["the General agent works in its own folder — leave the working directory empty to make \"{0}\" the General, or choose another name", "General agent 使用自己的資料夾 —— 若要讓「{0}」成為 General,請把工作目錄留空,或換一個名稱"],
39
+ ["the name \"{0}\" is kept for the fleet's General agent — choose another name", "「{0}」這個名稱保留給 fleet 的 General agent —— 請換一個名稱"],
38
40
  ["a connection called \"{0}\" already exists — a new connection never replaces one", "已經有名為「{0}」的連線 —— 新的連線不會取代既有的連線"],
39
41
  ["this fleet already has a chat connection — add the agent with New agent instead", "這個 fleet 已經有聊天連線 —— 請改用「新增 Agent」加入 agent"],
40
42
  ["{0} is reserved", "{0} 是保留名稱"],
@@ -48,12 +48,43 @@ export declare function localDate(t: number, zone: string): string;
48
48
  * so a fold repeated after a crash adds nothing twice.
49
49
  */
50
50
  export declare function foldHours(days: UsageDays, ledger: Ledger, cutoff: number, zone: string): boolean;
51
- /** Drop day rows older than the rollup keeps (by date, against `now` in `zone`). */
52
- export declare function pruneDays(days: UsageDays, now: number, zone: string): boolean;
51
+ /**
52
+ * Drop day rows older than the rollup keeps (by date, against `now` in `zone`). #2661: `carriedThrough` (the lifetime
53
+ * file's `through` as it is on disk): only rows at or before it are dropped — one not carried yet stays until it is.
54
+ */
55
+ export declare function pruneDays(days: UsageDays, now: number, zone: string, carriedThrough: string | null): boolean;
56
+ /**
57
+ * #2661: what the days pruned past ROLLUP_KEEP_DAYS held, so an instance's lifetime work (its Office level,
58
+ * office-levels.ts) never shrinks as history ages out — `usage-lifetime.json` beside the rollup, a file no version
59
+ * before #2661 opens (one that rewrites usage-days.json cannot wipe it).
60
+ * - `by`: the carried rows' totals summed by backend; `first`: the earliest date among them; `through`: the latest
61
+ * date carried. A day row at or before `through` is in `by` already and is never added again.
62
+ * - Order: carryPruned adds the rows about to be pruned, the lifetime file is written (atomic rename), and only then
63
+ * does pruneDays drop them from the rollup. A crash between the two writes leaves those rows in both; the next pass
64
+ * skips them here (at or before `through`) and the prune finishes — never counted twice, never lost. A lifetime file
65
+ * that cannot be written prunes nothing that pass.
66
+ * - Rollback gap: an older version running meanwhile prunes days without carrying them; those days are missing from
67
+ * the lifetime total for good (nothing else is lost).
68
+ */
69
+ export interface UsageLifetime {
70
+ v: 1;
71
+ through: string | null;
72
+ first: string | null;
73
+ by: Record<string, HourTotals>;
74
+ }
75
+ export declare const emptyLifetime: () => UsageLifetime;
76
+ /** A lifetime file read back from disk; anything that is not one is empty. */
77
+ export declare function reviveLifetime(raw: unknown): UsageLifetime;
78
+ /**
79
+ * Carry the day rows pruneDays is about to drop (older than ROLLUP_KEEP_DAYS against `now` in `zone`) and not carried
80
+ * yet into `life`. Mutates `life`; returns whether anything was added.
81
+ */
82
+ export declare function carryPruned(life: UsageLifetime, days: UsageDays, now: number, zone: string): boolean;
83
+ export declare function readLifetime(path: string): Promise<UsageLifetime>;
53
84
  /** Record that the scanner has read [from, to]: merged into the intervals, at most COVERAGE_MAX of them. */
54
85
  export declare function addCoverage(days: UsageDays, from: number, to: number): void;
55
86
  /** Whether the scanner has read the hour starting at `h`. */
56
87
  export declare const covered: (days: UsageDays, h: number) => boolean;
57
88
  export declare function readDays(path: string): Promise<UsageDays>;
58
89
  /** Written whole and renamed into place (0600): a crash leaves the old file or the new one, never a mix. */
59
- export declare function writeDays(path: string, days: UsageDays): Promise<boolean>;
90
+ export declare function writeDays(path: string, days: UsageDays | UsageLifetime): Promise<boolean>;
@@ -129,16 +129,69 @@ export function foldHours(days, ledger, cutoff, zone) {
129
129
  days.checkpoint = hours[hours.length - 1];
130
130
  return true;
131
131
  }
132
- /** Drop day rows older than the rollup keeps (by date, against `now` in `zone`). */
133
- export function pruneDays(days, now, zone) {
132
+ /**
133
+ * Drop day rows older than the rollup keeps (by date, against `now` in `zone`). #2661: `carriedThrough` (the lifetime
134
+ * file's `through` as it is on disk): only rows at or before it are dropped — one not carried yet stays until it is.
135
+ */
136
+ export function pruneDays(days, now, zone, carriedThrough) {
134
137
  const oldest = localDate(now - ROLLUP_KEEP_DAYS * DAY, zone);
135
138
  const before = days.rows.length;
136
- days.rows = days.rows.filter(r => r.date >= oldest);
139
+ days.rows = days.rows.filter(r => r.date >= oldest || carriedThrough === null || r.date > carriedThrough);
137
140
  const cov = days.coverage.filter(([, to]) => to >= now - ROLLUP_KEEP_DAYS * DAY);
138
141
  const changed = days.rows.length !== before || cov.length !== days.coverage.length;
139
142
  days.coverage = cov;
140
143
  return changed;
141
144
  }
145
+ export const emptyLifetime = () => ({ v: 1, through: null, first: null, by: dict() });
146
+ const isDate = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
147
+ /** A lifetime file read back from disk; anything that is not one is empty. */
148
+ export function reviveLifetime(raw) {
149
+ const l = emptyLifetime();
150
+ if (!raw || typeof raw !== "object" || raw.v !== 1)
151
+ return l;
152
+ const r = raw;
153
+ if (isDate(r.through))
154
+ l.through = r.through;
155
+ if (isDate(r.first))
156
+ l.first = r.first;
157
+ for (const [be, t] of Object.entries((r.by && typeof r.by === "object" ? r.by : {}))) {
158
+ if (Array.isArray(t) && t.length === 7 && t.every(n => Number.isFinite(n)))
159
+ l.by[be] = t;
160
+ }
161
+ return l;
162
+ }
163
+ /**
164
+ * Carry the day rows pruneDays is about to drop (older than ROLLUP_KEEP_DAYS against `now` in `zone`) and not carried
165
+ * yet into `life`. Mutates `life`; returns whether anything was added.
166
+ */
167
+ export function carryPruned(life, days, now, zone) {
168
+ const oldest = localDate(now - ROLLUP_KEEP_DAYS * DAY, zone);
169
+ let changed = false;
170
+ for (const r of days.rows) {
171
+ if (r.date >= oldest || (life.through !== null && r.date <= life.through))
172
+ continue;
173
+ const into = life.by[r.be] ??= [0, 0, 0, 0, 0, 0, 0];
174
+ for (const t of Object.values(r.m))
175
+ add(into, t);
176
+ if (life.first === null || r.date < life.first)
177
+ life.first = r.date;
178
+ changed = true;
179
+ }
180
+ if (!changed)
181
+ return false;
182
+ // Every row before `oldest` is carried now: `through` is the latest of them.
183
+ const last = days.rows.filter(r => r.date < oldest).reduce((m, r) => (r.date > m ? r.date : m), life.through ?? "");
184
+ life.through = last;
185
+ return true;
186
+ }
187
+ export async function readLifetime(path) {
188
+ try {
189
+ return reviveLifetime(JSON.parse(await readFile(path, "utf8")));
190
+ }
191
+ catch {
192
+ return emptyLifetime();
193
+ }
194
+ }
142
195
  /** Record that the scanner has read [from, to]: merged into the intervals, at most COVERAGE_MAX of them. */
143
196
  export function addCoverage(days, from, to) {
144
197
  if (!(from <= to))