@songsid/agend 2.3.2-beta.5 → 2.3.2-beta.6

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 (60) hide show
  1. package/dist/channel/adapters/discord.d.ts +11 -0
  2. package/dist/channel/adapters/discord.js +50 -8
  3. package/dist/channel/adapters/discord.js.map +1 -1
  4. package/dist/config-validator.js +25 -3
  5. package/dist/config-validator.js.map +1 -1
  6. package/dist/fleet-manager.d.ts +21 -5
  7. package/dist/fleet-manager.js +154 -133
  8. package/dist/fleet-manager.js.map +1 -1
  9. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +14 -1
  10. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +14 -1
  11. package/dist/locale.js +8 -0
  12. package/dist/locale.js.map +1 -1
  13. package/dist/notice-delivery.d.ts +7 -6
  14. package/dist/notice-delivery.js +16 -18
  15. package/dist/notice-delivery.js.map +1 -1
  16. package/dist/office-share-page.d.ts +13 -0
  17. package/dist/office-share-page.js +82 -0
  18. package/dist/office-share-page.js.map +1 -0
  19. package/dist/office-share-service.d.ts +89 -0
  20. package/dist/office-share-service.js +172 -0
  21. package/dist/office-share-service.js.map +1 -0
  22. package/dist/office-share.d.ts +228 -0
  23. package/dist/office-share.js +489 -0
  24. package/dist/office-share.js.map +1 -0
  25. package/dist/public-web-gateway.js +6 -0
  26. package/dist/public-web-gateway.js.map +1 -1
  27. package/dist/quickstart.js +0 -2
  28. package/dist/quickstart.js.map +1 -1
  29. package/dist/settings-api.js +1 -1
  30. package/dist/settings-api.js.map +1 -1
  31. package/dist/setup-host.js +1 -2
  32. package/dist/setup-host.js.map +1 -1
  33. package/dist/setup-wizard.js +0 -2
  34. package/dist/setup-wizard.js.map +1 -1
  35. package/dist/types.d.ts +12 -3
  36. package/dist/ui/fleet-office-pack.js +7 -2
  37. package/dist/ui/fleet-office-skin.js +5 -4
  38. package/dist/ui/fleet-office-zones.js +17 -6
  39. package/dist/ui/fleet-office.js +20 -10
  40. package/dist/ui/office-share-dialog.js +145 -0
  41. package/dist/ui/office-share-page.js +116 -0
  42. package/dist/ui/panel-settings.js +12 -8
  43. package/dist/ui/settings-registry.js +2 -2
  44. package/dist/ui/settings-strings.js +6 -6
  45. package/dist/ui/shared/app.css +28 -0
  46. package/dist/web-api.d.ts +14 -0
  47. package/dist/web-api.js +91 -0
  48. package/dist/web-api.js.map +1 -1
  49. package/dist/web-office-skins.d.ts +2 -1
  50. package/dist/web-office-skins.js +2 -1
  51. package/dist/web-office-skins.js.map +1 -1
  52. package/dist/web-settings-patch.js +1 -1
  53. package/dist/web-settings-patch.js.map +1 -1
  54. package/dist/working-recovery.d.ts +2 -2
  55. package/dist/working-recovery.js +43 -8
  56. package/dist/working-recovery.js.map +1 -1
  57. package/dist/working-state-journal.d.ts +2 -0
  58. package/dist/working-state-journal.js +16 -1
  59. package/dist/working-state-journal.js.map +1 -1
  60. package/package.json +1 -1
@@ -0,0 +1,116 @@
1
+ // #2636: an Office share link's page (/o/<token> on the share tunnel, src/office-share.ts) — the live Office view for
2
+ // people outside the fleet, read-only, with no sign-in. The same Office as Fleet → Org chart → Office (fleet-office.js),
3
+ // fed by the share's own reads instead of the app shell's: its status and structure (/o/<t>/state, every POLL_MS while
4
+ // the page is shown), the offices, acts and skin packs (the Office's own reads, sent to the share's addresses by the
5
+ // lease's fetch). Nothing opens a chat, edits or writes; there is no other page. When the share ends (revoked, expired,
6
+ // its office deleted) its reads answer 404 and the page says so and stops.
7
+ import { html, render, useEffect, useMemo, useState } from "/assets/app-html.js";
8
+ import { appStore, useStore } from "/assets/app-store.js";
9
+ import { createLease } from "/assets/app-ctx.js";
10
+ import { t, register } from "/assets/app-i18n.js";
11
+ import { buildOrgView } from "./fleet-org.js";
12
+ import { createTrafficStore, POLL_MS } from "./fleet-traffic.js";
13
+ import { Office } from "./fleet-office.js";
14
+ import { ALL_ID } from "./fleet-office-overview.js";
15
+
16
+ register("share", {
17
+ en: { title: "Office", live: "Live view", ends: "until {0}", readOnly: "Read-only: names and states only.", ended: "This Office link has ended.", loading: "Loading the office…", offline: "Can't reach the office right now. Trying again…" },
18
+ "zh-TW": { title: "辦公室", live: "即時畫面", ends: "到 {0} 為止", readOnly: "唯讀:只顯示名稱與狀態。", ended: "這個辦公室連結已結束。", loading: "正在載入辦公室…", offline: "目前連不上辦公室,正在重試…" },
19
+ });
20
+ const to = (k, ...v) => t(`share.${k}`, ...v);
21
+
22
+ /** The share's own address: `/o/<token>` (the page's path). */
23
+ export function shareBase(pathname) {
24
+ const m = /^\/o\/([A-Za-z0-9_-]{43})\/?$/.exec(pathname || "");
25
+ return m ? `/o/${m[1]}` : null;
26
+ }
27
+
28
+ /** The Office's reads, at the share's addresses (anything else is not the share's to answer: refused here). */
29
+ export function shareUrl(base, path) {
30
+ const p = String(path);
31
+ if (p === "/ui/offices") return `${base}/offices`;
32
+ if (p === "/ui/office-acts") return `${base}/office-acts`;
33
+ if (p === "/ui/office-skins" || p.startsWith("/ui/office-skins/") || p.startsWith("/ui/office-skins?")) return `${base}${p.slice(3)}`;
34
+ return null;
35
+ }
36
+
37
+ /** A lease whose fetch reads the share's addresses (createLease's timers), refusing any other. */
38
+ export function shareLease(base, timers = globalThis) {
39
+ return createLease({
40
+ setTimeout: (...a) => timers.setTimeout(...a), clearTimeout: (h) => timers.clearTimeout(h),
41
+ setInterval: (...a) => timers.setInterval(...a), clearInterval: (h) => timers.clearInterval(h),
42
+ fetch: (path, opts) => {
43
+ const url = shareUrl(base, path);
44
+ return url ? timers.fetch(url, opts) : Promise.resolve(new Response("", { status: 404 }));
45
+ },
46
+ });
47
+ }
48
+
49
+ /** The share's /state into the app store's shape (the status frame's, as app-store.js takes it). */
50
+ export function appFromState(d) {
51
+ const instances = Array.isArray(d && d.instances) ? d.instances : [];
52
+ const exec = {}, awaiting = {};
53
+ for (const i of instances) {
54
+ exec[i.name] = (i.execution_state !== undefined ? i.execution_state : i.state) ?? null;
55
+ if (i.state === "awaiting_input") awaiting[i.name] = "";
56
+ }
57
+ return { ready: true, instances, exec, awaiting, needs: Array.isArray(d && d.needs) ? d.needs : [], connection: "polling" };
58
+ }
59
+
60
+ const clock = (ms) => {
61
+ try { return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(ms)); } catch { return ""; }
62
+ };
63
+
64
+ export function SharePage({ base, timers = globalThis, pollMs = POLL_MS }) {
65
+ const lease = useMemo(() => shareLease(base, timers), [base]);
66
+ const traffic = useMemo(createTrafficStore, []);
67
+ const [state, setState] = useState({ phase: "loading" }); // loading | live | ended | offline
68
+ const [items, setItems] = useState([]);
69
+ useEffect(() => {
70
+ let stopped = false;
71
+ const read = async () => {
72
+ try {
73
+ const r = await timers.fetch(`${base}/state`, { method: "GET", headers: { Accept: "application/json" } });
74
+ if (stopped || !lease.current()) return;
75
+ if (r.status === 404) { stopped = true; setState({ phase: "ended" }); return; }
76
+ if (!r.ok) { setState((s) => (s.phase === "live" ? s : { phase: "offline" })); return; }
77
+ const d = await r.json();
78
+ if (stopped || !lease.current()) return;
79
+ appStore.set((s) => ({ ...s, ...appFromState(d) }));
80
+ if (d.traffic && traffic.load(d.traffic)) setItems(traffic.items());
81
+ setState({ phase: "live", org: d.org, office: typeof d.office === "string" ? d.office : null, ends: typeof d.ends === "number" ? d.ends : null });
82
+ } catch { if (!stopped) setState((s) => (s.phase === "live" ? s : { phase: "offline" })); }
83
+ };
84
+ void read();
85
+ const hs = [
86
+ lease.interval(() => { if (!stopped && !globalThis.document?.hidden) void read(); }, pollMs),
87
+ lease.interval(() => { if (traffic.sweep()) setItems(traffic.items()); }, 1000),
88
+ ];
89
+ return () => { stopped = true; for (const h of hs) lease.clear(h); };
90
+ }, [lease]);
91
+ useEffect(() => () => lease.dispose(), [lease]);
92
+ const app = useStore(appStore);
93
+ const view = useMemo(() => (state.phase === "live" ? buildOrgView(state.org, app) : null), [state, app.instances, app.needs]);
94
+ if (state.phase === "ended") return html`<main class="share-page share-ended"><p>${to("ended")}</p></main>`;
95
+ return html`<main class="share-page">
96
+ <header class="share-head">
97
+ <strong>${to("title")}</strong>
98
+ <span class="muted">${to("live")}${state.ends ? ` · ${to("ends", clock(state.ends))}` : ""}</span>
99
+ <span class="muted share-note">${to("readOnly")}</span>
100
+ </header>
101
+ ${state.phase === "offline" ? html`<p class="note share-note" role="status">${to("offline")}</p>` : null}
102
+ ${view ? html`<div class="share-stage"><${Office} view=${view} traffic=${items} lease=${lease} assetUrl=${(p) => shareUrl(base, p) || p}
103
+ opening=${state.office === "all" ? ALL_ID : state.office || undefined} /></div>`
104
+ : html`<p class="muted share-loading" role="status">${to("loading")}</p>`}
105
+ </main>`;
106
+ }
107
+
108
+ /** Mount on the page (the module's entry): the share's address from the path, else nothing to show. */
109
+ export function mount(root = globalThis.document?.getElementById("app")) {
110
+ const base = shareBase(globalThis.location?.pathname);
111
+ if (!root || !base) return;
112
+ globalThis.document.body.dataset.webTransport = "poll";
113
+ render(html`<${SharePage} base=${base} />`, root);
114
+ }
115
+
116
+ if (globalThis.document?.getElementById?.("app")?.dataset?.share === "1") mount();
@@ -46,6 +46,7 @@ import { startTour } from "./chat-tour.js";
46
46
  import "./chat-strings.js";
47
47
  import { AppearanceSection } from "./settings-appearance.js";
48
48
  import { GuestsCard } from "./settings-guests.js";
49
+ import { OfficeSharesCard } from "./office-share-dialog.js";
49
50
  import { syncStage, useDraft } from "./settings-draft.js";
50
51
  import { WebAuthorityCard } from "./settings-web-authority.js";
51
52
  import { SignInList } from "./backend-signin.js";
@@ -952,10 +953,11 @@ function Classic({ ctx, search, openDialog }) {
952
953
  })}</div>`;
953
954
  }
954
955
 
955
- const configuredNoticeMode = (value, route) => value === false ? false : value === "dm" || route === "dm" ? "dm" : true;
956
- const noticeMode = (v) => v === "dm" ? "dm" : v === "false" || v === false ? false : true;
957
- const noticeChoices = () => [{ value: "true", label: tn("noticeTopic") }, { value: "dm", label: tn("noticeDm") }, { value: "false", label: tn("noticeOff") }];
958
- const noticeLabel = (v) => tn(v === "dm" ? "noticeDm" : v === false ? "noticeOff" : "noticeTopic");
956
+ // Sign-in notices and daily tips post in the topic or not at all: the DM choice was withdrawn (hmsg-d1809f2a,
957
+ // hmsg-3b8ac2af), and an old "dm" reads as on.
958
+ const noticeMode = (v) => v !== "false" && v !== false;
959
+ const noticeChoices = () => [{ value: "true", label: tn("noticeTopic") }, { value: "false", label: tn("noticeOff") }];
960
+ const noticeLabel = (v) => tn(v ? "noticeTopic" : "noticeOff");
959
961
 
960
962
  /** General's form, from the saved defaults (`d`) and ClassicBot's (`c`). */
961
963
  function generalForm(d, c, fleet = {}, schema = {}) {
@@ -971,7 +973,7 @@ function generalForm(d, c, fleet = {}, schema = {}) {
971
973
  // #2347 §10 review: unset startup concurrency is automatic (deriveSpawnConcurrency: 2–10 by free memory and cores),
972
974
  // not 10 — an empty field, and a save that leaves it unset (the defaults merge replaces `startup` whole).
973
975
  concurrency: startup.concurrency == null ? "" : String(startup.concurrency), stagger: String(startup.stagger_delay_ms ?? 500),
974
- tips: String(configuredNoticeMode(d.tips, fleet.web?.notice_delivery?.tips)),
976
+ tips: String(noticeMode(d.tips)),
975
977
  replyTriggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default),
976
978
  classicReplyTriggers: Boolean(c.reply_triggers ?? d.reply_triggers ?? schema.reply_triggers_default), classicReplyTriggersInherit: !hasOwn(c, "reply_triggers"),
977
979
  classicBackend: c.backend || d.backend || "claude-code",
@@ -1029,7 +1031,7 @@ function General({ ctx, part = "all" }) {
1029
1031
  }, {
1030
1032
  backend: d.backend || "claude-code", model: d.model || undefined, locale: d.locale || null, auto_pause_after: d.auto_pause_after ?? 0,
1031
1033
  agent_mode: d.agent_mode || "mcp", tool_set: TOOL_SETS.includes(d.tool_set) ? d.tool_set : null, tool_progress: d.tool_progress ?? "off",
1032
- tips: configuredNoticeMode(d.tips, ctx.fleet.web?.notice_delivery?.tips), cross_instance_visibility: visibilityDefault(d), reply_triggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
1034
+ tips: d.tips ?? true, cross_instance_visibility: visibilityDefault(d), reply_triggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
1033
1035
  dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => k !== "office").map((k) => [k, dashboardDefault(d, k)])),
1034
1036
  office_access: dashboardDefault(d, "office"),
1035
1037
  hang_detector: { enabled: hang.enabled !== false, timeout_minutes: hang.timeout_minutes ?? HANG_TIMEOUT_DEFAULT_MINUTES },
@@ -1190,7 +1192,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1190
1192
  // serves their reads there).
1191
1193
  const overPublicLink = globalThis.document?.body?.dataset?.publicLink === "1";
1192
1194
  const original = useMemo(() => ({
1193
- notify: configuredNoticeMode(web.notify_login, web.notice_delivery?.login),
1195
+ notify: noticeMode(web.notify_login),
1194
1196
  echo: web.echo_to_channel ?? true,
1195
1197
  log: web.log_tab === true, // #1218: off by default
1196
1198
  usagePub: web.usage_report_on_public_link === true, // #2122: off by default
@@ -1353,7 +1355,9 @@ function WebSection({ ctx, staged, part = "all" }) {
1353
1355
  ${named || f.autoOpen ? html`<label class=${fc("check", "autoOpen")}><input id="publicAutoOpen" type="checkbox" checked=${f.autoOpen} onChange=${(e) => set("autoOpen")(e.target.checked)} /><span>${tn("publicAutoOpen")}</span><${Impact} schema=${schema} field="web.public_link.auto_open" /></label>
1354
1356
  <p class="note">${tn("publicAutoOpenHint")}</p><${Feedback} error=${errors.autoOpen} />` : null}
1355
1357
  </section>`) : null}
1356
- ${has("access") && !overPublicLink ? html`<${PublicTunnelToken} /><${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />` : null}
1358
+ ${has("access") && !overPublicLink ? html`<${PublicTunnelToken} />` : null}
1359
+ ${has("access") ? html`<${OfficeSharesCard} />` : null}
1360
+ ${has("access") && !overPublicLink ? html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />` : null}
1357
1361
  ${has("chat") ? lock(html`<section class="card"><h3>${tn("webChat")}</h3>
1358
1362
  <label class=${fc("check", "echo")}><input id="webEchoToChannel" type="checkbox" checked=${f.echo} onChange=${(e) => set("echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="web.echo_to_channel" /></label>
1359
1363
  <p class="note">${tn("webEchoHint")}</p>
@@ -33,8 +33,8 @@ const ROWS = [
33
33
  // Fleet · Agents
34
34
  ["agents.new", "agents", "#newAgentBtn", "settings.newAgent", null, ["new agent", "create", "add agent", "instance"], ["新增 agent", "建立", "新 agent"]],
35
35
  ["agents.provider-keys", "agents", ".s-provider-keys", "settings.providerKeys", "web.provider_secrets", ["api key", "provider", "openai", "anthropic", "token", "secret"], ["API 金鑰", "金鑰", "供應商", "token"]],
36
- ["defaults.tips", "agent-defaults", "#g-tips", "settings.dailyTips", "defaults.tips", ["daily tips", "dm", "notices"], ["每日提示", "私訊"]],
37
- ["web.notify-login", "access", "#webNotifyLogin", "settings.webNotifyLogin", "web.notify_login", ["sign in", "login", "dm", "notices"], ["登入通知", "私訊"]],
36
+ ["defaults.tips", "agent-defaults", "#g-tips", "settings.dailyTips", "defaults.tips", ["daily tips", "notices"], ["每日提示"]],
37
+ ["web.notify-login", "access", "#webNotifyLogin", "settings.webNotifyLogin", "web.notify_login", ["sign in", "login", "notices"], ["登入通知"]],
38
38
  // Fleet · Agent defaults
39
39
  ["defaults.locale", "agent-defaults", "#g-locale", "settings.language", "defaults.locale", ["language", "bot language", "reply language", "locale"], ["語言", "回覆語言", "bot 語言"]],
40
40
  ["defaults.backend", "agent-defaults", "#g-be", "settings.defaultBackend", "defaults.backend", ["backend", "cli", "claude", "codex", "kiro"], ["後端", "CLI"]],
@@ -4,10 +4,10 @@ import { register } from "/assets/app-i18n.js";
4
4
 
5
5
  register("settings", {
6
6
  en: {
7
- noticeTopic: "Topic", noticeDm: "Direct message (topic fallback)", noticeOff: "Off",
7
+ noticeTopic: "Topic", noticeOff: "Off",
8
8
  webNotifyLogin: "Web sign-in notices", dailyTips: "Daily tips",
9
- webNotifyLoginHint: "DM the signing-in operator or public-link owner; local guest notices go to the owning connection’s first configured admin. Missing account or failed DM falls back to the topic. Off suppresses operator notices only; guest and public-link security notices remain in the topic. Existing default: Topic; new fleets: DM.",
10
- dailyTipsHint: "DM the General connection’s first configured admin, with topic fallback. Default: Topic. /tips still posts where invoked.",
9
+ webNotifyLoginHint: "Posted in the topic. Off suppresses operator notices only; guest and public-link security notices still post. Default: Topic.",
10
+ dailyTipsHint: "Posted in the General topic. Default: Topic. /tips still posts where invoked.",
11
11
  title: "Settings",
12
12
  myAgents: "My Agents",
13
13
  bots: "Bots",
@@ -679,10 +679,10 @@ register("settings", {
679
679
  se_externalEmojiHint: "使用其他伺服器的 emoji,bot 在要加反應的頻道需有「使用外部表情符號」權限。",
680
680
  se_unavailable: "無法使用",
681
681
  se_hint: "留空即使用預設值。預覽由 AgEnD 依 bot 實際 react 的方式解析。進度前綴不是反應,而是進度訊息開頭的 emoji。",
682
- noticeTopic: "頻道", noticeDm: "私訊(失敗時改送頻道)", noticeOff: "關閉",
682
+ noticeTopic: "頻道", noticeOff: "關閉",
683
683
  webNotifyLogin: "網頁登入通知", dailyTips: "每日提示",
684
- webNotifyLoginHint: "私訊登入的管理者或公開連結擁有者;本機訪客登入通知送給所屬連線的第一位設定管理員。帳號不明或私訊失敗時改送原頻道。關閉只停用管理者登入通知;訪客與公開連結安全通知仍送頻道。既有預設:頻道;新 fleet:私訊。",
685
- dailyTipsHint: "私訊 General 所屬連線的第一位設定管理員,失敗時改送頻道。預設:頻道。/tips 仍送至呼叫的聊天室。",
684
+ webNotifyLoginHint: "發在頻道。關閉只停用管理者登入通知;訪客與公開連結安全通知仍會發出。預設:頻道。",
685
+ dailyTipsHint: "發在 General 頻道。預設:頻道。/tips 仍送至呼叫的聊天室。",
686
686
  title: "設定",
687
687
  myAgents: "我的 Agent",
688
688
  bots: "機器人",
@@ -2504,3 +2504,31 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
2504
2504
  .msg.user .msg-reactions { justify-content: flex-end; }
2505
2505
  .msg-reactions .reaction { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; height: 24px; padding: 0 6px;
2506
2506
  border: 1px solid var(--border); border-radius: var(--r-full); background: var(--surface-2); font-family: var(--font-emoji); font-size: 15px; line-height: 1; }
2507
+
2508
+ /* #2636: an Office share link's page (office-share-page.js) — the Office alone, filling the window under a one-line head. */
2509
+ .share-page { display: flex; flex-direction: column; min-height: 100dvh; background: var(--bg); color: var(--text); }
2510
+ .share-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s-1) var(--s-3); padding: var(--s-3) var(--s-4); border-bottom: 1px solid var(--border); }
2511
+ .share-head .share-note { margin-left: auto; }
2512
+ .share-stage { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; padding: var(--s-2); }
2513
+ .share-loading, .share-page > .note { padding: var(--s-4); }
2514
+ .share-ended { align-items: center; justify-content: center; padding: var(--s-6); text-align: center; }
2515
+ @media (max-width: 640px) { .share-head .share-note { margin-left: 0; flex-basis: 100%; } }
2516
+
2517
+ /* #2636: Office share links — the Share… dialog and the open links (office-share-dialog.js; Settings → Access & security). */
2518
+ .os { display: flex; flex-direction: column; gap: var(--s-3); }
2519
+ .os > p { margin: 0; }
2520
+ .os-form { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-3); align-items: end; }
2521
+ .os-form .check, .os-form .note { grid-column: 1 / -1; margin: 0; }
2522
+ .os-made { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); }
2523
+ .os-url { flex: 1 1 260px; min-width: 0; min-height: 40px; padding: var(--s-2) var(--s-3); border: 1px solid var(--border-strong); border-radius: var(--r-sm);
2524
+ background: var(--bg); color: var(--text); font-size: var(--fs-sm); outline: none; }
2525
+ .os-url:focus { border-color: var(--focus); box-shadow: 0 0 0 3px var(--accent-dim); }
2526
+ .os-made .note { flex-basis: 100%; margin: 0; }
2527
+ .os-head { margin: var(--s-2) 0 0; font-size: var(--fs-sm); font-weight: var(--fw-medium); color: var(--text-2); }
2528
+ .os-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s-2); }
2529
+ .os-row { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-2) var(--s-3); border: 1px solid var(--border); border-radius: var(--r-sm); }
2530
+ .os-what { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--fs-sm); }
2531
+ .os-all { margin-top: var(--s-2); }
2532
+ .os-none { margin: 0; }
2533
+ .os-form .os-err { grid-column: 1 / -1; margin: 0; padding: var(--s-2) var(--s-3); border: 1px solid var(--danger); border-radius: var(--r-sm); }
2534
+ @media (max-width: 480px) { .os-form { grid-template-columns: 1fr; } }
package/dist/web-api.d.ts CHANGED
@@ -200,6 +200,20 @@ export interface WebApiContext {
200
200
  readonly officeWrites?: OfficeWriteLimiter;
201
201
  /** #2578: the agents' live acts in the Office view (office_set); absent: /ui/office-acts is not offered. */
202
202
  readonly officeActs?: Pick<OfficeActs, "live">;
203
+ /** #2636: Office share links (office-share-service.ts): the open ones, making and ending one. */
204
+ readonly officeShares?: {
205
+ enabled(): boolean;
206
+ list(): import("./office-share.js").OfficeShare[];
207
+ create(opts: {
208
+ office: string;
209
+ minutes?: number;
210
+ text?: boolean;
211
+ owner: string;
212
+ }): Promise<import("./office-share-service.js").OfficeShareMade>;
213
+ revoke(id: string): number;
214
+ };
215
+ /** #2636: a share was made or ended from the page — said in General (who, which office, until when). */
216
+ officeShareNotice?(event: "created" | "revoked", who: string, share: import("./office-share.js").OfficeShare | null, count: number): void;
203
217
  /** #2513: the Office view's uploaded skin packs (web-office-skins/); absent: /ui/office-skins is not offered. */
204
218
  readonly officeSkins?: OfficeSkinStore;
205
219
  /** How often a person may upload or delete a skin pack (absent: no limit). */
package/dist/web-api.js CHANGED
@@ -448,6 +448,95 @@ function handleAppIcon(req, res, url, ctx, method) {
448
448
  });
449
449
  return true;
450
450
  }
451
+ /**
452
+ * #2636: Office share links — GET /ui/office-share (the open ones, and whether this fleet offers them), POST { office,
453
+ * minutes?, text? } (a new one; answers its URL once its tunnel is up, or why not), DELETE /ui/office-share/<id|all>.
454
+ * An admin session only — on the dashboard reached directly, or (the user, #2636 follow-up) a fleet admin signed in over
455
+ * the /web public link: the same check either way. Never the CLI token, a read-only session or a guest. The share's own
456
+ * tunnel and gateway stay apart from /web's. The person is asked again once the body is in.
457
+ */
458
+ function handleOfficeShare(req, res, url, ctx, path, method) {
459
+ const service = ctx.officeShares;
460
+ if (!service) {
461
+ json(res, 404, { error: "Office share links are not kept on this fleet" });
462
+ return true;
463
+ }
464
+ const one = /^\/ui\/office-share\/([0-9a-f]{8}|all)$/.exec(path);
465
+ const known = (method === "GET" && path === "/ui/office-share") || (method === "POST" && path === "/ui/office-share") || (method === "DELETE" && !!one);
466
+ if (!known) {
467
+ json(res, 404, { error: "not found" });
468
+ return true;
469
+ }
470
+ const who = () => {
471
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
472
+ if (verdict.kind !== "allow")
473
+ return { status: 401 };
474
+ if (verdict.via !== "session" || verdict.session?.tier !== "admin")
475
+ return { status: 403 };
476
+ const owner = verdict.session.owner;
477
+ return { person: owner ? webPersonKey(owner) : "admin-session", name: owner ? ctx.webPerson?.(owner)?.name ?? owner.userId : "admin" };
478
+ };
479
+ const refuse = (status) => json(res, status, { error: status === 401 ? WEB_TOKEN_INVALID_MESSAGE : "office_share_needs_an_admin_session" });
480
+ const first = who();
481
+ if ("status" in first) {
482
+ refuse(first.status);
483
+ return true;
484
+ }
485
+ res.setHeader("Cache-Control", "no-store");
486
+ if (method === "GET") {
487
+ // Each with its office's name as it is now (Settings' card has no office list of its own).
488
+ const names = new Map((ctx.officeStore?.list().offices ?? []).map(o => [o.id, o.name]));
489
+ json(res, 200, { enabled: service.enabled(), shares: service.list().map(s => ({ ...s, ...(names.has(s.office) ? { office_name: names.get(s.office) } : {}) })) });
490
+ return true;
491
+ }
492
+ if (method === "DELETE") {
493
+ const id = one[1];
494
+ const before = id === "all" ? null : service.list().find(s => s.id === id) ?? null;
495
+ const n = service.revoke(id);
496
+ if (n)
497
+ ctx.officeShareNotice?.("revoked", first.name, before, n);
498
+ json(res, n ? 200 : 404, n ? { revoked: n } : { error: "not_found" });
499
+ return true;
500
+ }
501
+ (async () => {
502
+ let body;
503
+ try {
504
+ body = await parseBody(req);
505
+ }
506
+ catch {
507
+ json(res, 400, { error: "Invalid JSON" });
508
+ return;
509
+ }
510
+ if (!permitWebContinuation(req, res, ctx))
511
+ return;
512
+ const now = who();
513
+ if ("status" in now) {
514
+ refuse(now.status);
515
+ return;
516
+ }
517
+ if (!body || typeof body !== "object" || Array.isArray(body) || typeof body.office !== "string"
518
+ || (body.minutes !== undefined && typeof body.minutes !== "number") || (body.text !== undefined && typeof body.text !== "boolean")) {
519
+ json(res, 400, { error: "office (string), minutes? (number), text? (boolean)" });
520
+ return;
521
+ }
522
+ const made = await service.create({ office: body.office, owner: now.person, ...(typeof body.minutes === "number" ? { minutes: body.minutes } : {}),
523
+ ...(typeof body.text === "boolean" ? { text: body.text } : {}) });
524
+ if (!made.ok) {
525
+ const status = made.error === "disabled" ? 404 : made.error === "tunnel" ? 503 : made.error === "too_many" ? 409 : 400;
526
+ json(res, status, { error: made.error, ...(made.reason ? { reason: made.reason } : {}) });
527
+ return;
528
+ }
529
+ ctx.officeShareNotice?.("created", now.name, made.share, 1);
530
+ json(res, 200, { share: made.share, url: made.url });
531
+ })().catch(err => {
532
+ ctx.logger.error({ err: err.message }, "Office share failed");
533
+ try {
534
+ json(res, 500, { error: "Office share failed" });
535
+ }
536
+ catch { /* already answered */ }
537
+ });
538
+ return true;
539
+ }
451
540
  /**
452
541
  * #2513: the Office view's skin packs (web-office-skins.ts). The reads are the offices' (GET /ui/offices): whoever may
453
542
  * open the org chart, the public link too (its manifest lists them; a guest's routes do not): the list, a pack's
@@ -2322,6 +2411,8 @@ export function handleWebRequest(req, res, url, ctx) {
2322
2411
  }
2323
2412
  if (path === "/ui/office-skins" || path.startsWith("/ui/office-skins/"))
2324
2413
  return handleOfficeSkins(req, res, url, ctx, path, method);
2414
+ if (path === "/ui/office-share" || path.startsWith("/ui/office-share/"))
2415
+ return handleOfficeShare(req, res, url, ctx, path, method);
2325
2416
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
2326
2417
  const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
2327
2418
  if (method === "GET" && imageMatch) {