@songsid/agend 2.3.0-beta.11 → 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 (48) 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 +4 -0
  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-store.js +32 -10
  32. package/dist/ui/chat-strings.js +16 -0
  33. package/dist/ui/chat-thread.js +5 -2
  34. package/dist/ui/panel-chat.js +24 -3
  35. package/dist/ui/panel-settings.js +24 -0
  36. package/dist/ui/settings-strings.js +2 -0
  37. package/dist/ui/shared/effect-owner-source.js +8 -1
  38. package/dist/ui/shared/effect-registry.js +10 -2
  39. package/dist/ui/shared/owner-css.js +30 -1
  40. package/dist/web-api.d.ts +6 -2
  41. package/dist/web-api.js +50 -14
  42. package/dist/web-api.js.map +1 -1
  43. package/dist/web-owner-css-store.d.ts +43 -1
  44. package/dist/web-owner-css-store.js +65 -9
  45. package/dist/web-owner-css-store.js.map +1 -1
  46. package/dist/web-settings-tools.js +7 -4
  47. package/dist/web-settings-tools.js.map +1 -1
  48. package/package.json +1 -1
@@ -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(() => {
@@ -332,6 +337,15 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
332
337
  export const THREAD_KEEP = 12;
333
338
  export const THREAD_KEEP_NARROW = 6;
334
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; }
335
349
  /**
336
350
  * The kept threads to let go, in order. `kept`: [name, drawn messages], least recently shown first; `current`: the
337
351
  * one on screen (never let go).
@@ -428,7 +442,9 @@ function Thread({ name, th }) {
428
442
  if (e) keptThreads.delete(name);
429
443
  else e = threadEntry(name, doc);
430
444
  keptThreads.set(name, e); // the most recent last
431
- trimKept(name);
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();
432
448
  const thread = e.thread, sc = scroller.current, ls = e.list;
433
449
  e.sink = { setJump, setEmpty };
434
450
  host.current.append(ls);
@@ -447,7 +463,9 @@ function Thread({ name, th }) {
447
463
  ls.removeEventListener("click", thread.onClick);
448
464
  e.sink = null;
449
465
  th.current = null;
450
- 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
451
469
  else letGo(e, false); // let go meanwhile (a new language)
452
470
  };
453
471
  }, [name]);
@@ -471,6 +489,9 @@ function Dock({ name, inst, lease, exec, awaiting, workingLabel }) {
471
489
  ${s.failedSends[name] ? html`<div class="failed-send" role="alert"><span>${t("chat.notSent")}</span><span class="txt">${s.failedSends[name]}</span>
472
490
  <button type="button" class="btn btn-primary btn-sm" onClick=${() => store.putBack(name)}>${t("chat.putBack")}</button>
473
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}
474
495
  <${Composer} name=${name} lease=${lease} busy=${R().isBusy(exec)} />
475
496
  <p class="hint" title=${t(guestPage() ? "chat.guestMore" : "chat.composerMore")}>${t("chat.composerHint")}</p>
476
497
  ${guestPage() ? html`<p class="guest-note">${t("chat.guestNote")}</p>` : html`<${AuthorizeChip} />`}
@@ -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",
@@ -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)