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),
1,287 lines • 73.2 kB
JavaScript
// Claude Agent SDK backend — the provider-specific adapter behind the
// AgentBackend port. It owns everything that knows about
// `@anthropic-ai/claude-agent-sdk`: the lazy SDK import, the `query()` call,
// Claude `Options` building (including forkSession/resumeSessionAt/resume),
// model/command enumeration, the live model setter, interrupt, and the
// normalization of `SDKMessage` → canonical `AgentEvent`.
//
// PanelAgent keeps all provider-agnostic orchestration (queue, turn-gate, bridge
// push, rewind-anchor tracking, self-restart) and drives this backend via
// `for await (const ev of backend.run({...}))`. See
// design/agent-backend-injection.md.
import { logger } from "../utils/logger.js";
import { errorText } from "./error-text.js";
import { buildAgentSpawnEnv } from "../services/panel-secrets.js";
import { loadTurnRegistry, saveTurnRegistry, tombstoneTurn } from "./turn-registry.js";
import { degradedMcpNotice, emptyToolsMcpNotice, inspectMcpServers, serversWithoutTools, reconnectableMcpStatus, recoveredMcpNotice, unrecoveredMcpNotice, } from "./mcp-session-health.js";
import { randomUUID } from "node:crypto";
import { CLAUDE_CAPABILITIES, } from "./agent-backend.js";
import { makeBlindNativeHook } from "./blind-native-gate.js";
// ---- reasoning effort mapping ----
// Effort is now a provider-neutral union (it must survive a provider switch — see
// panel-agent.ts Effort). The Agent SDK only accepts the Claude scale
// (low|medium|high|xhigh|max), so map any off-scale neutral value (Codex's
// "none"/"minimal") to the nearest valid Claude level. Shared levels pass through.
const CLAUDE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
function toClaudeEffort(effort) {
if (!effort)
return undefined;
const e = effort.toLowerCase();
if (CLAUDE_EFFORTS.has(e))
return e;
if (e === "none" || e === "minimal")
return "low"; // Codex's sub-low → Claude's floor
return undefined; // unknown → SDK default
}
// The Agent SDK is an OPTIONAL dependency (it pulls in ~100 packages and is only
// needed for the panel orchestrator), so load it lazily and fail with a clear
// message rather than at import time for everyone.
let queryFn = null;
export async function loadQuery() {
if (queryFn)
return queryFn;
try {
const mod = await import("@anthropic-ai/claude-agent-sdk");
queryFn = mod.query;
}
catch {
throw new Error("The panel orchestrator requires the optional dependency @anthropic-ai/claude-agent-sdk. Install it with: npm i @anthropic-ai/claude-agent-sdk");
}
return queryFn;
}
function msgOf(err) {
return errorText(err);
}
/**
* Ask the SDK which models the current account can actually use — so the panel's
* picker reflects the live subscription instead of a hardcoded list. This is the
* only model-enumeration path that works on the subscription/OAuth lane:
* `query.supportedModels()` (the public Models API and `claude` CLI both require
* an API key, which we deliberately don't have here).
*
* Runs a minimal throwaway session — no MCP servers, no plugins/skills — so it's
* cheap; the control request resolves right after init, then we tear it down.
* Returns [] on any failure so the panel can fall back gracefully.
*/
export async function fetchSupportedModels(model) {
const query = await loadQuery();
let stop = false;
let wake = null;
// An idle channel: keeps the control connection open without ever consuming a
// turn, until we tear it down.
async function* idle() {
while (!stop) {
await new Promise((resolve) => {
wake = resolve;
});
}
}
const q = query({
prompt: idle(),
options: {
model,
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
strictMcpConfig: true,
mcpServers: {},
// SECURITY: the SDK spawns a Claude Code subprocess; `env` REPLACES its
// environment entirely. Pass the agent env (process.env MINUS tool-only
// secrets) so RunPod/HF/CivitAI tokens never reach the LLM subprocess.
// Claude auth is OAuth-file-based (ANTHROPIC_API_KEY is deliberately unset).
env: buildAgentSpawnEnv(),
},
});
// The transport is pumped by iterating the query — do it in the background so
// the control request (supportedModels) gets its response.
const drain = (async () => {
try {
for await (const _ of q) {
void _;
}
}
catch {
// torn down below
}
})();
let timer;
try {
// Never hang forever: a stuck probe would leave a permanently-pending
// cached promise and the panel would never get its model list.
const models = await Promise.race([
q.supportedModels(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("supportedModels timed out")), 20000);
}),
]);
logger.info(`[panel-orchestrator] supportedModels: ${models.map((m) => m.value).join(", ") || "(none)"}`);
return models;
}
catch (err) {
logger.warn(`[panel-orchestrator] supportedModels probe failed: ${msgOf(err)}`);
return [];
}
finally {
if (timer)
clearTimeout(timer);
stop = true;
// Cast re-widens: TS narrows the closure-mutated local back to its init value.
wake?.();
try {
await q.interrupt();
}
catch {
// already winding down
}
void drain;
}
}
/**
* Probe the SDK for the slash commands this account/session exposes (built-ins
* like /compact, plus any loaded skills) so the panel can surface them in the
* composer's completion menu. Same throwaway-session pattern as
* fetchSupportedModels; returns [] on any failure so the panel degrades cleanly.
*/
export async function fetchSupportedCommands(model) {
const query = await loadQuery();
let stop = false;
let wake = null;
async function* idle() {
while (!stop) {
await new Promise((resolve) => {
wake = resolve;
});
}
}
const q = query({
prompt: idle(),
options: {
model,
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
strictMcpConfig: true,
mcpServers: {},
// SECURITY: agent env only (no tool-only secrets) — see fetchSupportedModels.
env: buildAgentSpawnEnv(),
},
});
const drain = (async () => {
try {
for await (const _ of q) {
void _;
}
}
catch {
// torn down below
}
})();
let timer;
try {
const commands = await Promise.race([
q.supportedCommands(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("supportedCommands timed out")), 20000);
}),
]);
logger.info(`[panel-orchestrator] supportedCommands: ${commands.map((c) => "/" + c.name).join(", ") || "(none)"}`);
return commands;
}
catch (err) {
logger.warn(`[panel-orchestrator] supportedCommands probe failed: ${msgOf(err)}`);
return [];
}
finally {
if (timer)
clearTimeout(timer);
stop = true;
wake?.();
try {
await q.interrupt();
}
catch {
// already winding down
}
void drain;
}
}
/** Bound on unresolved turn traces (wedged turns whose result never arrives).
* Overflow evicts the oldest trace — and POISONS classification rather than
* silently realigning it: the pop that crosses the evicted gap (and any
* traceless result) fails closed as unverifiable, never ok:true (#745 r3). */
const MAX_UNRESOLVED_TURNS = 16;
/** Bound on REMEMBERED dead-session turn ids (#886). The set exists to
* recognize a late result from a restarted session — it is not a
* classification input, so a cap here only bounds how far back recognition
* reaches; beyond it a stray falls back to the plain traceless report. */
const MAX_REMEMBERED_DEAD_TURNS = 64;
/**
* The Claude Agent SDK adapter. One instance per PanelAgent; it holds the live
* `Query` for the current session and re-creates it on each `run()`.
*/
export class ClaudeBackend {
id = "claude";
capabilities = CLAUDE_CAPABILITIES;
deps;
q = null;
// #740 turn-truthfulness, with TURN IDENTITY (#745 r2 review): every submitted
// turn gets a trace; stream messages accrue to the LATEST trace, and each
// terminal result is classified against the trace it belongs to (results
// arrive in submission order, possibly with a turn's result missing or LATE —
// the panel's no-result fallback releases the next turn and tolerates the
// late result, panel-agent.ts:671 — so the backend must too). A late result
// from a superseded turn therefore classifies by ITS OWN flags and never
// resets or consumes the in-flight turn's state. A turn that produced NO
// user-visible content is never a successful turn, and a blocking SDK
// informational message (prevent_continuation — e.g. a UserPromptSubmit hook
// denied the prompt) fails its turn even when the SDK's own result still
// arrives with subtype "success". `interrupted` mirrors PanelAgent's
// interruptRequested: an interrupted turn's empty result is the interrupt
// landing, not an empty-turn failure — and only the turn that was in flight
// when interrupt() landed may be marked.
//
// FAIL CLOSED (#745 r3/r4 review): classification must never produce ok:true
// without positive evidence. A normal successful turn ALWAYS has a trace, so
// a result whose trace is missing (stray from a dead session, or pushed off
// by the MAX_UNRESOLVED_TURNS bound) or whose id crosses an evicted-turn gap
// is UNVERIFIABLE → ok:false, never a forwarded success. The gap case is
// additionally STICKY: once a pop crosses an evicted-turn gap the FIFO
// alignment is unrecoverable (content accrued to a mismatched trace is not
// proof of ITS turn's success), so `classificationPoisoned` fails every later
// result in the session closed — cleared only by a genuine run() restart.
turnSeq = 0;
lastResultTurnId = 0;
classificationPoisoned = false;
/** Run-relative zero for turn MARKERS (#728): set at every run() entry to the
* backend-wide lastResultTurnId, so event stamps are 1-based per run and
* match PanelAgent's per-dispatch mirror. */
markerBase = 0;
/** Interrupted traces PARKED by subtype-aware correlation (#728 r5): an
* interrupted turn ends with an INTERRUPTED-subtype result, so when a result
* with any other subtype arrives while the oldest trace is interrupted and a
* newer trace exists, that turn's result is permanently LOST and the arriving
* result belongs to the newer turn — the interrupted trace is skipped
* (parked) rather than popped. The park legitimizes the resulting forward
* jump in lastResultTurnId, and lets a genuinely interrupted late landing
* still pop its OWN trace (a backward pop) and classify/stamp by its flags. */
parkedInterrupted = new Set();
/** Interrupted traces whose late landing already HAPPENED (moved out of
* parkedInterrupted on the pop) — consumption tracking so a SECOND
* interrupted landing can never re-consume a landed trace; with no
* unconsumed candidate it fails closed as traceless (#728 r6). */
consumedInterrupted = new Set();
turns = [];
// #886 — "submitted" must survive the session: when a run() restart drops the
// live traces above, their ids move here (and a RESUMED session rehydrates
// them from the durable registry written by the previous process). A
// traceless result while this set is non-empty is then RECOGNIZED for what
// it most likely is — a turn that was in flight when its session died,
// completing late — and reported as "completed but unverified": never a
// fabricated success (fail-closed #745 stands), but also no longer the false
// "could not be matched to any submitted turn", which claimed the turn never
// existed when only its record was lost. Insertion-ordered; recognition
// consumes the oldest entry (results land in submission order).
//
// Entries are owner-TAGGED (`<instanceId>:<turnId>`): turnSeq restarts per
// process, so two instances can each leave their own turn "1" outstanding —
// untagged ids would collapse those into one record and the second late
// result would be falsely reported as matching no submission (codex gate).
instanceId = randomUUID();
rememberedDeadTurns = new Set();
/** The remembered set's bound dropped a record at some point — persisted
* into the registry file (`incomplete`) so a RESPAWN also knows the record
* is not provably complete, not just this process (codex gate r2). */
rememberedIncomplete = false;
/** Session id the durable registry file belongs to (learned at init). Null
* until then — a submission pulled before init is processed can't be filed
* under a session nobody can resume yet. */
registrySessionId = null;
/** A registry read/write failed — the record of outstanding turns is not
* provably complete, so log it and never lean on the registry's ABSENCE as
* evidence in that state. */
registryDegraded = false;
// #885 — a stall is not a user rejection. The Agent SDK has no mid-turn steer
// (Codex's turn/steer equivalent): a user message pushed into the stream is
// queued by the CLI until the current turn ENDS — exactly what a frozen turn
// cannot do — so the notice cannot ride the stalled turn itself. What must
// not happen is the transcript keeping ONLY the interrupt's generic
// user-rejection payload: on the next turn the model reads it and tells the
// user "you cancelled" — a lie about the user's own actions. So the notice
// is held here and prepended (clearly delimited as harness text, not the
// user's words) to the NEXT user turn, where it lands in the transcript
// right beside that payload. recoverStalledTurn therefore returns false:
// PanelAgent keeps its proven bounded interrupt/restart recovery for the
// frozen turn, and the correction rides the turn after.
pendingStallNotice = null;
/** turnSeq when the notice was stored — a genuine `success` landing for that
* turn means the "stall" recovered by itself and the notice is stale. */
pendingStallNoticeTurn = 0;
constructor(deps) {
this.deps = deps;
}
/** Preflight: lazy-load the Agent SDK once so a missing optional dependency
* fails fast (a clear "install @anthropic-ai/claude-agent-sdk" reject) rather
* than being retried as a dropped session inside the restart loop. Idempotent. */
async prepare() {
await loadQuery();
}
/** Fetch a ComfyUI image and wrap it as an Anthropic base64 image block, or
* null on any failure (the text reference still names it as a fallback). */
async fetchImageBlock(ref) {
if (!this.deps.comfyuiUrl || !ref?.filename)
return null;
try {
const u = new URL("/view", this.deps.comfyuiUrl);
u.searchParams.set("filename", ref.filename);
u.searchParams.set("type", ref.type || "input");
if (ref.subfolder)
u.searchParams.set("subfolder", ref.subfolder);
const res = await fetch(u, { signal: AbortSignal.timeout(15000) });
if (!res.ok)
return null;
let mt = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
if (!["image/png", "image/jpeg", "image/gif", "image/webp"].includes(mt)) {
mt = "image/png"; // Anthropic-supported set; ComfyUI outputs are PNG by default
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length > 12 * 1024 * 1024)
return null; // keep context sane
return { type: "image", source: { type: "base64", media_type: mt, data: buf.toString("base64") } };
}
catch {
return null;
}
}
/** Shape one provider-neutral turn into a Claude `SDKUserMessage`, resolving
* image refs to inline base64 blocks so the agent SEES them in this turn (no
* get_image view/fetch round-trip). */
async shapeTurn(turn) {
let text = turn.text;
// #885 — deliver the held stall correction AHEAD of the user's words, so
// the model reads the true origin of the interrupt right next to the
// generic rejection payload it would otherwise take at face value. The
// notice names the PREVIOUS turn explicitly: on this new turn, a bare
// "this turn stalled" would mispoint.
if (this.pendingStallNotice) {
text =
`${this.pendingStallNotice}\n\n` +
`[harness note: the notice above is about the PREVIOUS turn — the one the harness cleared. ` +
`It is not part of the user's message and the user did not write it. The user's message follows.]\n\n` +
text;
this.pendingStallNotice = null;
}
const images = turn.images ?? [];
let content = text;
if (images.length) {
const blocks = [];
for (const ref of images) {
const b = await this.fetchImageBlock(ref);
if (b)
blocks.push(b);
}
if (blocks.length)
content = [{ type: "text", text }, ...blocks];
}
return {
type: "user",
message: { role: "user", content },
parent_tool_use_id: null,
};
}
/** MCP servers this run currently knows are DOWN, name → reconnect attempted.
* An episode opens when the `init` report or a turn-end status poll finds a
* configured server unusable (#1524), and closes when a later poll reads it
* connected again — whether our reconnect brought it back or the harness's
* own supervision did. The boolean bounds retries: ONE automatic reconnect
* per episode, so a server that stays down is reported once, not fought on
* every turn. */
mcpDown = new Map();
/** Say — in the log — that this session does not have servers it was given.
* Shared by the init check and the turn-end watch so both report identically. */
reportDegradedMcp(degraded, reported, sessionId, when = "started") {
logger.error(`[claude-backend] session ${sessionId.slice(0, 8)} ${when} WITHOUT ` +
`${degraded.map((d) => `${d.name}=${d.status ?? "absent"}`).join(" ")} ` +
`(reported: ${(reported ?? []).map((s) => `${s.name}=${s.status}`).join(" ") || "none"})`);
}
/**
* One best-effort read of the harness's MCP server statuses. `undefined`
* means "nothing learned" — a harness without the method, a poll that
* throws, or an empty report all land here, and all three must leave the
* session exactly as informed as before: silent, never guessed at.
*/
async pollMcpStatus(q) {
try {
const reported = await q.mcpServerStatus?.();
return Array.isArray(reported) && reported.length > 0 ? reported : undefined;
}
catch (err) {
logger.debug(`[claude-backend] mcpServerStatus: ${msgOf(err)}`);
return undefined;
}
}
/**
* Watch the toolset for a MID-SESSION drop, and try to bring it back (#1524).
*
* The reporter's session came up healthy and lost all 92 panel_* tools hours
* in: the harness's own reconnect brought `comfyui` back and never retried
* the in-process panel server — and since `init` is the only MCP report a
* session PUSHES, nothing host-side ever learned of it. There is no
* mid-session push to listen for (the SDK's message vocabulary has none), so
* the only detection is to ASK: one `mcpServerStatus()` poll per turn end —
* turn-driven, so it needs no timer and cannot spam — read against the set
* this session was actually given.
*
* Recovery is the SDK's own verb, `reconnectMcpServer()` ("reconnects a
* disconnected or failed MCP server"), bounded to ONE attempt per
* down-episode. The attempt is never claimed to have worked on the strength
* of the call alone: the status is re-read once and the re-read is what the
* notice quotes. `needs-auth` and `disabled` are reported but not retried —
* the first waits on the user, the second is a deliberate off switch.
*
* Best-effort throughout: a harness without these control methods, or a poll
* that throws, leaves the session as informed as it was before this existed
* — silent — and never takes the turn stream down with it.
*/
async checkMcpServers(q) {
const names = Object.keys(this.mcpServersForRun() ?? {});
if (names.length === 0)
return [];
let reported = await this.pollMcpStatus(q);
if (!reported)
return [];
const events = [];
// Attempt the one reconnect each down server is owed BEFORE reporting, so a
// drop the reconnect fixes right away is narrated once and accurately,
// instead of "gone" followed by "back". Servers just found down (no open
// episode) and episodes opened at init (attempt owed to this turn boundary)
// are both due; an episode already retried is not.
const triable = inspectMcpServers(names, reported).degraded.filter((d) => this.mcpDown.get(d.name) !== true && reconnectableMcpStatus(d.status));
const attempted = new Set();
// Whether the post-attempt status re-read was READ. "Reconnect failed" is
// only claimable when it was — otherwise the still-down reading is the
// pre-attempt one and the notice must not assert an outcome nobody saw.
let verdictRead = true;
if (typeof q.reconnectMcpServer === "function") {
for (const d of triable) {
attempted.add(d.name);
this.mcpDown.set(d.name, true); // the attempt is spent, whatever it returns
try {
await q.reconnectMcpServer(d.name);
}
catch (err) {
logger.debug(`[claude-backend] reconnectMcpServer(${d.name}): ${msgOf(err)}`);
}
}
if (attempted.size > 0) {
// The verdict on the attempts: re-read once. A failed re-read keeps the
// pre-attempt reading — the servers stay "down" — the safe direction.
const after = await this.pollMcpStatus(q);
if (after)
reported = after;
else
verdictRead = false;
}
}
const degraded = inspectMcpServers(names, reported).degraded;
const downNow = new Set(degraded.map((d) => d.name));
// Episodes that CLOSED: the server reads connected again. An episode our
// own attempt just closed says so; one the harness healed on its own
// (the reporter watched `comfyui` do exactly this) is reported as what it
// is — it came back, and we did not do it.
const backByUs = [];
const backOnItsOwn = [];
for (const name of [...this.mcpDown.keys()]) {
if (downNow.has(name))
continue;
this.mcpDown.delete(name);
(attempted.has(name) ? backByUs : backOnItsOwn).push(name);
}
if (backByUs.length) {
logger.info(`[claude-backend] MCP reconnect brought back: ${backByUs.join(", ")}`);
events.push({ type: "error", sessionNotice: true, message: recoveredMcpNotice(backByUs, true) });
}
if (backOnItsOwn.length) {
logger.info(`[claude-backend] MCP servers connected again on their own: ${backOnItsOwn.join(", ")}`);
events.push({ type: "error", sessionNotice: true, message: recoveredMcpNotice(backOnItsOwn, false) });
}
// Episodes still OPEN that have not been reported yet: a fresh drop whose
// reconnect just failed, an init-time episode whose owed attempt failed
// (the init notice promised the outcome), and drops there is no attempt
// for. One line per server per episode — a server that stays down is not
// re-narrated on every turn.
const unreported = degraded.filter((d) => attempted.has(d.name) || !this.mcpDown.has(d.name));
const failedAttempt = unreported.filter((d) => attempted.has(d.name));
const notRetriable = unreported.filter((d) => !attempted.has(d.name) && !reconnectableMcpStatus(d.status));
const unsupported = unreported.filter((d) => !attempted.has(d.name) && reconnectableMcpStatus(d.status));
for (const d of unreported)
this.mcpDown.set(d.name, true);
if (failedAttempt.length) {
// "Did not bring it back" is claimable only off the post-attempt re-read;
// when that re-read could not be read the attempt is reported WITHOUT an
// outcome — "did not come back" would be a claim nobody checked (#1228).
const why = verdictRead ? "reconnect-failed" : "unverified";
this.reportDegradedMcp(failedAttempt, reported, this.registrySessionId ?? "", "lost");
events.push({
type: "error",
sessionNotice: true,
message: unrecoveredMcpNotice(failedAttempt, why),
});
}
if (notRetriable.length) {
this.reportDegradedMcp(notRetriable, reported, this.registrySessionId ?? "", "lost");
events.push({
type: "error",
sessionNotice: true,
message: unrecoveredMcpNotice(notRetriable, "not-retriable"),
});
}
if (unsupported.length) {
this.reportDegradedMcp(unsupported, reported, this.registrySessionId ?? "", "lost");
events.push({
type: "error",
sessionNotice: true,
message: unrecoveredMcpNotice(unsupported, "unsupported"),
});
}
return events;
}
/**
* The MCP server set this session is given: the comfyui stdio child, plus the
* in-process panel server when this key drives the canvas.
*
* Split out of buildOptions() so #1524's init check can name the servers we
* ASKED for without re-deriving them. Deriving them twice is how the check
* would end up comparing the session's report against a set the session was
* never handed — which is a false alarm in one direction and blindness in the
* other, and neither shows up in a test that builds both sides itself.
*/
mcpServersForRun() {
return {
...this.deps.mcpServers,
// Live-graph control of THIS tab's open workflow (in-process; talks to
// the bridge). Lets the agent build on what the user sees.
...(this.deps.panelServer ? { panel: this.deps.panelServer } : {}),
};
}
buildOptions(opts) {
const model = opts.model;
const effort = toClaudeEffort(opts.effort);
const resume = opts.resume;
const rewindAnchor = opts.rewindAnchor;
// Rewind: fork the conversation at the anchor (resume up to that message, then
// branch into a new session id) so everything after it is dropped. A null
// anchor means "fresh session" (handled by clearing resume below).
//
// #1700 — a plain `resume` restores the MCP server set recorded WITH that
// session, so a panel_remove_mcp that re-read ~/.claude.json still launched
// the deleted server (and kept reporting it as failed). `forkSession`
// without an anchor keeps history but applies THIS run's mcpServers.
const resumeTarget = opts.sessionId ?? resume;
const rewindOpts = rewindAnchor
? { resume: resumeTarget, resumeSessionAt: rewindAnchor, forkSession: true }
: opts.forkSession && resumeTarget
? { resume: resumeTarget, forkSession: true }
: null;
return {
model,
permissionMode: "bypassPermissions",
// Required alongside bypassPermissions (intentional, isolated background agent).
allowDangerouslySkipPermissions: true,
// Stream partial assistant messages so the panel can show thinking + reply
// text live (token-by-token) instead of only the final block. route() turns
// these into onStream deltas; the final assistant message still commits the
// authoritative text via onSay (reconciled by message id).
includePartialMessages: true,
mcpServers: this.mcpServersForRun(),
// Only our comfyui MCP — never inherit the user's project/user MCP config
// (which may run a second comfyui that grabs the bridge port).
strictMcpConfig: true,
// Blind (issue #90) must also bind the subprocess's NATIVE tools: Read
// renders image files (and PDFs / notebook outputs) and WebFetch can hand
// back ComfyUI's own /view content, both entirely outside the MCP scrub.
// The hook consults isBlind() per call, so a live toggle applies to the
// NEXT tool call — no respawn required, unlike the env gate.
hooks: {
PreToolUse: [{ hooks: [makeBlindNativeHook(this.deps.isBlind)] }],
},
systemPrompt: {
type: "preset",
preset: "claude_code",
append: this.deps.systemAppend,
},
// Reasoning effort, when the picker has set one.
...(effort ? { effort } : {}),
// Load the bundled comfyui-mcp plugin so the agent has model expertise
// (IDEOGRAM/WAN/LTX/Qwen/… skills) out of the box — "install the package
// = expert agent". Omitted if the plugin dir can't be found.
...(this.deps.pluginPath
? {
plugins: [{ type: "local", path: this.deps.pluginPath }],
skills: "all",
}
: {}),
...(rewindOpts ?? (resume ? { resume } : {})),
// SECURITY: the SDK spawns a Claude Code subprocess; `env` REPLACES its
// environment entirely. Pass the agent env (process.env MINUS tool-only
// secrets — RunPod/HF/CivitAI tokens) so tool credentials never reach the
// LLM subprocess. Claude auth is OAuth-file-based (no env key needed).
env: buildAgentSpawnEnv(),
};
}
/**
* Open the Claude session and yield canonical AgentEvents. The user channel
* (PanelAgent's gated queue) is shaped turn-by-turn into `SDKUserMessage`s by
* `shapeTurn`; the SDK's `SDKMessage`s are normalized into `AgentEvent`s.
*/
async *run(opts) {
const query = await loadQuery();
const self = this;
// A (re)started session can never produce results for turns submitted to a
// PREVIOUS session through the live FIFO — clear it and advance the result
// sequence past EVERY turn stamped so far (turnSeq), whether or not traces
// remain: in a FULLY DRAINED (e.g. poisoned) session the FIFO is already
// empty, but the sequence must still move past the old ids or the new
// session's first result re-crosses the old gap and falsely re-poisons
// (#745 r5). This genuine restart boundary is also the ONLY reset of the
// sticky classification poison (#745 r4). Done here, at run() entry (BEFORE
// the new query exists), rather than on system/init: the SDK's prompt drain
// legitimately pulls the first batch before init is processed, and an
// init-time clear would unmark that genuinely in-flight turn (#745).
//
// But "the trace is gone" must not become "no turn was ever submitted"
// (#886): the dead session's unresolved ids are REMEMBERED (and, when this
// run RESUMES a session, rehydrated from the durable registry a previous
// process wrote), so a stray late result is recognized as a submitted
// turn's late landing and reported "completed but unverified" instead of
// "matched to no submitted turn". Recognition never upgrades a result to
// ok:true — fail-closed stands.
for (const dead of this.turns)
this.rememberDeadTurn(`${this.instanceId}:${dead.id}`);
this.turns.length = 0;
this.lastResultTurnId = this.turnSeq;
this.classificationPoisoned = false;
// #1524 — MCP health is per SESSION. A restart re-runs every connection, so
// the previous run's down-episodes and their "already retried" marks must
// not suppress the new session's report or its one reconnect attempt.
this.mcpDown.clear();
this.parkedInterrupted.clear(); // dead session's parks die with it
this.consumedInterrupted.clear(); // …and its landing history
this.markerBase = this.lastResultTurnId; // turn markers are run-relative (#728)
const resumeTarget = opts.resume ?? opts.sessionId ?? null;
// Registry writes before init (the prompt drain can submit ahead of it)
// file under the resume target — the session they will actually run in.
// A fresh start files nothing until init names the new session.
this.registrySessionId = resumeTarget;
if (resumeTarget) {
const loaded = loadTurnRegistry(resumeTarget);
// Hydrate whatever the session's files prove is outstanding — including
// this instance's OWN file (tags dedupe against the in-memory carry, and
// tombstones keep already-reported strays from resurrecting). A degraded
// or self-declared-incomplete record must not later ground the confident
// "matched to no submitted turn".
for (const tag of loaded.tags)
this.rememberDeadTurn(tag);
if (loaded.kind === "degraded" || loaded.incomplete) {
this.registryDegraded = true;
logger.warn(`[claude-backend] turn registry for the resumed session is ${loaded.kind === "degraded" ? `unreadable (${loaded.reason})` : "incomplete"} — outstanding-turn recognition is degraded (#886)`);
}
}
// Wrap the neutral channel: shape each turn into the SDK's native form right
// before it's read, so PanelAgent never deals in SDKUserMessage.
async function* prompt() {
for await (const turn of opts.channel) {
const msg = await self.shapeTurn(turn);
// The batch is handed to the SDK: stamp the turn's identity. Its trace
// accrues this turn's content/block/interrupt flags until its terminal
// result pops it (FIFO) for classification. Bounded: wedged turns whose
// result never arrives would otherwise grow the queue without limit.
// Eviction is recorded via the id sequence — the pop that crosses the
// evicted gap fails closed as unverifiable (#745 r3), it does NOT
// silently realign classification onto the wrong turn.
self.turns.push({ id: ++self.turnSeq, hasContent: false, blockReason: null, interrupted: false });
if (self.turns.length > MAX_UNRESOLVED_TURNS) {
const evicted = self.turns.shift();
logger.warn(`[claude-backend] over ${MAX_UNRESOLVED_TURNS} unresolved turns — evicted turn ${evicted?.id}'s trace; its result will fail closed as unverifiable (#740)`);
}
self.saveRegistry(); // the submission record must survive a restart (#886)
yield msg;
}
}
const q = query({ prompt: prompt(), options: this.buildOptions(opts) });
this.q = q;
// TURN MARKERS (#728): stamp every event with the marker of the turn it
// BELONGS to, derived from the #745 per-turn trace FIFO (the SDK's input
// pump and output reader are independent streams, so the marker can't ride
// the input side). RESULT-message events are stamped by route() itself with
// the trace it popped (the result's OWN turn, subtype-aware — #728 r5);
// every other message is stamped here with the NEWEST in-flight trace (the
// turn currently producing output). markerBase re-zeroes the backend-wide
// trace ids per run (1 = this run's first turn), matching PanelAgent's
// per-dispatch mirror (Nth submitted turn = marker N).
for await (const message of q) {
// LIVENESS: every SDKMessage — including the ones route() doesn't translate
// (tool-progress, rate-limit, etc.) — is a sign the session is alive, so
// re-arm PanelAgent's idle watchdog. Claude already streams continuously, so
// this is effectively a no-op for behavior here; it keeps the watchdog's
// re-arm source uniform across both backends via the port.
opts.onActivity?.();
const streamTurn = this.turns[this.turns.length - 1]?.id;
for (const ev of this.route(message)) {
// Session init arrives OUTSIDE any turn — leave it unstamped (an
// unmarked event is never dead-lettered). Result-message events arrive
// pre-stamped by route().
if (ev.type === "session" || message.type === "result") {
yield ev;
continue;
}
yield streamTurn === undefined ? ev : { ...ev, turn: streamTurn - this.markerBase };
}
// #1524 — watch the toolset at every turn end: poll the servers this
// session was given, attempt the one reconnect each down server is owed,
// and narrate the outcome. Placed here rather than in route() because it
// has to await control requests, and after the turn's own events so a
// session notice never interleaves into the middle of a reply.
if (message.type === "result") {
for (const ev of await this.checkMcpServers(q))
yield ev;
}
}
}
/** Switch the model live (the SDK applies it to the next turn). */
async setModel(model) {
try {
// setModel is live: no session restart, the next turn uses it. The optional
// call keeps an older SDK (or a test double) without setModel a silent no-op.
await this.q?.setModel?.(model);
}
catch (err) {
logger.debug(`[claude-backend] setModel: ${msgOf(err)}`);
}
}
async interrupt() {
// Scope the empty-turn blessing to the turn actually in flight (the latest
// unresolved submission): an interrupt() with NO turn in flight leaves no
// state behind, and a superseded turn's trace keeps whatever mark IT earned
// (#745 review).
const active = this.turns[this.turns.length - 1];
if (active)
active.interrupted = true;
await this.q?.interrupt();
}
/**
* #885 — record the harness-stall notice so the NEXT user turn carries it
* into the transcript beside the interrupt's generic user-rejection payload
* (see the pendingStallNotice field comment for why the notice cannot ride
* the stalled turn itself on this provider). Returns false: no mid-turn
* steer exists here, so PanelAgent keeps its bounded interrupt/restart
* recovery for the frozen turn — the notice's job is only to stop the agent
* from telling the user THEY cancelled, and to tell it a retry is safe.
*/
async recoverStalledTurn(notice) {
if (notice.trim()) {
this.pendingStallNotice = notice;
this.pendingStallNoticeTurn = this.turnSeq;
}
return false;
}
/** Permanently dispose of the live SDK query. The Agent SDK has no explicit
* "dispose" beyond interrupt(), which both stops the in-flight turn and lets
* the underlying transport wind down once the prompt generator is no longer
* iterated (PanelAgent.stop() closes the channel before calling this). We then
* drop our reference so the query can be GC'd. Idempotent + safe when never
* started (q is null) — and a true no-op for Claude's behavior: stop() already
* called interrupt(), so this only releases the reference. */
async close() {
const q = this.q;
this.q = null;
if (!q)
return;
try {
await q.interrupt();
}
catch {
// already winding down / never fully started
}
}
async listModels() {
return [];
}
/** Remember a submitted turn whose result can no longer arrive through the
* live FIFO (its session restarted). Recognition-only (#886): membership
* changes what an unmatched result can honestly be CALLED, never how it is
* classified. Bounded — oldest drops first, but a drop means the record is
* no longer provably complete, so it degrades recognition (the hedged
* wording) rather than silently reverting to the confident negative. */
rememberDeadTurn(tag) {
if (this.rememberedDeadTurns.has(tag))
return;
this.rememberedDeadTurns.add(tag);
if (this.rememberedDeadTurns.size > MAX_REMEMBERED_DEAD_TURNS) {
const oldest = this.rememberedDeadTurns.keys().next().value;
if (oldest !== undefined)
this.rememberedDeadTurns.delete(oldest);
this.registryDegraded = true;
this.rememberedIncomplete = true; // persisted — a respawn must hedge too
logger.warn(`[claude-backend] over ${MAX_REMEMBERED_DEAD_TURNS} remembered dead turns — dropped the oldest record; outstanding-turn recognition is degraded (#886)`);
}
}
/** Consume the oldest remembered dead-turn tag, if any — a recognized late
* landing spends the record (in memory, in this instance's file, and in the
* session's tombstones) so a second stray can never re-claim it. */
takeOldestRememberedDeadTurn() {
const oldest = this.rememberedDeadTurns.keys().next().value;
if (oldest === undefined)
return null;
this.rememberedDeadTurns.delete(oldest);
const sid = this.registrySessionId;
if (sid) {
try {
tombstoneTurn(sid, oldest);
}
catch (err) {
// A lost tombstone can only REVIVE an already-reported record (a
// repeated hedged disclosure) — never a fabricated success. Log, move on.
logger.warn(`[claude-backend] turn tombstone write failed: ${msgOf(err)} (#886)`);
}
}
this.saveRegistry();
return oldest;
}
/** Persist which submitted turns are still outstanding (#886). A failure
* degrades recognition (flagged + logged) but must never break the turn
* itself — the registry is a recognition aid, not load-bearing for the
* turn in flight.
*
* Ordering: saves happen AFTER the trace push/pop they record. A crash in
* the pop→save window leaves a STALE record (a turn whose result was seen
* still listed outstanding) — over-recognition, which produces the hedged
* "completed but unverified" disclosure. The reverse order could LOSE a
* record, and a lost record produces the confident "matched to no
* submitted turn" — a false assertion — so over-recognition is the
* deliberate failure side. */
saveRegistry() {
const sid = this.registrySessionId;
if (!sid)
return;
try {
saveTurnRegistry(sid, this.instanceId, this.turns.map((t) => t.id), [...this.rememberedDeadTurns], this.rememberedIncomplete);
}
catch (err) {
this.registryDegraded = true;
logger.warn(`[claude-backend] turn registry write failed: ${msgOf(err)} — outstanding-turn recognition is degraded (#886)`);
}
}
/** Normalize one SDK message into zero or more canonical AgentEvents. */
*route(message) {
switch (message.type) {
case "system":
if (message.subtype === "init") {
// New session (or a restarted one). No turn state is reset here: all
// of it is per-turn (the `turns` FIFO), and dead traces from a
// previous session were already carried into `rememberedDeadTurns`
// at run() entry — an init-time clear could unmark a turn the prompt
// drain already handed to the SDK before this init was processed
// (#745 review).
// The durable turn registry is keyed by THIS session id from now on
// (#886) — including a pre-init submission the prompt drain already
// pulled, which couldn't be filed before the session id was known.
this.registrySessionId = message.session_id;
this.saveRegistry();
yield {
type: "session",
sessionId: message.session_id,
...(message.model ? { model: message.model } : {}),
};
logger.info(`[panel-agent] init model=${message.model} session=${message.session_id.slice(0, 8)} apiKeySource=${message.apiKeySource} skills=${message.skills?.length ?? 0}`);
// #1524 — the session just told us which of its MCP servers actually
// connected. Say so when one did not, instead of letting the agent run
// for hours against a toolset that was silently thinned. This is the
// reported failure exactly: `comfyui` came back after a mid-session
// drop, the 92 `panel_*` tools did not, and the only signal anywhere
// was a tool-list diff the agent had to notice on its own.
//
// It reports the observation, not a cause. WHY a server goes missing is
// still open on #1524 and the candidates need different fixes; this
// fires the same either way, which is the point — it turns a silent,
// unreproducible degradation into one that names itself the moment it
// happens, on whichever transport it happens to.
const health = inspectMcpServers(Object.keys(this.mcpServersForRun() ?? {}), message.mcp_servers);
// A server still CONNECTING at init is not a failure — server startup
// does not block the session, so a slow one is legitimately `pending`
// here and connected a moment later. It needs no tracking of its own:
// the turn-end watch (checkMcpServers) reads every configured server
// at each turn boundary, so a pending server that goes on to FAIL
// surfaces there as a fresh drop, and one that connects simply never
// opens an episode.
//
// A server already DOWN at init opens its episode now — reported
// below, with the reconnect it is owed landing at the first turn end.
for (const d of health.degraded)
this.mcpDown.set(d.name, false);
if (health.degraded.length) {
this.reportDegradedMcp(health.degraded, message.mcp_servers, message.session_id);
yield { type: "error", sessionNotice: true, message: degradedMcpNotice(health.degraded) };
}
// #2742 — the check above compares STATUS, and the variant five reporters
// hit on 0.52.174-0.52.178 passes it: `panel=connected` in every session
// with zero panel_* tools reaching the model. `connected` was being read
// as proof of a usable toolset. The init message carries the tool list, so
// the emptiness is right there and was simply never looked at — which is
// why the reporters had to establish it with ToolSearch, and one
// reinstalled twice while every signal said the panel was fine.
const emptyServers = serversWithoutTools(Object.keys(this.mcpServersForRun() ?? {}), message.mcp_servers, message.tools);
if (emptyServers.length) {
logger.error(`[claude-backend] session ${message.session_id.slice(0, 8)} started with MCP server(s) ` +
`${emptyServers.join(", ")} CONNECTED but contributing zero tools ` +
`(tools=${message.tools?.length ?? 0}, mcp tools=${(message.tools ?? []).filter((t) => t.startsWith("mcp__")).length})`);
yield { type: "error", sessionNotice: true, message: emptyToolsMcpNotice(emptyServers) };
}
}
else if (message.subtype === "thinking_tokens") {
// Live extended-thinking token count → drives a "thinking… (N)" meter
// so the user can see the agent reasoning (not stuck) before any text.
const t = message.estimated_tokens;
if (typeof t === "number") {
yield { type: "thinking", tokens: t };
}
}
else if (message.subtype === "informational") {
// #740: SDK informational banners are host-rendered plaintext (hook
// feedback, status lines, slash-command notices) — never drop one the
// turn logic depends on. A BLOCKING one (prevent_continuation, e.g. a
// UserPromptSubmit hook denied the prompt) means execution stops here,
// yet the SDK still ends the turn with a result whose subtype is
// "success". Record the reason on the CURRENT turn's trace so that
// turn's result is classified as the failure it actually is, and
// surface the reason now so the user never watches a turn silently
// produce nothing.
// Older SDK builds spelled the flag in camelCase; the `in` probe reads
// it without claiming the typed message carries it.
const prevent = message.prevent_continuation === true ||
("preventContinuation" in message && message.preventContinuation === true);
const content = typeof message.content === "string" ? message.content.trim() : "";
if (prevent) {
const reason = content || "blocked by a hook";
const turn = this.turns[this.turns.length - 1];
if (turn)
turn.blockReason = reason;
logger.warn(`[claude-backend] turn blocked: ${reason}`);
yield {
type: "error",
message: content || "The turn was blocked before it could produce a reply.",
};
}
else if (content) {
// Non-blocking notices don't affect turn classification — the panel
// doesn't render them, but keep them visible in the log.
logger.info(`[claude-backend] informational (${message.level ?? "info"}): ${content}`);
}
}
else if (message.subtype === "local_command_output") {
// Slash-command output (/usage, /voice, …) IS the turn's visible
// payload, so the turn counts as having content and is not
// misclassified as an empty one. (The panel doesn't render these
// banners today — unchanged.)
const turn = this.turns[this.turns.length - 1];
if (turn)
turn.hasContent = true;
}
break;
case "stream_event": {
// Live partial output (includePartialMessages). Turn the raw Anthropic
// stream events into thinking/reply deltas the panel renders token-by-
// token. The authoritative text still commits via the `assistant` case.
const ev = message.event;
if (!ev)
break;
if (ev.type === "message_start") {
const mid = ev.message?.id;
yield { type: "stream_start", id: typeof mid === "string" ? mid : null };
}
else if (ev.type === "content_block_delta") {
const d = ev.delta;
if (!d)
break;
if (d.type === "thinking_delta" && typeof d.thinking === "string" && d.thinking) {
yield { type: "assistant_delta", text: d.thinking, thinking: true };
}
else if (d.type === "text_delta" && typeof d.text === "string" && d.text) {
yield { type: "assistant_delta", text: d.text };
}
}
else if (ev.type === "message_stop") {
yield { type: "stream_end" };
}
break;
}
case "assistant": {
// An assistant message means the model actually responded — the CURRENT
// (latest submitted, still unresolved) turn counts as having content
// (#740). In-stream messages always belong to the newest in-flight turn:
// a superseded turn's output ends with its own result, which pops it.
const turn = this.turns[this.turns.length - 1];
if (turn)
turn.hasContent = true;
// Remember this message's UUID — it's the rewind anchor for the turn.
const auid = message.uuid;
// Each assistant API response carries the CURRENT context size — report
// it live so the meter updates throughout the turn, not just at the end.
const u = message.message?.usage;
// Commit the authoritative reply text as ONE message. With streaming on,
// the panel already showed a live preview (matched by this message id);
// the commit replaces it with the final text. Without streaming (or for
// injected events), it just renders a normal bubble.
const content = (message.message?.content ?? []);
const text = content
.filter((b) => b.type === "text" && typeof b.text === "string")
.map((b) => b.text)
.join("\n\n")
.trim();
const id = message.message?.id;
yield {
type: "assistant",
text,
...(typeof auid === "string" ? { uuid: auid } : {}),
...(id ? { id } : {}),
...(u ? { usage: u } : {}),
};
// TOOL VISIBILITY: surface each tool the agent invoked as a tool_call
// event, so a canvas-less mobile client can show the agent's actions
// (not just a spinner). The panel infers these from its live canvas.
for (const b of content) {
if (b.type === "tool_use" && typeof b.name === "string") {
yield { type: "tool_call", name: b.name, phase: "start" };
}
}
break;
}
case "result": {
// Cache the context window + cost from the result, then re-report using
// the last assistant usage (the true current context).
let contextWindow;
for (const mu of Object.values(message.modelUsage ?? {})) {
if (mu?.contextWindow && (contextWindow === undefined || mu.contextWindow > contextWindow)) {
contextWindow = mu.contextWindow;
}
}
// #740: classify the result truthfully, against the trace of the turn it
// BELONGS to (#745 r2). Results arrive in submission order, so the oldest
// unresolved trace is this result's turn — even when the result is LATE
// (an earlier turn was interrupted, produced nothing, and the panel's
// fallback already released the next turn; panel-agent.ts:671 tolerates
// that late result, and so does this FIFO: popping the old trace leaves
// the in-flight turn's state untouched). The SDK reports subtype
// "success" even for a turn a blocking hook prevented from producing ANY
// output, and for a turn that simply ended with no assistant message —
// the panel must never present either as a silent empty success.
//
// FAIL CLOSED (#745 r3/r4): ok:true requires positive evidence. A normal
// successful turn ALWAYS has a trace (stamped at submission), so a
// result that is TRACELESS (stray from a dead session, or pushed off by
// the MAX_UNRESOLVED_TURNS bound) or whose id crosses an EVICTED-turn
// gap is anomalous by construction — unverifiable, never a forwarded
// success. (The panel consumes every result as completion/done, so a
// pass-through would NOT be inert.) The gap case is STICKY: alignment
// is unrecoverable once crossed, so the poison is terminal for the
// session — content evidence must not rescue it (it accrued to a
// mismatched trace, which is not proof of ITS turn's success).
// SUBTYPE-AWARE correlation on the SDK's REAL result vocabulary
// (#728 r5–r10): results arrive as `success` or `error_*` — an
// interrupted turn ends with an error_during_execution result (the
// interrupt landing); there is no "interrupted" result subtype.
//
// UNIFORM write-off (r10): an interrupted turn the PANEL killed (its
// trace is marked) that was then SUPERSEDED by a newer submission never
// delivers — the turn gate only submits newer work after the older
// turn's result was written off. So for EVERY landing (success or
// error_*): any interrupted-marked trace OLDER than the oldest LIVE
// non-interrupted trace is written off (parked as lost), and the
// landing pops that oldest live non-interrupted trace — a healthy
// turn's genuine success or error classifies by ITS flags (a genuine
// error_during_execution from a healthy newer turn is NOT blessed and
// is NOT attributed to the killed older turn). With NO live
// non-interrupted trace, the landing is an interrupt landing and pops
// the oldest LIVE interrupted trace (kills settle in submission order —
// the older turn's legitimate landing still pops the older trace, even
// when a newer interrupted turn's trace exists from queued input), else
// the oldest PARKED-but-unconsumed interrupted trace (a late landing
// for a written-off turn — the r6 tolerance), else nothing (anomalous
// → traceless fail-closed below). Each trace still lands at most once
// (landed parks move to consumedInterrupted).
const resultSubtype = message.subtype;
const oldestLive = this.turns.findIndex((t) => !t.interrupted);
if (oldestLive > 0) {
for (let i = 0; i < oldestLive; i++) {
if (this.turns[i].interrupted)
this.parkedInterrupted.add(this.turns[i].id);
}
}
let idx = oldestLive;
if (idx < 0) {
idx = this.turns.findIndex((t) => t.interrupted && !this.parkedInterrupted.has(t.id));
}
if (idx < 0) {
idx = this.turns.findIndex((t) => t.interrupted && this.parkedInterrupted.has(t.id) && !this.consumedInterrupted.has(t.id));
}
const trace = idx >= 0 ? (this.turns.splice(idx, 1)[0] ?? null) : null;
if (trace) {
// The pop retires the turn's outstanding record (#886) — in this
// instance's own file AND in the session's tombstones: a concurrent
// resumer may have copied this turn's id into its own file as a dead
// record while it was still live, and only a tombstone invalidates
// that replica (rewriting this instance's file does not). A lost
// tombstone can only revive the copy as a hedged over-recognition —
// never a fabricated success — so a failure logs and moves on.
const sid = this.registrySessionId;
if (sid) {
try {
tombstoneTurn(sid, `${this.instanceId}:${trace.id}`);
}
catch (err) {
logger.warn(`[claude-backend] turn tombstone write failed: ${msgOf(err)} (#886)`);
}
}
this.saveRegistry();
}
let unverifiable = null;
// #886: an unmatched result with a REMEMBERED outstanding submission is
// a different report than one with none. Both fail closed — only the
// honesty of the message differs.
let unverifiedCompletion = false;
if (this.classificationPoisoned) {
unverifiable = "Turn bookkeeping was poisoned by an earlier overflow — no result in this session can be verified";
}
else if (!trace) {
const deadId = this.takeOldestRememberedDeadTurn();
if (deadId !== null) {
// A turn WAS submitted (before a session restart dropped its trace)
// and never reported an outcome — this result is most plausibly its
// late landing. Say exactly that: not a success (unverifiable — the
// match is inference, not proof), and not a failure either — the
// turn may have finished real work, and "turn failed, try again"
// would invite a destructive duplicate retry.
unverifiedCompletion = true;
unverifiable =
`A result (reported as "${resultSubtype}") arrived that matches no live turn, but a turn ` +
`submitted before the session restarted never reported an outcome — this is most likely that ` +
`turn completing late. It may have finished real work (files written, changes made), yet I ` +
`cannot verify which turn it was, so I am NOT counting it as a success. Check what that turn ` +
`was doing before asking for it again, so the work is not done twice.`;
logger.warn(`[claude-backend] traceless result (${resultSubtype}) recognized as a likely late landing for a pre-restart submitted turn — completed but unverified, failing closed (#886)`);
}
else if (this.registryDegraded) {
// The recognition record itself is not provably complete — do not
// assert the negative as confidently as a healthy registry allows.
unverifiable =
"The result could not be matched to any submitted turn, and the durable turn record " +
"could not be fully read or written, so an outstanding earlier submission can't be ruled out";
logger.warn(`[claude-backend] ${unverifiable} — unverifiable, failing closed (#740/#886)`);
}
else {
unverifiable = "The result could not be matched to any submitted turn";
logger.warn(`[claude-backend] ${unverifiable} — unverifiable, failing closed (#740)`);
}
}
else {
let crossesGap = false;
if (trace.id > this.lastResultTurnId + 1) {
// Forward jump: legit only when EVERY skipped id is a parked
// interrupted trace; anything else is an evicted-turn gap.
for (let id = this.lastResultTurnId + 1; id < trace.id && !crossesGap; id++) {
if (!this.parkedInterrupted.has(id))
crossesGap = true;
}
}
else if (trace.id <= this.lastResultTurnId) {
// Backward pop: legit only for a parked (or landed-once) interrupted
// trace's deferred landing.
crossesGap = !(this.parkedInterrupted.has(trace.id) || this.consumedInterrupted.has(trace.id));
}
if (crossesGap) {
// The pop crossed an evicted-turn gap → poison the session (do NOT
// advance lastResultTurnId to the wrong trace's id — alignment is
// unrecoverable, so resyncing would just hide the next mismatch).
this.classificationPoisoned = true;
unverifiable = "Turn bookkeeping overflowed, so the result cannot be matched to its turn";
logger.warn(`[claude-backend] result crosses an evicted-turn gap (popped turn ${trace.id}, expected ${this.lastResultTurnId + 1}) — unverifiable; classification poisoned for the rest of the session (#740)`);
}
else {
// A legitimately popped park is now LANDED — consumed once, so a
// second landing for it finds no candidate (#728 r6).
if (this.parkedInterrupted.delete(trace.id))
this.consumedInterrupted.add(trace.id);
if (trace.id > this.lastResultTurnId)
this.lastResultTurnId = trace.id;
}
}
// The result's OWN turn marker (#728): stamped here, where the trace it
// popped is known (a per-message stamp in run() could not see the
// subtype-aware skip). A traceless result stays UNMARKED so its
// fail-closed error and gate completion still reach the panel.
const stamp = trace ? { turn: trace.id - this.markerBase } : {};
// An interrupted turn's terminal (an error_during_execution landing for
// its own interrupted-marked trace) is the interrupt LANDING, not a
// failure — blessed like a success; the empty-turn guard below still
// spares it. Any other error_* subtype classifies by the trace's own
// flags (a genuine failure for a non-interrupted turn).
let ok = resultSubtype === "success" ||
(resultSubtype === "error_during_execution" && trace !== null && trace.interrupted);
if (unverifiable !== null) {
// Fail closed with the reason VISIBLE — including an anomalous
// interrupted landing with no unconsumed candidate (#728 r6), whose
// ok is already false.
ok = false;
yield {
type: "error",
// The recognized late landing's message is self-contained (it says
// "not counted as a success" itself) and flagged so the panel
// doesn't frame it as a failure (#886).
message: unverifiedCompletion
? unverifiable
: `${unverifiable} — reporting the turn as unverifiable rather than a success.`,
...(unverifiedCompletion ? { unverifiedCompletion: true } : {}),
...stamp,
};
}
else if (ok && trace !== null && trace.blockReason !== null) {
// The blocking informational already yielded the visible error above.
ok = false;
}
else if (ok && trace !== null && !trace.hasContent && !trace.interrupted) {
ok = false;
logger.warn("[claude-backend] result/success with no assistant content — reporting a failed turn (#740)");
yield {
type: "error",
message: "The model ended the turn without producing a reply.",
...stamp,
};
}
// #885: a SELF landing for the stalled turn (success OR a genuine
// error — i.e. not the interrupt's doing) means the turn ended on its
// own and no rejection payload ever landed in the transcript, so the
// held correction would be stale; drop it. An INTERRUPT landing
// (trace.interrupted) is exactly the case the notice exists for.
if (this.pendingStallNotice !== null &&
trace !== null &&
!trace.interrupted &&
trace.id >= this.pendingStallNoticeTurn) {
this.pendingStallNotice = null;
}
yield {
type: "result",
ok,
subtype: message.subtype,
...(contextWindow !== undefined ? { contextWindow } : {}),
...(typeof message.total_cost_usd === "number" ? { costUsd: message.total_cost_usd } : {}),
...stamp,
};
break;
}
default:
// Parity with the original route(): other SDK message types (incl. SDK
// rate-limit and tool-progress events) are not surfaced — the panel never
// consumed them. The canonical AgentEvent union still declares `rate_limit`
// and `tool_call` for other backends / a future Claude enhancement to emit.
break;
}
}
}
//# sourceMappingURL=claude-backend.js.map