/** * dsh-fleet-control — browser half. * * Adds three buttons to the conversation header: * • Stop All — gracefully cancel the running turn of every session and * remember which ones were actually running (persisted host-side). * • Resume Stopped — re-open only the remembered sessions, send each a "continue" * prompt, then clear the remembered set. * • Resume All — send "continue" to every session, whether or not Stop All * touched it; also clears the remembered set. * * Cancel/send require a live per-session binding (dsh-client-ui-conversation * throws "resolved no binding" otherwise), which only exists once a session is * opened. So both actions open a session and wait for its binding before acting, * and restore the originally-current session when done. * * @module dsh-fleet-control/client */ window.__ModuleLoader__.load({ id: "dsh-fleet-control", factory: (require) => { var module = { exports: {} }; var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); const react = require("react"); const jsx = require("react/jsx-runtime"); /** Prompt sent to each session by Resume All. Edit to taste. */ const RESUME_TEXT = "continue"; /** How long to wait for a freshly-opened session to bind before giving up. */ const BINDING_TIMEOUT_MS = 8000; const STYLE_ID = "dsh-fleet-control-style"; const STYLE_TEXT = ".dsh-fleet-control{display:inline-flex;align-items:center;gap:6px}.dsh-fleet-control__btn{border:1px solid var(--dsw-alias-border-l2);height:32px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);cursor:pointer;background:transparent;border-radius:18px;display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:6px 12px;font-size:13px;font-weight:400;line-height:20px;white-space:nowrap}.dsh-fleet-control__btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.dsh-fleet-control__btn:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}.dsh-fleet-control__note{color:var(--dsw-alias-label-tertiary);font-family:var(--dsw-font-family);font-size:12px;white-space:nowrap}"; function installStyle() { if (document.getElementById(STYLE_ID) !== null) return; const style = document.createElement("style"); style.id = STYLE_ID; style.dataset.plugin = "dsh-fleet-control"; style.textContent = STYLE_TEXT; document.head.appendChild(style); } async function request(path, options) { let response; try { response = await fetch(path, options); } catch { return { ok: false }; } try { const envelope = await response.json(); if (envelope && envelope.ok === true) return { ok: true, value: envelope.value }; return { ok: false }; } catch { return { ok: false }; } } const loadStopped = () => request("/fleet/stopped"); const saveStopped = (ids) => request("/fleet/stopped", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ids }), }); function toMillis(value) { if (typeof value === "number") return value; if (typeof value === "string") { const parsed = Date.parse(value); return Number.isNaN(parsed) ? 0 : parsed; } return 0; } /** All session ids, most-recently-updated first (mirrors dsh-web-hotkeys). */ function orderedIds(sessions) { const snapshot = sessions.list.getSnapshot(); const byId = snapshot.byId || {}; return Object.keys(byId).sort((a, b) => toMillis(byId[b] && byId[b].updatedAt) - toMillis(byId[a] && byId[a].updatedAt)); } /** * Best-effort read of a session's running state from the list snapshot, used * only to skip obviously-idle sessions during Stop All. Returns undefined * when the snapshot shape carries no such flag, in which case the caller * attempts the session anyway (cancel is a safe no-op on idle sessions). */ function runningHint(byId, id) { const row = byId && byId[id]; if (!row || typeof row !== "object") return undefined; if (typeof row.running === "boolean") return row.running; if (typeof row.busy === "boolean") return row.busy; if (typeof row.status === "string") return row.status !== "idle" && row.status !== "closed"; if (typeof row.activity === "string") return row.activity !== "idle"; return undefined; } function conversationOf(sessions, id) { try { const scoped = sessions.scope(id); return scoped && typeof scoped.get === "function" ? scoped.get("conversation") : undefined; } catch { return undefined; } } const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitForBinding(sessions, id, timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { if (typeof sessions.binding === "function" && sessions.binding(id) !== undefined) return true; } catch { /* keep polling */ } await delay(120); } return false; } function isNoBinding(error) { return /no binding/i.test(String(error && error.message)); } /** Cancel one session's running turn. Returns true only if a turn was stopped. */ async function stopOne(sessions, id) { let conversation = conversationOf(sessions, id); if (conversation && typeof conversation.cancel === "function") { try { await conversation.cancel(); return true; } catch (error) { // idle / nothing-to-cancel -> not running; only retry when unbound. if (!isNoBinding(error)) return false; } } try { if (typeof sessions.open === "function") sessions.open(id); if (!(await waitForBinding(sessions, id, BINDING_TIMEOUT_MS))) return false; conversation = conversationOf(sessions, id); if (!conversation || typeof conversation.cancel !== "function") return false; await conversation.cancel(); return true; } catch { return false; } } /** Open a session, wait for its binding, then send the resume prompt. */ async function resumeOne(sessions, id) { try { if (typeof sessions.open === "function") sessions.open(id); if (!(await waitForBinding(sessions, id, BINDING_TIMEOUT_MS))) return false; const conversation = conversationOf(sessions, id); if (!conversation || typeof conversation.send !== "function") return false; await conversation.send(RESUME_TEXT); return true; } catch { return false; } } async function stopAll(sessions) { const original = sessions.list.getSnapshot().current; const byId = sessions.list.getSnapshot().byId || {}; const ids = orderedIds(sessions); const stopped = []; for (const id of ids) { if (runningHint(byId, id) === false) continue; if (await stopOne(sessions, id)) stopped.push(id); } if (original && typeof sessions.open === "function") sessions.open(original); await saveStopped(stopped); return stopped; } /** Resume only the sessions Stop All recorded, then clear the set. */ async function resumeStopped(sessions) { const original = sessions.list.getSnapshot().current; const loaded = await loadStopped(); const ids = loaded.ok && loaded.value && Array.isArray(loaded.value.ids) ? loaded.value.ids : []; const resumed = []; for (const id of ids) { if (await resumeOne(sessions, id)) resumed.push(id); } if (original && typeof sessions.open === "function") sessions.open(original); await saveStopped([]); return resumed; } /** Resume every session, regardless of whether Stop All touched it; clears the set. */ async function resumeAll(sessions) { const original = sessions.list.getSnapshot().current; const ids = orderedIds(sessions); const resumed = []; for (const id of ids) { if (await resumeOne(sessions, id)) resumed.push(id); } if (original && typeof sessions.open === "function") sessions.open(original); await saveStopped([]); return resumed; } function FleetControlButtons(props) { const [busy, setBusy] = react.useState(""); const [note, setNote] = react.useState(""); const flash = react.useCallback((text) => { setNote(text); window.setTimeout(() => setNote(""), 5000); }, []); const onStop = react.useCallback(async () => { if (busy) return; setBusy("stop"); try { const stopped = await props.stopAll(); flash(`Stopped ${stopped.length}`); } catch { flash("Stop failed"); } finally { setBusy(""); } }, [busy, props, flash]); const onResumeStopped = react.useCallback(async () => { if (busy) return; setBusy("resume-stopped"); try { const resumed = await props.resumeStopped(); flash(`Resumed ${resumed.length} stopped`); } catch { flash("Resume failed"); } finally { setBusy(""); } }, [busy, props, flash]); const onResumeAll = react.useCallback(async () => { if (busy) return; setBusy("resume-all"); try { const resumed = await props.resumeAll(); flash(`Resumed ${resumed.length}`); } catch { flash("Resume failed"); } finally { setBusy(""); } }, [busy, props, flash]); return jsx.jsxs("div", { className: "dsh-fleet-control", children: [ jsx.jsx("button", { type: "button", className: "dsh-fleet-control__btn", disabled: busy !== "", title: "Gracefully stop the running turn of every session and remember which were running", onClick: onStop, children: busy === "stop" ? "Stopping…" : "Stop All", }), jsx.jsx("button", { type: "button", className: "dsh-fleet-control__btn", disabled: busy !== "", title: `Re-open only the sessions Stop All stopped and send "${RESUME_TEXT}"`, onClick: onResumeStopped, children: busy === "resume-stopped" ? "Resuming…" : "Resume Stopped", }), jsx.jsx("button", { type: "button", className: "dsh-fleet-control__btn", disabled: busy !== "", title: `Send "${RESUME_TEXT}" to every session, whether or not Stop All touched it`, onClick: onResumeAll, children: busy === "resume-all" ? "Resuming…" : "Resume All", }), note ? jsx.jsx("span", { className: "dsh-fleet-control__note", children: note }) : null, ], }); } const inject = ["slots", "sessions"]; function apply(ctx) { installStyle(); ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({ name: "conversation.session.header.utilities", id: "fleet-control", order: 70, label: "Fleet control", inject: () => ({ stopAll: () => stopAll(ctx.sessions), resumeStopped: () => resumeStopped(ctx.sessions), resumeAll: () => resumeAll(ctx.sessions), }), }, FleetControlButtons)); } exports.apply = apply; exports.inject = inject; return module.exports; }, });