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),
194 lines • 10.5 kB
JavaScript
// Orchestrator-scoped agent sessions (#884).
//
// THE INVARIANT (owner-stated, absolute): agents are SESSION-bound, not
// workflow-bound. One agent session spans every panel, every browser tab and
// every open workflow served by this orchestrator; the sessions are stored and
// managed by the orchestrator and persisted on disk (~/.comfyui-mcp/sessions),
// not in the browser. A workflow-scoped, tab-scoped or panel-scoped agent is a
// bug, never the design.
//
// This module separates the two jobs the panel `tab_id` used to do at once:
// (a) SESSION IDENTITY — which conversation/agent a message belongs to. That
// is now the orchestrator-owned shared scope below plus the backend name
// (switching provider already restarts the agent, so the backend half of
// the composite key is legitimately part of agent identity — the
// workflow half was the regression).
// (b) ROUTING TARGET — which panel/workflow a tool call acts on and where
// frames fan back out to. That job keeps the real per-tab ids: commands
// addressed to the shared scope resolve to the ACTIVE tab at dispatch
// time (UiBridge.resolveTarget), and conversation frames fan out to every
// connected tab participating in the backend's conversation.
//
// The scope constant deliberately contains no "::" (the agent-key separator)
// and can never collide with a panel tab id (those are `wf:<path>` /
// `tmp:<uuid>` / test `e2e-*`/`spike-*` ids).
/** The single orchestrator-owned session scope. `agentKeyFor` composes it with
* the tab's backend: `orchestrator::claude`, `orchestrator::codex`, … */
export const SHARED_SESSION_SCOPE = "orchestrator";
/** The composite agent key for a backend's shared conversation. */
export function sharedAgentKey(backend, sep = "::") {
return SHARED_SESSION_SCOPE + sep + backend;
}
/** Is this id the shared session scope (as opposed to a real panel tab id)? */
export function isSharedScopeId(id) {
return id === SHARED_SESSION_SCOPE;
}
/**
* Is this id a scope ADDRESS — the bare scope, or a backend-qualified scope
* (`orchestrator::<backend>`, i.e. an agent key used as a routing address)?
* The panel MCP servers bind the QUALIFIED form so the workflow-stamp resolver
* can answer per CONVERSATION (two backends' concurrently in-flight turns must
* not share one issue-time stamp); the bridge routes both forms to the active
* tab. A real panel tab id (`wf:…`/`tmp:…`) never matches.
*/
export function isScopeAddress(id) {
return (typeof id === "string" &&
(id === SHARED_SESSION_SCOPE || id.startsWith(SHARED_SESSION_SCOPE + "::")));
}
/**
* The CONVERSATION a scope address names: the backend-qualified agent key
* (`orchestrator::<backend>`), or undefined for the bare scope and for a real
* panel tab id.
*
* This is the identity a journal ticket is OWNED by (#704). A panel tab id is a
* routing address and it churns — a reconnecting panel re-registers under a new
* id, and only a same-socket re-hello leaves a migration trail to follow — so a
* run ticketed by tab alone stopped being recognizable as the agent's own after
* a reconnect. The conversation spans every tab and workflow (#884) and outlives
* the address. Deliberately NOT the bare scope: the conversation is per BACKEND
* (two providers are two conversations), so an unqualified address names no one
* and yields undefined rather than a value that could match the wrong one.
*/
export function conversationOfScopeAddress(id) {
return typeof id === "string" && id.startsWith(SHARED_SESSION_SCOPE + "::") ? id : undefined;
}
/**
* The BACKEND half of an agent key (`orchestrator::codex` -> `"codex"`,
* and the legacy per-tab form `wf:x::claude` -> `"claude"`).
*
* `undefined` when the id carries no backend half at all — the bare scope, or a
* real panel tab id (`wf:…`/`tmp:…`), which is what `PanelToolCtx.tabId` becomes
* after a tab-id migration rebind (PanelAgent.rebindTabId strips `::backend`).
* That case is UNKNOWN and callers must treat it as such: substituting the
* default backend here would let a caller state a per-backend fact about an
* agent whose backend it never established (#2311).
*/
export function backendOfAgentKey(id) {
if (typeof id !== "string")
return undefined;
const i = id.lastIndexOf("::");
if (i < 0)
return undefined;
const backend = id.slice(i + 2);
return backend.length ? backend : undefined;
}
/**
* The connected panel tabs participating in a backend's shared conversation —
* every tab whose selected backend matches. Agent output (say/stream/turn/…)
* fans out to ALL of them: the same conversation is visible from every tab.
* When this is EMPTY the orchestrator PARKS the frame per agent key (backend-
* qualified — a claude turn finishing while only a codex tab is open must never
* leak into the codex conversation) and flushes it to the next hello /
* set_backend join on that backend.
*/
export function conversationTabs(opts) {
return opts.connected.filter((t) => opts.backendForTab(t) === opts.backend);
}
/**
* May a provider switch RETIRE the outgoing backend's shared agent? Only when
* no OTHER connected tab still runs on that backend — the conversation is
* shared, so one tab switching provider must never stop an agent other tabs
* are actively using. (retire() preserves the durable session either way, so a
* wrongly-kept agent is merely idle, and a retired one resumes on demand.)
*/
export function shouldRetireSharedAgent(opts) {
return !opts.connected.some((t) => t !== opts.switchingTab && opts.backendForTab(t) === opts.prevBackend);
}
/**
* Map a hello frame's raw `backend` field to the conversation it JOINS: a
* known backend name (case-insensitive), else the default. This one function
* is used BOTH by the orchestrator's hello handler (deciding which
* conversation the tab joins) and by the bridge's backend-qualified mailbox
* drain (deciding whose buffers it receives) — confirming gate 2, P1: when
* those two mappings disagreed, a tab joined one conversation and stranded
* another's mailbox, so they now share this single implementation (and the
* ui-bridge tests drive it, not a test-local approximation — gate 3, P2).
*/
export function normalizeHelloBackend(raw, knownBackends, defaultBackend) {
const named = typeof raw === "string" ? raw.toLowerCase() : undefined;
return named && knownBackends.has(named) ? named : defaultBackend;
}
/** A message's workflow origin, for detecting that the conversation's focus
* moved to a different workflow/tab between two user messages. */
export function messageOrigin(tabId, workflowUuid) {
return `${tabId}|${workflowUuid ?? ""}`;
}
/**
* The context line prepended to a user message when its ORIGIN workflow/tab
* differs from the previous message's in the same conversation — this is how
* one session keeps "knowledge of all open workflows": the agent is told,
* mechanically and only on a change, which canvas it is now operating on.
* Returns null when the origin is unchanged (or on the conversation's very
* first message, where there is nothing to contrast with).
*/
export function workflowOriginNote(opts) {
if (!opts.prevOrigin || opts.prevOrigin === opts.origin)
return null;
const label = opts.title?.trim() ? `“${opts.title.trim()}” (${opts.tabId})` : opts.tabId;
return (`[panel: this message was sent from a different workflow than the previous one — ` +
`you are now operating on ${label}. Panel/graph tools target THIS workflow now; ` +
`the conversation itself continues (one session spans all open workflows).]`);
}
/**
* A SHORT, still-DISTINGUISHING rendering of a tab id (#934).
*
* Tab ids come in two shapes. A browser tab is a uuid, where `.slice(0, 8)` is a
* fine abbreviation — eight hex chars separate any two tabs you are likely to
* hold at once. A workflow-keyed tab is `wf:<path>`, and there `.slice(0, 8)`
* yields the literal string `wf:workf` for EVERY workflow under `workflows/`.
*
* That is how panel_set_workflow_target came to report
*
* Rebound this session from tab wf:workf onto the active tab wf:workf
*
* for a rebind between two different workflows: the note read as a no-op, in the
* middle of a wedge whose whole question was whether the retarget had done
* anything. Two different tabs must not render identically.
*
* So: keep the uuid behaviour, and for a `wf:` id keep the part that actually
* varies — the tail of the path, which carries the filename.
*/
export function shortTabId(id) {
if (!id)
return String(id ?? "");
if (!id.startsWith("wf:"))
return id.slice(0, 8);
const rest = id.slice(3);
// #1285 — A TAB ID IS A ROUTE, NOT A PATH. The panel's own bridge-route.js
// (#640) defines two shapes and says they are not interchangeable:
//
// savedWorkflowHandle(path) -> wf:<path> names a WORKFLOW
// savedWorkflowRoute(tabRouteId,path) -> wf:<tabRouteId>:<path> names a TAB
//
// `bridge.tabs()` returns ROUTES, and this treated the whole remainder as a
// path — so it rendered `wf:<tabRouteId>:<path>` down to just the basename and
// threw away the ONE component that distinguishes two tabs showing the SAME
// file. That is #934's failure returning by another door: two different tabs
// rendering identically, in the messages written to tell them apart.
//
// The separator is the FIRST colon, because savedWorkflowRoute refuses a
// tabRouteId containing one (verified against the live served panel). A path
// may contain colons — `C:\…` — so a head that is one character, or that holds
// a path separator, is not a route id and the whole remainder stays a path.
const sep = rest.indexOf(":");
const head = sep === -1 ? "" : rest.slice(0, sep);
const isRoute = sep > 1 && !/[/\\]/.test(head);
const path = isRoute ? rest.slice(sep + 1) : rest;
// The basename is what distinguishes two workflow tabs; the directory prefix
// is shared by all of them and is exactly what the old slice kept.
const base = path.split(/[/\\]/).pop() || path;
const MAX = 40;
const shownPath = base.length > MAX ? `…${base.slice(-MAX)}` : base;
return isRoute ? `wf:${head.slice(0, 6)}:${shownPath}` : `wf:${shownPath}`;
}
//# sourceMappingURL=session-scope.js.map