src/server/engine/claude-code.ts
Rama main · réplica de lectura del repositorio original.
import { query } from '@anthropic-ai/claude-agent-sdk';
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { signInternalToken } from '../auth.ts';
import { buildHooks } from '../hooks.ts';
import { offloadEnv } from '../offload.ts';
import { listSkills } from '../skills.ts';
import { loadAgents } from '../agents.ts';
import { registerProactiveMessage } from '../slack/sessions.ts';
import { registerPoll } from '../polls.ts';
import { getSession, setSessionModel, getSessionModel, setClaudeResume, type ConvMessage } from '../sessions.ts';
import {
isResumeEnabled, decideClaudeTurn, claudeConfigDir, findClaudeTranscript, shortHash, sectionHashes, speakerKey,
sectionsAfterSubmit, isResumeFailure, conversationSummaryKey, buildContextDelta, buildResumePrompt, renderConvMessage,
type TurnPath, type ClaudeResumeState,
} from './claude-resume.ts';
import { DEFAULT_MODEL } from '../directives.ts';
import { claudeCodeModels } from '../../shared/models';
import { resolveModelSwitch, MODEL_ALIASES } from '../model-aliases.ts';
import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../claude.ts';
import type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
import { getPromptSuffix } from '../prompt-suffix.ts';
import { APP_ROOT } from '../paths.ts';
import { writeMcpConfigFile } from './mcp-config-file.ts';
import { claudeUsageFor } from '../claude-usage.ts';
import { claudeAccountDir, applyClaudeAccount, applyForcedSubscription, claudeAccountRef, type ClaudeAccountRef } from '../claude-account.ts';
import { buildAgentEnv, builtinTools, filterMcpServers, allowsEscalate, allowsInternalToken, allowsShare, SHARE_TOOL_ID, SENSITIVE_PATH_PATTERNS } from '../security/enforce.ts';
import { escalateMcpServer } from '../security/escalate.ts';
import { shareMcpServer } from '../share-file.ts';
import { jobsMcpServer, JOBS_TOOL_IDS } from '../jobs-mcp.ts';
const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
const BG_TASK_MAX_WAIT_MS = 15 * 60_000;
const BG_HEARTBEAT_MS = 60_000;
/** How long to wait for the CLI's follow-up turn on a bg task that finished before the turn's result. */
const BG_FOLLOWUP_GRACE_MS = 5_000;
const HISTORY_LIMIT = 50;
const NO_INTERACTIVE_ANSWER = 'No interactive channel is available to answer right now. Use your best judgement to proceed, and surface these options to the user in your reply so they can redirect if needed.';
const SENSITIVE_BASH_PATTERNS = [
/\.env\b/i, /\bprintenv\b/i, /\b(env|set)\s*\|/i, /\bsecrets?\//i,
/credentials/i, /service.account/i, /\.(pem|key)\b/i,
/\$[A-Z_]*(KEY|SECRET|TOKEN|PASSWORD)\b/i, /process\.env/i,
];
const DESTRUCTIVE_DATA_PATTERNS = [
/\b(rm|rmdir|unlink|mv)\b.*\bdata\/(conversations?|sessions?|schedules?)\b/i,
/\b(rm|rmdir|unlink|mv)\b.*\b(whitelist\.json|api-keys\.json|agent-config\.json)\b/i,
/\bfind\b.*\bdata\/(conversations?|sessions?|schedules?).*(-delete|-exec\s+rm)\b/i,
/>\s*data\/(conversations?|sessions?|schedules?)\//i,
];
/** Is `id` a model this engine can actually run? Anything else — notably a foreign id like
* `cursor/composer-2.5` or the bare `composer-2.5` a schedule pinned for another engine — used to be
* forwarded straight to the Anthropic SDK, billing Anthropic for a model the caller never asked it
* for. Shape-based rather than an exact list so dated ids (`claude-sonnet-4-5-20250929`) still pass.
* An explicit `anthropic/` prefix is this engine's own provider and is stripped first. */
function isOwnModel(id: string): boolean {
const bare = id.startsWith('anthropic/') ? id.slice('anthropic/'.length) : id;
return bare.startsWith('claude-') || bare.toLowerCase() in MODEL_ALIASES;
}
type DenyResult = { behavior: 'deny'; message: string };
function checkSensitiveAccess(toolName: string, input: Record<string, unknown>): DenyResult | null {
const filePath = (input.file_path ?? input.path ?? '') as string;
if ((toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') && filePath) {
if (SENSITIVE_PATH_PATTERNS.some(p => p.test(filePath))) {
console.log(`[security] Blocked ${toolName} on sensitive file: ${filePath}`);
return { behavior: 'deny', message: 'Access to sensitive files (.env, secrets, credentials) is blocked.' };
}
}
if (toolName === 'Bash') {
const cmd = (input.command ?? '') as string;
if (SENSITIVE_BASH_PATTERNS.some(p => p.test(cmd))) {
console.log(`[security] Blocked Bash command targeting sensitive data: ${cmd.slice(0, 80)}`);
return { behavior: 'deny', message: 'Commands accessing sensitive files or environment secrets are blocked.' };
}
}
return null;
}
function isDestructiveDataOp(toolName: string, input: Record<string, unknown>): boolean {
if (toolName !== 'Bash') return false;
return DESTRUCTIVE_DATA_PATTERNS.some(p => p.test((input.command ?? '') as string));
}
function buildHistoryPrompt(conv: ConvMessage[], contextBlock: string, userPrompt: string): string {
if (!conv.length) {
return contextBlock ? `${contextBlock}\n\n${userPrompt}` : userPrompt;
}
const summaryIdx = conv.findLastIndex((m) => m.blocks.some((b) => b.type === 'summary'));
let summary: string | null = null;
let recent: ConvMessage[];
if (summaryIdx >= 0) {
summary = (conv[summaryIdx].blocks.find((b) => b.type === 'summary') as any)?.text ?? null;
recent = conv.slice(summaryIdx + 1).slice(-HISTORY_LIMIT);
} else {
recent = conv.slice(-HISTORY_LIMIT);
}
const parts: string[] = [];
if (summary) parts.push(`<conversation_summary>\n${summary}\n</conversation_summary>`);
for (const m of recent) {
const line = renderConvMessage(m);
if (line) parts.push(line);
}
if (parts.length) {
const prefix = contextBlock ? `${contextBlock}\n\n` : '';
return `${prefix}<conversation_history>\n${parts.join('\n\n')}\n</conversation_history>\n\nUser: ${userPrompt}`;
}
return contextBlock ? `${contextBlock}\n\n${userPrompt}` : userPrompt;
}
/**
* Log prompt-cache effectiveness from the SDK result `usage`. The hit rate is
* cache_read / (cache_read + cache_creation + uncached input) — a low rate over
* many turns points to a silent prefix invalidator or sessions spread past the
* 5-min cache TTL. `path` says how the prompt was built — `fresh` (history re-sent in one message),
* `resume` (SDK session resumed, only the new message sent) or `fallback:<reason>` (resume enabled but a
* fresh query ran) — so journal lines can be A/B-compared per path.
*/
function logCacheUsage(usage: any, model: string, path: TurnPath): void {
if (!usage) return;
const read = usage.cache_read_input_tokens ?? 0;
const created = usage.cache_creation_input_tokens ?? 0;
const fresh = usage.input_tokens ?? 0;
const totalIn = read + created + fresh;
if (totalIn === 0) return;
const hitRate = ((read / totalIn) * 100).toFixed(1);
console.log(`[claude] Cache: hit=${hitRate}% read=${read} write=${created} uncached=${fresh} out=${usage.output_tokens ?? 0} model=${model} path=${path}`);
}
/** How one query attempt is built. `persist` is set when resume is enabled: after a query that produced
* output, the CC session id + these facts are saved so the next turn can resume. */
interface RunPlan {
path: TurnPath;
prompt: string;
accountDir: string | null;
resumeId?: string;
persist?: Omit<ClaudeResumeState, 'claudeSessionId' | 'model' | 'interruptedBy'>;
/** Resume only: section hashes to store once the prompt is submitted (see sectionsAfterSubmit). */
submitSections?: Record<string, string>;
}
/** Exit promises of CLI processes still running, per shraga session. A turn that took over a session (external
* steer) can start while the aborted run's CLI is still exiting; resuming then would put two writers on one
* transcript. */
const liveCli = new Map<string, Set<Promise<void>>>();
function trackCliExit(sessionId: string | undefined, child: { once: (event: string, fn: () => void) => unknown }): void {
if (!sessionId) return;
const set = liveCli.get(sessionId) ?? new Set();
liveCli.set(sessionId, set);
const exited = new Promise<void>((resolve) => { child.once('exit', resolve); child.once('error', resolve); });
set.add(exited);
void exited.then(() => { set.delete(exited); if (!set.size && liveCli.get(sessionId) === set) liveCli.delete(sessionId); });
}
/** true once every CLI process of the session has exited, false if one is still alive after `ms`. */
async function cliExited(sessionId: string, ms: number): Promise<boolean> {
const set = liveCli.get(sessionId);
if (!set?.size) return true;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<false>((r) => { timer = setTimeout(() => r(false), ms); });
try { return await Promise.race([Promise.all(set).then(() => true), timeout]); } finally { clearTimeout(timer); }
}
/** Events that prove a resumed query actually runs; until one arrives the attempt can still be retried fresh unseen. */
const LIVE_EVENTS = new Set<WsEvent['type']>(['text_delta', 'thinking_delta', 'tool_use', 'tool_use_input', 'tool_result', 'tool_result_image', 'done']);
/** SDK spawn hook: same call the SDK would make, plus `detached` (own process group). See the
* call site for why. Shape mirrors the SDK's own spawnLocalProcess return. */
function spawnDetached(cfg: { command: string; args: string[]; cwd?: string; env: Record<string, string | undefined>; signal?: AbortSignal }) {
const child = spawn(cfg.command, cfg.args, {
cwd: cfg.cwd,
env: cfg.env,
signal: cfg.signal,
// Keep SDK debug output visible when it is asked for; otherwise the SDK's own default.
stdio: ['pipe', 'pipe', cfg.env.DEBUG_CLAUDE_AGENT_SDK ? 'inherit' : 'ignore'],
detached: true,
windowsHide: true,
});
return {
stdin: child.stdin,
stdout: child.stdout,
get killed() { return child.killed; },
get exitCode() { return child.exitCode; },
kill: child.kill.bind(child),
on: child.on.bind(child),
once: child.once.bind(child),
off: child.off.bind(child),
};
}
const INLINE_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']);
async function* buildAttachmentPrompt(text: string, attachments: { path: string; name: string; mimeType: string }[], sessionId: string): AsyncIterable<any> {
const content: any[] = [];
const fileRefs: string[] = [];
const audioRefs: string[] = [];
const inlined: string[] = [];
for (const att of attachments) {
if (INLINE_MIMES.has(att.mimeType)) {
try {
const buf = readFileSync(att.path);
const blockType = att.mimeType === 'application/pdf' ? 'document' : 'image';
content.push({ type: blockType, source: { type: 'base64', media_type: att.mimeType, data: buf.toString('base64') } });
inlined.push(`${att.name} (${att.path})`);
} catch (err) {
console.error(`[claude] Failed to read attachment ${att.path}:`, err);
fileRefs.push(`${att.name} (at ${att.path} — failed to read)`);
}
} else if (att.mimeType.startsWith('audio/')) {
// The model can't ingest audio directly — pass the path and route to mcp-audio.
audioRefs.push(`${att.name} (at ${att.path})`);
} else {
fileRefs.push(`${att.name} (at ${att.path})`);
}
}
if (audioRefs.length > 0) text += `\n\n[Audio attached — transcribe with the mcp-audio tool (post_audio_transcribe { file }) before answering]: ${audioRefs.join(', ')}`;
if (fileRefs.length > 0) text += `\n\n[Attached files — use Read tool to access]: ${fileRefs.join(', ')}`;
// Say IN THE TEXT that the inlined blocks exist. `text` carries the whole conversation history, so
// the image blocks sit tens of thousands of tokens above the actual question and the model has
// answered "I don't see any images attached" to a message that had four — the transcript proves the
// blocks were sent. This line makes the prompt agree with its own content, and names the on-disk
// paths so the model can Read them if it still can't see a block.
if (inlined.length > 0) text += `\n\n[${inlined.length} file(s) are attached to THIS message and included above as image/document blocks — look at them: ${inlined.join(', ')}]`;
content.push({ type: 'text', text });
yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
}
async function* buildTextPrompt(text: string, sessionId: string): AsyncIterable<any> {
yield { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: sessionId };
}
async function* buildLegacyImagePrompt(text: string, images: string[], sessionId: string): AsyncIterable<any> {
const content: any[] = images.map((dataUrl) => {
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!match) return null;
const blockType = match[1] === 'application/pdf' ? 'document' : 'image';
return { type: blockType, source: { type: 'base64', media_type: match[1], data: match[2] } };
}).filter(Boolean);
content.push({ type: 'text', text });
yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
}
export class ClaudeCodeEngine implements AgentEngine {
readonly name = 'claude-code';
readonly enforcesProfile = true;
getModels(): EngineModel[] {
return claudeCodeModels();
}
/** How long a resume-enabled turn waits for an earlier CLI process on the session to exit before going fresh. */
static cliExitWaitMs = 5_000;
async *stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent> {
let cliAlive = false;
if (opts.sessionId && isResumeEnabled(opts.directives, opts.config) && !(await cliExited(opts.sessionId, ClaudeCodeEngine.cliExitWaitMs))) {
cliAlive = true;
console.warn(`[claude] An earlier CLI process on session=${opts.sessionId} is still running after ${ClaudeCodeEngine.cliExitWaitMs}ms — not resuming its transcript`);
}
const plan = this.plan(opts, cliAlive);
if (plan.path !== 'resume') { yield* this.run(opts, plan); return; }
// Hold non-output events (init's model_resolved / switch notice) until the resumed query proves it
// runs, so a failed resume can be retried fresh with nothing duplicated on the user's side.
const attempt = this.run(opts, plan);
const held: WsEvent[] = [];
let live = false;
let r: IteratorResult<WsEvent, 'resume-failed' | void>;
try {
while (!(r = await attempt.next()).done) {
if (!live && !LIVE_EVENTS.has(r.value.type)) { held.push(r.value); continue; }
if (!live) { live = true; yield* held; }
yield r.value;
}
} finally {
await attempt.return(undefined);
}
if (r.value !== 'resume-failed') { if (!live) yield* held; return; }
console.warn(`[claude] Resume of ${plan.resumeId} failed before any output — retrying fresh in the same turn (session=${opts.sessionId})`);
setClaudeResume(opts.sessionId, undefined);
yield* this.run(opts, this.freshPlan(opts, plan.accountDir, 'fallback:resume-failed', plan.persist));
}
private freshPlan(opts: EngineStreamOpts, accountDir: string | null, path: TurnPath, persist?: RunPlan['persist']): RunPlan {
return {
path, accountDir,
prompt: buildHistoryPrompt(opts.conversation, opts.contextBlock, opts.prompt),
...(persist ? { persist: { ...persist, sections: sectionHashes(opts.contextSections ?? { context: opts.contextBlock }), startedAt: Date.now() } } : {}),
};
}
/** Resume vs fresh, and why — see claude-resume.ts. */
private plan(opts: EngineStreamOpts, cliAlive: boolean): RunPlan {
const accountDir = claudeAccountDir(opts.userEmail);
if (!isResumeEnabled(opts.directives, opts.config)) return this.freshPlan(opts, accountDir, 'fresh');
const configDir = claudeConfigDir(accountDir);
const configDirHash = shortHash(configDir);
const state = opts.sessionId ? getSession(opts.sessionId)?.claudeResume : undefined;
const speaker = speakerKey(opts.uid, opts.userEmail);
const persist = { configDirHash, speaker, markId: opts.conversation.at(-1)?.id, summaryKey: conversationSummaryKey(opts.conversation), sections: {}, startedAt: Date.now() };
const decision = decideClaudeTurn({
enabled: true, state, conversation: opts.conversation, configDirHash, speaker, cliAlive, conversationReset: opts.conversationReset,
hasTranscript: (id) => !!findClaudeTranscript(id, configDir),
});
if (decision.path !== 'resume' || !state) return this.freshPlan(opts, accountDir, decision.path, persist);
const sections = opts.contextSections ?? { context: opts.contextBlock };
const hashes = sectionHashes(sections);
return {
path: 'resume', accountDir, resumeId: state.claudeSessionId,
prompt: buildResumePrompt(opts.prompt, decision.unseen, buildContextDelta(state.sections, sections)),
persist: { ...persist, sections: hashes, startedAt: state.startedAt },
submitSections: sectionsAfterSubmit(state.sections, hashes),
};
}
private async *run(opts: EngineStreamOpts, plan: RunPlan): AsyncGenerator<WsEvent, 'resume-failed' | void> {
const { config, directives } = opts;
const cwd = APP_ROOT;
const fullPrompt = plan.prompt;
const permMode = opts.onPermissionRequest ? 'default' : (config.permissionMode ?? 'acceptEdits');
// SECURITY_ENFORCE: the effective profile at spawn decides env/tools/MCPs; the per-call gate re-reads it (enforce.ts).
const guard = opts.security;
const eff = guard?.current();
const sdkEnv: Record<string, string> = eff ? buildAgentEnv(process.env, eff.profile) : {};
if (!eff) for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) sdkEnv[k] = v;
}
// Injected for the agent's own tools/scripts. Each is written under both the canonical
// `SHRAGA_*` name and the legacy `UNCLAW_*` one — deployed workspaces still contain scripts
// and extensions that read the legacy names, and we don't control those callers.
sdkEnv.SHRAGA_USER_UID = sdkEnv.UNCLAW_USER_UID = opts.uid;
if (opts.userEmail) sdkEnv.SHRAGA_USER_EMAIL = sdkEnv.UNCLAW_USER_EMAIL = opts.userEmail;
sdkEnv.SHRAGA_SESSION_ID = sdkEnv.UNCLAW_SESSION_ID = opts.sessionId ?? '';
Object.assign(sdkEnv, offloadEnv());
// No CLAUDE.md, ever. settingSources:['project'] does NOT stop it: CC walks UP from cwd and loads
// every ancestor's CLAUDE.md / .claude/CLAUDE.md as a "Project" file — with the app under $HOME
// that is the host's ~/.claude/CLAUDE.md (an operator's personal dev rules: "plan first, let me
// review"), which made the agent and its sub-agents stop for approval (feedox, 2026-10-25). The
// agent's instructions are ours (system prompt + DATA_DIR/skills); a repo CLAUDE.md is dev docs.
sdkEnv.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1';
// Per-command wall-clock cap for the SDK's Bash tool. Without it a single unbounded command
// (e.g. `curl` with no `-m` against an SSE endpoint that streams nothing) eats the whole turn and
// the agent answers with "reads were interrupted". The model still sees `[exit 124]` + partial
// output and can adapt, and may ask for up to BASH_MAX_TIMEOUT_MS on a legitimately slow command.
// `??=` is not enough: the SDK treats a set-but-empty var as absent and silently falls back to
// its own 120s, so a blank host value must take our default too — only a real value wins.
const setIfBlank = (key: string, value: string) => { if (!sdkEnv[key]?.trim()) sdkEnv[key] = value; };
setIfBlank('BASH_DEFAULT_TIMEOUT_MS', process.env.AGENT_SHELL_TIMEOUT_MS?.trim() || '60000');
setIfBlank('BASH_MAX_TIMEOUT_MS', process.env.AGENT_SHELL_MAX_TIMEOUT_MS?.trim() || '600000');
// The subscription login's claude.ai connectors (Gmail/Calendar/… and this agent's own MCP) load
// regardless of settingSources; headless they are auth stubs or self-recursion. Our MCPs come from mcp-config.
setIfBlank('ENABLE_CLAUDEAI_MCP_SERVERS', 'false');
// Per-user subscription (workspace/users/<contactId>/.claude): run on that login, never the box's credentials.
const accountDir = plan.accountDir;
if (accountDir) applyClaudeAccount(sdkEnv, accountDir);
else applyForcedSubscription(sdkEnv);
// Which login this run is on (email/plan, never a token) — read lazily from the login's local
// files, only once init proves the run is on a subscription login (see init below).
let accountRefP: Promise<ClaudeAccountRef | undefined> | undefined;
const accountRef = () => (accountRefP ??= claudeAccountRef(accountDir));
let runAccount: ClaudeAccountRef | undefined;
let sawInit = false;
// Did THIS attempt produce model output? Gates both the resume-failure retry and saving the session id
// (a resume that died on "No conversation found" can report a session id it never wrote a turn to).
let sawOutput = false;
let initModel: string | undefined;
sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');
if (eff && !allowsInternalToken(eff.profile)) delete sdkEnv.INTERNAL_API_TOKEN;
const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
const withToolSearch = baseAllowed.includes('ToolSearch') ? baseAllowed : [...baseAllowed, 'ToolSearch'];
// Enforced: built-in tool AVAILABILITY is the profile's (tools outside it are not in the model's context), and
// auto-approval never widens it.
const available = eff ? builtinTools(eff.profile) : 'all';
// Same gate that mounts the jobs server below — the hooks steer to job_start only where it exists.
const durableJobs = !!opts.sessionId && (available === 'all' || available.includes('Bash'));
const allowedTools = available === 'all' ? withToolSearch : withToolSearch.filter((t) => available.includes(t));
const maxTurns = directives.turns ?? config.maxTurns ?? 50;
const options: Record<string, unknown> = {
tools: available === 'all' ? { type: 'preset', preset: 'claude_code' } : available,
env: sdkEnv,
allowedTools,
cwd,
// Omitted = ALL sources, so the host's ~/.claude (CLAUDE.md, MCP instructions, hooks) rode into every
// query — ~24k tokens re-written to cache per fresh turn on a dev box. Keep 'project': the deployment
// ships its own .claude/skills (e.g. shraga-circles' video-ad-pipeline) that the agent relies on.
settingSources: ['project'],
permissionMode: permMode === 'bypassPermissions' ? 'acceptEdits' : permMode,
maxTurns,
includePartialMessages: true,
// The SDK `skills` option is a context filter over skills the SDK DISCOVERS on disk
// (.claude/skills / settingSources / plugins) — NOT our DATA_DIR/skills. We never point the
// SDK at that dir, so these names match nothing: the built-in `Skill` tool turns on but can
// resolve zero of them → every Skill{name} returns "Unknown skill". Our real skill path is
// trigger-injection + the <available-skills> index (which tells the model to `Read` them).
// Disable the phantom tool so the model doesn't waste a turn (and then fly blind) on it.
skills: listSkills(),
disallowedTools: ['Skill'],
agents: loadAgents(),
// Enforced: the profile gate runs FIRST on every tool call — hooks fire even for auto-approved `allowedTools`,
// which never reach canUseTool.
hooks: ((h) => (guard ? { ...h, PreToolUse: [{ hooks: [guard.hook()] }, ...(h.PreToolUse ?? [])] } : h))(buildHooks({ exemptBashCommand: opts.foregroundBashCommand, durableJobs })),
};
const userHandler = opts.onPermissionRequest;
const destructiveHandler = opts.onDestructiveApproval;
const questionHandler = opts.onUserQuestion;
options['canUseTool'] = async (toolName: string, input: Record<string, unknown>) => {
// Enforced: TurnGuard owns the file-path deny (tightened list); the legacy path list stays flag-OFF behavior only.
const denied = guard && toolName !== 'Bash' ? null : checkSensitiveAccess(toolName, input);
if (denied) return denied;
// Enforced: the profile gate (re-reads the session floor) runs before ANY handler below, so an
// `onPermissionRequest: allow` call site can never override a profile deny.
if (guard) {
const gate = guard.check(toolName, input, cwd);
if (!gate.allow) return { behavior: 'deny' as const, message: gate.message };
}
if (toolName === 'AskUserQuestion') {
const questions = (input.questions ?? []) as AskQuestion[];
const answers = questionHandler
? await questionHandler(crypto.randomUUID(), questions).catch((err) => {
console.error(`[claude] onUserQuestion failed:`, (err as Error)?.message);
return null;
})
: null;
if (answers && Object.keys(answers).length) {
return { behavior: 'allow' as const, updatedInput: { ...input, questions, answers } };
}
const sentinel: QuestionAnswers = {};
for (const q of questions) sentinel[q.question] = NO_INTERACTIVE_ANSWER;
return { behavior: 'allow' as const, updatedInput: { ...input, questions, answers: sentinel } };
}
if (isDestructiveDataOp(toolName, input)) {
const cmd = ((input.command ?? '') as string).slice(0, 100);
console.log(`[security] Destructive data op requires approval: ${cmd}`);
if (destructiveHandler) {
const id = crypto.randomUUID();
const result = await destructiveHandler(id, toolName, input);
if (result.allow) return { behavior: 'allow' as const, updatedInput: input };
return { behavior: 'deny' as const, message: 'User denied this destructive action' };
}
return { behavior: 'deny' as const, message: 'Destructive data operations require interactive approval.' };
}
if (userHandler) {
const id = crypto.randomUUID();
const result = await userHandler(id, toolName, input);
if (result.allow) return { behavior: 'allow' as const, updatedInput: input };
return { behavior: 'deny' as const, message: 'User denied this action' };
}
return { behavior: 'allow' as const, updatedInput: input };
};
// Always pass an explicit model — left unset, the CLI applies its own default
// (observed: Opus 4.7), not what the UI's "Default" label promises.
const requestedModel = directives.model || config.model || DEFAULT_MODEL;
// Refuse a model that isn't ours instead of posting it to Anthropic. Substituting our default
// silently would be the same billing lie in a different costume, so this ends the turn.
if (!isOwnModel(requestedModel)) {
const message =
`Model "${requestedModel}" does not belong to the ${this.name} engine, so this run was stopped ` +
`rather than billed to Anthropic under another provider's model name. Pick a Claude model, or ` +
`request the engine that owns it (e.g. \`[engine:cursor,model:${requestedModel}]\`) and make sure ` +
`that engine is registered on this server.`;
console.error(`[claude] ${message}`);
yield { type: 'error', message };
return;
}
options['model'] = requestedModel;
const thinkingMode = directives.thinking ?? config.thinking;
if (thinkingMode) options['thinking'] = thinkingMode === 'enabled' ? { type: 'enabled' } : { type: thinkingMode };
const effort = directives.effort ?? config.effort;
if (effort) options['effort'] = effort;
const userPrompt = config.systemPrompt || DEFAULT_USER_PROMPT;
// Optional add-ons may append a system-prompt suffix decided off the opaque turn hints (add-on-owned
// text). With nothing registered (CE's own state) this is '' and the prompt is byte-identical to before.
const addonSuffix = getPromptSuffix(opts.turnHints);
options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
if (opts.abortController) options['abortController'] = opts.abortController;
if (plan.resumeId) options['resume'] = plan.resumeId;
// Spawn the CLI in its OWN process group. The service manager signals the whole JOB on restart
// (`launchctl kickstart -k`, `systemctl restart`), so an inherited process group means the child
// dies instantly with SIGTERM — surfacing mid-reply as `exited with code 143` and making the
// server's 90s drain (gracefulShutdown) protect nothing: it only ever waited for a turn that was
// already dead. Detached, the signal reaches the server alone and the drain can finish the turn.
// Teardown is unaffected: the SDK still kills the child on abort/close and on process exit.
options['spawnClaudeCodeProcess'] = (cfg: Parameters<typeof spawnDetached>[0]) => {
const child = spawnDetached(cfg);
trackCliExit(opts.sessionId, child);
return child;
};
// Passed as a FILE, never as `options.mcpServers` — the SDK would put the whole config (every MCP
// server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
// writeMcpConfigFile. Setting both would re-add the argv copy, so it's one or the other.
let mcpConfigFile: { path: string; cleanup: () => void } | undefined;
// Enforced: servers outside profile.mcps never reach the file, so the CLI never starts them.
const mcpServers = eff ? filterMcpServers(opts.mcpServers, eff.profile) : opts.mcpServers;
if (mcpServers && Object.keys(mcpServers).length > 0) {
mcpConfigFile = writeMcpConfigFile(mcpServers);
options['extraArgs'] = { ...(options['extraArgs'] as Record<string, string> | undefined), 'mcp-config': mcpConfigFile.path };
}
// `escalate` is an in-process SDK server (no credentials): `options.mcpServers` hands the CLI only its name.
if (guard && eff && allowsEscalate(eff.profile)) {
const { principal } = guard.options;
const lastUser = opts.conversation.findLast((m) => m.role === 'user');
const excerpt = lastUser?.blocks.map((b) => (b.type === 'text' ? b.text : '')).filter(Boolean).join('\n') || opts.prompt;
const server = escalateMcpServer({
principal, role: () => guard.current().role, sessionId: opts.sessionId, excerpt,
channel: [principal.kind, principal.attrs.lane, opts.context?.source].filter(Boolean).join(':'),
});
options['mcpServers'] = { [server.name]: server };
}
// `share_file` (in-process, like escalate): full-access turns only — flag OFF every turn is full.
if (!eff || allowsShare(eff.profile)) {
const server = shareMcpServer();
options['mcpServers'] = { ...(options['mcpServers'] as object | undefined), [server.name]: server };
options['allowedTools'] = [...allowedTools, SHARE_TOOL_ID];
}
// Durable background jobs (in-process, like share_file): server-owned children that outlive the
// turn, the CLI process, and the bg-wait budget, with wake-on-exit (background-jobs.ts). Only
// where the profile grants Bash — job_start is arbitrary shell — and only for a real session id
// (wake reports need a session to land in).
if (durableJobs) {
// The job store scrubs credentials from children, but here the turn's own Bash already sees this
// env (profile-filtered by buildAgentEnv). Scrubbing it made job_start strictly weaker than Bash, and
// the hook steers long scripts INTO job_start: a nested agent died "No provider key found" (2026-09-24),
// AGF Post-Daily lost MIXPANEL_API_SECRET_AGF/GOOGLE_SERVICE_ACCOUNT (2026-10-20). Hand the job the
// turn's env, minus PORT (the server's own; a job honouring it would shadow us).
const jobEnv = Object.fromEntries(Object.entries(sdkEnv).filter(([k, v]) => v && k !== 'PORT'));
const server = jobsMcpServer({ sessionId: opts.sessionId!, uid: opts.uid, userEmail: opts.userEmail, cwd, env: jobEnv });
options['mcpServers'] = { ...(options['mcpServers'] as object | undefined), [server.name]: server };
options['allowedTools'] = [...((options['allowedTools'] as string[] | undefined) ?? allowedTools), ...JOBS_TOOL_IDS];
}
const mcpNames = mcpServers ? Object.keys(mcpServers) : [];
const activeModel = (options['model'] as string) || 'default';
const directivesTag = Object.keys(directives).length ? ` directives=${JSON.stringify(directives)}` : '';
console.log(`[claude] Starting query user=${opts.uid} session=${opts.sessionId ?? 'new'} model=${activeModel} perms=${config.permissionMode} mcps=[${mcpNames.join(',')}]${directivesTag} cwd=${cwd}`);
const startTime = Date.now();
const elapsed = () => `${Date.now() - startTime}ms`;
const sessionKey = opts.sessionId ?? crypto.randomUUID();
const hasAttachments = opts.attachments && opts.attachments.length > 0;
const hasLegacyImages = opts.images && opts.images.length > 0;
const source = hasAttachments
? buildAttachmentPrompt(fullPrompt, opts.attachments!, sessionKey)
: hasLegacyImages
? buildLegacyImagePrompt(fullPrompt, opts.images!, sessionKey)
: buildTextPrompt(fullPrompt, sessionKey);
// Streaming input, held open until the turn is over. A string prompt runs the CLI one-shot: it exits after
// the first result and kills `run_in_background` jobs ~5s later. Open, the job keeps running and its
// task_notification gets the model a follow-up turn, as interactive Claude Code does. The uuid is echoed on
// the result that answers THIS prompt (`user_message_uuids`), telling it apart from a resumed session's stale one.
const promptUuid = crypto.randomUUID();
let closeInput!: () => void;
const inputClosed = new Promise<void>((r) => { closeInput = r; });
const prompt = (async function* () {
for await (const msg of source) yield { ...msg, uuid: promptUuid };
await inputClosed;
})();
const q = query({ prompt, options: options as any });
// The CLI writes this prompt to the transcript before any output: from here a cancel can't un-deliver it.
if (plan.submitSections && opts.sessionId) setClaudeResume(opts.sessionId, undefined, { sections: plan.submitSections });
let lastSessionId = '';
let messageCount = 0;
let textDeltaCount = 0;
let turnCount = 0;
const pendingToolUses = new Map<string, { tool: string; input: any }>();
const streamedToolUseIds = new Set<string>();
const streamingToolInputsByIndex = new Map<number, { id: string; name: string; inputJson: string }>();
const outstandingTasks = new Set<string>();
const foregroundTasks = new Set<string>();
const iter = q[Symbol.asyncIterator]();
let pendingNext: Promise<IteratorResult<any>> | null = null;
let waitingForBg = false;
// Grace hold: no task outstanding, but one finished mid-turn and the CLI may still run its follow-up turn.
let graceStop: string | null = null;
let notifiedBeforeAnswer = false;
// Has the result answering THIS prompt arrived? Later results are the model's follow-ups on bg-task completions.
let answered = false;
let turnBreak = false;
let bgTimer: Promise<'__bgtimeout'> | null = null;
let bgTimerHandle: ReturnType<typeof setTimeout> | null = null;
let bgHeartbeat: ReturnType<typeof setInterval> | null = null;
const clearBgTimer = () => { if (bgTimerHandle) { clearTimeout(bgTimerHandle); bgTimerHandle = null; } bgTimer = null; if (bgHeartbeat) { clearInterval(bgHeartbeat); bgHeartbeat = null; } };
try {
while (true) {
if (!pendingNext) pendingNext = iter.next();
let res: IteratorResult<any>;
if (waitingForBg) {
if (!bgTimer) bgTimer = new Promise((r) => { bgTimerHandle = setTimeout(() => r('__bgtimeout'), graceStop ? BG_FOLLOWUP_GRACE_MS : BG_TASK_MAX_WAIT_MS); });
const raced = await Promise.race([pendingNext, bgTimer]);
if (raced === '__bgtimeout' && graceStop) {
console.log(`[claude] No follow-up turn within grace — ending (${elapsed()})`);
yield { type: 'done', sessionId: lastSessionId, stopReason: graceStop };
return;
}
// The follow-up turn started: it gets the full bg wait, not the grace window. CLI housekeeping
// (status/rate-limit events) is not a turn — letting it cancel the grace held every such turn for 15 min.
const v = raced === '__bgtimeout' ? undefined : (raced as IteratorResult<any>).value;
const housekeeping = v?.type === 'rate_limit_event' || (v?.type === 'system' && !String(v.subtype).startsWith('task_'));
if (graceStop && !housekeeping) { graceStop = null; clearBgTimer(); }
if (raced === '__bgtimeout') {
console.warn(`[claude] Background-task wait timed out (${elapsed()})`);
yield { type: 'done', sessionId: lastSessionId, stopReason: 'end_turn' };
return;
}
res = raced as IteratorResult<any>;
} else {
res = await pendingNext;
}
pendingNext = null;
if (res.done) break;
const m = res.value as any;
messageCount++;
if (m.session_id && !lastSessionId) {
lastSessionId = m.session_id;
console.log(`[claude] Got session_id=${lastSessionId} at msg #${messageCount} (${elapsed()})`);
}
if (m.type === 'system' && m.subtype === 'init') {
// Which credentials the SDK actually resolved (env key vs claude.ai login) — a process-global
// fact surfaced per-run so logs (incl. headless/scheduler) answer "subscription or API key?".
// Runtime `apiKeySource` is the RESOLVED source string (verified against the SDK, not its .d.ts
// enum): a named env var ("ANTHROPIC_API_KEY") ⇒ API key; "none"/"oauth" ⇒ stored login ⇒ sub.
const src = m.apiKeySource as string | undefined;
const authSource: 'subscription' | 'api-key' | undefined =
src == null ? undefined : src === 'none' || src === 'oauth' ? 'subscription' : 'api-key';
if (authSource) console.log(`[claude] Auth: ${authSource} (apiKeySource=${src}) account=${accountDir ? opts.userEmail!.trim().toLowerCase() : 'default'}`);
sawInit = true;
// A routed dir is a login by construction (applyClaudeAccount strips every key); the box
// default only counts when the SDK says it resolved a login, not an API key.
if ((authSource ?? (accountDir ? 'subscription' : undefined)) === 'subscription') runAccount = await accountRef();
if (m.model) {
initModel = m.model;
console.log(`[claude] Init model=${m.model}${m.model !== options['model'] ? ` (requested ${options['model']})` : ''}`);
// If the user explicitly asked to switch models via a [directive], announce the change
// inline so the response confirms the switch took effect.
const sw = resolveModelSwitch({
requested: opts.directives.model ? m.model : undefined,
current: m.model,
prior: opts.sessionId ? getSessionModel(opts.sessionId) : undefined,
});
if (sw.notice) yield { type: 'text_delta', text: sw.notice };
if (opts.sessionId) setSessionModel(opts.sessionId, m.model, this.name, runAccount);
// Live ground-truth so the header pill confirms the actually-resolved model mid-turn
// (catches inline overrides like [opus] and silent rate-limit fallbacks) instead of
// only updating on session reload.
yield { type: 'model_resolved', sessionId: opts.sessionId ?? '', model: m.model, engine: this.name, ...(runAccount ? { account: runAccount } : {}) };
}
const servers = m.mcp_servers;
if (Array.isArray(servers) && servers.length > 0) {
console.log(`[claude] MCP: ${servers.map((s: any) => `${s.name}:${s.status}`).join(', ')}`);
}
continue;
}
if (m.type === 'system' && m.subtype === 'task_started') {
// The CLI also reports a FOREGROUND Bash (>~2s) as a task. Its result is already in the turn, so it gets
// no follow-up — counting it armed the follow-up hold and kept every such turn open for 15 min.
if (m.is_backgrounded === false) { foregroundTasks.add(m.task_id); continue; }
outstandingTasks.add(m.task_id);
console.log(`[claude] Background task started: ${m.task_id} (${outstandingTasks.size} pending) (${elapsed()})`);
continue;
}
if (m.type === 'system' && m.subtype === 'task_notification') {
if (foregroundTasks.delete(m.task_id)) continue;
outstandingTasks.delete(m.task_id);
if (!answered) notifiedBeforeAnswer = true;
console.log(`[claude] Background task ${m.status}: ${m.task_id} (${outstandingTasks.size} pending) (${elapsed()})`);
// Not the end of the turn: the CLI now runs a follow-up turn on the result, whose own result ends it
// (still bounded by the bg timer).
continue;
}
if (m.type === 'system') continue;
// Free, always-current limit status from the agent's own inference calls — the only signal
// that still works while the usage endpoint is throttling this account.
if (m.type === 'rate_limit_event') { claudeUsageFor(accountDir).observeRateLimit(m.rate_limit_info); continue; }
if (m.type === 'result') {
// A resumed session first replays a pending task_notification as its own zero-turn result, before
// our prompt runs — ending on it drops the user's message. Skip any result that is not ours until
// ours arrives (older CLIs don't echo the uuid: then only a zero-turn result counts as stale).
const echoed = m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : undefined);
if (!answered && !m.is_error && (echoed ? !echoed.includes(promptUuid) : !m.num_turns)) {
notifiedBeforeAnswer = false; // that notification belonged to the stale turn
console.log(`[claude] Skipping result of an earlier turn: turns=${m.num_turns ?? 0} result_index=${m.result_index ?? '?'} (${elapsed()})`);
continue;
}
answered = true;
lastSessionId = m.session_id || lastSessionId;
const sdkTurns = m.num_turns ?? 0;
const raw = m.subtype ?? 'unknown';
const sub = raw === 'error_max_turns' || (raw === 'end_turn' && sdkTurns >= maxTurns) ? 'max_turns_reached' : raw;
console.log(`[claude] Result: subtype=${m.subtype}→${sub} session=${lastSessionId} turns=${sdkTurns}/${maxTurns} cost=$${m.total_cost_usd?.toFixed(4) ?? '?'} msgs=${messageCount} deltas=${textDeltaCount} (${elapsed()})`);
logCacheUsage(m.usage, activeModel, plan.path);
if (outstandingTasks.size > 0) {
if (!waitingForBg) {
waitingForBg = true; clearBgTimer();
console.log(`[claude] Holding stream for ${outstandingTasks.size} bg tasks (${elapsed()})`);
// The hold is invisible to the user (their next message queues behind it), so beat
// every minute — otherwise a stuck task reads as a dead agent with nothing in the log.
bgHeartbeat = setInterval(() => console.log(`[claude] Still holding for ${outstandingTasks.size} bg task(s): ${[...outstandingTasks].join(',')} (${elapsed()})`), BG_HEARTBEAT_MS);
bgHeartbeat.unref?.();
}
turnBreak = textDeltaCount > 0;
continue;
}
// A task that finished BEFORE this result is already out of the set, yet the CLI queues a follow-up
// turn on its notification — ending here drops it (the model said "will report" and never did).
const firstResult = !waitingForBg && !graceStop;
if (firstResult && (m.queued_turn_count > 0 || notifiedBeforeAnswer) && !m.is_error) {
waitingForBg = true; clearBgTimer(); graceStop = sub;
console.log(`[claude] Holding for follow-up turn on a bg task that finished mid-turn (queued=${m.queued_turn_count ?? '?'}) (${elapsed()})`);
turnBreak = textDeltaCount > 0;
continue;
}
// The SDK reports `subtype: 'success'` even when the API call failed (e.g. an org spend
// cap → HTTP 400) — the only tells are `is_error` / `api_error_status`. Trusting the
// subtype makes an all-turns-failing agent look healthy: schedules store lastRun 'ok',
// the failure notifier stays silent, and Slack simply gets nothing. Surface it as an
// error so the run fails loudly.
// `error_max_turns` also sets is_error but is a benign stop reason we already model.
if ((m.is_error || m.api_error_status) && sub !== 'max_turns_reached') {
const detail = typeof m.result === 'string' && m.result ? m.result : `api_error_status=${m.api_error_status ?? '?'}`;
// The CLI's reason ("No conversation found with session ID …") rides in `errors`, not `result`.
const reasons = Array.isArray(m.errors) && m.errors.length ? m.errors.join('; ') : detail;
if (plan.resumeId && !sawOutput && isResumeFailure(reasons)) {
console.error(`[claude] Resume error result (subtype=${raw}) — ${reasons}`);
return 'resume-failed';
}
// A usage/rate-limit failure is about ONE login's quota — name it, or a routed user's own
// exhausted Pro plan reads as the shared agent subscription being out.
const limitHit = m.api_error_status === 429 || /\b(usage|rate.?limit(ed)?|hit your limit)\b/i.test(detail);
const account = limitHit ? runAccount ?? (!sawInit && accountDir ? await accountRef() : undefined) : undefined;
console.error(`[claude] API error result — ${detail}`);
yield { type: 'error', message: `Claude API error${account ? ` (${account.email})` : ''}: ${detail}` };
return;
}
if (textDeltaCount === 0) {
console.warn(`[claude] Empty response — result keys: ${Object.keys(m).join(',')}`);
}
yield { type: 'done', sessionId: lastSessionId, stopReason: sub };
return;
}
if (m.type === 'stream_event') {
const event = m.event;
if (event?.type === 'content_block_delta' && event.delta?.type === 'thinking_delta') {
sawOutput = true;
yield { type: 'thinking_delta', text: event.delta.thinking };
}
if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
textDeltaCount++;
sawOutput = true;
if (turnBreak) { turnBreak = false; yield { type: 'text_delta', text: '\n\n' }; }
yield { type: 'text_delta', text: event.delta.text };
}
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
const { id, name } = event.content_block;
const idx = event.index ?? -1;
streamedToolUseIds.add(id);
streamingToolInputsByIndex.set(idx, { id, name, inputJson: '' });
yield { type: 'tool_use', tool: name, toolUseId: id, input: {} };
}
if (event?.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
const entry = streamingToolInputsByIndex.get(event.index ?? -1);
if (entry) {
entry.inputJson += event.delta.partial_json ?? '';
yield { type: 'tool_use_input', toolUseId: entry.id, input: entry.inputJson };
}
}
continue;
}
if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
turnCount++;
sawOutput = true;
for (const block of m.message.content) {
if (block.type === 'tool_use') {
pendingToolUses.set(block.id, { tool: block.name, input: block.input });
if (streamedToolUseIds.has(block.id)) {
yield { type: 'tool_use_input', toolUseId: block.id, input: block.input };
} else {
yield { type: 'tool_use', tool: block.name, toolUseId: block.id, input: block.input };
}
}
}
streamingToolInputsByIndex.clear();
continue;
}
if (m.type === 'user' && Array.isArray(m.message?.content)) {
for (const block of m.message.content) {
if (block.type === 'tool_result') {
const contentArr = Array.isArray(block.content) ? block.content : [];
const output = contentArr.length > 0
? contentArr.filter((c: any) => c.type === 'text').map((c: any) => c.text ?? '').join('')
: String(block.content ?? '');
// `is_error` is the ONLY signal that a tool failed — the text alone is indistinguishable
// from a successful result. Scheduled `bash` tasks rely on it to notice a non-zero exit.
yield { type: 'tool_result', toolUseId: String(block.tool_use_id), output, isError: block.is_error === true };
let foundImage = false;
for (const c of contentArr) {
if (c.type === 'image') {
foundImage = true;
if (c.source?.type === 'base64' && c.source?.data) {
yield { type: 'tool_result_image', toolUseId: String(block.tool_use_id), dataUrl: `data:${c.source.media_type || 'image/png'};base64,${c.source.data}` };
} else if (c.data && c.mimeType) {
yield { type: 'tool_result_image', toolUseId: String(block.tool_use_id), dataUrl: `data:${c.mimeType};base64,${c.data}` };
}
}
}
if (!foundImage && output.includes('"dataUrl":"data:')) {
try {
const parsed = JSON.parse(output);
if (parsed.dataUrl?.startsWith('data:')) {
yield { type: 'tool_result_image', toolUseId: String(block.tool_use_id), dataUrl: parsed.dataUrl };
}
} catch (e) { console.warn('[claude] Failed to parse dataUrl from tool result text', e); }
}
const pending = pendingToolUses.get(String(block.tool_use_id));
if (pending?.tool === 'mcp__mcp-slack-use__post_slack_message') {
try {
const parsed = typeof output === 'string' ? JSON.parse(output) : output;
const ts = parsed?.ts || parsed?.preview?.ts;
const inp = pending.input as any;
const channel = inp?.body?.channel || inp?.channel_id || inp?.channel;
const sid = opts.sessionId || lastSessionId;
if (ts && channel && sid) {
const session = getSession(sid);
registerProactiveMessage(channel, ts, sid, session?.title || sid);
}
} catch (err) { console.warn('[claude] Failed to track proactive message:', (err as Error).message); }
}
if (pending?.tool === 'mcp__mcp-slack-use__post_slack_poll' || pending?.tool === 'mcp__mcp-slack-use__post_slack_card') {
try {
const parsed = typeof output === 'string' ? JSON.parse(output) : output;
const body = ((pending.input as any)?.body ?? {}) as Record<string, any>;
const sid = opts.sessionId || lastSessionId;
if (parsed?.ok && parsed.pollId && parsed.ts && parsed.channel && sid) {
registerPoll({
pollId: parsed.pollId, channel: parsed.channel, ts: parsed.ts, title: String(parsed.title ?? body.title ?? ''),
options: parsed.options ?? body.options ?? [], kind: parsed.kind ?? 'poll',
multi: parsed.multi, targetUser: parsed.targetUser,
deadlineMinutes: typeof body.deadline_minutes === 'number' ? body.deadline_minutes : undefined,
quorum: typeof body.quorum === 'number' ? body.quorum : undefined,
sessionId: sid, uid: opts.uid, userEmail: opts.userEmail,
cardBlocks: Array.isArray(parsed.blocks) ? parsed.blocks : undefined,
});
}
} catch (err) { console.warn('[claude] Failed to register poll:', (err as Error).message); }
}
pendingToolUses.delete(String(block.tool_use_id));
}
}
continue;
}
}
} catch (err: any) {
if (err.name === 'AbortError' || opts.abortController?.signal.aborted) {
console.log(`[claude] Stream aborted after ${messageCount} msgs (${elapsed()})`);
return;
}
console.error(`[claude] Error after ${messageCount} msgs (${elapsed()}):`, err.message || err);
if (plan.resumeId && !sawOutput && isResumeFailure(err.message || String(err))) return 'resume-failed';
yield { type: 'error', message: err.message || String(err) };
return;
} finally {
clearBgTimer();
closeInput();
// The CLI has read the file by now (it loads MCP config at startup); holding it any longer just
// widens the window in which the credentials sit on disk.
mcpConfigFile?.cleanup();
// Save the mapping once this attempt really ran a turn (incl. an aborted one — the transcript holds it).
if (plan.persist && opts.sessionId && lastSessionId && sawOutput) {
setClaudeResume(opts.sessionId, { ...plan.persist, claudeSessionId: lastSessionId, ...(initModel ? { model: initModel } : {}) });
if (plan.resumeId && plan.resumeId !== lastSessionId) console.warn(`[claude] Resume returned a new session id ${lastSessionId} (was ${plan.resumeId})`);
}
}
const inferredReason = turnCount >= maxTurns ? 'max_turns_reached' : 'end_turn';
console.warn(`[claude] Stream ended without result. session=${lastSessionId || 'none'} msgs=${messageCount} turns=${turnCount}/${maxTurns} (${elapsed()})`);
if (lastSessionId) {
yield { type: 'done', sessionId: lastSessionId, stopReason: inferredReason };
}
}
}