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): 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): 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(`\n${summary}\n`); 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}\n${parts.join('\n\n')}\n\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:` (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; /** Resume only: section hashes to store once the prompt is submitted (see sectionsAfterSubmit). */ submitSections?: Record; } /** 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>>(); 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((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 { const set = liveCli.get(sessionId); if (!set?.size) return true; let timer: ReturnType | undefined; const timeout = new Promise((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(['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; 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 { 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 { 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 { 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 { 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; 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 { 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 = 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//.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 | 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 = { 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 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) => { // 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[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 | 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((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(); const streamedToolUseIds = new Set(); const streamingToolInputsByIndex = new Map(); const outstandingTasks = new Set(); const foregroundTasks = new Set(); const iter = q[Symbol.asyncIterator](); let pendingNext: Promise> | 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 | null = null; let bgHeartbeat: ReturnType | 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; 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).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; } 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; 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 }; } } }