@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.
- package/dist/agent-whats-new.js +14 -9
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
- package/dist/channel/adapters/discord-client-retirement.js +57 -0
- package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +2 -0
- package/dist/channel/adapters/discord.js +14 -4
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/cli.js +12 -79
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +25 -0
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +8 -1
- package/dist/fleet-manager.js +101 -33
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +18 -0
- package/dist/fleet-process-handlers.js +69 -0
- package/dist/fleet-process-handlers.js.map +1 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -0
- package/dist/instance-lifecycle.d.ts +2 -0
- package/dist/instance-lifecycle.js +11 -6
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/settings-api.d.ts +4 -0
- package/dist/settings-api.js +23 -0
- package/dist/settings-api.js.map +1 -1
- package/dist/ui/app-effects.js +13 -1
- package/dist/ui/chat-store.js +32 -10
- package/dist/ui/chat-strings.js +16 -0
- package/dist/ui/chat-thread.js +5 -2
- package/dist/ui/panel-chat.js +24 -3
- package/dist/ui/panel-settings.js +24 -0
- package/dist/ui/settings-strings.js +2 -0
- package/dist/ui/shared/effect-owner-source.js +8 -1
- package/dist/ui/shared/effect-registry.js +10 -2
- package/dist/ui/shared/owner-css.js +30 -1
- package/dist/web-api.d.ts +6 -2
- package/dist/web-api.js +50 -14
- package/dist/web-api.js.map +1 -1
- package/dist/web-owner-css-store.d.ts +43 -1
- package/dist/web-owner-css-store.js +65 -9
- package/dist/web-owner-css-store.js.map +1 -1
- package/dist/web-settings-tools.js +7 -4
- package/dist/web-settings-tools.js.map +1 -1
- package/package.json +1 -1
package/dist/ui/panel-chat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
130
|
-
|
|
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
|
|
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
|
|
2619
|
-
if (!
|
|
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,
|
|
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: "
|
|
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
|
|
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
|
-
//
|
|
2834
|
-
//
|
|
2835
|
-
|
|
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)
|