comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
111 lines • 5.63 kB
JavaScript
/**
* The conversation-wide Blind-mode gate, as a pure decision over the blind-tab
* set — extracted from the orchestrator so the one predicate that decides
* whether the shared agent ever receives pixels is unit-testable without a
* websocket (the same reason `panel-sync.ts` is a pure module).
*
* WHY THIS EXISTS AT ALL (#1841). Blind (#90) is a per-tab UI setting; #884 made
* the GATE conversation-wide, because the agent is shared and a per-tab gate
* would leak pixels to it through some other tab. The set that backs it was only
* ever written by an explicit blind-off FROM THAT SAME TAB ID, or by the hello
* migration path — so nothing removed a tab that simply WENT AWAY (browser
* closed, a reconnect that minted a new id outside the migration path, a ComfyUI
* restart). One departed blind tab therefore pinned `anyTabBlind()` true for the
* life of the orchestrator process: every later agent and tool spawn got
* `COMFYUI_MCP_BLIND=1` and every run completion was stripped, while the user's
* current tab reported Blind as OFF and the panel composed its SIGHTED note.
*
* WHY NOT JUST `canReach()`. The obvious repair — count only tabs the bridge can
* reach right now — inverts the failure direction of a privacy promise. A
* websocket drop is routine and brief; during it every blind tab reads as
* unreachable, so a completion landing in that window would hand pixels to the
* agent for a user who never turned Blind off. Blind must fail CLOSED.
*
* WHAT THIS DOES INSTEAD. Unreachability starts a clock; it does not decide
* anything. A blind tab that cannot be reached KEEPS blinding the conversation
* for `graceMs`, and is dropped only once it has been continuously unreachable
* past that grace. A reconnect inside the window clears the clock, and the tab's
* own hello re-asserts its setting either way — so the reconnect path needs no
* cooperation from this module. The exposure window a bare `canReach()` opened is
* closed, and the permanent pin is bounded.
*
* The sweep is LEVEL-TRIGGERED — it needs no disconnect hook, no tab-lifecycle
* callback, and no cooperation from the bridge beyond a reachability probe.
*/
/** How long a blind tab keeps blinding the conversation after it stops being
* reachable. Comfortably longer than a websocket reconnect (seconds), short
* enough that a closed tab cannot pin a long session. */
export const BLIND_TAB_GRACE_MS = 5 * 60_000;
/**
* Sweep the blind-tab set and answer whether the conversation is blind.
*
* Every entry is visited on every call — no early return — so a live blind tab
* can never mask the pruning of a stale one. That matters because the live tab
* is usually the one that comes and goes, and an early return would leave the
* permanently-dead entry unswept for exactly as long as anything else was blind.
*
* `blindTabs` and `unreachableSince` are mutated in place: this is the only
* writer that retires an entry on evidence rather than on an explicit user
* action, and keeping the bookkeeping beside the decision is what stops the two
* from drifting apart.
*/
export function resolveBlindTabGate({ blindTabs, unreachableSince, canReach, now, graceMs = BLIND_TAB_GRACE_MS, }) {
const pruned = [];
const pendingGone = [];
let blind = false;
for (const tabId of [...blindTabs]) {
let reachable = false;
try {
reachable = canReach(tabId) === true;
}
catch {
// A probe that THREW told us nothing. Treat it as unreachable so the grace
// clock still runs (a permanently-throwing probe must not pin the session
// forever either), but never as proof the tab is gone.
reachable = false;
}
if (reachable) {
// Present and blind: the clock is irrelevant, so clear it. Without this a
// tab that flapped once would carry a stale stamp and could be pruned mid-
// session while its user is still watching.
unreachableSince.delete(tabId);
blind = true;
continue;
}
const since = unreachableSince.get(tabId);
if (since == null) {
// FIRST observation of absence is not evidence of departure — start the
// clock and stay blind. This is the branch that makes the gate fail closed.
unreachableSince.set(tabId, now);
pendingGone.push(tabId);
blind = true;
continue;
}
// A clock running backwards (system time moved) must not retire an entry
// early — re-stamp and keep blinding rather than trust the difference.
if (now < since) {
unreachableSince.set(tabId, now);
pendingGone.push(tabId);
blind = true;
continue;
}
if (now - since < graceMs) {
pendingGone.push(tabId);
blind = true;
continue;
}
// Continuously unreachable past the grace window: retire it. If that user
// returns, their hello re-asserts Blind and re-adds the id.
blindTabs.delete(tabId);
unreachableSince.delete(tabId);
pruned.push(tabId);
}
// Stamps for ids no longer in the set (an explicit blind-off arrived while a
// clock was running) would otherwise accumulate for the process's lifetime.
for (const tabId of [...unreachableSince.keys()]) {
if (!blindTabs.has(tabId))
unreachableSince.delete(tabId);
}
return { blind, pruned, pendingGone };
}
//# sourceMappingURL=blind-tab-gate.js.map