mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-29 02:34:26 -05:00
* feat(sdk): add SDK foundation — type declarations, errors, and utilities Adds standalone SDK building blocks with no SDK source dependencies: - sdk.d.ts: ambient type declarations for SDK bundle - coreSchemas.ts + coreTypes.generated.ts: Zod schemas and generated types - errors.ts: SDK-specific error classes - validation.ts: input validation utilities - messageFilters.ts: extracted message filter logic - handlePromptSubmit.ts: imports from messageFilters - 16 generated-types tests * fix(sdk): narrow assertFunction type from broad Function to callable signature Code review finding: assertFunction used `asserts value is Function` which accepts any function-like value without narrowing. Changed to `(...args: any[]) => any` for better type safety. * fix(sdk): update sdk.d.ts header — manually maintained, not generated Reviewer noted the header said "Generated from index.ts" but no generator produces this file. Updated to "Manually maintained — keep in sync with index.ts". Drift detection added in validate-externals.ts (PR 3). * fix(sdk): align sdk.d.ts types with canonical coreTypes.generated.ts Tighten SDK public type contract to resolve reviewer blockers: - PermissionResult: unknown[] → precise 6-shape discriminated union (addRules/replaceRules/removeRules/setMode/addDirectories/removeDirectories) - SDKSessionInfo: snake_case → camelCase (sessionId, lastModified, etc.) - ForkSessionResult: session_id → sessionId - SDKPermissionRequestMessage: uuid + session_id now required - SDKPermissionTimeoutMessage: added uuid + session_id - SessionMessage: parent_uuid → parentUuid - SDKMessage/SDKUserMessage/SDKResultMessage: replaced loose inline definitions with re-exports from coreTypes.generated.ts * feat(sdk): wire existing code modules + SDK shared utilities Modifies core modules for SDK integration: - QueryEngine, tools, state, commands: SDK type hooks - SDK shared utilities (shared.ts, permissions.ts) - 21 SDK tests (shared-utils, permissions) Stack: main ← pr1-foundation ← pr2-sdk-core * feat(sdk): add snake_case ↔ camelCase key mapping utilities casing.ts provides recursive key transformation for the SDK boundary layer. Internal runtime uses snake_case; public API exposes camelCase. Will be used by shared.ts, sessions.ts, query.ts at export boundaries. * test(sdk): add tests for snake_case ↔ camelCase mapping utilities Covers snakeToCamel, camelToSnake, mapKeysToCamel, mapKeysToSnake including nested objects, arrays, null/undefined, and round-trips. * feat(sdk): add SDK runtime — query engine, sessions, build pipeline Completes the SDK implementation: - SDK build target (dist/sdk.mjs) with TUI dependency stubbing - External dependency lists (scripts/externals.ts) - SDK type generation from Zod schemas (scripts/generate-sdk-types.ts) - External validation (scripts/validate-externals.ts) - SDK source: index, query, v2, sessions modules - agentSdkTypes: re-exports SDK functions (query, createSession, etc.) - 136 SDK tests + 7 build scanner tests Stack: main ← pr1-foundation ← pr2-sdk-core ← pr3-sdk-runtime * fix(sdk): align internal SDK types with camelCase public contract shared.ts: SDKSessionInfo, ForkSessionResult, SessionMessage fields now use camelCase matching sdk.d.ts. SDKPermissionRequestMessage and SDKPermissionTimeoutMessage gain required uuid + session_id fields. permissions.ts: onPermissionRequest/onTimeout callbacks now include uuid and session_id in emitted messages. * fix(sdk): update runtime modules to use camelCase field names sessions.ts: toSDKSessionInfo outputs camelCase keys, entryToSessionMessage uses parentUuid, forkSession returns sessionId. query.ts: reads sessionId from listSessions/forkSession results instead of snake_case session_id. * fix(test): update session tests to use camelCase field names session_id → sessionId in forkSession result assertions and getSessionMessages calls. * fix(sdk): prevent permission timeout race condition with once-only resolve wrapper Add createOnceOnlyResolve utility to prevent double-resolution of promises when timeout and host response happen simultaneously. This ensures deterministic behavior in the permission handling flow. * fix(sdk): improve race condition test robustness * fix(sdk): handle consecutive underscores in snakeToCamel conversion Changes: - Use _+([a-z]) regex to match multiple consecutive underscores before letters - Add lookahead (?=. ) to preserve underscore-letter pairs at string end - Handle dunder names (__proto__, __typename) by stripping wrapper and capitalizing - Add tests for consecutive underscores and trailing underscore preservation * fix(sdk): include original error message in permission callback denial When a canUseTool callback throws an error, the catch block now includes the original error message in the denial message, making debugging easier for SDK consumers. * feat(sdk): add optional timeout to env mutex for deadlock prevention Add timeout parameter to acquireEnvMutex() to prevent infinite waits in deadlock scenarios. The timeout is optional and defaults to no timeout (wait forever) for backward compatibility. Returns a MutexAcquireResult object with acquired status and optional timeout reason for failed acquisitions. * fix(sdk): remove timed-out callback from mutex queue to prevent deadlock * test(sdk): add missing error path and timeout scenario tests Add tests for timeout scenarios when host doesn't respond to permission requests, fallback behavior when no onPermissionRequest callback, and MCP connection edge cases for undefined/empty config. * fix(sdk): address code review issues - race conditions, validation, error handling - Add createPermissionTarget() factory that applies onceOnlyResolve at registration time, fixing race condition where timeout and host response could both try to resolve the same promise - Add try-catch to releaseEnvMutex() to prevent permanent lock if callback throws - Extract DEFAULT_PERMISSION_TIMEOUT_MS constant (30 seconds) - Add MCP config validation rejecting null, non-objects, and arrays - Preserve error stack traces in MCP connection failures - Add runtime validation to mapMessageToSDK for null/non-object/invalid type - Update tests to use createPermissionTarget and add validation tests * fix(sdk): syntax fixes and MCP connection error handling - Remove extra closing parenthesis in permissions.ts - Remove extra closing braces in shared.ts type definitions - Wrap MCP connection in try/catch to continue without MCP tools on failure * fix(sdk): syntax fixes, MCP error handling, and logic clarity - Remove extra closing parenthesis in permissions.ts - Remove extra closing braces in shared.ts type definitions - Wrap MCP connection in try/catch to continue without MCP tools on failure - Clarify thinkingConfig logic: use ?? true instead of !== false - Add explanatory comment about thinkingEnabled default behavior - Apply createOnceOnlyResolve wrapper in QueryImpl.registerPendingPermission * fix(sdk): comprehensive error handling and resource cleanup - Add try-catch around injectAgents() to gracefully handle plugin agent tool validation failures (prevents test crashes from unknown 'LS' tool) - Add console.warn logging to agent loading/injection catch blocks for debugging visibility (matches v2.ts pattern) - Add pendingPermissionPrompts.clear() to close() and interrupt() methods in both query.ts and v2.ts to prevent memory accumulation - Add close() method to SDKSession interface and SDKSessionImpl - Wrap MCP connection in query.ts with try-catch (matches v2.ts behavior) - Add timeoutQueue cleanup in finally blocks (query.ts + v2.ts) - Remove error.stack from MCP error messages to prevent internal path leak All 208 SDK tests pass. TypeScript errors are pre-existing. * fix(sdk): address code review non-blocking issues - Add SDKAgentLoadFailureMessage type for agent load failure events - Emit agent definition/injection failures to SDK message stream - Add tool name to permission timeout denial message - Replace 'as any' casts with proper typed state access - Fix supportedCommands to use correct mcp.commands/plugins.commands paths - Update test for correct AppState structure * fix(sdk): address code review blocking and non-blocking issues Blocking Issues Fixed: - MCP cleanup missing on session/query close - now disconnects MCP clients to prevent resource leaks in long-running processes with multiple sessions - Engine reference not cleared on close - now sets _engine = null to prevent memory leaks - Added MCP cleanup tests (9 new tests covering cleanup scenarios) Non-Blocking Issues Fixed: - Removed redundant catch block that just rethrew errors (query.ts) - Fixed inconsistent timeout denial message format (permissions.ts) - Fixed hardcoded tool name 'Bash' in test (permissions.test.ts) - Exported PermissionResolveDecision type for SDK consumers (index.ts) All 217 SDK tests pass. * fix(sdk): address code review type consistency issues - Add close() method to SDKSession interface (documented but missing from type) - Fix SDKSessionInfo, ForkSessionResult, SessionMessage field naming: snake_case → camelCase to match sdk.d.ts public contract and implementation - Add uuid and session_id to SDKPermissionTimeoutMessage for correlation - Fix JSDoc comment in forkSession to use sessionId (not session_id) These changes align internal types (shared.ts) with the public SDK contract (sdk.d.ts) and actual implementation output. The merge from origin/main introduced snake_case types that mismatched camelCase implementation and tests. * fix: restore openclaude.json comment in REPL.tsx Merge0f3aa7aincorrectly took main's side for this comment, reverting PR2 fixc725c48. Project has migrated to ~/.openclaude.json, not ~/.claude.json. This is the only PR2 fix lost during merge - all other PR2 fixes (permissions.ts race conditions, state.ts parentSessionId, etc.) are preserved in PR3 via subsequent fix commits. * fix(sdk): add missing type declarations to sdk.d.ts Add SDKAgentLoadFailureMessage and PermissionResolveDecision to sdk.d.ts to resolve type declaration drift detected by build validation. - SDKAgentLoadFailureMessage: Agent loading failure notification (stage: definitions/injection, error_message) - PermissionResolveDecision: SDK-specific permission resolution result (allow with updatedInput, deny with message + decisionReason) Build validation now passes: 56 exports match between index.ts and sdk.d.ts. * fix(sdk): resource leak and null safety in close/interrupt paths - unstable_v2_prompt: wrap session in try/finally to guarantee session.close() on both success and error paths, preventing MCP connection and engine resource leaks - QueryImpl.interrupt(): add null guard on _engine so calling interrupt() after close() is a safe no-op instead of throwing - SDKSessionImpl.interrupt(): add matching null guard for v2 sessions, consistent with the Query fix - QueryImpl.close(): call this.interrupt() before cleanup to properly stop in-flight engine operations, matching v2's close() pattern and ensuring engine.interrupt() runs before nulling * fix(sdk): abort AbortController in SDKSessionImpl.close() to prevent resource leak SDKSessionImpl.close() was not aborting the AbortController, unlike QueryImpl.close() which does. This meant in-flight HTTP requests and async operations could continue running after session closure. - Store AbortController reference via _abortController field + late-bind setter - Abort and null the controller in close(), mirroring QueryImpl pattern - Also null _appStateStore in close() to release state snapshots - Wire abortController through createEngineFromOptions return value * fix(sdk): index ALL entries in byUuid for compact preserved segment The byUuid map must index system compact_boundary entries, not just user/assistant. When anchorUuid === boundary.uuid, the relink walk needs to find the boundary in byUuid. Changes: - query.ts: Index ALL non-sidechain entries (user, assistant, system) - v2.ts: Same fix — index ALL entries, leaf selection user/assistant only - Add regression test: boundary.uuid as anchorUuid scenario Test verifies preserved messages kept, stale pre-compact dropped, post-boundary chain intact when anchorUuid points to boundary itself. * fix(sdk): complete preserved segment handling for compact resumes Multiple fixes for compact-aware transcript loading: 1. Index ALL entries in byUuid (including system compact_boundary) - Needed when anchorUuid === boundary.uuid 2. Keep anchorUuid when pruning preserved segment entries - The anchor is the parent of preserved head after relink - Deleting it breaks the conversation chain 3. Filter system entries from final messages - compact_boundary is metadata, shouldn't pass to engine 4. Fix test timestamp format (ISO 8601 requires 2-digit hours) - '2025-01-04T0:00:00Z' → '2025-01-04T00:00:00Z' 5. Update test expectations for anchor inclusion - When anchor is a stale entry, it appears in messages - preserved(4) + anchor(1) + post(4) = 9 max All 224 SDK tests pass. * fix(sdk): MCP type:sdk tools properly convert SdkMcpToolDefinition to Tool - Import MCPTool base from tools/MCPTool/MCPTool.js - Spread MCPTool properties for proper Tool interface compliance - Add tools field to SdkMcpSdkConfig type declaration - Add regression tests for type:sdk tools wiring Fix ensures in-process SDK tools match Tool interface expected by QueryEngine and permission handlers. * test(sdk): strengthen preserved segment and MCP tools tests Preserved segment test improvements: - Fix content extraction (access message.content, not message) - Add exact count assert: messages.length === 6 - Add exact content asserts: preserved turn 1/2, post-boundary present - Assert no stale, no system entries in final messages MCP tools test additions: - Direct test of connectSdkMcpServers() function - Assert clients.length === 0 (in-process, no MCP connections) - Assert tools.length === 1 with proper name/description - Verify handler works via direct call (not via Tool.call which needs context) * fix(sdk): published types complete, init errors fatal, permission session IDs Three fixes for SDK production readiness: 1. HIGH: Published SDK types incomplete - Add coreTypes.generated.d.ts to package.json "files" array - sdk.d.ts re-exports from ./sdk/coreTypes.generated.js which was missing - TypeScript consumers would get module resolution errors 2. MEDIUM: query() swallows real init() failures - Add _engineWasInjected field to track pre-injected vs fresh engine - Check _engineWasInjected, not _engine !== null (always true after setEngine) - Auth/config/init errors now properly fatal for normal query() calls 3. MEDIUM: SDK permission events lose real session id - Pass sessionId to createExternalCanUseTool() in both query.ts and v2.ts - Permission_request/timeout messages now have correct session_id - Hosts can correlate permission callbacks to sessions Test result: 225 pass, 0 fail * fix(sdk): complete package types + dynamic permission session_id Two fixes for SDK production readiness: 1. Published SDK types now include actual definitions - Replace 215-byte wrapper with 63KB coreTypes.generated.ts - TypeScript consumers get full type definitions (SDKMessage, etc.) - npm pack now includes real generated types 2. Permission event session_id dynamic for all query() paths - createExternalCanUseTool accepts string | (() => string | undefined) - query.ts passes () => queryImpl.sessionId getter - Fresh/fork/continue queries emit correct session_id at event time - V2 passes static sessionId (stable at creation/resume) - Add 4 tests: static sessionId, getter resolution, undefined fallback, timeout Test result: 229 pass, 0 fail * fix(sdk): fix sdk.d.ts for real TypeScript consumer compilation Two issues prevented external consumers from compiling against packed SDK types: 1. SDKRateLimitError used constructor parameter properties (readonly resetsAt, readonly rateLimitType) which are invalid in .d.ts declarations — moved to class properties with separate constructor signature. 2. Re-exported SDKMessage/SDKUserMessage/SDKResultMessage were not imported into local scope — added import type alongside export type so TypeScript can resolve them for use in other declarations within the same file. Added package-consumer-types.test.ts that compiles a real temp project against the SDK types with skipLibCheck:false, catching both regressions. * fix(sdk): eliminate React/Ink imports from SDK bundle SDK bundle leaked React/Ink imports via tool UI modules, keybindings, react-compiler-runtime, and spawnMultiAgent's static React import. Changes: - Stub root ink.js barrel, tool UI.js, keybindings/, react-compiler-runtime, It2SetupPrompt, and React hook files in SDK build - Add local no-op stub for react/jsx-dev-runtime (jsxDEV returns null) - Convert spawnMultiAgent's static React/It2SetupPrompt imports to dynamic await import() — spawnTeammate logic stays fully intact - Add post-build leakage validation (fails on from "react"/"ink"/jsx-dev-runtime) - Remove react/jsx-dev-runtime from SDK externals (now handled by build plugin) * fix(sdk): wire disallowedTools through permission context QueryOptions.disallowedTools was declared but never used. buildPermissionContext() now passes it to alwaysDenyRules.cliArg so getTools() filters denied tools from the model-visible list. Also added to V2 SDKSessionOptions for API consistency. * fix(sdk): defer permission warning to execution time createDefaultCanUseTool() warned at construction time even when the caller provided canUseTool/onPermissionRequest. Move warning to first actual default denial so valid SDK consumers never see false warnings. Add tests for disallowedTools filtering, tool exclusion, and warning timing. * refactor(sdk): extract transcript helpers + fix permission typing - Extract shared transcript utilities to transcript.ts (parseJsonlEntries, findLastCompactBoundary, applyPreservedSegmentRelinks, buildConversationChain, stripExtraFields) deduplicating query.ts and v2.ts - Add PermissionTarget interface to hide internal pendingPermissionPrompts map from createExternalCanUseTool, with deletePendingPermission and denyPendingPermission methods on QueryImpl and SDKSessionImpl - Fix sessionId stability: preserve constructor UUID for fresh queries when continue:true finds no existing sessions, and when explicit sessionId does not resolve to a valid transcript file - Add getMcpClients/setMcpClients to QueryEngine for SDK cleanup access * fix(sdk): resolve remaining TypeScript errors in SDK modules - Fix PermissionDecision type compatibility: import from types/permissions and cast PermissionResolveDecision to PermissionDecision properly - Fix AsyncIterator/AsyncGenerator: async generators must return AsyncGenerator (which implements AsyncIterable), not AsyncIterator - Fix Map method callable errors: cast additionalWorkingDirectories to Map<string, unknown> before calling .set() and .keys() - Fix ApiKeySource type: map internal ApiKeySource to SDK's narrower type using conversion function, spread info before apiKeySource to avoid override - Fix MCP config scope type: cast 'session' scope to ScopedMcpServerConfig for connectToServer compatibility - Add PermissionMode import and cast for decisionReason.mode - Deny pending permissions in interrupt(): resolve all pending promises with deny before clearing the map (both query.ts and v2.ts) * fix(sdk): correct init skip logic and test mocks - query.ts: skip init() entirely for injected engines (mocks, SDK host overrides) instead of calling init() and swallowing errors. Pass { injected: false } from query() factory to distinguish real engine from test mocks. - mock-engine.ts: add getMcpClients() and setMcpClients() methods to match QueryEngine API added in this PR. - permissions.test.ts: use filterToolsByDenyRules instead of getTools for disallowedTools tests, with proper base tool fixtures. * fix: address code review feedback for exports and build script package.json exports (Breaking Change Mitigation): - Add "./package.json": "./package.json" for tool compatibility - Add "./dist/cli.mjs": "./dist/cli.mjs" for CLI bundle access - Keep ./sdk as sole library entrypoint - Root import intentionally blocked (CLI-first package, no main field) build.ts (Bug Fix): - Add | undefined to result/sdkResult type declarations - Add optional chaining: result?.success, sdkResult?.success - Prevents TypeError masking actual build errors when Bun.build throws tests/sdk/package-consumer-types.test.ts: - Update simulated exports to match real package.json - Add tests verifying exports map structure and file existence --------- Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
1431 lines
51 KiB
TypeScript
1431 lines
51 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
|
import { randomUUID } from 'crypto'
|
|
import last from 'lodash-es/last.js'
|
|
import {
|
|
getSessionId,
|
|
isSessionPersistenceDisabled,
|
|
} from 'src/bootstrap/state.js'
|
|
import type {
|
|
PermissionMode,
|
|
SDKCompactBoundaryMessage,
|
|
SDKMessage,
|
|
SDKPermissionDenial,
|
|
SDKStatus,
|
|
SDKUserMessageReplay,
|
|
} from 'src/entrypoints/agentSdkTypes.js'
|
|
import { accumulateUsage, updateUsage } from 'src/services/api/claude.js'
|
|
import type { NonNullableUsage } from 'src/services/api/logging.js'
|
|
import { EMPTY_USAGE } from 'src/services/api/logging.js'
|
|
import stripAnsi from 'strip-ansi'
|
|
import type { Command } from './commands.js'
|
|
import { getSlashCommandToolSkills } from './commands.js'
|
|
import {
|
|
LOCAL_COMMAND_STDERR_TAG,
|
|
LOCAL_COMMAND_STDOUT_TAG,
|
|
} from './constants/xml.js'
|
|
import {
|
|
getModelUsage,
|
|
getTotalAPIDuration,
|
|
getTotalCost,
|
|
} from './cost-tracker.js'
|
|
import type { CanUseToolFn } from './hooks/useCanUseTool.js'
|
|
import { loadMemoryPrompt } from './memdir/memdir.js'
|
|
import { hasAutoMemPathOverride } from './memdir/paths.js'
|
|
import { query } from './query.js'
|
|
import { categorizeRetryableAPIError } from './services/api/errors.js'
|
|
import type { MCPServerConnection } from './services/mcp/types.js'
|
|
import type { AppState } from './state/AppState.js'
|
|
import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
|
|
import type { AgentDefinition } from './tools/AgentTool/loadAgentsDir.js'
|
|
import { SYNTHETIC_OUTPUT_TOOL_NAME } from './tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
|
import type { Message } from './types/message.js'
|
|
import type { OrphanedPermission } from './types/textInputTypes.js'
|
|
import { createAbortController } from './utils/abortController.js'
|
|
import { validateArrayOf, assertNonEmptyString, assertObject, assertFunction } from './utils/validation.js'
|
|
import { invalidateRemovedToolSchemas } from './utils/toolSchemaCache.js'
|
|
import type { AttributionState } from './utils/commitAttribution.js'
|
|
import { getGlobalConfig } from './utils/config.js'
|
|
import { getCwd } from './utils/cwd.js'
|
|
import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
|
|
import { logForDebugging } from './utils/debug.js'
|
|
import { getFastModeState } from './utils/fastMode.js'
|
|
import {
|
|
type FileHistoryState,
|
|
fileHistoryEnabled,
|
|
fileHistoryMakeSnapshot,
|
|
} from './utils/fileHistory.js'
|
|
import {
|
|
cloneFileStateCache,
|
|
type FileStateCache,
|
|
} from './utils/fileStateCache.js'
|
|
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
|
|
import { registerStructuredOutputEnforcement } from './utils/hooks/hookHelpers.js'
|
|
import { getInMemoryErrors } from './utils/log.js'
|
|
import { countToolCalls, SYNTHETIC_MESSAGES } from './utils/messages.js'
|
|
import {
|
|
getMainLoopModel,
|
|
parseUserSpecifiedModel,
|
|
} from './utils/model/model.js'
|
|
import { loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js'
|
|
import {
|
|
type ProcessUserInputContext,
|
|
processUserInput,
|
|
} from './utils/processUserInput/processUserInput.js'
|
|
import { fetchSystemPromptParts } from './utils/queryContext.js'
|
|
import { setCwd } from './utils/Shell.js'
|
|
import {
|
|
flushSessionStorage,
|
|
recordTranscript,
|
|
} from './utils/sessionStorage.js'
|
|
import { asSystemPrompt } from './utils/systemPromptType.js'
|
|
import { resolveThemeSetting } from './utils/systemTheme.js'
|
|
import {
|
|
shouldEnableThinkingByDefault,
|
|
type ThinkingConfig,
|
|
} from './utils/thinking.js'
|
|
import { selectableUserMessagesFilter } from './utils/messageFilters.js'
|
|
|
|
import {
|
|
localCommandOutputToSDKAssistantMessage,
|
|
toSDKCompactMetadata,
|
|
} from './utils/messages/mappers.js'
|
|
import {
|
|
buildSystemInitMessage,
|
|
sdkCompatToolName,
|
|
} from './utils/messages/systemInit.js'
|
|
import {
|
|
getScratchpadDir,
|
|
isScratchpadEnabled,
|
|
} from './utils/permissions/filesystem.js'
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
import {
|
|
handleOrphanedPermission,
|
|
isResultSuccessful,
|
|
normalizeMessage,
|
|
} from './utils/queryHelpers.js'
|
|
|
|
// Dead code elimination: conditional import for coordinator mode
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
const getCoordinatorUserContext: (
|
|
mcpClients: ReadonlyArray<{ name: string }>,
|
|
scratchpadDir?: string,
|
|
) => { [k: string]: string } = feature('COORDINATOR_MODE')
|
|
? require('./coordinator/coordinatorMode.js').getCoordinatorUserContext
|
|
: () => ({})
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
|
|
// Dead code elimination: conditional import for snip compaction
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
const snipModule = feature('HISTORY_SNIP')
|
|
? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js'))
|
|
: null
|
|
const snipProjection = feature('HISTORY_SNIP')
|
|
? (require('./services/compact/snipProjection.js') as typeof import('./services/compact/snipProjection.js'))
|
|
: null
|
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
|
|
export type QueryEngineConfig = {
|
|
cwd: string
|
|
tools: Tools
|
|
commands: Command[]
|
|
mcpClients: MCPServerConnection[]
|
|
agents: AgentDefinition[]
|
|
canUseTool: CanUseToolFn
|
|
getAppState: () => AppState
|
|
setAppState: (f: (prev: AppState) => AppState) => void
|
|
initialMessages?: Message[]
|
|
readFileCache: FileStateCache
|
|
customSystemPrompt?: string
|
|
appendSystemPrompt?: string
|
|
userSpecifiedModel?: string
|
|
fallbackModel?: string
|
|
thinkingConfig?: ThinkingConfig
|
|
maxTurns?: number
|
|
maxBudgetUsd?: number
|
|
taskBudget?: { total: number }
|
|
jsonSchema?: Record<string, unknown>
|
|
verbose?: boolean
|
|
replayUserMessages?: boolean
|
|
/** Handler for URL elicitations triggered by MCP tool -32042 errors. */
|
|
handleElicitation?: ToolUseContext['handleElicitation']
|
|
includePartialMessages?: boolean
|
|
setSDKStatus?: (status: SDKStatus) => void
|
|
abortController?: AbortController
|
|
orphanedPermission?: OrphanedPermission
|
|
/**
|
|
* Snip-boundary handler: receives each yielded system message plus the
|
|
* current mutableMessages store. Returns undefined if the message is not a
|
|
* snip boundary; otherwise returns the replayed snip result. Injected by
|
|
* ask() when HISTORY_SNIP is enabled so feature-gated strings stay inside
|
|
* the gated module (keeps QueryEngine free of excluded strings and testable
|
|
* despite feature() returning false under bun test). SDK-only: the REPL
|
|
* keeps full history for UI scrollback and projects on demand via
|
|
* projectSnippedView; QueryEngine truncates here to bound memory in long
|
|
* headless sessions (no UI to preserve).
|
|
*/
|
|
snipReplay?: (
|
|
yieldedSystemMsg: Message,
|
|
store: Message[],
|
|
) => { messages: Message[]; executed: boolean } | undefined
|
|
}
|
|
|
|
/**
|
|
* QueryEngine owns the query lifecycle and session state for a conversation.
|
|
* It extracts the core logic from ask() into a standalone class that can be
|
|
* used by both the headless/SDK path and (in a future phase) the REPL.
|
|
*
|
|
* One QueryEngine per conversation. Each submitMessage() call starts a new
|
|
* turn within the same conversation. State (messages, file cache, usage, etc.)
|
|
* persists across turns.
|
|
*/
|
|
export class QueryEngine {
|
|
private config: QueryEngineConfig
|
|
private mutableMessages: Message[]
|
|
private abortController: AbortController
|
|
private permissionDenials: SDKPermissionDenial[]
|
|
private totalUsage: NonNullableUsage
|
|
private hasHandledOrphanedPermission = false
|
|
private readFileState: FileStateCache
|
|
// Turn-scoped skill discovery tracking (feeds was_discovered on
|
|
// tengu_skill_tool_invocation). Must persist across the two
|
|
// processUserInputContext rebuilds inside submitMessage, but is cleared
|
|
// at the start of each submitMessage to avoid unbounded growth across
|
|
// many turns in SDK mode.
|
|
private discoveredSkillNames = new Set<string>()
|
|
private loadedNestedMemoryPaths = new Set<string>()
|
|
|
|
constructor(config: QueryEngineConfig) {
|
|
this.config = config
|
|
this.mutableMessages = config.initialMessages ?? []
|
|
this.abortController = config.abortController ?? createAbortController()
|
|
this.permissionDenials = []
|
|
this.readFileState = config.readFileCache
|
|
this.totalUsage = EMPTY_USAGE
|
|
}
|
|
|
|
async *submitMessage(
|
|
prompt: string | ContentBlockParam[],
|
|
options?: { uuid?: string; isMeta?: boolean },
|
|
): AsyncGenerator<SDKMessage, void, unknown> {
|
|
const {
|
|
cwd,
|
|
commands,
|
|
tools,
|
|
mcpClients,
|
|
verbose = false,
|
|
thinkingConfig,
|
|
maxTurns,
|
|
maxBudgetUsd,
|
|
taskBudget,
|
|
canUseTool,
|
|
customSystemPrompt,
|
|
appendSystemPrompt,
|
|
userSpecifiedModel,
|
|
fallbackModel,
|
|
jsonSchema,
|
|
getAppState,
|
|
setAppState,
|
|
replayUserMessages = false,
|
|
includePartialMessages = false,
|
|
agents = [],
|
|
setSDKStatus,
|
|
orphanedPermission,
|
|
} = this.config
|
|
|
|
this.discoveredSkillNames.clear()
|
|
setCwd(cwd)
|
|
const persistSession = !isSessionPersistenceDisabled()
|
|
const startTime = Date.now()
|
|
|
|
// Wrap canUseTool to track permission denials
|
|
const wrappedCanUseTool: CanUseToolFn = async (
|
|
tool,
|
|
input,
|
|
toolUseContext,
|
|
assistantMessage,
|
|
toolUseID,
|
|
forceDecision,
|
|
) => {
|
|
const result = await canUseTool(
|
|
tool,
|
|
input,
|
|
toolUseContext,
|
|
assistantMessage,
|
|
toolUseID,
|
|
forceDecision,
|
|
)
|
|
|
|
// Track denials for SDK reporting
|
|
if (result.behavior !== 'allow') {
|
|
this.permissionDenials.push({
|
|
tool_name: sdkCompatToolName(tool.name),
|
|
tool_use_id: toolUseID,
|
|
tool_input: input,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
const initialAppState = getAppState()
|
|
const initialMainLoopModel = userSpecifiedModel
|
|
? parseUserSpecifiedModel(userSpecifiedModel)
|
|
: getMainLoopModel()
|
|
|
|
const initialThinkingConfig: ThinkingConfig = thinkingConfig
|
|
? thinkingConfig
|
|
: shouldEnableThinkingByDefault() !== false
|
|
? { type: 'adaptive' }
|
|
: { type: 'disabled' }
|
|
|
|
headlessProfilerCheckpoint('before_getSystemPrompt')
|
|
// Narrow once so TS tracks the type through the conditionals below.
|
|
const customPrompt =
|
|
typeof customSystemPrompt === 'string' ? customSystemPrompt : undefined
|
|
const {
|
|
defaultSystemPrompt,
|
|
userContext: baseUserContext,
|
|
systemContext,
|
|
} = await fetchSystemPromptParts({
|
|
tools,
|
|
mainLoopModel: initialMainLoopModel,
|
|
additionalWorkingDirectories: Array.from(
|
|
initialAppState.toolPermissionContext.additionalWorkingDirectories.keys(),
|
|
),
|
|
mcpClients,
|
|
customSystemPrompt: customPrompt,
|
|
})
|
|
headlessProfilerCheckpoint('after_getSystemPrompt')
|
|
const userContext = {
|
|
...baseUserContext,
|
|
...getCoordinatorUserContext(
|
|
mcpClients,
|
|
isScratchpadEnabled() ? getScratchpadDir() : undefined,
|
|
),
|
|
}
|
|
|
|
// When an SDK caller provides a custom system prompt AND has set
|
|
// CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, inject the memory-mechanics prompt.
|
|
// The env var is an explicit opt-in signal — the caller has wired up
|
|
// a memory directory and needs Claude to know how to use it (which
|
|
// Write/Edit tools to call, MEMORY.md filename, loading semantics).
|
|
// The caller can layer their own policy text via appendSystemPrompt.
|
|
const memoryMechanicsPrompt =
|
|
customPrompt !== undefined && hasAutoMemPathOverride()
|
|
? await loadMemoryPrompt()
|
|
: null
|
|
|
|
const systemPrompt = asSystemPrompt([
|
|
...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt),
|
|
...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),
|
|
...(appendSystemPrompt ? [appendSystemPrompt] : []),
|
|
])
|
|
|
|
// Register function hook for structured output enforcement
|
|
const hasStructuredOutputTool = tools.some(t =>
|
|
toolMatchesName(t, SYNTHETIC_OUTPUT_TOOL_NAME),
|
|
)
|
|
if (jsonSchema && hasStructuredOutputTool) {
|
|
registerStructuredOutputEnforcement(setAppState, getSessionId())
|
|
}
|
|
|
|
let processUserInputContext: ProcessUserInputContext = {
|
|
messages: this.mutableMessages,
|
|
// Slash commands that mutate the message array (e.g. /force-snip)
|
|
// call setMessages(fn). In interactive mode this writes back to
|
|
// AppState; in print mode we write back to mutableMessages so the
|
|
// rest of the query loop (push at :389, snapshot at :392) sees
|
|
// the result. The second processUserInputContext below (after
|
|
// slash-command processing) keeps the no-op — nothing else calls
|
|
// setMessages past that point.
|
|
setMessages: fn => {
|
|
this.mutableMessages = fn(this.mutableMessages)
|
|
},
|
|
onChangeAPIKey: () => {},
|
|
handleElicitation: this.config.handleElicitation,
|
|
options: {
|
|
commands,
|
|
debug: false, // we use stdout, so don't want to clobber it
|
|
tools,
|
|
verbose,
|
|
mainLoopModel: initialMainLoopModel,
|
|
thinkingConfig: initialThinkingConfig,
|
|
mcpClients,
|
|
mcpResources: {},
|
|
ideInstallationStatus: null,
|
|
isNonInteractiveSession: true,
|
|
customSystemPrompt,
|
|
appendSystemPrompt,
|
|
agentDefinitions: { activeAgents: agents, allAgents: agents },
|
|
theme: resolveThemeSetting(getGlobalConfig().theme),
|
|
maxBudgetUsd,
|
|
},
|
|
getAppState,
|
|
setAppState,
|
|
abortController: this.abortController,
|
|
readFileState: this.readFileState,
|
|
nestedMemoryAttachmentTriggers: new Set<string>(),
|
|
loadedNestedMemoryPaths: this.loadedNestedMemoryPaths,
|
|
dynamicSkillDirTriggers: new Set<string>(),
|
|
discoveredSkillNames: this.discoveredSkillNames,
|
|
setInProgressToolUseIDs: () => {},
|
|
setResponseLength: () => {},
|
|
updateFileHistoryState: (
|
|
updater: (prev: FileHistoryState) => FileHistoryState,
|
|
) => {
|
|
setAppState(prev => {
|
|
const updated = updater(prev.fileHistory)
|
|
if (updated === prev.fileHistory) return prev
|
|
return { ...prev, fileHistory: updated }
|
|
})
|
|
},
|
|
updateAttributionState: (
|
|
updater: (prev: AttributionState) => AttributionState,
|
|
) => {
|
|
setAppState(prev => {
|
|
const updated = updater(prev.attribution)
|
|
if (updated === prev.attribution) return prev
|
|
return { ...prev, attribution: updated }
|
|
})
|
|
},
|
|
setSDKStatus,
|
|
}
|
|
|
|
// Handle orphaned permission (only once per engine lifetime)
|
|
if (orphanedPermission && !this.hasHandledOrphanedPermission) {
|
|
this.hasHandledOrphanedPermission = true
|
|
for await (const message of handleOrphanedPermission(
|
|
orphanedPermission,
|
|
tools,
|
|
this.mutableMessages,
|
|
processUserInputContext,
|
|
)) {
|
|
yield message
|
|
}
|
|
}
|
|
|
|
const {
|
|
messages: messagesFromUserInput,
|
|
shouldQuery,
|
|
allowedTools,
|
|
model: modelFromUserInput,
|
|
resultText,
|
|
} = await processUserInput({
|
|
input: prompt,
|
|
mode: 'prompt',
|
|
setToolJSX: () => {},
|
|
context: {
|
|
...processUserInputContext,
|
|
messages: this.mutableMessages,
|
|
},
|
|
messages: this.mutableMessages,
|
|
uuid: options?.uuid,
|
|
isMeta: options?.isMeta,
|
|
querySource: 'sdk',
|
|
})
|
|
|
|
// Push new messages, including user input and any attachments
|
|
this.mutableMessages.push(...messagesFromUserInput)
|
|
|
|
// Update params to reflect updates from processing /slash commands
|
|
const messages = [...this.mutableMessages]
|
|
|
|
// Persist the user's message(s) to transcript BEFORE entering the query
|
|
// loop. The for-await below only calls recordTranscript when ask() yields
|
|
// an assistant/user/compact_boundary message — which doesn't happen until
|
|
// the API responds. If the process is killed before that (e.g. user clicks
|
|
// Stop in cowork seconds after send), the transcript is left with only
|
|
// queue-operation entries; getLastSessionLog filters those out, returns
|
|
// null, and --resume fails with "No conversation found". Writing now makes
|
|
// the transcript resumable from the point the user message was accepted,
|
|
// even if no API response ever arrives.
|
|
//
|
|
// --bare / SIMPLE: fire-and-forget. Scripted calls don't --resume after
|
|
// kill-mid-request. The await is ~4ms on SSD, ~30ms under disk contention
|
|
// — the single largest controllable critical-path cost after module eval.
|
|
// Transcript is still written (for post-hoc debugging); just not blocking.
|
|
if (persistSession && messagesFromUserInput.length > 0) {
|
|
const transcriptPromise = recordTranscript(messages)
|
|
if (isBareMode()) {
|
|
void transcriptPromise
|
|
} else {
|
|
await transcriptPromise
|
|
if (
|
|
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
|
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
|
) {
|
|
await flushSessionStorage()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter messages that should be acknowledged after transcript
|
|
const replayableMessages = messagesFromUserInput.filter(
|
|
msg =>
|
|
(msg.type === 'user' &&
|
|
!msg.isMeta && // Skip synthetic caveat messages
|
|
!msg.toolUseResult && // Skip tool results (they'll be acked from query)
|
|
selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.)
|
|
(msg.type === 'system' && msg.subtype === 'compact_boundary'), // Always ack compact boundaries
|
|
)
|
|
const messagesToAck = replayUserMessages ? replayableMessages : []
|
|
|
|
// Update the ToolPermissionContext based on user input processing (as necessary)
|
|
setAppState(prev => ({
|
|
...prev,
|
|
toolPermissionContext: {
|
|
...prev.toolPermissionContext,
|
|
alwaysAllowRules: {
|
|
...prev.toolPermissionContext.alwaysAllowRules,
|
|
command: allowedTools,
|
|
},
|
|
},
|
|
}))
|
|
|
|
const mainLoopModel = modelFromUserInput ?? initialMainLoopModel
|
|
|
|
// Recreate after processing the prompt to pick up updated messages and
|
|
// model (from slash commands).
|
|
processUserInputContext = {
|
|
messages,
|
|
setMessages: () => {},
|
|
onChangeAPIKey: () => {},
|
|
handleElicitation: this.config.handleElicitation,
|
|
options: {
|
|
commands,
|
|
debug: false,
|
|
tools,
|
|
verbose,
|
|
mainLoopModel,
|
|
thinkingConfig: initialThinkingConfig,
|
|
mcpClients,
|
|
mcpResources: {},
|
|
ideInstallationStatus: null,
|
|
isNonInteractiveSession: true,
|
|
customSystemPrompt,
|
|
appendSystemPrompt,
|
|
theme: resolveThemeSetting(getGlobalConfig().theme),
|
|
agentDefinitions: { activeAgents: agents, allAgents: agents },
|
|
maxBudgetUsd,
|
|
},
|
|
getAppState,
|
|
setAppState,
|
|
abortController: this.abortController,
|
|
readFileState: this.readFileState,
|
|
nestedMemoryAttachmentTriggers: new Set<string>(),
|
|
loadedNestedMemoryPaths: this.loadedNestedMemoryPaths,
|
|
dynamicSkillDirTriggers: new Set<string>(),
|
|
discoveredSkillNames: this.discoveredSkillNames,
|
|
setInProgressToolUseIDs: () => {},
|
|
setResponseLength: () => {},
|
|
updateFileHistoryState: processUserInputContext.updateFileHistoryState,
|
|
updateAttributionState: processUserInputContext.updateAttributionState,
|
|
setSDKStatus,
|
|
}
|
|
|
|
headlessProfilerCheckpoint('before_skills_plugins')
|
|
// Cache-only: headless/SDK/CCR startup must not block on network for
|
|
// ref-tracked plugins. CCR populates the cache via CLAUDE_CODE_SYNC_PLUGIN_INSTALL
|
|
// (headlessPluginInstall) or CLAUDE_CODE_PLUGIN_SEED_DIR before this runs;
|
|
// SDK callers that need fresh source can call /reload-plugins.
|
|
const [skills, { enabled: enabledPlugins }] = await Promise.all([
|
|
getSlashCommandToolSkills(getCwd()),
|
|
loadAllPluginsCacheOnly(),
|
|
])
|
|
headlessProfilerCheckpoint('after_skills_plugins')
|
|
|
|
yield buildSystemInitMessage({
|
|
tools,
|
|
mcpClients,
|
|
model: mainLoopModel,
|
|
permissionMode: initialAppState.toolPermissionContext
|
|
.mode as PermissionMode, // TODO: avoid the cast
|
|
commands,
|
|
agents,
|
|
skills,
|
|
plugins: enabledPlugins,
|
|
fastMode: initialAppState.fastMode,
|
|
})
|
|
|
|
// Record when system message is yielded for headless latency tracking
|
|
headlessProfilerCheckpoint('system_message_yielded')
|
|
|
|
if (!shouldQuery) {
|
|
// Return the results of local slash commands.
|
|
// Use messagesFromUserInput (not replayableMessages) for command output
|
|
// because selectableUserMessagesFilter excludes local-command-stdout tags.
|
|
for (const msg of messagesFromUserInput) {
|
|
if (
|
|
msg.type === 'user' &&
|
|
typeof msg.message.content === 'string' &&
|
|
(msg.message.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ||
|
|
msg.message.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`) ||
|
|
msg.isCompactSummary)
|
|
) {
|
|
yield {
|
|
type: 'user',
|
|
message: {
|
|
...msg.message,
|
|
content: stripAnsi(msg.message.content),
|
|
},
|
|
session_id: getSessionId(),
|
|
parent_tool_use_id: null,
|
|
uuid: msg.uuid,
|
|
timestamp: msg.timestamp,
|
|
isReplay: !msg.isCompactSummary,
|
|
isSynthetic: msg.isMeta || msg.isVisibleInTranscriptOnly,
|
|
} as SDKUserMessageReplay
|
|
}
|
|
|
|
// Local command output — yield as a synthetic assistant message so
|
|
// RC renders it as assistant-style text rather than a user bubble.
|
|
// Emitted as assistant (not the dedicated SDKLocalCommandOutputMessage
|
|
// system subtype) so mobile clients + session-ingress can parse it.
|
|
if (
|
|
msg.type === 'system' &&
|
|
msg.subtype === 'local_command' &&
|
|
typeof msg.content === 'string' &&
|
|
(msg.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ||
|
|
msg.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`))
|
|
) {
|
|
yield localCommandOutputToSDKAssistantMessage(msg.content, msg.uuid)
|
|
}
|
|
|
|
if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
|
|
yield {
|
|
type: 'system',
|
|
subtype: 'compact_boundary' as const,
|
|
session_id: getSessionId(),
|
|
uuid: msg.uuid,
|
|
compact_metadata: toSDKCompactMetadata(msg.compactMetadata),
|
|
} as SDKCompactBoundaryMessage
|
|
}
|
|
}
|
|
|
|
if (persistSession) {
|
|
await recordTranscript(messages)
|
|
if (
|
|
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
|
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
|
) {
|
|
await flushSessionStorage()
|
|
}
|
|
}
|
|
|
|
yield {
|
|
type: 'result',
|
|
subtype: 'success',
|
|
is_error: false,
|
|
duration_ms: Date.now() - startTime,
|
|
duration_api_ms: getTotalAPIDuration(),
|
|
num_turns: messages.length - 1,
|
|
result: resultText ?? '',
|
|
stop_reason: null,
|
|
session_id: getSessionId(),
|
|
total_cost_usd: getTotalCost(),
|
|
usage: this.totalUsage,
|
|
modelUsage: getModelUsage(),
|
|
permission_denials: this.permissionDenials,
|
|
fast_mode_state: getFastModeState(
|
|
mainLoopModel,
|
|
initialAppState.fastMode,
|
|
),
|
|
uuid: randomUUID(),
|
|
}
|
|
return
|
|
}
|
|
|
|
if (fileHistoryEnabled() && persistSession) {
|
|
messagesFromUserInput
|
|
.filter(selectableUserMessagesFilter)
|
|
.forEach(message => {
|
|
void fileHistoryMakeSnapshot(
|
|
(updater: (prev: FileHistoryState) => FileHistoryState) => {
|
|
setAppState(prev => ({
|
|
...prev,
|
|
fileHistory: updater(prev.fileHistory),
|
|
}))
|
|
},
|
|
message.uuid,
|
|
)
|
|
})
|
|
}
|
|
|
|
// Track current message usage (reset on each message_start)
|
|
let currentMessageUsage: NonNullableUsage = EMPTY_USAGE
|
|
let turnCount = 1
|
|
let hasAcknowledgedInitialMessages = false
|
|
// Track structured output from StructuredOutput tool calls
|
|
let structuredOutputFromTool: unknown
|
|
// Track the last stop_reason from assistant messages
|
|
let lastStopReason: string | null = null
|
|
// Reference-based watermark so error_during_execution's errors[] is
|
|
// turn-scoped. A length-based index breaks when the 100-entry ring buffer
|
|
// shift()s during the turn — the index slides. If this entry is rotated
|
|
// out, lastIndexOf returns -1 and we include everything (safe fallback).
|
|
const errorLogWatermark = getInMemoryErrors().at(-1)
|
|
// Snapshot count before this query for delta-based retry limiting
|
|
const initialStructuredOutputCalls = jsonSchema
|
|
? countToolCalls(this.mutableMessages, SYNTHETIC_OUTPUT_TOOL_NAME)
|
|
: 0
|
|
|
|
for await (const message of query({
|
|
messages,
|
|
systemPrompt,
|
|
userContext,
|
|
systemContext,
|
|
canUseTool: wrappedCanUseTool,
|
|
toolUseContext: processUserInputContext,
|
|
fallbackModel,
|
|
querySource: 'sdk',
|
|
maxTurns,
|
|
taskBudget,
|
|
})) {
|
|
// Record assistant, user, and compact boundary messages
|
|
if (
|
|
message.type === 'assistant' ||
|
|
message.type === 'user' ||
|
|
(message.type === 'system' && message.subtype === 'compact_boundary')
|
|
) {
|
|
// Before writing a compact boundary, flush any in-memory-only
|
|
// messages up through the preservedSegment tail. Attachments and
|
|
// progress are now recorded inline (their switch cases below), but
|
|
// this flush still matters for the preservedSegment tail walk.
|
|
// If the SDK subprocess restarts before then (claude-desktop kills
|
|
// between turns), tailUuid can point to a never-written message. In
|
|
// that case strip preservedSegment before transcript persistence so
|
|
// resume falls back to ordinary boundary pruning instead of relying on
|
|
// broken relink metadata.
|
|
let transcriptMessage = message
|
|
if (
|
|
persistSession &&
|
|
message.type === 'system' &&
|
|
message.subtype === 'compact_boundary'
|
|
) {
|
|
const tailUuid = message.compactMetadata?.preservedSegment?.tailUuid
|
|
if (tailUuid) {
|
|
const tailIdx = this.mutableMessages.findLastIndex(
|
|
m => m.uuid === tailUuid,
|
|
)
|
|
if (tailIdx !== -1) {
|
|
await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
|
|
} else {
|
|
transcriptMessage = {
|
|
...message,
|
|
compactMetadata: {
|
|
...message.compactMetadata,
|
|
preservedSegment: undefined,
|
|
},
|
|
}
|
|
logForDebugging(
|
|
`[QueryEngine] stripped preservedSegment before transcript write; missing tail ${tailUuid}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
messages.push(transcriptMessage)
|
|
if (persistSession) {
|
|
// Fire-and-forget for assistant messages. claude.ts yields one
|
|
// assistant message per content block, then mutates the last
|
|
// one's message.usage/stop_reason on message_delta — relying on
|
|
// the write queue's 100ms lazy jsonStringify. Awaiting here
|
|
// blocks ask()'s generator, so message_delta can't run until
|
|
// every block is consumed; the drain timer (started at block 1)
|
|
// elapses first. Interactive CC doesn't hit this because
|
|
// useLogMessages.ts fire-and-forgets. enqueueWrite is
|
|
// order-preserving so fire-and-forget here is safe.
|
|
if (message.type === 'assistant') {
|
|
void recordTranscript(messages)
|
|
} else {
|
|
await recordTranscript(messages)
|
|
}
|
|
}
|
|
|
|
// Acknowledge initial user messages after first transcript recording
|
|
if (!hasAcknowledgedInitialMessages && messagesToAck.length > 0) {
|
|
hasAcknowledgedInitialMessages = true
|
|
for (const msgToAck of messagesToAck) {
|
|
if (msgToAck.type === 'user') {
|
|
yield {
|
|
type: 'user',
|
|
message: msgToAck.message,
|
|
session_id: getSessionId(),
|
|
parent_tool_use_id: null,
|
|
uuid: msgToAck.uuid,
|
|
timestamp: msgToAck.timestamp,
|
|
isReplay: true,
|
|
} as SDKUserMessageReplay
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (message.type === 'user') {
|
|
turnCount++
|
|
}
|
|
|
|
switch (message.type) {
|
|
case 'tombstone':
|
|
// Tombstone messages are control signals for removing messages, skip them
|
|
break
|
|
case 'assistant':
|
|
// Capture stop_reason if already set (synthetic messages). For
|
|
// streamed responses, this is null at content_block_stop time;
|
|
// the real value arrives via message_delta (handled below).
|
|
if (message.message.stop_reason != null) {
|
|
lastStopReason = message.message.stop_reason
|
|
}
|
|
this.mutableMessages.push(message)
|
|
yield* normalizeMessage(message)
|
|
break
|
|
case 'progress':
|
|
this.mutableMessages.push(message)
|
|
// Record inline so the dedup loop in the next ask() call sees it
|
|
// as already-recorded. Without this, deferred progress interleaves
|
|
// with already-recorded tool_results in mutableMessages, and the
|
|
// dedup walk freezes startingParentUuid at the wrong message —
|
|
// forking the chain and orphaning the conversation on resume.
|
|
if (persistSession) {
|
|
messages.push(message)
|
|
void recordTranscript(messages)
|
|
}
|
|
yield* normalizeMessage(message)
|
|
break
|
|
case 'user':
|
|
this.mutableMessages.push(message)
|
|
yield* normalizeMessage(message)
|
|
break
|
|
case 'stream_event':
|
|
if (message.event.type === 'message_start') {
|
|
// Reset current message usage for new message
|
|
currentMessageUsage = EMPTY_USAGE
|
|
currentMessageUsage = updateUsage(
|
|
currentMessageUsage,
|
|
message.event.message.usage,
|
|
)
|
|
}
|
|
if (message.event.type === 'message_delta') {
|
|
currentMessageUsage = updateUsage(
|
|
currentMessageUsage,
|
|
message.event.usage,
|
|
)
|
|
// Capture stop_reason from message_delta. The assistant message
|
|
// is yielded at content_block_stop with stop_reason=null; the
|
|
// real value only arrives here (see claude.ts message_delta
|
|
// handler). Without this, result.stop_reason is always null.
|
|
if (message.event.delta.stop_reason != null) {
|
|
lastStopReason = message.event.delta.stop_reason
|
|
}
|
|
}
|
|
if (message.event.type === 'message_stop') {
|
|
// Accumulate current message usage into total
|
|
this.totalUsage = accumulateUsage(
|
|
this.totalUsage,
|
|
currentMessageUsage,
|
|
)
|
|
}
|
|
|
|
if (includePartialMessages) {
|
|
yield {
|
|
type: 'stream_event' as const,
|
|
event: message.event,
|
|
session_id: getSessionId(),
|
|
parent_tool_use_id: null,
|
|
uuid: randomUUID(),
|
|
}
|
|
}
|
|
|
|
break
|
|
case 'attachment':
|
|
this.mutableMessages.push(message)
|
|
// Record inline (same reason as progress above).
|
|
if (persistSession) {
|
|
messages.push(message)
|
|
void recordTranscript(messages)
|
|
}
|
|
|
|
// Extract structured output from StructuredOutput tool calls
|
|
if (message.attachment.type === 'structured_output') {
|
|
structuredOutputFromTool = message.attachment.data
|
|
}
|
|
// Handle max turns reached signal from query.ts
|
|
else if (message.attachment.type === 'max_turns_reached') {
|
|
if (persistSession) {
|
|
if (
|
|
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
|
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
|
) {
|
|
await flushSessionStorage()
|
|
}
|
|
}
|
|
yield {
|
|
type: 'result',
|
|
subtype: 'error_max_turns',
|
|
duration_ms: Date.now() - startTime,
|
|
duration_api_ms: getTotalAPIDuration(),
|
|
is_error: true,
|
|
num_turns: message.attachment.turnCount,
|
|
stop_reason: lastStopReason,
|
|
session_id: getSessionId(),
|
|
total_cost_usd: getTotalCost(),
|
|
usage: this.totalUsage,
|
|
modelUsage: getModelUsage(),
|
|
permission_denials: this.permissionDenials,
|
|
fast_mode_state: getFastModeState(
|
|
mainLoopModel,
|
|
initialAppState.fastMode,
|
|
),
|
|
uuid: randomUUID(),
|
|
errors: [
|
|
`Reached maximum number of turns (${message.attachment.maxTurns})`,
|
|
],
|
|
}
|
|
return
|
|
}
|
|
// Yield queued_command attachments as SDK user message replays
|
|
else if (
|
|
replayUserMessages &&
|
|
message.attachment.type === 'queued_command'
|
|
) {
|
|
yield {
|
|
type: 'user',
|
|
message: {
|
|
role: 'user' as const,
|
|
content: message.attachment.prompt,
|
|
},
|
|
session_id: getSessionId(),
|
|
parent_tool_use_id: null,
|
|
uuid: message.attachment.source_uuid || message.uuid,
|
|
timestamp: message.timestamp,
|
|
isReplay: true,
|
|
} as SDKUserMessageReplay
|
|
}
|
|
break
|
|
case 'stream_request_start':
|
|
// Don't yield stream request start messages
|
|
break
|
|
case 'system': {
|
|
// Snip boundary: replay on our store to remove zombie messages and
|
|
// stale markers. The yielded boundary is a signal, not data to push —
|
|
// the replay produces its own equivalent boundary. Without this,
|
|
// markers persist and re-trigger on every turn, and mutableMessages
|
|
// never shrinks (memory leak in long SDK sessions). The subtype
|
|
// check lives inside the injected callback so feature-gated strings
|
|
// stay out of this file (excluded-strings check).
|
|
const snipResult = this.config.snipReplay?.(
|
|
message,
|
|
this.mutableMessages,
|
|
)
|
|
if (snipResult !== undefined) {
|
|
if (snipResult.executed) {
|
|
this.mutableMessages.length = 0
|
|
this.mutableMessages.push(...snipResult.messages)
|
|
}
|
|
break
|
|
}
|
|
this.mutableMessages.push(message)
|
|
// Yield compact boundary messages to SDK
|
|
if (
|
|
message.subtype === 'compact_boundary' &&
|
|
message.compactMetadata
|
|
) {
|
|
// Release pre-compaction messages for GC. The boundary was just
|
|
// pushed so it's the last element. query.ts already uses
|
|
// getMessagesAfterCompactBoundary() internally, so only
|
|
// post-boundary messages are needed going forward.
|
|
const mutableBoundaryIdx = this.mutableMessages.length - 1
|
|
if (mutableBoundaryIdx > 0) {
|
|
this.mutableMessages.splice(0, mutableBoundaryIdx)
|
|
}
|
|
const localBoundaryIdx = messages.length - 1
|
|
if (localBoundaryIdx > 0) {
|
|
messages.splice(0, localBoundaryIdx)
|
|
}
|
|
|
|
yield {
|
|
type: 'system',
|
|
subtype: 'compact_boundary' as const,
|
|
session_id: getSessionId(),
|
|
uuid: message.uuid,
|
|
compact_metadata: toSDKCompactMetadata(message.compactMetadata),
|
|
}
|
|
}
|
|
if (message.subtype === 'api_error') {
|
|
yield {
|
|
type: 'system',
|
|
subtype: 'api_retry' as const,
|
|
attempt: message.retryAttempt,
|
|
max_retries: message.maxRetries,
|
|
retry_delay_ms: message.retryInMs,
|
|
error_status: message.error.status ?? null,
|
|
error: categorizeRetryableAPIError(message.error),
|
|
session_id: getSessionId(),
|
|
uuid: message.uuid,
|
|
}
|
|
}
|
|
// Don't yield other system messages in headless mode
|
|
break
|
|
}
|
|
case 'tool_use_summary':
|
|
// Yield tool use summary messages to SDK
|
|
yield {
|
|
type: 'tool_use_summary' as const,
|
|
summary: message.summary,
|
|
preceding_tool_use_ids: message.precedingToolUseIds,
|
|
session_id: getSessionId(),
|
|
uuid: message.uuid,
|
|
}
|
|
break
|
|
}
|
|
|
|
// Check if USD budget has been exceeded
|
|
if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) {
|
|
if (persistSession) {
|
|
if (
|
|
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
|
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
|
) {
|
|
await flushSessionStorage()
|
|
}
|
|
}
|
|
yield {
|
|
type: 'result',
|
|
subtype: 'error_max_budget_usd',
|
|
duration_ms: Date.now() - startTime,
|
|
duration_api_ms: getTotalAPIDuration(),
|
|
is_error: true,
|
|
num_turns: turnCount,
|
|
stop_reason: lastStopReason,
|
|
session_id: getSessionId(),
|
|
total_cost_usd: getTotalCost(),
|
|
usage: this.totalUsage,
|
|
modelUsage: getModelUsage(),
|
|
permission_denials: this.permissionDenials,
|
|
fast_mode_state: getFastModeState(
|
|
mainLoopModel,
|
|
initialAppState.fastMode,
|
|
),
|
|
uuid: randomUUID(),
|
|
errors: [`Reached maximum budget ($${maxBudgetUsd})`],
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if structured output retry limit exceeded (only on user messages)
|
|
if (message.type === 'user' && jsonSchema) {
|
|
const currentCalls = countToolCalls(
|
|
this.mutableMessages,
|
|
SYNTHETIC_OUTPUT_TOOL_NAME,
|
|
)
|
|
const callsThisQuery = currentCalls - initialStructuredOutputCalls
|
|
const parsed = parseInt(
|
|
process.env.MAX_STRUCTURED_OUTPUT_RETRIES || '5',
|
|
10,
|
|
)
|
|
const maxRetries = Number.isNaN(parsed) ? 5 : parsed
|
|
if (callsThisQuery >= maxRetries) {
|
|
if (persistSession) {
|
|
if (
|
|
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
|
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
|
) {
|
|
await flushSessionStorage()
|
|
}
|
|
}
|
|
yield {
|
|
type: 'result',
|
|
subtype: 'error_max_structured_output_retries',
|
|
duration_ms: Date.now() - startTime,
|
|
duration_api_ms: getTotalAPIDuration(),
|
|
is_error: true,
|
|
num_turns: turnCount,
|
|
stop_reason: lastStopReason,
|
|
session_id: getSessionId(),
|
|
total_cost_usd: getTotalCost(),
|
|
usage: this.totalUsage,
|
|
modelUsage: getModelUsage(),
|
|
permission_denials: this.permissionDenials,
|
|
fast_mode_state: getFastModeState(
|
|
mainLoopModel,
|
|
initialAppState.fastMode,
|
|
),
|
|
uuid: randomUUID(),
|
|
errors: [
|
|
`Failed to provide valid structured output after ${maxRetries} attempts`,
|
|
],
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop hooks yield progress/attachment messages AFTER the assistant
|
|
// response (via yield* handleStopHooks in query.ts). Since #23537 pushes
|
|
// those to `messages` inline, last(messages) can be a progress/attachment
|
|
// instead of the assistant — which makes textResult extraction below
|
|
// return '' and -p mode emit a blank line. Allowlist to assistant|user:
|
|
// isResultSuccessful handles both (user with all tool_result blocks is a
|
|
// valid successful terminal state).
|
|
const result = messages.findLast(
|
|
m => m.type === 'assistant' || m.type === 'user',
|
|
)
|
|
// Capture for the error_during_execution diagnostic — isResultSuccessful
|
|
// is a type predicate (message is Message), so inside the false branch
|
|
// `result` narrows to never and these accesses don't typecheck.
|
|
const edeResultType = result?.type ?? 'undefined'
|
|
const edeLastContentType =
|
|
result?.type === 'assistant'
|
|
? (last(result.message.content)?.type ?? 'none')
|
|
: 'n/a'
|
|
|
|
// Flush buffered transcript writes before yielding result.
|
|
// The desktop app kills the CLI process immediately after receiving the
|
|
// result message, so any unflushed writes would be lost.
|
|
if (persistSession) {
|
|
if (
|
|
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
|
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
|
) {
|
|
await flushSessionStorage()
|
|
}
|
|
}
|
|
|
|
if (!isResultSuccessful(result, lastStopReason)) {
|
|
yield {
|
|
type: 'result',
|
|
subtype: 'error_during_execution',
|
|
duration_ms: Date.now() - startTime,
|
|
duration_api_ms: getTotalAPIDuration(),
|
|
is_error: true,
|
|
num_turns: turnCount,
|
|
stop_reason: lastStopReason,
|
|
session_id: getSessionId(),
|
|
total_cost_usd: getTotalCost(),
|
|
usage: this.totalUsage,
|
|
modelUsage: getModelUsage(),
|
|
permission_denials: this.permissionDenials,
|
|
fast_mode_state: getFastModeState(
|
|
mainLoopModel,
|
|
initialAppState.fastMode,
|
|
),
|
|
uuid: randomUUID(),
|
|
// Diagnostic prefix: these are what isResultSuccessful() checks — if
|
|
// the result type isn't assistant-with-text/thinking or user-with-
|
|
// tool_result, and stop_reason isn't end_turn, that's why this fired.
|
|
// errors[] is turn-scoped via the watermark; previously it dumped the
|
|
// entire process's logError buffer (ripgrep timeouts, ENOENT, etc).
|
|
errors: (() => {
|
|
const all = getInMemoryErrors()
|
|
const start = errorLogWatermark
|
|
? all.lastIndexOf(errorLogWatermark) + 1
|
|
: 0
|
|
return [
|
|
`[ede_diagnostic] result_type=${edeResultType} last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`,
|
|
...all.slice(start).map(_ => _.error),
|
|
]
|
|
})(),
|
|
}
|
|
return
|
|
}
|
|
|
|
// Extract the text result based on message type
|
|
let textResult = ''
|
|
let isApiError = false
|
|
|
|
if (result.type === 'assistant') {
|
|
const lastContent = last(result.message.content)
|
|
if (
|
|
lastContent?.type === 'text' &&
|
|
!SYNTHETIC_MESSAGES.has(lastContent.text)
|
|
) {
|
|
textResult = lastContent.text
|
|
}
|
|
isApiError = Boolean(result.isApiErrorMessage)
|
|
}
|
|
|
|
yield {
|
|
type: 'result',
|
|
subtype: 'success',
|
|
is_error: isApiError,
|
|
duration_ms: Date.now() - startTime,
|
|
duration_api_ms: getTotalAPIDuration(),
|
|
num_turns: turnCount,
|
|
result: textResult,
|
|
stop_reason: lastStopReason,
|
|
session_id: getSessionId(),
|
|
total_cost_usd: getTotalCost(),
|
|
usage: this.totalUsage,
|
|
modelUsage: getModelUsage(),
|
|
permission_denials: this.permissionDenials,
|
|
structured_output: structuredOutputFromTool,
|
|
fast_mode_state: getFastModeState(
|
|
mainLoopModel,
|
|
initialAppState.fastMode,
|
|
),
|
|
uuid: randomUUID(),
|
|
}
|
|
}
|
|
|
|
interrupt(): void {
|
|
this.abortController.abort()
|
|
}
|
|
|
|
getMessages(): readonly Message[] {
|
|
return this.mutableMessages
|
|
}
|
|
|
|
/**
|
|
* Inject messages into the engine's message store.
|
|
* Used by SDK query() when fork=true to resume from a forked session.
|
|
*/
|
|
injectMessages(messages: Message[]): void {
|
|
const validated = validateArrayOf(messages, (msg, _i) => {
|
|
const m = msg as Record<string, unknown>
|
|
assertNonEmptyString(m.type, 'type')
|
|
if (m.message !== undefined) {
|
|
assertObject(m.message, 'message')
|
|
const inner = m.message as Record<string, unknown>
|
|
if (inner.role !== undefined) {
|
|
assertNonEmptyString(inner.role, 'message.role')
|
|
}
|
|
if (inner.content !== undefined && typeof inner.content !== 'string' && !Array.isArray(inner.content)) {
|
|
throw new TypeError("'message.content' must be a string or array")
|
|
}
|
|
}
|
|
return msg
|
|
}, 'injectMessages')
|
|
this.mutableMessages.push(...validated)
|
|
}
|
|
|
|
/**
|
|
* Inject agent definitions into the engine's config.
|
|
* Used by SDK to load agents after engine creation (async loading).
|
|
* Validates that agents have the internal format fields
|
|
* (agentType, whenToUse, getSystemPrompt) since SDK agents
|
|
* are converted to this format before injection.
|
|
*/
|
|
injectAgents(agents: AgentDefinition[]): void {
|
|
const validated = validateArrayOf(agents, (agent, _i) => {
|
|
const a = agent as Record<string, unknown>
|
|
assertNonEmptyString(a.agentType, 'agentType')
|
|
assertNonEmptyString(a.whenToUse, 'whenToUse')
|
|
if (typeof a.getSystemPrompt !== 'function') {
|
|
throw new TypeError("missing or invalid 'getSystemPrompt' (expected function)")
|
|
}
|
|
if (a.tools !== undefined) {
|
|
const validToolNames = new Set(this.config.tools.map(t => t.name))
|
|
for (const toolSpec of a.tools as string[]) {
|
|
// Wildcard '*' means all tools are allowed - skip validation
|
|
if (toolSpec === '*') continue
|
|
// Parse tool spec to get base tool name (may contain permission rules)
|
|
const toolName = toolSpec.split(':')[0] ?? toolSpec
|
|
if (!validToolNames.has(toolName)) {
|
|
throw new TypeError(`agent references unknown tool '${toolSpec}'`)
|
|
}
|
|
}
|
|
}
|
|
return agent
|
|
}, 'injectAgents')
|
|
this.config.agents = validated
|
|
}
|
|
|
|
/**
|
|
* Update the engine's tool list dynamically.
|
|
* Used by SDK setPermissionMode to refresh tools when permission mode changes.
|
|
*/
|
|
updateTools(tools: Tools): void {
|
|
if (!Array.isArray(tools) && !(Symbol.iterator in Object(tools))) {
|
|
throw new TypeError(`updateTools: expected iterable, got ${typeof tools}`)
|
|
}
|
|
const toolArray = Array.from(tools as Iterable<unknown>)
|
|
|
|
// Phase 1: Validate new tools
|
|
validateArrayOf(toolArray, (tool, _i) => {
|
|
const t = tool as Record<string, unknown>
|
|
assertNonEmptyString(t.name, 'name')
|
|
assertFunction(t.call, 'call')
|
|
return tool
|
|
}, 'updateTools')
|
|
|
|
// Phase 2: Validate agent compatibility BEFORE commit (transactional)
|
|
const validToolNames = new Set(toolArray.map(t => (t as Record<string, unknown>).name as string))
|
|
for (const agent of this.config.agents) {
|
|
if (agent.tools) {
|
|
for (const toolSpec of agent.tools) {
|
|
if (toolSpec === '*') continue
|
|
const toolName = toolSpec.split(':')[0] ?? toolSpec
|
|
if (!validToolNames.has(toolName)) {
|
|
throw new TypeError(
|
|
`updateTools: agent '${agent.agentType}' references tool '${toolSpec}' which is not in the new tool set`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 3: Commit — only reached if all validations pass
|
|
this.config.tools = toolArray as Tools
|
|
|
|
// Phase 4: Invalidate schema cache for removed tools only.
|
|
// Selective invalidation preserves cached schemas for tools that remain,
|
|
// avoiding unnecessary recomputation for concurrent engines in multi-session
|
|
// SDK scenarios. New tools (not yet cached) will be computed on first render.
|
|
invalidateRemovedToolSchemas(validToolNames)
|
|
}
|
|
|
|
getReadFileState(): FileStateCache {
|
|
return this.readFileState
|
|
}
|
|
|
|
getSessionId(): string {
|
|
return getSessionId()
|
|
}
|
|
|
|
setModel(model: string): void {
|
|
this.config.userSpecifiedModel = model
|
|
}
|
|
|
|
/**
|
|
* Update the engine's thinking config dynamically.
|
|
* Used by SDK setMaxThinkingTokens to change the thinking token budget.
|
|
*/
|
|
setThinkingConfig(config: ThinkingConfig): void {
|
|
this.config.thinkingConfig = config
|
|
}
|
|
|
|
/**
|
|
* Get MCP server connections. Returns a readonly array to prevent
|
|
* external mutation (use setMcpClients or updateTools to modify).
|
|
*/
|
|
getMcpClients(): readonly MCPServerConnection[] {
|
|
return this.config.mcpClients
|
|
}
|
|
|
|
/**
|
|
* Set MCP server connections. Replaces the entire mcpClients array.
|
|
* Used by SDK to inject session-scoped MCP servers after connection.
|
|
*/
|
|
setMcpClients(clients: MCPServerConnection[]): void {
|
|
this.config.mcpClients = clients
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a single prompt to the Claude API and returns the response.
|
|
* Assumes that claude is being used non-interactively -- will not
|
|
* ask the user for permissions or further input.
|
|
*
|
|
* Convenience wrapper around QueryEngine for one-shot usage.
|
|
*/
|
|
export async function* ask({
|
|
commands,
|
|
prompt,
|
|
promptUuid,
|
|
isMeta,
|
|
cwd,
|
|
tools,
|
|
mcpClients,
|
|
verbose = false,
|
|
thinkingConfig,
|
|
maxTurns,
|
|
maxBudgetUsd,
|
|
taskBudget,
|
|
canUseTool,
|
|
mutableMessages = [],
|
|
getReadFileCache,
|
|
setReadFileCache,
|
|
customSystemPrompt,
|
|
appendSystemPrompt,
|
|
userSpecifiedModel,
|
|
fallbackModel,
|
|
jsonSchema,
|
|
getAppState,
|
|
setAppState,
|
|
abortController,
|
|
replayUserMessages = false,
|
|
includePartialMessages = false,
|
|
handleElicitation,
|
|
agents = [],
|
|
setSDKStatus,
|
|
orphanedPermission,
|
|
}: {
|
|
commands: Command[]
|
|
prompt: string | Array<ContentBlockParam>
|
|
promptUuid?: string
|
|
isMeta?: boolean
|
|
cwd: string
|
|
tools: Tools
|
|
verbose?: boolean
|
|
mcpClients: MCPServerConnection[]
|
|
thinkingConfig?: ThinkingConfig
|
|
maxTurns?: number
|
|
maxBudgetUsd?: number
|
|
taskBudget?: { total: number }
|
|
canUseTool: CanUseToolFn
|
|
mutableMessages?: Message[]
|
|
customSystemPrompt?: string
|
|
appendSystemPrompt?: string
|
|
userSpecifiedModel?: string
|
|
fallbackModel?: string
|
|
jsonSchema?: Record<string, unknown>
|
|
getAppState: () => AppState
|
|
setAppState: (f: (prev: AppState) => AppState) => void
|
|
getReadFileCache: () => FileStateCache
|
|
setReadFileCache: (cache: FileStateCache) => void
|
|
abortController?: AbortController
|
|
replayUserMessages?: boolean
|
|
includePartialMessages?: boolean
|
|
handleElicitation?: ToolUseContext['handleElicitation']
|
|
agents?: AgentDefinition[]
|
|
setSDKStatus?: (status: SDKStatus) => void
|
|
orphanedPermission?: OrphanedPermission
|
|
}): AsyncGenerator<SDKMessage, void, unknown> {
|
|
const engine = new QueryEngine({
|
|
cwd,
|
|
tools,
|
|
commands,
|
|
mcpClients,
|
|
agents,
|
|
canUseTool,
|
|
getAppState,
|
|
setAppState,
|
|
initialMessages: mutableMessages,
|
|
readFileCache: cloneFileStateCache(getReadFileCache()),
|
|
customSystemPrompt,
|
|
appendSystemPrompt,
|
|
userSpecifiedModel,
|
|
fallbackModel,
|
|
thinkingConfig,
|
|
maxTurns,
|
|
maxBudgetUsd,
|
|
taskBudget,
|
|
jsonSchema,
|
|
verbose,
|
|
handleElicitation,
|
|
replayUserMessages,
|
|
includePartialMessages,
|
|
setSDKStatus,
|
|
abortController,
|
|
orphanedPermission,
|
|
...(feature('HISTORY_SNIP')
|
|
? {
|
|
snipReplay: (yielded: Message, store: Message[]) => {
|
|
if (!snipProjection!.isSnipBoundaryMessage(yielded))
|
|
return undefined
|
|
return snipModule!.snipCompactIfNeeded(store, { force: true })
|
|
},
|
|
}
|
|
: {}),
|
|
})
|
|
|
|
try {
|
|
yield* engine.submitMessage(prompt, {
|
|
uuid: promptUuid,
|
|
isMeta,
|
|
})
|
|
} finally {
|
|
setReadFileCache(engine.getReadFileState())
|
|
}
|
|
}
|