@songsid/agend 2.3.0-beta.10 → 2.3.0-beta.12

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 (63) hide show
  1. package/dist/agent-whats-new.js +14 -9
  2. package/dist/agent-whats-new.js.map +1 -1
  3. package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
  4. package/dist/channel/adapters/discord-client-retirement.js +57 -0
  5. package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
  6. package/dist/channel/adapters/discord.d.ts +2 -0
  7. package/dist/channel/adapters/discord.js +14 -4
  8. package/dist/channel/adapters/discord.js.map +1 -1
  9. package/dist/cli.js +12 -79
  10. package/dist/cli.js.map +1 -1
  11. package/dist/daemon.d.ts +3 -0
  12. package/dist/daemon.js +25 -0
  13. package/dist/daemon.js.map +1 -1
  14. package/dist/fleet-manager.d.ts +8 -1
  15. package/dist/fleet-manager.js +101 -33
  16. package/dist/fleet-manager.js.map +1 -1
  17. package/dist/fleet-process-handlers.d.ts +18 -0
  18. package/dist/fleet-process-handlers.js +69 -0
  19. package/dist/fleet-process-handlers.js.map +1 -0
  20. package/dist/general-knowledge/skills/web-appearance/SKILL.md +1 -1
  21. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +8 -2
  22. package/dist/instance-lifecycle.d.ts +2 -0
  23. package/dist/instance-lifecycle.js +11 -6
  24. package/dist/instance-lifecycle.js.map +1 -1
  25. package/dist/outbound-schemas.js +1 -1
  26. package/dist/outbound-schemas.js.map +1 -1
  27. package/dist/settings-api.d.ts +4 -0
  28. package/dist/settings-api.js +23 -0
  29. package/dist/settings-api.js.map +1 -1
  30. package/dist/ui/app-effects.js +13 -1
  31. package/dist/ui/chat-render.js +16 -1
  32. package/dist/ui/chat-store.js +32 -10
  33. package/dist/ui/chat-strings.js +16 -0
  34. package/dist/ui/chat-thread.js +14 -1
  35. package/dist/ui/panel-chat.js +57 -9
  36. package/dist/ui/panel-fleet.js +2 -1
  37. package/dist/ui/panel-settings.js +24 -0
  38. package/dist/ui/settings-strings.js +2 -0
  39. package/dist/ui/shared/app-i18n.js +1 -1
  40. package/dist/ui/shared/app-route.js +18 -0
  41. package/dist/ui/shared/app-shell.js +17 -18
  42. package/dist/ui/shared/app.css +1 -1
  43. package/dist/ui/shared/effect-owner-source.js +8 -1
  44. package/dist/ui/shared/effect-registry.js +10 -2
  45. package/dist/ui/shared/owner-css.js +30 -1
  46. package/dist/web-api.d.ts +6 -2
  47. package/dist/web-api.js +50 -14
  48. package/dist/web-api.js.map +1 -1
  49. package/dist/web-chat-history.d.ts +4 -0
  50. package/dist/web-chat-history.js +7 -2
  51. package/dist/web-chat-history.js.map +1 -1
  52. package/dist/web-image-meta.d.ts +13 -0
  53. package/dist/web-image-meta.js +62 -0
  54. package/dist/web-image-meta.js.map +1 -1
  55. package/dist/web-owner-css-store.d.ts +43 -1
  56. package/dist/web-owner-css-store.js +65 -9
  57. package/dist/web-owner-css-store.js.map +1 -1
  58. package/dist/web-settings-tools.js +7 -4
  59. package/dist/web-settings-tools.js.map +1 -1
  60. package/dist/web-upload.d.ts +13 -0
  61. package/dist/web-upload.js +39 -3
  62. package/dist/web-upload.js.map +1 -1
  63. package/package.json +1 -1
@@ -526,7 +526,20 @@ export function createThread(list, scroller, opts) {
526
526
  // newest bubble clipped behind it, and the next growth past 48 px put the pill over it.
527
527
  const atBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight, 1);
528
528
  function reportJump() { opts.onJump({ show: !nearBottom(), unseen }); }
529
- function toBottom() { scroller.scrollTop = scroller.scrollHeight; pinned = true; mark(); }
529
+ function toBottom() { snap(); scroller.scrollTop = scroller.scrollHeight; pinned = true; mark(); }
530
+ /**
531
+ * #2184: a thread's height is rarely a whole number of pixels, and a pinned view's offset is: so each time older messages
532
+ * were drawn above, the whole view moved by the change in that fraction (0.6–0.7 px — the faint jitter of a chat
533
+ * opened cold). A sub-pixel margin above the list keeps the thread's height whole, so the bottom never moves.
534
+ */
535
+ // #2188: one read (the list's box excludes its own margin, so nothing is reset first) and a write only on a change —
536
+ // a reset-then-measure laid a 200-message thread out twice on every pinned draw (2.3 s at 4× CPU on a kept chat).
537
+ let snapped = null;
538
+ function snap() {
539
+ const h = list.getBoundingClientRect().height, f = h - Math.floor(h);
540
+ const want = f > 0.001 && f < 0.999 ? (1 - f) + "px" : "0px";
541
+ if (want !== snapped) { snapped = want; list.style.setProperty("--thread-snap", want); }
542
+ }
530
543
  // Sizes change without a scroll event (layout after the render, the dock under the thread): while pinned, the view
531
544
  // stays flush with the bottom; a reader who scrolled up is never moved.
532
545
  const resized = () => { if (pinned && !atBottom()) toBottom(); reportJump(); };
@@ -11,7 +11,7 @@ import { lang, t } from "/assets/app-i18n.js";
11
11
  import { appStore, guestPage, useStore } from "/assets/app-store.js";
12
12
  import { AuthorizeChip } from "/assets/web-authorize.js";
13
13
  import { useLease } from "/assets/app-ctx.js";
14
- import { modelText, namesOf, namesStore } from "/assets/instance-nav.js";
14
+ import { modelText, namesOf, namesStore, navStore as instanceNavStore } from "/assets/instance-nav.js";
15
15
  import { PanelHeader, onPanelKey, setTitle, statusClass, statusLabel, openDrawer, requestNewInstance } from "/assets/app-shell.js";
16
16
  import { detailsPath, textLinkSegments } from "/assets/app-route.js";
17
17
  import { InstanceSwitch } from "/assets/instance-switch.js";
@@ -238,7 +238,12 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
238
238
  }, [themeKey]);
239
239
  useEffect(() => { wrapListeners.add(setWrap); return () => wrapListeners.delete(setWrap); }, []);
240
240
  // First visit to this instance's chat on this page: its history (a navigation — it counts as use). Back again: nothing.
241
- useEffect(() => { store.openHistory(name, lease); }, [name]);
241
+ // #2188: left before it answered (passing through on the way to another chat) — the read is aborted.
242
+ useEffect(() => {
243
+ const ac = typeof AbortController === "function" ? new AbortController() : null;
244
+ store.openHistory(name, lease, ac?.signal);
245
+ return () => ac?.abort();
246
+ }, [name]);
242
247
  useEffect(() => { try { localStorage.setItem("agend_last_instance", name); } catch { /* none */ } }, [name]);
243
248
  // Files dragged over the chat show where they go; a drop attaches them (counted, so moving over messages does not flicker).
244
249
  useEffect(() => {
@@ -322,9 +327,48 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
322
327
  * each switch built the thread again: a revisit was no faster than a first visit). A kept thread is its list element
323
328
  * and its renderer, and nothing more: it leaves as a chat always did (previews stop), with no subscription or timer
324
329
  * left; shown again, one render draws only what changed meanwhile, where the reader was. A new language, another
325
- * document, or more than THREAD_KEEP lets one go.
330
+ * document, or more than the budget lets one go.
331
+ *
332
+ * #2184: the budget (raised for people who switch among ~10 favourites): up to THREAD_KEEP threads on a wide screen and
333
+ * THREAD_KEEP_NARROW on a narrow one (a phone), and THREAD_KEEP_NODES drawn messages in all, so memory stays bounded
334
+ * however long the chats are. Over it, the least recently shown goes first — one that is not a favourite before a
335
+ * favourite; the chat on screen never goes.
336
+ */
337
+ export const THREAD_KEEP = 12;
338
+ export const THREAD_KEEP_NARROW = 6;
339
+ export const THREAD_KEEP_NODES = 2400;
340
+ /**
341
+ * #2188: a chat shown for less than this, on the way to another (clicking down the sidebar), is not kept: it is let go
342
+ * as it leaves, so passing through chats never evicts the ones in use. One that was already kept stays kept.
343
+ */
344
+ export const THREAD_PASS_MS = 1000;
345
+ const monotonic = () => (typeof performance === "object" && typeof performance.now === "function" ? performance.now() : Date.now());
346
+ let now = monotonic;
347
+ /** Tests only: the clock THREAD_PASS_MS is measured on (null: the page's monotonic clock). */
348
+ export function setThreadClock(fn) { now = typeof fn === "function" ? fn : monotonic; }
349
+ /**
350
+ * The kept threads to let go, in order. `kept`: [name, drawn messages], least recently shown first; `current`: the
351
+ * one on screen (never let go).
326
352
  */
327
- export const THREAD_KEEP = 4;
353
+ export function threadsToLetGo(kept, current, { keep, maxNodes, favorites }) {
354
+ let count = kept.length, nodes = kept.reduce((n, [, d]) => n + d, 0);
355
+ const out = [];
356
+ const order = [...kept.filter(([n]) => n !== current && !favorites.has(n)), ...kept.filter(([n]) => n !== current && favorites.has(n))];
357
+ for (const [n, d] of order) {
358
+ if (count <= keep && nodes <= maxNodes) break;
359
+ out.push(n); count--; nodes -= d;
360
+ }
361
+ return out;
362
+ }
363
+ const narrowScreen = () => typeof matchMedia === "function" && matchMedia("(max-width: 899px)").matches;
364
+ /** Let go what the budget does not hold (after `current` was shown, or drew more). */
365
+ function trimKept(current) {
366
+ const kept = [...keptThreads].map(([n, e]) => [n, e.thread.nodes.size]);
367
+ const favorites = new Set(instanceNavStore.get().favorites);
368
+ for (const n of threadsToLetGo(kept, current, { keep: narrowScreen() ? THREAD_KEEP_NARROW : THREAD_KEEP, maxNodes: THREAD_KEEP_NODES, favorites })) {
369
+ letGo(keptThreads.get(n), true); keptThreads.delete(n);
370
+ }
371
+ }
328
372
  const keptThreads = new Map(); // name → { thread, list, sink, lang }
329
373
  /** Tests only: let every kept thread go. */
330
374
  export function forgetKeptThreads() { for (const e of keptThreads.values()) letGo(e, true); keptThreads.clear(); }
@@ -398,10 +442,9 @@ function Thread({ name, th }) {
398
442
  if (e) keptThreads.delete(name);
399
443
  else e = threadEntry(name, doc);
400
444
  keptThreads.set(name, e); // the most recent last
401
- for (const [k, old] of keptThreads) {
402
- if (keptThreads.size <= THREAD_KEEP) break;
403
- if (k !== name) { letGo(old, true); keptThreads.delete(k); }
404
- }
445
+ // A thread already kept is trimmed against at once; a new one first has to stay (see THREAD_PASS_MS).
446
+ if (kept) trimKept(name);
447
+ const shownAt = now();
405
448
  const thread = e.thread, sc = scroller.current, ls = e.list;
406
449
  e.sink = { setJump, setEmpty };
407
450
  host.current.append(ls);
@@ -420,7 +463,9 @@ function Thread({ name, th }) {
420
463
  ls.removeEventListener("click", thread.onClick);
421
464
  e.sink = null;
422
465
  th.current = null;
423
- if (keptThreads.get(name) === e) { thread.park(); ls.remove(); } // kept for a later visit: the chat left
466
+ // Passed through (new, and shown under THREAD_PASS_MS): let go. Otherwise kept for a later visit, within the budget.
467
+ if (keptThreads.get(name) === e && !kept && now() - shownAt < THREAD_PASS_MS) { keptThreads.delete(name); letGo(e, false); }
468
+ else if (keptThreads.get(name) === e) { thread.park(); ls.remove(); trimKept(null); } // kept for a later visit: the chat left
424
469
  else letGo(e, false); // let go meanwhile (a new language)
425
470
  };
426
471
  }, [name]);
@@ -444,6 +489,9 @@ function Dock({ name, inst, lease, exec, awaiting, workingLabel }) {
444
489
  ${s.failedSends[name] ? html`<div class="failed-send" role="alert"><span>${t("chat.notSent")}</span><span class="txt">${s.failedSends[name]}</span>
445
490
  <button type="button" class="btn btn-primary btn-sm" onClick=${() => store.putBack(name)}>${t("chat.putBack")}</button>
446
491
  <button type="button" class="btn btn-sm" onClick=${() => store.discardFailed(name)}>${t("chat.discard")}</button></div>` : null}
492
+ ${s.waking[name] ? html`<p class="send-waking" role="status">${t("chat.waking", name)}</p>` : null}
493
+ ${s.sendFailures[name] ? html`<div class="failed-send send-failure" role="alert"><span>${s.sendFailures[name].error}</span>
494
+ <button type="button" class="btn btn-sm" disabled=${!store.canRetrySend(name)} onClick=${() => store.retrySend(name)}>${t("chat.retrySend")}</button></div>` : null}
447
495
  <${Composer} name=${name} lease=${lease} busy=${R().isBusy(exec)} />
448
496
  <p class="hint" title=${t(guestPage() ? "chat.guestMore" : "chat.composerMore")}>${t("chat.composerHint")}</p>
449
497
  ${guestPage() ? html`<p class="guest-note">${t("chat.guestNote")}</p>` : html`<${AuthorizeChip} />`}
@@ -11,7 +11,7 @@ import { useLease } from "/assets/app-ctx.js";
11
11
  import { PanelHeader, setTitle } from "/assets/app-shell.js";
12
12
  import { HeaderTools } from "/assets/header-tools.js";
13
13
  import { renavigate } from "/assets/app-nav.js";
14
- import { chatPath, fleetPath, FLEET_TABS } from "/assets/app-route.js";
14
+ import { chatPath, fleetPath, FLEET_TABS, rememberFleetTab } from "/assets/app-route.js";
15
15
  import { Dialog } from "/assets/ui-dialog.js";
16
16
  import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
17
17
  import { Icon } from "/assets/ui-icons.js";
@@ -142,6 +142,7 @@ export function FleetPanel({ route, navKey }) {
142
142
  const lease = useLease(navKey);
143
143
  const tab = route.tab;
144
144
  useEffect(() => { setTitle(`${t("fleet.title")} · ${t(`fleet.${tab}`)}`); }, [tab, navKey]);
145
+ useEffect(() => { rememberFleetTab(tab); }, [tab]); // #2183: the sidebar's and the phone's Fleet reopen it
145
146
  const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, usage: UsageTab, config: Config }[tab] || Tasks;
146
147
  // #1685: host memory pressure slowing or holding new agents — shown here, where macOS's routine "elevated" is not a chat notice.
147
148
  const { hostMemory, hostMemoryTop } = useStore(appStore);
@@ -511,7 +511,30 @@ function Agents({ ctx, search, openDialog }) {
511
511
  })}</div>`;
512
512
  }
513
513
 
514
+ function ConnectionRetry({ id, ctx }) {
515
+ const lease = useLease(`connection-retry:${id}`);
516
+ const claimed = useRef(false);
517
+ const [busy, setBusy] = useState(false);
518
+ const retry = async () => {
519
+ if (claimed.current) return;
520
+ claimed.current = true; setBusy(true);
521
+ try {
522
+ const response = await lease.fetch(`/api/settings/connections/${encodeURIComponent(id)}/retry`, { method: "POST" });
523
+ if (!lease.current()) return;
524
+ toast(response.ok ? tn("connRetryQueued") : tn("connRetryFailed"));
525
+ await ctx.reload();
526
+ } catch {
527
+ if (lease.current()) toast(tn("connRetryFailed"));
528
+ } finally {
529
+ claimed.current = false;
530
+ if (lease.current()) setBusy(false);
531
+ }
532
+ };
533
+ return html`<button type="button" class="btn btn-sm" disabled=${busy} onClick=${retry}>${tn("connRetryNow")}</button>`;
534
+ }
535
+
514
536
  function Bots({ ctx, search, openDialog }) {
537
+ const overPublicLink = globalThis.document?.body?.dataset?.publicLink === "1";
515
538
  const all = channelsOf(ctx.fleet);
516
539
  const needle = search.trim().toLowerCase();
517
540
  const rows = all.map((ch, i) => ({ ch, i })).filter(({ ch }) => !needle || [ch.id, ch.type, ch.group_id, ch.bot_token_env].some((v) => String(v ?? "").toLowerCase().includes(needle)));
@@ -540,6 +563,7 @@ function Bots({ ctx, search, openDialog }) {
540
563
  <span class="s-actions">
541
564
  <span class=${`tag${token ? "" : " warn"}`}>${token ? tn("tokenConfigured") : tn("tokenMissing")}</span>
542
565
  <span class=${`s-state ${state.cls}`} title=${stateText}>${stateText}</span>
566
+ ${!overPublicLink && ctx.fleetUp && meta.status !== "connected" ? html`<${ConnectionRetry} id=${channelId(ch, i)} ctx=${ctx} />` : null}
543
567
  <button type="button" class="btn btn-sm" onClick=${() => openDialog({ kind: "bot", id: channelId(ch, i) })}>${tn("settingsButton")}</button></span>
544
568
  </div>`;
545
569
  })}</div>`}
@@ -131,6 +131,7 @@ register("settings", {
131
131
  applyPartial: "Some changes failed",
132
132
  applyClose: "Close",
133
133
  settingsButton: "Settings",
134
+ connRetryNow: "Retry now", connRetryQueued: "Connection retry requested.", connRetryFailed: "Could not retry this connection. Check its status and try again.",
134
135
  modalDone: "Stage change",
135
136
  wizardButton: "Setup wizard",
136
137
  wizardTitle: "Set up AgEnD",
@@ -666,6 +667,7 @@ register("settings", {
666
667
  applyPartial: "有部分變更失敗",
667
668
  applyClose: "關閉",
668
669
  settingsButton: "設定",
670
+ connRetryNow: "立即重試", connRetryQueued: "已要求重新連線。", connRetryFailed: "無法重試此連線,請檢查狀態後再試。",
669
671
  modalDone: "加入待套用",
670
672
  wizardButton: "設定精靈",
671
673
  wizardTitle: "設定 AgEnD",
@@ -60,7 +60,7 @@ export function t(key, ...values) {
60
60
 
61
61
  register("app", {
62
62
  en: {
63
- brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet overview", view: "View", settings: "Settings", instances: "Instances", newInstance: "New instance", newAgent: "New agent",
63
+ brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet", view: "View", settings: "Settings", instances: "Instances", newInstance: "New instance", newAgent: "New agent",
64
64
  collapse: "Hide the sidebar", expand: "Show the sidebar", openMenu: "Open the sidebar", closeMenu: "Close the sidebar",
65
65
  resizeSidebar: "Sidebar width", resizeSidebarHint: "Drag to resize · double-click to reset",
66
66
  names: "Names", namesNickname: "Nickname first", namesInstance: "Real name first",
@@ -50,6 +50,24 @@ export function viewPath(instance) { return instance ? `/view/${encodeURICompone
50
50
  export function detailsPath(instance) { return `/ui/fleet/agent/${encodeURIComponent(instance)}`; }
51
51
  export function logPath(instance) { return `/ui/log/${encodeURIComponent(instance)}`; }
52
52
  export function fleetPath(tab) { return tab && tab !== "tasks" ? `/ui/fleet/${tab}` : "/ui/fleet"; }
53
+ /** #2183: this device's last Fleet tab (Fleet remembers it; the Org chart the first time). */
54
+ export const LAST_FLEET_TAB_KEY = "agend_last_fleet_tab";
55
+ export function lastFleetTab() {
56
+ let tab = null;
57
+ try { tab = localStorage.getItem(LAST_FLEET_TAB_KEY); } catch { /* storage blocked: the default */ }
58
+ return FLEET_TABS.includes(tab) ? tab : "org";
59
+ }
60
+ /**
61
+ * #2183: where Fleet (the sidebar's entry, the phone's tab) goes — on a Fleet tab, that tab (the route, not storage: the
62
+ * panel stores it in an effect after this render, and a storage write re-renders nothing); anywhere else, the last one.
63
+ */
64
+ export function fleetEntryPath(route) {
65
+ return fleetPath(route && route.panel === "fleet" ? route.tab : lastFleetTab()); // parseRoute names only FLEET_TABS
66
+ }
67
+ export function rememberFleetTab(tab) {
68
+ if (!FLEET_TABS.includes(tab)) return;
69
+ try { localStorage.setItem(LAST_FLEET_TAB_KEY, tab); } catch { /* this page only */ }
70
+ }
53
71
  export const NEEDS_PATH = "/ui/needs";
54
72
  export function settingsPath(section) { return section && section !== "agents" ? `/settings/${section}` : "/settings"; }
55
73
  export function routePath(route) {
@@ -9,7 +9,7 @@ import { ColorDialogHost } from "./identity-color.js";
9
9
  import { t, lang, setLang, onLang } from "./app-i18n.js";
10
10
  import { appStore, createStore, guestBadge, guestPage, useStore } from "./app-store.js";
11
11
  import { navStore } from "./app-nav.js";
12
- import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, detailsPath, fleetPath } from "./app-route.js";
12
+ import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, fleetEntryPath } from "./app-route.js";
13
13
  import { SessionMenu } from "./app-session.js";
14
14
  import { savePref } from "./app-prefs.js";
15
15
  import { ErrorState, Skeleton } from "./ui-states.js";
@@ -267,9 +267,7 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
267
267
  const app = useStore(appStore);
268
268
  const shell = useStore(shellStore);
269
269
  const canCreate = useCanCreate();
270
- // #1675: the Fleet panel is "Fleet overview"'s (one current row); the "Fleet" row is current on an instance's Details.
271
- const on = (panel) => !!route && (panel === "fleet" ? route.panel === "details" : route.panel === panel);
272
- const openInstance = openInstanceOf(route);
270
+ const on = (panel) => !!route && route.panel === panel;
273
271
  // A count badge is hidden at 0; its number is also in the link's accessible name.
274
272
  const navLink = (panel, href, icon, label, count = 0) => html`<a class=${`side-row${on(panel) ? " active" : ""}`} href=${href}
275
273
  aria-current=${on(panel) ? "page" : undefined} aria-label=${count ? `${label} (${t("app.needsCount", count)})` : undefined} onClick=${closeDrawer}>
@@ -307,10 +305,11 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
307
305
  <${SidebarResize} />
308
306
  </aside>`;
309
307
  }
310
- // #1675: the whole fleet, on its Org chart (#1389), from anywhere — top left, so at the top of a phone's drawer too. The
311
- // "Fleet" row under it goes to the open instance's Details (#1523 N2); this one is the Fleet panel, any of its tabs.
308
+ // #1675: the whole fleet, from anywhere — top left, so at the top of a phone's drawer too. #2183 (the user's call): the
309
+ // one Fleet entry ("Fleet" / "Fleet 概覽"), on the tab this device used last (the Org chart the first time); an agent's
310
+ // View, Log and Details are its header's tabs, so the sidebar has no "Fleet" (Details) or "View" row of its own.
312
311
  const onFleet = !!route && route.panel === "fleet";
313
- const overview = html`<a class=${`side-row side-overview${onFleet ? " active" : ""}`} href=${fleetPath("org")}
312
+ const overview = html`<a class=${`side-row side-overview${onFleet ? " active" : ""}`} href=${fleetEntryPath(route)}
314
313
  aria-current=${onFleet ? "page" : undefined} onClick=${closeDrawer}><${Icon} name="org" /><span class="grow">${t("app.fleetOverview")}</span></a>`;
315
314
  return html`<aside id="sidebar" class="sidebar" aria-label=${t("app.menu")}>
316
315
  <div class="side-head" data-trust="navigation">
@@ -320,8 +319,6 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
320
319
  <nav data-trust="navigation" class="side-nav" aria-label=${t("app.menu")}>
321
320
  ${overview}
322
321
  ${navLink("needs", NEEDS_PATH, "inbox", t("app.needsNav"), needsCount)}
323
- ${navLink("fleet", openInstance ? detailsPath(openInstance) : "/ui/fleet", "fleet", t("app.fleet"))}
324
- ${navLink("view", "/view", "view", t("app.view"))}
325
322
  ${canCreate && onNewInstance ? html`<button type="button" class="side-row side-new" onClick=${onNewInstance}><${Icon} name="plus" /><span class="grow">${t("app.newAgent")}</span></button>` : null}
326
323
  </nav>
327
324
  ${shell.side ? html`<${shell.side.Component} />` : html`<${InstanceNav} route=${route} items=${app.instances} loaded=${app.ready !== false}
@@ -409,8 +406,8 @@ export function PanelHeader({ title, sub, children, headingRef, nav, notice }) {
409
406
  }
410
407
 
411
408
  /**
412
- * #1523 N2: the instance a page is about — Chat, View, Log (#1218) or Details of it — or null. With one open, Fleet goes to its
413
- * Details (the user's complaint: Fleet always landed on Tasks), and Chat and View keep it; with none, as before.
409
+ * #1523 N2: the instance a page is about — Chat, View, Log (#1218) or Details of it — or null. #2183: with one open, the
410
+ * phone's Chat tab is current and goes to its chat.
414
411
  */
415
412
  export function openInstanceOf(route) {
416
413
  return route && (route.panel === "chat" || route.panel === "view" || route.panel === "log" || route.panel === "details") && route.instance ? route.instance : null;
@@ -421,14 +418,15 @@ function BottomTabs({ route, viewOnly }) {
421
418
  let last = null;
422
419
  try { last = localStorage.getItem("agend_last_instance"); } catch { /* none */ }
423
420
  const open = openInstanceOf(route);
424
- const chatHref = open && route.panel !== "view" ? chatPath(open) : route && route.panel === "chat" && route.instance ? chatPath(route.instance) : last ? chatPath(last) : "/ui";
421
+ // #2183: Chat stands for the open agent's pages (its View, Log and Details are the tabs under its header), so it goes to that agent's chat.
422
+ const chatHref = open ? chatPath(open) : last ? chatPath(last) : "/ui";
425
423
  const anyAwaiting = Object.keys(app.awaiting).length;
426
424
  const tab = (href, icon, label, active, badge, badgeLabel) => html`<a class=${`tab${active ? " active" : ""}`} href=${href} aria-current=${active ? "page" : undefined}>
427
425
  <${Icon} name=${icon} size=${20} /><span>${label}</span>${badge ? html`<span class="tab-badge" aria-label=${badgeLabel || t("app.needsYou")}>${badge}</span>` : null}</a>`;
428
- let lastView = null;
429
- try { lastView = localStorage.getItem("agend_last_view"); } catch { /* none */ }
430
- const viewHref = route && (route.panel === "view" || route.panel === "log" || route.panel === "details") && route.instance ? viewPath(route.instance) : viewPath(lastView);
431
426
  if (viewOnly) {
427
+ let lastView = null;
428
+ try { lastView = localStorage.getItem("agend_last_view"); } catch { /* none */ }
429
+ const viewHref = route && route.panel === "view" && route.instance ? viewPath(route.instance) : viewPath(lastView);
432
430
  return html`<nav data-trust="navigation" class="tabs" aria-label=${t("app.menu")}>
433
431
  ${tab(viewHref, "view", t("app.view"), route && route.panel === "view")}
434
432
  ${tab(signInHref(), "user", t("app.signIn"), false)}
@@ -436,11 +434,12 @@ function BottomTabs({ route, viewOnly }) {
436
434
  }
437
435
  // #1920: a guest has only its chats (the drawer lists them): no tabs to switch between.
438
436
  if (guestPage()) return null;
437
+ // #2183 (the user's call): four tabs — Chat (current on /ui and on any of an agent's pages), Needs you, Fleet (on its
438
+ // last tab), Settings.
439
439
  return html`<nav data-trust="navigation" class="tabs" aria-label=${t("app.menu")}>
440
- ${tab(chatHref, "chat", t("app.chat"), route && route.panel === "chat", anyAwaiting)}
440
+ ${tab(chatHref, "chat", t("app.chat"), !!open || (!!route && route.panel === "chat"), anyAwaiting)}
441
441
  ${tab(NEEDS_PATH, "inbox", t("app.needsTab"), route && route.panel === "needs", (app.needs || []).length, t("app.needsCount", (app.needs || []).length))}
442
- ${tab(open ? detailsPath(open) : "/ui/fleet", "fleet", t("app.fleet"), route && (route.panel === "fleet" || route.panel === "details"))}
443
- ${tab(viewHref, "view", t("app.view"), route && route.panel === "view")}
442
+ ${tab(fleetEntryPath(route), "fleet", t("app.fleet"), route && route.panel === "fleet")}
444
443
  ${tab(settingsPath(), "settings", t("app.settings"), route && route.panel === "settings")}
445
444
  </nav>`;
446
445
  }
@@ -423,7 +423,7 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
423
423
 
424
424
  /* ── Chat ── */
425
425
  .p-chat .scroller { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; position: relative; }
426
- .thread { max-width: var(--col); margin: 0 auto; padding: var(--s-4) var(--s-5) var(--s-3); display: flex; flex-direction: column; gap: var(--s-6); }
426
+ .thread { max-width: var(--col); margin: var(--thread-snap, 0px) auto 0; padding: var(--s-4) var(--s-5) var(--s-3); display: flex; flex-direction: column; gap: var(--s-6); }
427
427
  .msg { font-size: var(--fs-md); line-height: var(--lh-md); overflow-wrap: anywhere; min-width: 0; }
428
428
  .msg .meta { display: flex; align-items: baseline; gap: var(--s-2); font-size: var(--fs-xs); color: var(--text-3); margin-bottom: var(--s-1); }
429
429
  .msg .sender { font-weight: var(--fw-semibold); color: var(--text-2); }
@@ -74,7 +74,14 @@ export function createOwnerEffectSource({ current, readMetadata, readResource, r
74
74
  },
75
75
  descriptors() {
76
76
  if (!alive() || !metadata || error) return [];
77
- const body = metadata, gen = generation, proof = safe(() => effectStockSnapshot(stock()));
77
+ const body = metadata, gen = generation;
78
+ // #2188: the stock snapshot walks the whole page (thousands of computed styles). With no slot holding an effect
79
+ // that could play, there is nothing for it to admit: skipped — every page change (a status, a chat switch) ran it.
80
+ if (!SLOTS.some(slot => body.slots[slot]?.status === "available" && failed.get(slot) !== body.slots[slot].id)) {
81
+ for (const slot of SLOTS) drop(slot);
82
+ return [];
83
+ }
84
+ const proof = safe(() => effectStockSnapshot(stock()));
78
85
  if (!fresh(body, gen)) return [];
79
86
  if (!proof) { clear(); return []; }
80
87
  const result = [];
@@ -189,6 +189,12 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
189
189
  const proposal = revision;
190
190
  if (time() === null) blocked = "clock_unavailable";
191
191
  const hidden = context?.hidden === true || host.document.visibilityState !== "visible";
192
+ // #2188: nothing to place, nothing to prove — the stock walk (thousands of computed styles) is not run for an empty
193
+ // list; a frame still held is retired as superseded, exactly as an allocation of nothing would.
194
+ if (!blocked && descriptors.length === 0) {
195
+ for (const s of [...slots.values()]) { retire(s, "superseded"); if (dead || proposal !== revision) return; }
196
+ results.clear(); tell(); return;
197
+ }
192
198
  const stock = eligible() ? coverage() : null;
193
199
  if (dead || proposal !== revision) return;
194
200
  if (blocked || !eligible() || !stock) {
@@ -440,7 +446,8 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
440
446
  } catch { return false; }
441
447
  }
442
448
  function pointer(event) {
443
- if (dead || blocked || !pointerGate() || event.isTrusted !== true) return;
449
+ // #2188: no frame to forward to — no gate to read (each read walks the page's computed styles, per pointer event).
450
+ if (dead || blocked || slots.size === 0 || !pointerGate() || event.isTrusted !== true) return;
444
451
  const kinds = { pointermove: "move", pointerdown: "down", pointerup: "up", click: "click", wheel: "wheel" }, kind = kinds[event.type];
445
452
  if (!kind || !finite(event.clientX) || !finite(event.clientY) || Math.abs(event.clientX) > 1e7 || Math.abs(event.clientY) > 1e7 || !Number.isInteger(event.buttons) || event.buttons < 0 || event.buttons > 31) return;
446
453
  try { const target = host.document.elementFromPoint(event.clientX, event.clientY); if (!target || typeof target.closest !== "function" || target.closest("[data-trust]")) return; } catch { return; }
@@ -511,7 +518,8 @@ export function createEffectRegistry({ window: host, origin, boot, load, safety,
511
518
  },
512
519
  /** Host-only content-free signal API. Raw pointer publishing is refused; native pointer metadata is coalesced here. */
513
520
  publish(type, fields, instance = null) {
514
- if (dead || blocked || !globalAllowed() || context.hidden || !coverage() || !TYPES.includes(type)) return 0;
521
+ // #2188: no frame to send to — nothing to prove (the stock walk ran twice on every page change).
522
+ if (dead || blocked || slots.size === 0 || !globalAllowed() || context.hidden || !coverage() || !TYPES.includes(type)) return 0;
515
523
  const ts = time(); if (ts === null) { blocked = "clock_unavailable"; stopAll(blocked); tell(); return 0; }
516
524
  let sent = 0; const proposal = revision;
517
525
  for (const s of [...slots.values()]) {
@@ -80,6 +80,7 @@ function sync() {
80
80
  let valid = false;
81
81
  try { valid = link.disabled !== true && !!link.sheet && link.sheet.ownerNode === link && link.sheet.disabled !== true && link.sheet.href === new URL(href, location.href).href; } catch { /* unknown stylesheet */ }
82
82
  linkState = valid ? "on" : "unknown"; life(linkState, valid ? "loaded" : "load_unverified");
83
+ if (valid) shownHere(sha); // #2198: an agent's trial is on this screen now
83
84
  });
84
85
  link.addEventListener("error", () => { if (!current()) return; linkState = "unknown"; life("unknown", "load_failed"); });
85
86
  life("loading", "loading");
@@ -189,12 +190,40 @@ async function reread() {
189
190
  if (body && mine === gen) reconcile(body); // nothing newer happened since this was asked
190
191
  } catch { /* the next change tries again */ }
191
192
  }
193
+ /**
194
+ * #2198: an agent's trial (General saved it) waits on the host until a person's page shows it. This page shows it once
195
+ * its stylesheet has loaded on screen, and says so: the host starts the keep window then (the dead-man switch, from when
196
+ * a person can see the Keep button). If the host cannot be told, this page reverts it itself at KEEP_SECONDS, as it does
197
+ * for a version it saved.
198
+ */
199
+ let waitingShown = null; // { id, sha } of an agent's trial this page is showing and has not reported yet
200
+ function shownHere(sha) {
201
+ const w = waitingShown;
202
+ if (!w || w.sha !== sha) return;
203
+ waitingShown = null;
204
+ const trial = ownerCssTrial.get();
205
+ if (!trial || trial.id !== w.id) return;
206
+ post("/ui/owner-css/shown", { trial: w.id }).then((r) => {
207
+ if (ownerCssTrial.get() !== trial) return;
208
+ const ms = r.ok && r.body && Number.isFinite(r.body.remaining_ms) ? r.body.remaining_ms : null;
209
+ if (ms === null) { startOwnerCssTrial(w.id, w.sha, trial.previous, KEEP_SECONDS, true); return; }
210
+ startOwnerCssTrial(w.id, w.sha, trial.previous, ms / 1000 + 1, false);
211
+ }, () => { if (ownerCssTrial.get() === trial) startOwnerCssTrial(w.id, w.sha, trial.previous, KEEP_SECONDS, true); });
212
+ }
192
213
  /** Match the host: the version in force, and whether one is on trial (another device's included) — or none any more. */
193
214
  function reconcile(state) {
194
215
  const pending = state.pending && state.pending.sha === state.active && typeof state.pending.id === "string" ? state.pending : null;
195
216
  const trial = ownerCssTrial.get();
196
- if (!pending) { if (trial) dropTrial(); showActive(state.active, state.effectState); return; }
217
+ if (!pending) { waitingShown = null; if (trial) dropTrial(); showActive(state.active, state.effectState); return; }
197
218
  if (trial && trial.id === pending.id) return; // already asking about this one
219
+ if (pending.waiting === true) {
220
+ // Shown here: its stylesheet goes in, and once it has loaded this page reports it (shownHere). Until then the bar
221
+ // waits on the host's word, as for another device's trial.
222
+ waitingShown = { id: pending.id, sha: pending.sha };
223
+ startOwnerCssTrial(pending.id, pending.sha, pending.previous, HOST_TRIAL_SECONDS, false);
224
+ if (linkState === "on" && ownedLink && ownedLink.getAttribute("href") === `/ui/owner-css.css?v=${encodeURIComponent(pending.sha)}`) shownHere(pending.sha);
225
+ return;
226
+ }
198
227
  // The host's own remaining time and a second (never this device's clock): it has settled the trial by then.
199
228
  const remaining = typeof pending.remaining_ms === "number" && Number.isFinite(pending.remaining_ms) ? pending.remaining_ms : 0;
200
229
  startOwnerCssTrial(pending.id, pending.sha, pending.previous, remaining / 1000 + 1, false);
package/dist/web-api.d.ts CHANGED
@@ -126,8 +126,12 @@ export interface WebApiContext {
126
126
  /** #1920 §5.2: a web message's channel copy was sent — guests of that channel may see it now. */
127
127
  webMessageInChannel?(instance: string, messageId: string): void;
128
128
  /** false: definitely not delivered (the instance's IPC is gone, or it was restarted meanwhile). */
129
- deliverToInstance(instanceName: string, payload: Record<string, unknown>): Promise<boolean | void>;
130
- deliverHumanToInstance?(instanceName: string, payload: Record<string, unknown>, origin: HumanOrigin, originalBody: string): Promise<boolean | void>;
129
+ deliverToInstance(instanceName: string, payload: Record<string, unknown>, options?: {
130
+ stillCurrent?: () => boolean;
131
+ }): Promise<boolean | void>;
132
+ deliverHumanToInstance?(instanceName: string, payload: Record<string, unknown>, origin: HumanOrigin, originalBody: string, options?: {
133
+ stillCurrent?: () => boolean;
134
+ }): Promise<boolean | void>;
131
135
  getUiStatus(): unknown;
132
136
  /** Current owned pane reader shared with status; absent means context is unavailable. */
133
137
  getPaneContextSource?(name: string): PaneContextSource | null;
package/dist/web-api.js CHANGED
@@ -280,7 +280,7 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
280
280
  json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
281
281
  return true;
282
282
  }
283
- if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
283
+ if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/shown", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
284
284
  json(res, 404, { error: "not found" });
285
285
  return true;
286
286
  }
@@ -329,6 +329,17 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
329
329
  announce();
330
330
  json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
331
331
  }
332
+ else if (path === "/ui/owner-css/shown") {
333
+ // #2198: this page shows the trial (its stylesheet loaded): an agent's waiting trial starts its keep window now.
334
+ const shown = store.shown(person, body.trial);
335
+ if (!shown.ok) {
336
+ json(res, shown.status, { error: shown.error });
337
+ return;
338
+ }
339
+ if (shown.started)
340
+ announce(); // the person's other devices take the new deadline
341
+ json(res, 200, { remaining_ms: shown.remaining_ms });
342
+ }
332
343
  else if (path === "/ui/owner-css/keep") {
333
344
  const kept = store.keep(person, body.trial);
334
345
  if (!kept.ok) {
@@ -1908,7 +1919,7 @@ export function handleWebRequest(req, res, url, ctx) {
1908
1919
  return true;
1909
1920
  }
1910
1921
  // Own keys only: "constructor" is in every object and names no instance.
1911
- const known = (ctx.fleetConfig ? Object.hasOwn(ctx.fleetConfig.instances, name) : false) || ctx.daemons.has(name);
1922
+ const known = registeredChatInstance(ctx, name) || ctx.daemons.has(name);
1912
1923
  if (!ctx.cancelInstance || !known) {
1913
1924
  json(res, 404, { error: `Instance not found: ${name}` });
1914
1925
  return true;
@@ -2578,6 +2589,27 @@ function webEchoPreview(message, names) {
2578
2589
  const truncated = message.length > 500 || preview.length > 1400;
2579
2590
  return wellFormed(preview.slice(0, 1400)) + (truncated ? ` … (${t("web.echo_full_text")})` : "");
2580
2591
  }
2592
+ /** Registration and liveness are different: a paused CLI may have neither daemon nor IPC. */
2593
+ function registeredChatInstance(ctx, name) {
2594
+ return (!!ctx.fleetConfig && Object.hasOwn(ctx.fleetConfig.instances, name)) || ctx.isClassicInstance?.(name) === true;
2595
+ }
2596
+ function uploadRecipient(ctx, name) {
2597
+ return registeredChatInstance(ctx, name)
2598
+ && (ctx.instanceIpcClients.has(name) || ctx.getInstanceStatus?.(name) === "paused");
2599
+ }
2600
+ /** Closed, path-free explanations; arbitrary launch errors stay in the host log. */
2601
+ function webDeliveryFailure(err) {
2602
+ const message = err instanceof Error ? err.message : "";
2603
+ if (/No warm slot is free/.test(message))
2604
+ return { reason: "wake_capacity", error: "No slot is free to wake this instance. Retry when another instance is idle." };
2605
+ if (/login|log in|sign in|\bauth(?:entication|orization)?\b|credential|api.?key/i.test(message))
2606
+ return { reason: "wake_credentials", error: "The instance could not sign in. Check its account on the host, then retry." };
2607
+ if (/timed? out|timeout/i.test(message))
2608
+ return { reason: "wake_timeout", error: "The instance did not become ready in time. Retry, or check its status on the host." };
2609
+ if (/Cannot wake stopped|no longer configured/.test(message))
2610
+ return { reason: "instance_stopped", error: "This instance was stopped or removed. Start it on the host before retrying." };
2611
+ return { reason: "delivery_unavailable", error: "The instance could not receive this message. Check its status on the host, then retry." };
2612
+ }
2581
2613
  /** Handle POST /ui/send — extracted for readability. */
2582
2614
  function handleSendMessage(req, res, ctx) {
2583
2615
  const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
@@ -2615,8 +2647,8 @@ function handleSendMessage(req, res, ctx) {
2615
2647
  json(res, 403, { error: "guest_no_commands" });
2616
2648
  return;
2617
2649
  }
2618
- const ipc = ctx.instanceIpcClients.get(instance);
2619
- if (!ipc) {
2650
+ const registered = registeredChatInstance(ctx, instance);
2651
+ if (!registered && !ctx.instanceIpcClients.has(instance)) {
2620
2652
  json(res, 404, { error: `Instance not found: ${instance}` });
2621
2653
  return;
2622
2654
  }
@@ -2639,6 +2671,12 @@ function handleSendMessage(req, res, ctx) {
2639
2671
  json(res, 403, { error: t("web.raw_public_refused") });
2640
2672
  return;
2641
2673
  }
2674
+ // Recheck the same admission all the way through a wake, before the facade's actual IPC handoff.
2675
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
2676
+ const admission = admissionOf(req, requestUrl, ctx);
2677
+ const stillCurrent = () => !res.destroyed && admission !== null && admissionOf(req, requestUrl, ctx) === admission
2678
+ && (!guest || (guest.current() && !guestChatRefusal(guest, instance)))
2679
+ && (registered ? registeredChatInstance(ctx, instance) : ctx.instanceIpcClients.has(instance));
2642
2680
  const message = typed;
2643
2681
  // #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
2644
2682
  // body is in: a session revoked meanwhile sends nothing (not as the operator).
@@ -2685,8 +2723,8 @@ function handleSendMessage(req, res, ctx) {
2685
2723
  let delivered;
2686
2724
  try {
2687
2725
  const deliver = (name, payload) => ctx.deliverHumanToInstance
2688
- ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed)
2689
- : ctx.deliverToInstance(name, payload);
2726
+ ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed, { stillCurrent })
2727
+ : ctx.deliverToInstance(name, payload, { stillCurrent });
2690
2728
  delivered = await deliver(instance, {
2691
2729
  type: "fleet_inbound",
2692
2730
  content: delivery.text,
@@ -2712,7 +2750,7 @@ function handleSendMessage(req, res, ctx) {
2712
2750
  settleClassicEcho?.(false);
2713
2751
  ctx.webFiles?.release(files); // not delivered: the same ids can be sent again
2714
2752
  ctx.logger.error({ err, instance }, "Web message delivery failed");
2715
- json(res, 503, { error: "Instance delivery failed" });
2753
+ json(res, 503, webDeliveryFailure(err));
2716
2754
  return;
2717
2755
  }
2718
2756
  if (delivered === false) {
@@ -2720,7 +2758,7 @@ function handleSendMessage(req, res, ctx) {
2720
2758
  settleClassicEcho?.(false);
2721
2759
  ctx.webFiles?.release(files);
2722
2760
  ctx.logger.error({ instance }, "Web message not delivered (the instance went away or restarted)");
2723
- json(res, 503, { error: "Instance delivery failed" });
2761
+ json(res, 503, { reason: "delivery_changed", error: "The session or instance changed before this message was sent. Sign in again if needed, then retry." });
2724
2762
  return;
2725
2763
  }
2726
2764
  settleEcho?.(true);
@@ -2767,7 +2805,7 @@ function handleUpload(req, res, url, ctx) {
2767
2805
  json(res, guestRefused.status, { error: guestRefused.error });
2768
2806
  return;
2769
2807
  }
2770
- if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
2808
+ if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !uploadRecipient(ctx, instance)) {
2771
2809
  json(res, 404, { error: `Instance not found: ${instance}` });
2772
2810
  return;
2773
2811
  }
@@ -2830,11 +2868,9 @@ function handleUpload(req, res, url, ctx) {
2830
2868
  req.on("error", () => drop()); // the client went away
2831
2869
  req.on("close", () => { if (!over && req.complete === false)
2832
2870
  drop(); });
2833
- // The instance is checked again now the whole body is here: it may have been deleted (and its files
2834
- // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
2835
- // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
2836
- // in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
2837
- const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
2871
+ // Recheck registration and delivery readiness after both waits. A paused recipient may have no IPC;
2872
+ // staging a file never wakes it. A stopped or deleted recipient must not regain an upload id.
2873
+ const deliverable = () => uploadRecipient(ctx, instance);
2838
2874
  const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
2839
2875
  req.on("end", () => {
2840
2876
  if (over)