DSH-Arch-Setup/plugins/dsh-fleet-control/lib/client.js
Шурупов Илья Викторович cf9443753e Add one-command Arch DSH setup
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.
2026-09-12 19:13:36 +03:00

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;
},
});