@songsid/agend 2.3.0-beta.15 → 2.3.0-beta.17

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 (128) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/backend/claude-code.js +13 -4
  4. package/dist/backend/claude-code.js.map +1 -1
  5. package/dist/backend/codex.js +15 -2
  6. package/dist/backend/codex.js.map +1 -1
  7. package/dist/backend-command.d.ts +2 -0
  8. package/dist/backend-command.js +3 -1
  9. package/dist/backend-command.js.map +1 -1
  10. package/dist/bug-report-cli.d.ts +30 -0
  11. package/dist/bug-report-cli.js +138 -0
  12. package/dist/bug-report-cli.js.map +1 -0
  13. package/dist/bug-report.d.ts +47 -0
  14. package/dist/bug-report.js +136 -0
  15. package/dist/bug-report.js.map +1 -0
  16. package/dist/channel/mcp-tools.js +1 -1
  17. package/dist/channel/mcp-tools.js.map +1 -1
  18. package/dist/cli.js +93 -8
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config-validator.d.ts +6 -0
  21. package/dist/config-validator.js +13 -0
  22. package/dist/config-validator.js.map +1 -1
  23. package/dist/daemon.d.ts +14 -1
  24. package/dist/daemon.js +59 -3
  25. package/dist/daemon.js.map +1 -1
  26. package/dist/diagnostic-redaction.d.ts +10 -0
  27. package/dist/diagnostic-redaction.js +167 -0
  28. package/dist/diagnostic-redaction.js.map +1 -0
  29. package/dist/fleet-manager.d.ts +18 -0
  30. package/dist/fleet-manager.js +109 -8
  31. package/dist/fleet-manager.js.map +1 -1
  32. package/dist/general-knowledge/skills/bug-report/SKILL.md +31 -0
  33. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +22 -1
  34. package/dist/instance-lifecycle.d.ts +8 -1
  35. package/dist/instance-lifecycle.js +68 -12
  36. package/dist/instance-lifecycle.js.map +1 -1
  37. package/dist/locale.js +73 -4
  38. package/dist/locale.js.map +1 -1
  39. package/dist/login-controller.d.ts +45 -0
  40. package/dist/login-controller.js +101 -5
  41. package/dist/login-controller.js.map +1 -1
  42. package/dist/ls-attach-resolve.d.ts +3 -2
  43. package/dist/ls-attach-resolve.js +10 -4
  44. package/dist/ls-attach-resolve.js.map +1 -1
  45. package/dist/ls-rows.d.ts +12 -0
  46. package/dist/ls-rows.js +82 -3
  47. package/dist/ls-rows.js.map +1 -1
  48. package/dist/needs-you.d.ts +10 -0
  49. package/dist/needs-you.js +2 -1
  50. package/dist/needs-you.js.map +1 -1
  51. package/dist/outbound-handlers.js +31 -5
  52. package/dist/outbound-handlers.js.map +1 -1
  53. package/dist/quickstart-api.d.ts +9 -0
  54. package/dist/quickstart-api.js +82 -0
  55. package/dist/quickstart-api.js.map +1 -1
  56. package/dist/service-installer.js +12 -0
  57. package/dist/service-installer.js.map +1 -1
  58. package/dist/settings-effect.js +15 -1
  59. package/dist/settings-effect.js.map +1 -1
  60. package/dist/setup-form.d.ts +1 -1
  61. package/dist/setup-form.js +23 -7
  62. package/dist/setup-form.js.map +1 -1
  63. package/dist/tunnel/cloudflared.js +37 -2
  64. package/dist/tunnel/cloudflared.js.map +1 -1
  65. package/dist/tunnel/types.d.ts +3 -1
  66. package/dist/tunnel/types.js.map +1 -1
  67. package/dist/ui/backend-signin.js +297 -0
  68. package/dist/ui/chat-msg-actions.js +176 -0
  69. package/dist/ui/chat-store.js +33 -3
  70. package/dist/ui/chat-strings.js +8 -2
  71. package/dist/ui/chat-thread.js +44 -2
  72. package/dist/ui/panel-chat.js +37 -0
  73. package/dist/ui/panel-needs.js +4 -2
  74. package/dist/ui/panel-settings.js +6 -3
  75. package/dist/ui/shared/app-shell.js +2 -1
  76. package/dist/ui/shared/app-stream.js +2 -1
  77. package/dist/ui/shared/app.css +42 -0
  78. package/dist/ui/shared/app.js +2 -0
  79. package/dist/ui/shared/usage-dialog.js +26 -3
  80. package/dist/ui/shared/view-strings.js +10 -0
  81. package/dist/ui/web-terminal/terminal.html +1 -1
  82. package/dist/update-launch.d.ts +9 -0
  83. package/dist/update-launch.js +38 -12
  84. package/dist/update-launch.js.map +1 -1
  85. package/dist/usage/format-rich.js +6 -3
  86. package/dist/usage/format-rich.js.map +1 -1
  87. package/dist/usage/i18n-keys.d.ts +1 -1
  88. package/dist/usage/i18n-keys.js +2 -1
  89. package/dist/usage/i18n-keys.js.map +1 -1
  90. package/dist/usage/i18n.d.ts +13 -1
  91. package/dist/usage/i18n.js +29 -0
  92. package/dist/usage/i18n.js.map +1 -1
  93. package/dist/usage/providers.d.ts +46 -1
  94. package/dist/usage/providers.js +68 -15
  95. package/dist/usage/providers.js.map +1 -1
  96. package/dist/usage/usage-api.d.ts +2 -0
  97. package/dist/usage/usage-api.js +180 -55
  98. package/dist/usage/usage-api.js.map +1 -1
  99. package/dist/user-systemd.d.ts +24 -0
  100. package/dist/user-systemd.js +75 -0
  101. package/dist/user-systemd.js.map +1 -0
  102. package/dist/web-api.d.ts +10 -1
  103. package/dist/web-api.js +134 -5
  104. package/dist/web-api.js.map +1 -1
  105. package/dist/web-backend-login.d.ts +130 -0
  106. package/dist/web-backend-login.js +225 -0
  107. package/dist/web-backend-login.js.map +1 -0
  108. package/dist/web-chat-history.d.ts +31 -0
  109. package/dist/web-chat-history.js +49 -1
  110. package/dist/web-chat-history.js.map +1 -1
  111. package/dist/web-commands.d.ts +5 -1
  112. package/dist/web-commands.js +1 -1
  113. package/dist/web-commands.js.map +1 -1
  114. package/dist/web-guest-access.d.ts +3 -2
  115. package/dist/web-guest-access.js +10 -6
  116. package/dist/web-guest-access.js.map +1 -1
  117. package/dist/web-terminal-http.js +15 -2
  118. package/dist/web-terminal-http.js.map +1 -1
  119. package/dist/windows-shortcuts.d.ts +78 -0
  120. package/dist/windows-shortcuts.js +203 -0
  121. package/dist/windows-shortcuts.js.map +1 -0
  122. package/launcher/agend-install.cjs +548 -0
  123. package/launcher/install-admission.cjs +4 -39
  124. package/launcher/postinstall.cjs +14 -12
  125. package/package.json +3 -2
  126. package/scripts/preinstall-guard.cjs +2 -5
  127. package/templates/windows-shortcuts.ps1 +181 -0
  128. package/launcher/old-updater-note.cjs +0 -48
@@ -28,6 +28,7 @@ const P = () => globalThis.AgendPreview;
28
28
  /** #1672: a status line longer than this (lines, or characters) is folded until it is opened. */
29
29
  export const STATUS_FOLD_LINES = 6;
30
30
  export const STATUS_FOLD_CHARS = 600;
31
+ const REPLY = '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg>';
31
32
  const COPY = '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
32
33
 
33
34
  /** Escapes & " ' < >: safe for text and inside a quoted attribute. */
@@ -141,7 +142,22 @@ export function createThread(list, scroller, opts) {
141
142
  const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
142
143
  // A person's click on a reply button shows as the button's label, never the line the agent was given (its value).
143
144
  const clicked = x.role === "user" && x.click && typeof x.click.label === "string";
144
- return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span>${time}${ticks}</div><div class="body">${clicked ? clickHtml(x.click) : `<div class="md">${renderedMarkdown(x.text, x.role === "agent", opts.emojis, opts.mentions)}</div>`}${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
145
+ // #2272: reply to it — a hover strip at its top right (phones: swipe left or long-press; R on a focused message).
146
+ const key = msgKey(x);
147
+ const act = opts.canReply?.() === false ? "" : `<div data-trust="thread-control" class="msg-actions"><button type="button" class="chip-btn icon-only" data-act="reply" data-arg="${escAttr(key)}" title="${escAttr(tr("chat.reply"))}" aria-label="${escAttr(tr("chat.reply"))}">${REPLY}</button></div>`;
148
+ return `<div class="msg ${u ? "user" : "agent"}" data-key="${escAttr(key)}" tabindex="-1">${act}<div class="meta"><span class="sender">${escAttr(x.sender)}</span>${time}${ticks}</div>${quoteHtml(x.replyTo)}<div class="body">${clicked ? clickHtml(x.click) : `<div class="md">${renderedMarkdown(x.text, x.role === "agent", opts.emojis, opts.mentions)}</div>`}${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
149
+ }
150
+ /**
151
+ * #2272: what a reply quotes, above its body — the quoted sender and first words (or that it had files), as the server
152
+ * read them when it was sent; a button that shows that message. A guest that may not see it gets only "an earlier
153
+ * message". Text only: everything is escaped.
154
+ */
155
+ function quoteHtml(q) {
156
+ if (!q || typeof q !== "object" || typeof q.boot !== "string" || typeof q.id !== "number") return "";
157
+ const words = q.hidden ? tr("chat.replyHidden")
158
+ : (typeof q.text === "string" && q.text) || (q.media === "photo" ? tr("chat.replyPhoto") : q.media === "document" ? tr("chat.replyFile") : "");
159
+ const who = !q.hidden && typeof q.sender === "string" && q.sender ? `<span class="quote-sender">${escAttr(q.sender)}</span> ` : "";
160
+ return `<button type="button" data-trust="thread-control" class="msg-quote" data-act="jumpQuote" data-arg="${escAttr(`${q.boot}-${q.id}`)}" title="${escAttr(tr("chat.replyJump"))}"><span class="quote-mark">${REPLY}</span> ${who}<span class="quote-text">${escAttr(words)}</span></button>`;
145
161
  }
146
162
  /**
147
163
  * A button click as a chip: 🔘 and the label (escaped: the label is the agent's text). With `of` (live, while its
@@ -656,8 +672,20 @@ export function createThread(list, scroller, opts) {
656
672
  }
657
673
  armDay();
658
674
  armPolls();
675
+ rove();
659
676
  return added;
660
677
  }
678
+ /**
679
+ * #2272 (review): one message in the thread is in the Tab order — the last one focused while it is still drawn, else
680
+ * the newest; ↑/↓ move between messages (chat-msg-actions.js). The rest are -1, so Tab passes the thread in one step.
681
+ */
682
+ let roving = null;
683
+ function rove(key) {
684
+ if (key !== undefined) roving = key;
685
+ const at = (roving && nodes.has(roving) ? nodes.get(roving).node : null) || [...list.querySelectorAll(".msg[data-key]")].at(-1) || null;
686
+ for (const el of list.querySelectorAll('.msg[data-key][tabindex="0"]')) if (el !== at) el.setAttribute("tabindex", "-1");
687
+ if (at) at.setAttribute("tabindex", "0");
688
+ }
661
689
 
662
690
  function onScroll() {
663
691
  // #2158: the thread got shorter under a pinned view (text settling on a cold load, a card) and the browser clamped
@@ -742,7 +770,21 @@ export function createThread(list, scroller, opts) {
742
770
  if (trialStatus(row) === "ready" && opts.canTryEffect?.(row) === true) opts.startEffectTrial?.(row);
743
771
  else opts.fixEffectTrial?.(row, trialStatus(row));
744
772
  } else if (act === "showButtons") { const x = msgs.find(y => y.buttons && y.buttons.id === el.dataset.arg); if (x) reveal(msgKey(x)); }
773
+ else if (act === "reply") { const x = msgs.find(y => msgKey(y) === el.dataset.arg); if (x) opts.startReply?.(x); }
774
+ else if (act === "jumpQuote") void jumpTo(el.dataset.arg);
745
775
  }
776
+ /**
777
+ * #2272: show the message a reply quotes, flashed — drawn now, or after the chat reads back the longest history the
778
+ * server keeps; otherwise it says it is no longer there.
779
+ */
780
+ async function jumpTo(key) {
781
+ if (typeof key !== "string" || !key) return;
782
+ if (reveal(key)) return;
783
+ const found = opts.findQuoted ? await opts.findQuoted(key) : false;
784
+ if (!found || !reveal(key)) opts.notice?.(tr("chat.replyGone"));
785
+ }
786
+ /** #2272: a drawn message's row (for the message menu and gestures), or undefined. */
787
+ function messageOf(key) { return msgs.find(y => msgKey(y) === key); }
746
788
 
747
789
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
748
790
  /**
@@ -785,5 +827,5 @@ export function createThread(list, scroller, opts) {
785
827
  armPolls();
786
828
  }
787
829
 
788
- return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, park, attach, dispose, nodes, get unseen() { return unseen; } };
830
+ return { render, onScroll, onClick, jumpLatest, reveal, jumpTo, messageOf, rove, refreshCards, park, attach, dispose, nodes, get unseen() { return unseen; } };
789
831
  }
@@ -66,6 +66,7 @@ import "./chat-strings.js";
66
66
  import { confirmedWrite, selfConfirmed } from "./settings-confirm.js";
67
67
  import { createChatStore } from "./chat-store.js";
68
68
  import { createThread, msgKey } from "./chat-thread.js";
69
+ import { installMessageActions } from "./chat-msg-actions.js";
69
70
  import { createEmojiIndex } from "./chat-emojis.js";
70
71
  import { pollPublicPreview } from "./preview-status.js";
71
72
  import { createMentionIndex } from "./chat-mentions.js";
@@ -419,6 +420,10 @@ function threadEntry(name, doc) {
419
420
  canTryEffect: x => getAppEffects()?.canTry(x) === true,
420
421
  startEffectTrial: x => { const seq = navStore.get().seq; void getAppEffects()?.startTrial(x).then(ok => { if (!ok && e.sink && navStore.get().seq === seq && document.hasFocus?.() === true && document.visibilityState === "visible") toast(t("chat.effectTrialUnavailable")); }); },
421
422
  clickReplyButton: (id, index) => store.clickReplyButton(name, id, index), // #1266
423
+ // #2272: reply to a message (the bar above the composer), and show the one a reply quotes.
424
+ startReply: (x) => { store.startReply(name, x); doc.getElementById("msgIn")?.focus(); },
425
+ findQuoted: (key) => store.findQuoted(name, key),
426
+ notice: (text) => toast(text, false),
422
427
  replyButtonBusy: (id) => store.state.rbBusy.has(id),
423
428
  pollMine: (id) => store.state.pollMine[id], // #1823: this device's own vote
424
429
  serverNow: () => Date.now() + store.state.clockSkew, // #1823: the deadline is the server's
@@ -484,8 +489,18 @@ function Thread({ name, th }) {
484
489
  const onScroll = () => { store.state.scrollMemo[name] = thread.onScroll(); };
485
490
  sc.addEventListener("scroll", onScroll, { passive: true });
486
491
  ls.addEventListener("click", thread.onClick);
492
+ // #2272: the message menu, swipe left / long-press on a phone, R on a focused message.
493
+ const offActions = installMessageActions(ls, {
494
+ t, messageOf: thread.messageOf, rove: thread.rove, reply: (x) => { store.startReply(name, x); document.getElementById("msgIn")?.focus(); },
495
+ copyText, notice: (text, ok = true) => toast(text, ok),
496
+ linkOf: (x) => `${location.origin}/ui/chat/${encodeURIComponent(name)}#m=${msgKey(x)}`,
497
+ });
498
+ // A copied message link (#m=<boot>-<id>): show that message, flashed.
499
+ const linked = /^#m=([0-9a-f]{1,32}-\d{1,15})$/.exec(location.hash);
500
+ if (linked) void thread.jumpTo(linked[1]);
487
501
  return () => {
488
502
  unfollow();
503
+ offActions();
489
504
  sc.removeEventListener("scroll", onScroll);
490
505
  ls.removeEventListener("click", thread.onClick);
491
506
  e.sink = null;
@@ -525,6 +540,13 @@ function Dock({ name, inst, lease, exec, awaiting, workingLabel }) {
525
540
  </div></div>`;
526
541
  }
527
542
 
543
+ /** #2233: the public link's page — the dashboard's Sign in is refused there, so it is never offered (#2262 review). */
544
+ const publicLinkPage = () => typeof document !== "undefined" && !!document.body && document.body.dataset.publicLink === "1";
545
+ /** #2233: a backend's "not signed in" choice offers Sign in (the module behind the session gate, loaded when asked). */
546
+ function openBackendSignIn(backend) {
547
+ import("./backend-signin.js").then((m) => m.openSignIn({ backend }), () => toast(t("app.loadFailed"), false));
548
+ }
549
+
528
550
  /** #1269: what the last chat command answered — its text, why it was refused, or a list to choose from. */
529
551
  function CommandCard({ name, lease }) {
530
552
  const c = store.state.commands[name];
@@ -543,6 +565,8 @@ function CommandCard({ name, lease }) {
543
565
  ${c.choices ? html`<div class="cmd-choices" role="group" aria-label=${t("chat.cmdChoose")}>${c.choices.options.map((o) => html`<button key=${o.id} type="button"
544
566
  class=${`btn btn-sm${o.id === c.choices.current ? " current" : ""}`} aria-current=${o.id === c.choices.current ? "true" : undefined}
545
567
  disabled=${!!o.disabled} title=${o.disabled || undefined} onClick=${() => { if (!o.disabled) run(o.id); }}>${o.label}</button>`)}</div>` : null}
568
+ ${c.choices && c.choices.options.some((o) => o.signIn) && !publicLinkPage() ? html`<div class="cmd-choices" role="group" aria-label=${t("chat.cmdSignIn")}>${c.choices.options.filter((o) => o.signIn).map((o) => html`<button
569
+ key=${`signin:${o.signIn}`} type="button" class="btn btn-sm btn-primary" onClick=${() => openBackendSignIn(o.signIn)}>${t("chat.cmdSignInTo", o.signIn)}</button>`)}</div>` : null}
546
570
  </div>`;
547
571
  }
548
572
 
@@ -589,6 +613,12 @@ function WorkBar({ name, lease, exec, awaiting, workingLabel }) {
589
613
  : html`<span class="elapsed" title=${t("chat.elapsedTitle")} aria-hidden="true">${since == null ? "" : R().formatElapsed(performance.now() - since)}</span>`}</div>`;
590
614
  }
591
615
 
616
+ /** #2272: the reply bar's quote — the first ~80 characters, or what it had instead of text. */
617
+ function replySnippet(r) {
618
+ const text = r.text ? (r.text.length > 80 ? `${r.text.slice(0, 79).trimEnd()}…` : r.text) : "";
619
+ return text || (r.media === "photo" ? t("chat.replyPhoto") : r.media === "document" ? t("chat.replyFile") : "");
620
+ }
621
+
592
622
  function Composer({ name, lease, busy }) {
593
623
  const s = store.state;
594
624
  const input = useRef(null), fileIn = useRef(null);
@@ -597,6 +627,7 @@ function Composer({ name, lease, busy }) {
597
627
  const [closedFor, setClosedFor] = useState(null); // the draft the palette was closed on (Esc); it opens again on change
598
628
  const value = s.drafts[name] || "";
599
629
  const files = s.pendingFiles[name] || [];
630
+ const reply = s.replies[name]; // #2272: the message this one will reply to
600
631
  const content = !!value.trim() || files.length > 0;
601
632
  // A file waiting: what goes is a message with its file, never a command (no palette, no command line). A guest never
602
633
  // runs one (#1920: the server refuses its slash text), so it has no palette either.
@@ -637,6 +668,7 @@ function Composer({ name, lease, busy }) {
637
668
  }
638
669
  if (pick && e.key === "Tab" && !e.shiftKey) { e.preventDefault(); complete(pick); return; }
639
670
  if (palette && e.key === "Escape" && !e.isComposing) { e.preventDefault(); setClosedFor(value); return; }
671
+ if (reply && e.key === "Escape" && !e.isComposing) { e.preventDefault(); store.clearReply(name); return; } // #2272
640
672
  if (R().composerKey(e) === "send") { e.preventDefault(); submit(); }
641
673
  };
642
674
  const onInput = (e) => { setValue(e.target.value); };
@@ -655,6 +687,11 @@ function Composer({ name, lease, busy }) {
655
687
  <span class="cmd-name mono">/${c.name}${c.arg ? html` <span class="cmd-arg">${c.arg}</span>` : null}</span><span class="cmd-desc">${t(`chat.cmd_${c.name}`)}</span></li>`)
656
688
  : html`<li class="cmd-none" role="option" aria-selected="false">${t("chat.cmdNotCommand")}</li>`}
657
689
  </ul>` : null}
690
+ ${reply ? html`<div class="reply-bar" data-trust="composer" role="status">
691
+ <span class="reply-mark" aria-hidden="true"><svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg></span><span class="reply-label">${t("chat.replyingTo", reply.sender)}</span>
692
+ <span class="reply-text">${replySnippet(reply)}</span>
693
+ <button type="button" class="icon-btn reply-cancel" title=${t("chat.replyCancel")} aria-label=${t("chat.replyCancel")} onClick=${() => { store.clearReply(name); input.current?.focus(); }}><${Icon} name="close" size=${14} /></button>
694
+ </div>` : null}
658
695
  ${files.length ? html`<div class="pending-files">${files.map((f, i) => html`<${FileChip} key=${`${f.name}-${i}`} f=${f} i=${i} name=${name} />`)}</div>` : null}
659
696
  <div class="composer">
660
697
  <button id="attachBtn" type="button" class="icon-btn" title=${t("chat.attachTitle")} aria-label=${t("chat.attachTitle")} onClick=${() => fileIn.current && fileIn.current.click()}><${Icon} name="attach" /></button>
@@ -20,11 +20,12 @@ import { Empty } from "/assets/ui-states.js";
20
20
  import { Icon } from "/assets/ui-icons.js";
21
21
  import { toast } from "/assets/ui-toast.js";
22
22
  import { notifyStore, notifySupport, reasonText, refreshNotify, setNotify } from "/assets/app-needs.js";
23
+ import { openSignIn, signInOffered } from "./backend-signin.js";
23
24
 
24
25
  register("needs", {
25
26
  en: {
26
27
  title: "Needs you", empty: "Nothing needs you right now", emptyHint: "When an agent waits for you, it shows here — and in your chat app's General.", emptyHintWeb: "When an agent waits for you, it shows here.",
27
- open: "Open chat", acknowledge: "Acknowledge", acknowledging: "Acknowledging…", answering: "Answering…",
28
+ open: "Open chat", signIn: "Sign in", acknowledge: "Acknowledge", acknowledging: "Acknowledging…", answering: "Answering…",
28
29
  ageNow: "just now", ageMin: "{0} min", ageHour: "{0} h", ageDay: "{0} d",
29
30
  notifyTitle: "Notify me on this device", notifyOn: "Notifications are on for this browser while a tab is open.",
30
31
  notifyOff: "Get a desktop notification when something new needs you while this tab is in the background.",
@@ -36,7 +37,7 @@ register("needs", {
36
37
  },
37
38
  "zh-TW": {
38
39
  title: "等你處理", empty: "目前沒有需要你處理的事", emptyHint: "agent 在等你時,會出現在這裡——也會出現在聊天 app 的 General。", emptyHintWeb: "agent 在等你時,會出現在這裡。",
39
- open: "開啟聊天", acknowledge: "確認", acknowledging: "確認中…", answering: "回覆中…",
40
+ open: "開啟聊天", signIn: "登入", acknowledge: "確認", acknowledging: "確認中…", answering: "回覆中…",
40
41
  ageNow: "剛剛", ageMin: "{0} 分鐘", ageHour: "{0} 小時", ageDay: "{0} 天",
41
42
  notifyTitle: "在這台裝置通知我", notifyOn: "這個瀏覽器的通知已開啟(需要開著分頁)。",
42
43
  notifyOff: "分頁在背景時,有新的事等你處理就跳出桌面通知。",
@@ -185,6 +186,7 @@ function NeedsItem({ item, nowMs, owner }) {
185
186
  disabled=${promptBusy} onClick=${() => answer(a.id)}>${(p ? p.busy : fallback === "busy") ? tn("answering") : a.label}</button>`) : null}
186
187
  ${item.type === "delivery" ? html`<button type="button" class="btn btn-sm" disabled=${!!ack} onClick=${acknowledge}>
187
188
  <${Icon} name="check" size=${14} />${ack === "busy" ? tn("acknowledging") : tn("acknowledge")}</button>` : null}
189
+ ${item.reason === "auth_paused" && item.signIn && signInOffered() ? html`<button type="button" class="btn btn-sm btn-primary" onClick=${() => openSignIn(item.signIn)}>${tn("signIn")}</button>` : null}
188
190
  <a class="btn btn-sm btn-ghost" href=${chatPath(item.instance)}>${tn("open")}</a>
189
191
  </div>
190
192
  </div>
@@ -45,6 +45,7 @@ import "./chat-strings.js";
45
45
  import { AppearanceSection } from "./settings-appearance.js";
46
46
  import { GuestsCard } from "./settings-guests.js";
47
47
  import { WebAuthorityCard } from "./settings-web-authority.js";
48
+ import { SignInList } from "./backend-signin.js";
48
49
 
49
50
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
50
51
  /** What a refused change says to a person: the server's code in words (the code itself only for one not listed). */
@@ -475,8 +476,10 @@ function Agents({ ctx, search, openDialog }) {
475
476
  };
476
477
  const head = html`<div class="list-head"><span>${Object.keys(insts).length === 1 ? tn("countAgent") : tn("countAgents", Object.keys(insts).length)}</span>
477
478
  <button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
478
- if (!Object.keys(insts).length) return html`${head}<${Empty} icon="bot" title=${tn("noAgents")} />`;
479
- if (!groups.length) return html`${head}<${Empty} icon="search" title=${tn("noAgentMatch", search.trim())} />`;
479
+ // #2233: after the agents, each login on this machine with Sign in (backend-signin.js).
480
+ const signIns = html`<${SignInList} />`;
481
+ if (!Object.keys(insts).length) return html`${head}<${Empty} icon="bot" title=${tn("noAgents")} />${signIns}`;
482
+ if (!groups.length) return html`${head}<${Empty} icon="search" title=${tn("noAgentMatch", search.trim())} />${signIns}`;
480
483
  return html`${head}<div class="s-list">${groups.map(({ group, names }) => {
481
484
  const folded = collapsed.has(group) && !search.trim(); // searching opens every group
482
485
  return html`<section key=${group} class="s-group">
@@ -508,7 +511,7 @@ function Agents({ ctx, search, openDialog }) {
508
511
  </div>`;
509
512
  })}
510
513
  </section>`;
511
- })}</div>`;
514
+ })}</div>${signIns}`;
512
515
  }
513
516
 
514
517
  function ConnectionRetry({ id, ctx }) {
@@ -20,7 +20,7 @@ import { OwnerCssKeepBar } from "./owner-css.js";
20
20
  import { AgentWidget, fleetBinds, sidebarWidget, sidebarWidgetStore } from "./agent-widget.js";
21
21
 
22
22
  /** Shell state shared with panels: the phone drawer, the collapsed sidebar, extensions a panel module adds. */
23
- export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(), keyboard: false, dialog: null, side: null });
23
+ export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(), keyboard: false, dialog: null, side: null, banner: null });
24
24
 
25
25
  /**
26
26
  * The mounted panel's own sidebar section, in place of the instance list (View's roster: its groups, filter and order,
@@ -617,6 +617,7 @@ export function Shell({ panels, onNewInstance, viewOnly = false }) {
617
617
  <main id="main" class="main" tabindex="-1" inert=${shell.drawer && narrow() ? true : undefined}>
618
618
  <${ConnectionLine} />
619
619
  ${viewOnly || guest ? null : html`<${OperationLine} route=${nav.route} />`}
620
+ ${viewOnly || guest || !shell.banner ? null : html`<${shell.banner} />`}
620
621
  <${Outlet} route=${nav.route} seq=${nav.seq} panels=${panels} />
621
622
  </main>
622
623
  ${guest ? null : html`<${UsageRail} route=${nav.route} />`}
@@ -32,7 +32,8 @@
32
32
  // #1218: steps_tick — an instance's transcript moved while a Log watches it (SSE only: a poll page asks the Log itself).
33
33
  // #1918: traffic — a message between instances moved; delegation — a task opened, or its report closed it (SSE only: the
34
34
  // org canvas reads /ui/traffic when it has no stream).
35
- const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "steps_tick", "clock", "traffic", "delegation"];
35
+ // #2233: backend_login — a dashboard sign-in started, moved or ended (no details: the page reads its own view).
36
+ const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "steps_tick", "clock", "traffic", "delegation", "backend_login"];
36
37
  const MAX_HELD = 1000; // live events held during one catch-up read; more → read again afterwards
37
38
  const RETRY_DELAYS = [1000, 2000, 4000]; // after the first failed catch-up read; then it waits for Retry
38
39
  const POLL_LIMIT_MS = 10000; // a poll that has not answered by then counts as failed
@@ -626,6 +626,24 @@ img.amb-p { object-fit: contain; }
626
626
  .pv-code { flex: 1; min-height: 0; margin: 0; overflow: auto; font-family: var(--font-mono); font-size: var(--fs-xs); line-height: 1.5;
627
627
  background: var(--surface); border-radius: var(--r-sm); padding: var(--s-3); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text); }
628
628
  .msg.flash { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: var(--r-sm); transition: outline-color var(--t-med); }
629
+ /* #2272: a message's reply action (top right, on hover or focus), the quote a reply shows, the message menu, a swipe. */
630
+ .msg[data-key] { position: relative; }
631
+ .msg[data-key]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: var(--r-sm); }
632
+ .msg-actions { position: absolute; top: -6px; right: 0; z-index: 2; display: flex; gap: var(--s-1); padding: 2px; border-radius: var(--r-sm);
633
+ background: var(--surface); box-shadow: var(--shadow-1); opacity: 0; pointer-events: none; transition: opacity var(--t-fast); }
634
+ .msg:hover > .msg-actions, .msg:focus-within > .msg-actions { opacity: 1; pointer-events: auto; }
635
+ @media (hover: none) { .msg-actions { display: none; } }
636
+ .msg-quote { display: flex; align-items: center; gap: 4px; max-width: 100%; margin: 0 0 4px; padding: 2px var(--s-2); border: 0;
637
+ border-left: 3px solid var(--border-strong, var(--border)); border-radius: var(--r-sm); background: transparent; color: var(--text-3);
638
+ font: inherit; font-size: var(--fs-sm); text-align: left; cursor: pointer; overflow: hidden; }
639
+ .msg.user .msg-quote { margin-left: auto; }
640
+ .msg-quote:hover, .msg-quote:focus-visible { color: var(--text-2); background: var(--surface-2); outline: none; }
641
+ .msg-quote .quote-sender { font-weight: var(--fw-semibold); white-space: nowrap; }
642
+ .msg-quote .quote-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
643
+ .msg-menu { position: fixed; z-index: 60; min-width: 180px; padding: var(--s-1); border-radius: var(--r-md); background: var(--surface);
644
+ box-shadow: var(--shadow-2); display: flex; flex-direction: column; }
645
+ .msg[data-key] { transition: transform var(--t-fast); }
646
+ .msg.swipe-ready { box-shadow: 3px 0 0 var(--accent); border-radius: var(--r-sm); } /* let go now: a reply */
629
647
  @media (max-width: 899px) {
630
648
  .only-wide { display: none; }
631
649
  .pv-divider { display: none; }
@@ -741,6 +759,13 @@ img.amb-p { object-fit: contain; }
741
759
  /* #1920: a guest's note under the composer — on a phone too, where the key hints are hidden. */
742
760
  .guest-note { margin: 2px 0 0; font-size: var(--fs-xs); color: var(--text-3); text-align: center; }
743
761
  .pending-files { display: flex; flex-wrap: wrap; gap: var(--s-2); padding: var(--s-2) var(--s-2) 0; }
762
+ /* #2272: replying — the bar above the composer (who and what is answered; ✕ / Esc cancels). */
763
+ .reply-bar { display: flex; align-items: center; gap: var(--s-2); min-width: 0; margin: var(--s-2) var(--s-2) 0; padding: 4px var(--s-2) 4px var(--s-3);
764
+ border-left: 3px solid var(--accent); border-radius: var(--r-sm); background: var(--surface-2); font-size: var(--fs-sm); }
765
+ .reply-bar .reply-mark { color: var(--accent); }
766
+ .reply-bar .reply-label { font-weight: var(--fw-semibold); white-space: nowrap; }
767
+ .reply-bar .reply-text { flex: 1; min-width: 0; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
768
+ .reply-bar .reply-cancel { flex: none; width: 28px; height: 28px; }
744
769
  .file-chip { display: flex; align-items: center; gap: 6px; font-size: var(--fs-xs); padding: 4px var(--s-2); border-radius: var(--r-md); background: var(--bg); max-width: 320px; }
745
770
  .file-chip img { width: 28px; height: 28px; object-fit: cover; border-radius: 4px; }
746
771
  .file-chip .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
@@ -1026,6 +1051,11 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1026
1051
  .u-meter { height: 10px; border-radius: var(--r-full); background: var(--surface-2); overflow: hidden; }
1027
1052
  .u-fill { height: 100%; width: 0; min-width: 3px; border-radius: var(--r-full); background: var(--accent); transition: width 0.3s var(--ease); }
1028
1053
  .u-fill.warn { background: var(--warn); } .u-fill.crit { background: var(--danger); }
1054
+ /* Last known numbers (the live read failed): greyed, so they never read as live; the mark says when they were read. */
1055
+ .u-provider.is-stale .u-pct, .u-provider.is-stale .u-val, .u-provider.is-stale .u-status { color: var(--text-3); }
1056
+ .u-provider.is-stale .u-fill { background: var(--text-3); }
1057
+ .u-stale { font-weight: var(--fw-semibold); }
1058
+ .u-stale-detail { color: var(--text-3); font-size: var(--fs-xs); }
1029
1059
  .u-sub { color: var(--text-3); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
1030
1060
  .u-err { color: var(--warn); font-size: var(--fs-sm); }
1031
1061
  .u-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); }
@@ -1181,6 +1211,18 @@ textarea.s-yaml { white-space: pre; overflow-x: auto; }
1181
1211
  .op-line { display: flex; align-items: center; justify-content: center; gap: var(--s-2); }
1182
1212
  .op-line.ok { color: var(--ok); }
1183
1213
  .op-line.bad { color: var(--danger); }
1214
+ /* #2233: the backend sign-in dialog and Settings' list */
1215
+ .signin-field { display: flex; align-items: center; gap: var(--s-2); flex-wrap: wrap; margin: var(--s-2) 0; }
1216
+ .signin-label { color: var(--text-2); font-size: var(--fs-xs); min-width: 6em; }
1217
+ .signin-value { font-size: var(--fs-md); letter-spacing: 0.04em; user-select: all; word-break: break-all; }
1218
+ .signin-url { color: var(--text-2); font-size: var(--fs-xs); word-break: break-all; }
1219
+ .signin-note { color: var(--text-2); font-size: var(--fs-xs); }
1220
+ .signin-ok { color: var(--ok); }
1221
+ .signin-message { white-space: pre-wrap; }
1222
+ .signin-lines { margin: var(--s-2) 0 0; padding-left: 1.2em; color: var(--text-2); font-size: var(--fs-xs); white-space: pre-wrap; }
1223
+ .signin-list { margin-top: var(--s-4); }
1224
+ .signin-list code { font-size: var(--fs-xs); word-break: break-all; }
1225
+ .signin-list .s-meta { white-space: normal; max-width: none; overflow: visible; }
1184
1226
  @media (max-width: 899px) {
1185
1227
  .grid2 { grid-template-columns: minmax(0, 1fr); }
1186
1228
  .se-row { grid-template-columns: minmax(0, 1fr) auto auto; }
@@ -93,6 +93,8 @@ if (mode === "full") {
93
93
  panels.set("log", { load: () => loadLog().then((m) => m.LogPanel) });
94
94
  // #1386 §6.3: desktop notifications for new "Needs you" items, in any panel, while this signed-in page is open.
95
95
  startNeedsNotifier({ open: (instance) => navigate(chatPath(instance)) });
96
+ // #2233: a backend sign-in running anywhere is shown on every admin page (the module lives behind the session gate).
97
+ import("/ui/js/backend-signin.js").then((m) => m.installSignIn(), () => {});
96
98
  }
97
99
  // #1665: this device's first upload of its own choices (once), then another device's changes at each status frame.
98
100
  // A guest's page only reads them (prefsWritable is false there).
@@ -39,6 +39,24 @@ function resetText(iso) {
39
39
  if (ms <= 0) return tn("usage.reset.soon");
40
40
  return usageText("", { key: "usage.reset.in", args: [usageDuration(ms)] });
41
41
  }
42
+ /** "14:05" today, "10/19 14:05" another day, in this browser's time: when last known numbers were read, a window reset. */
43
+ export function usageClock(iso, now = Date.now()) {
44
+ const at = new Date(iso || "");
45
+ if (Number.isNaN(at.getTime())) return "";
46
+ const hm = `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
47
+ return new Date(now).toDateString() === at.toDateString() ? hm : `${at.getMonth() + 1}/${at.getDate()} ${hm}`;
48
+ }
49
+ /**
50
+ * A row showing its last known numbers (the live read failed; #2236 follow-up): "已過期 · 快取於 14:05" and, for an expiry
51
+ * or another failure, what to do on a second line (its full text is the mark's tooltip too). A row from an older
52
+ * server (no `cachedAt`) says its hint, as before.
53
+ */
54
+ export function staleNote(p) {
55
+ if (!p.stale) return null;
56
+ if (!p.cachedAt || !p.staleCause) return { mark: usageText(p.hint || "", p.hintI18n), detail: "" };
57
+ const detail = p.staleCause !== "transient" && (p.hint || p.hintI18n) ? usageText(p.hint || "", p.hintI18n) : "";
58
+ return { mark: usageText("", { key: `usage.stale.${p.staleCause}`, args: [usageClock(p.cachedAt)] }), detail };
59
+ }
42
60
  function expiryText(iso) {
43
61
  if (!iso) return "";
44
62
  const at = new Date(iso), ms = at.getTime() - Date.now();
@@ -48,6 +66,8 @@ function expiryText(iso) {
48
66
  export function UsageMetric({ m }) {
49
67
  const bar = useRef(null);
50
68
  const label = usageText(m.label, m.labelI18n);
69
+ // Its window has reset since these numbers were read: no number and no bar — an old one is never shown as current.
70
+ if (m.resetPassed) return html`<div class="u-metric u-reset-since"><div class="u-row"><span class="u-label">${label}</span><span class="u-val">${usageText("", { key: "usage.metric.reset_since", args: [usageClock(m.resetsAt)] })}</span></div></div>`;
51
71
  const note = m.note ? usageText(m.note, m.noteI18n) : "";
52
72
  const pct = m.type === "percent" ? Math.min(100, Math.max(0, m.used ?? 0)) : 0;
53
73
  useLayoutEffect(() => { if (bar.current) bar.current.style.width = `${pct}%`; }, [pct]); // CSSOM (#1300)
@@ -70,10 +90,13 @@ export function UsageMetric({ m }) {
70
90
  /**
71
91
  * #2014: a row's title. A credential profile's row (`profile`; its API `name` is "Claude (<profile>)") shows the
72
92
  * provider name on the title line beside the plan badge, and the profile on its own full-width muted line under it;
73
- * the full name is the tooltip, and the dialog shows the profile unclipped. Any other row: its name.
93
+ * the full name is the tooltip, and the dialog shows the profile unclipped. The default login's row beside profiles
94
+ * (`defaultLogin`; "Claude (default)", #2236) does the same with "default" in the page's language, never a clipped
95
+ * "Claude (d…". Any other row: its name.
74
96
  */
75
97
  export function providerTitle(p) {
76
98
  const name = String((p && p.name) || ""), profile = p && typeof p.profile === "string" && p.profile ? p.profile : "";
99
+ if (!profile && p && p.defaultLogin === true && name.endsWith(" (default)")) return { base: name.slice(0, -" (default)".length), profile: tn("usage.default_login") };
77
100
  const suffix = ` (${profile})`;
78
101
  return profile && name.endsWith(suffix) ? { base: name.slice(0, -suffix.length), profile } : { base: name, profile };
79
102
  }
@@ -145,14 +168,14 @@ export function UsageBody({ owner }) {
145
168
  else {
146
169
  const providers = ordered(data.providers);
147
170
  content = html`${!providers.length ? html`<p class="note">${tn("usage.empty_active")}</p>` : null}
148
- ${providers.map((p, i) => html`<section key=${key(p)} class="u-provider">
171
+ ${providers.map((p, i) => html`<section key=${key(p)} class=${p.stale ? "u-provider is-stale" : "u-provider"}>
149
172
  <div class="u-head"><strong title=${p.name}>${providerTitle(p).base}</strong>${p.plan ? html`<span class="pill u-plan" title=${p.plan}><span aria-hidden="true">${planBadge(p.plan)}</span><span class="sr-only">${p.plan}</span></span>` : null}
150
173
  <span class="u-order">
151
174
  <button type="button" class="icon-btn" disabled=${i === 0} aria-label=${tn("usageMoveUp")} title=${tn("usageMoveUp")} onClick=${() => move(key(p), -1, data.providers)}><${Icon} name="up" size=${14} /></button>
152
175
  <button type="button" class="icon-btn" disabled=${i === providers.length - 1} aria-label=${tn("usageMoveDown")} title=${tn("usageMoveDown")} onClick=${() => move(key(p), 1, data.providers)}><${Icon} name="down" size=${14} /></button>
153
176
  </span></div>
154
177
  ${providerTitle(p).profile ? html`<div class="u-profile" title=${p.name}>${providerTitle(p).profile}</div>` : null}
155
- ${p.stale ? html`<p class="note">${usageText(p.hint || "", p.hintI18n)}</p>` : null}
178
+ ${p.stale ? html`<p class="note u-stale" title=${staleNote(p).detail || null}>${staleNote(p).mark}</p>${staleNote(p).detail ? html`<p class="note u-stale-detail">${staleNote(p).detail}</p>` : null}` : null}
156
179
  ${p.status === "error" && !p.transient ? html`<p class="u-err">${usageText(p.error || tn("usage.error_fallback"), p.errorI18n)}</p>`
157
180
  : p.status === "error" && p.transient ? html`<p class="note">${usageText(p.error || tn("usage.error_fallback"), p.errorI18n)}</p>`
158
181
  : p.status === "no-credentials" ? html`<p class="note">${tn("usage.not_logged_in")}</p>${p.hint ? html`<p class="note">${usageText(p.hint, p.hintI18n)}</p>` : null}`
@@ -75,6 +75,7 @@ register("view", {
75
75
  "usage.title_plain": "AI subscription usage",
76
76
  "usage.empty_active": "No active backends with usage tracking",
77
77
  "usage.not_logged_in": "not logged in",
78
+ "usage.default_login": "default",
78
79
  "usage.error_fallback": "error",
79
80
  "usage.no_data": "no data",
80
81
  "usage.reset.in": "resets in {0}",
@@ -137,6 +138,10 @@ register("view", {
137
138
  "usage.error.rate_limited": "Rate limited by {0} — try again later.",
138
139
  "usage.error.rate_limited_transient": "{0}: can't fetch usage right now (rate limited). This does not affect your agents.",
139
140
  "usage.stale_rate_limited": "data from {0} min ago — can't refresh right now",
141
+ "usage.stale.expired": "expired · cached at {0}",
142
+ "usage.stale.transient": "not updated · cached at {0}",
143
+ "usage.stale.error": "couldn't read · cached at {0}",
144
+ "usage.metric.reset_since": "reset since {0}",
140
145
  "usage.error.http": "Usage request failed (HTTP {0}).",
141
146
  "usage.error.billing_http": "Billing request failed (HTTP {0}).",
142
147
  "usage.error.invalid_response": "Invalid response from usage endpoint.",
@@ -303,6 +308,7 @@ register("view", {
303
308
  "usage.title_plain": "AI 訂閱用量",
304
309
  "usage.empty_active": "沒有使用中且支援用量追蹤的 Backend",
305
310
  "usage.not_logged_in": "未登入",
311
+ "usage.default_login": "預設",
306
312
  "usage.error_fallback": "錯誤",
307
313
  "usage.no_data": "無資料",
308
314
  "usage.reset.in": "{0} 後重置",
@@ -365,6 +371,10 @@ register("view", {
365
371
  "usage.error.rate_limited": "{0} 暫時限制請求速率,請稍後再試。",
366
372
  "usage.error.rate_limited_transient": "暫時查不到 {0} 用量({0} 限制查詢頻率),不影響你的 agent。",
367
373
  "usage.stale_rate_limited": "{0} 分鐘前的資料,暫時無法更新",
374
+ "usage.stale.expired": "已過期 · 快取於 {0}",
375
+ "usage.stale.transient": "暫時無法更新 · 快取於 {0}",
376
+ "usage.stale.error": "讀取失敗 · 快取於 {0}",
377
+ "usage.metric.reset_since": "已於 {0} 重置",
368
378
  "usage.error.http": "用量請求失敗(HTTP {0})。",
369
379
  "usage.error.billing_http": "計費請求失敗(HTTP {0})。",
370
380
  "usage.error.invalid_response": "用量端點回傳了無效資料。",
@@ -17,7 +17,7 @@
17
17
  </header>
18
18
  <section id="gate">
19
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 received in chat. It is separate from this link; the link alone grants nothing.</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>
21
21
  <form id="gate-form" autocomplete="off">
22
22
  <input id="token" type="text" inputmode="text" autocapitalize="characters" spellcheck="false" placeholder="access token" maxlength="40" required>
23
23
  <button type="submit">Open</button>
@@ -43,11 +43,20 @@ export declare function openUpdateLog(logPath: string, header: string): {
43
43
  } | null;
44
44
  export interface UpdateLaunchDeps {
45
45
  platform: string;
46
+ /** This process's uid: a system manager's scope is root's to ask for (#2274). */
47
+ uid?(): number;
46
48
  cgroup(): Promise<string>;
47
49
  version(): Promise<string | null>;
48
50
  nonce(): string;
49
51
  }
50
52
  export declare const defaultUpdateLaunchDeps: UpdateLaunchDeps;
53
+ /**
54
+ * Which systemd manager runs the service the fleet is in: "user" (a unit below user@UID.service — com.agend.fleet),
55
+ * "system" (a unit of the system manager — the legacy /etc/systemd/system/agend.service, #2274), false (no service: a
56
+ * detached fleet), null (unknown: unreadable, or rows that disagree). Unknown is not proof that a detached child can
57
+ * escape a service's KillMode.
58
+ */
59
+ export declare function serviceManagerOf(text: string): "user" | "system" | false | null;
51
60
  /** Unknown is not proof that a detached child can escape a service's KillMode. */
52
61
  export declare function inServiceCgroup(text: string): boolean | null;
53
62
  export type UpdateLaunch = {
@@ -69,14 +69,16 @@ function updateTarget(output) {
69
69
  */
70
70
  export function updateFixHint(output, opts = {}) {
71
71
  const target = updateTarget(output);
72
+ // #2264: one repair for an install left half-done — the installer, which puts back or removes what npm left aside.
73
+ const installer = `\`curl -fsSL https://songsid.github.io/AgEnD/install.sh | ${target === "<version>" ? "" : `AGEND_VERSION=${target} `}bash\``;
72
74
  if (opts.agendGone)
73
- return `\`npm install -g @songsid/agend@${target}\``;
75
+ return installer;
74
76
  if (/^npm (?:ERR!|error) code ENOTEMPTY\b/im.test(output)) {
75
77
  const named = /^npm (?:ERR!|error) dest (.+)$/im.exec(output)?.[1] ?? /ENOTEMPTY[^\n]*rename '[^'\n]+' -> '([^'\n]+)'/.exec(output)?.[1];
76
78
  const ours = named && isAbsolute(named) && !/['"\s\\`$]/.test(named)
77
79
  && basename(dirname(named)) === "@songsid" && /^\.agend[A-Za-z0-9._-]*$/.test(basename(named));
78
80
  if (ours)
79
- return t("update.failure.remove_staging", `\`rm -rf '${named}' && agend update\``);
81
+ return t("update.failure.remove_staging", installer);
80
82
  return null;
81
83
  }
82
84
  if (/systemd does not know /.test(output))
@@ -144,6 +146,7 @@ function readRunTail(logPath, offset) {
144
146
  }
145
147
  export const defaultUpdateLaunchDeps = {
146
148
  platform: process.platform,
149
+ uid: () => (typeof process.getuid === "function" ? process.getuid() : -1),
147
150
  cgroup: async () => {
148
151
  const started = performance.now();
149
152
  const result = await readFile("/proc/self/cgroup", { encoding: "utf8", signal: AbortSignal.timeout(2000) });
@@ -165,20 +168,38 @@ export const defaultUpdateLaunchDeps = {
165
168
  },
166
169
  nonce: () => randomBytes(16).toString("hex"),
167
170
  };
168
- /** Unknown is not proof that a detached child can escape a service's KillMode. */
169
- export function inServiceCgroup(text) {
171
+ /**
172
+ * Which systemd manager runs the service the fleet is in: "user" (a unit below user@UID.service — com.agend.fleet),
173
+ * "system" (a unit of the system manager — the legacy /etc/systemd/system/agend.service, #2274), false (no service: a
174
+ * detached fleet), null (unknown: unreadable, or rows that disagree). Unknown is not proof that a detached child can
175
+ * escape a service's KillMode.
176
+ */
177
+ export function serviceManagerOf(text) {
170
178
  if (!text || text.length > 64 * 1024 || text.includes("\0"))
171
179
  return null;
172
180
  const rows = text.trimEnd().split("\n");
173
- let service = false;
181
+ const managers = new Set();
174
182
  for (const row of rows) {
175
183
  const parsed = /^(\d+):([^:]*):(\/[^\r\n]*)$/.exec(row);
176
184
  if (!parsed || parsed[3].split("/").some(part => part === "." || part === ".."))
177
185
  return null;
178
- // user@UID.service is the user manager itself, not a fleet stop boundary.
179
- service ||= parsed[3].split("/").some(part => part.endsWith(".service") && !/^user@\d+\.service$/.test(part));
186
+ // user@UID.service is the user manager itself, not a fleet stop boundary: the units below it are the user's.
187
+ let underUserManager = false;
188
+ for (const part of parsed[3].split("/")) {
189
+ if (/^user@\d+\.service$/.test(part))
190
+ underUserManager = true;
191
+ else if (part.endsWith(".service"))
192
+ managers.add(underUserManager ? "user" : "system");
193
+ }
180
194
  }
181
- return service;
195
+ if (managers.size > 1)
196
+ return null;
197
+ return managers.size ? [...managers][0] : false;
198
+ }
199
+ /** Unknown is not proof that a detached child can escape a service's KillMode. */
200
+ export function inServiceCgroup(text) {
201
+ const manager = serviceManagerOf(text);
202
+ return manager === null ? null : manager !== false;
182
203
  }
183
204
  /** Same UID, no privileged or remote manager, no credentials in argv. No unsafe detached fallback. */
184
205
  export async function resolveUpdateLaunch(agend, deps = defaultUpdateLaunchDeps) {
@@ -192,11 +213,16 @@ export async function resolveUpdateLaunch(agend, deps = defaultUpdateLaunchDeps)
192
213
  catch {
193
214
  return { ok: false, reason: "cannot read the fleet's service cgroup" };
194
215
  }
195
- const service = inServiceCgroup(before);
196
- if (service === null)
216
+ const manager = serviceManagerOf(before);
217
+ if (manager === null)
197
218
  return { ok: false, reason: "cannot identify the fleet's service cgroup" };
198
- if (!service)
219
+ if (!manager)
199
220
  return { ok: true, ...plain };
221
+ // #2274: a fleet under the system manager (the legacy agend.service, root) has no user bus; its updater's scope is the
222
+ // system manager's — which only root may ask for.
223
+ if (manager === "system" && (deps.uid?.() ?? -1) !== 0) {
224
+ return { ok: false, reason: "the fleet runs as a system service, and only root can give its updater a scope of its own" };
225
+ }
200
226
  let version;
201
227
  try {
202
228
  version = await deps.version();
@@ -222,7 +248,7 @@ export async function resolveUpdateLaunch(agend, deps = defaultUpdateLaunchDeps)
222
248
  }
223
249
  const scope = `agend-updater-${nonce}.scope`;
224
250
  return { ok: true, command: "systemd-run", scope, args: [
225
- "--user", "--scope", "--quiet", "--collect", "--no-ask-password", `--unit=${scope}`,
251
+ ...(manager === "user" ? ["--user"] : []), "--scope", "--quiet", "--collect", "--no-ask-password", `--unit=${scope}`,
226
252
  ...expansion, "--", plain.command, ...plain.args,
227
253
  ] };
228
254
  }