Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
/**
|
|
* 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;
|
|
},
|
|
});
|