Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fcff11a
feat(sdk): add SDK foundation — type declarations, errors, and utilities
Apr 23, 2026
3320446
fix(sdk): narrow assertFunction type from broad Function to callable …
Apr 24, 2026
5b03f77
fix(sdk): update sdk.d.ts header — manually maintained, not generated
Apr 24, 2026
6ce7b18
fix(sdk): align sdk.d.ts types with canonical coreTypes.generated.ts
Apr 24, 2026
14768a5
feat(sdk): wire existing code modules + SDK shared utilities
Apr 23, 2026
9633184
feat(sdk): add snake_case ↔ camelCase key mapping utilities
Apr 24, 2026
aa22fb0
test(sdk): add tests for snake_case ↔ camelCase mapping utilities
Apr 24, 2026
743dd1a
Merge branch 'main' into sdk/pr2-sdk-core
Apr 29, 2026
1a8abb7
fix(sdk): prevent permission timeout race condition with once-only re…
Apr 29, 2026
e35b940
fix(sdk): improve race condition test robustness
Apr 29, 2026
2d1db7e
fix(sdk): handle consecutive underscores in snakeToCamel conversion
Apr 29, 2026
e245d56
fix(sdk): include original error message in permission callback denial
Apr 29, 2026
93d3845
feat(sdk): add optional timeout to env mutex for deadlock prevention
Apr 29, 2026
7f8780f
fix(sdk): remove timed-out callback from mutex queue to prevent deadlock
Apr 29, 2026
113566f
test(sdk): add missing error path and timeout scenario tests
Apr 29, 2026
10ab0e2
fix(sdk): address code review issues - race conditions, validation, e…
Apr 29, 2026
693b112
test(sdk): add sequential timeout-then-host-response race condition t…
Apr 30, 2026
c725c48
fix: restore openclaude.json comment in REPL.tsx
Apr 30, 2026
d64a269
fix(sdk): register pending permission before emitting onPermissionReq…
Apr 30, 2026
380fab3
fix(sdk): make state setters context-aware for SDK isolation
Apr 30, 2026
b2e5981
test(sdk): add context-aware state isolation tests
Apr 30, 2026
2abd87b
fix(sdk): selective tool schema cache invalidation for multi-engine i…
Apr 30, 2026
543c4e1
docs(sdk): address PR2 non-blocking documentation and logging issues
Apr 30, 2026
e380af7
fix(sdk): handle throwing onPermissionRequest and fix permission requ…
Apr 30, 2026
4b38af6
fix(sdk): use explicit no-session placeholder for standalone permissi…
Apr 30, 2026
69029f2
docs(sdk): add example code to permission denial warning
Apr 30, 2026
eaea430
fix(sdk): scope parentSessionId to SDK context for parallel isolation
Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 116 additions & 11 deletions src/QueryEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import { SYNTHETIC_OUTPUT_TOOL_NAME } from './tools/SyntheticOutputTool/Syntheti
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'
Expand Down Expand Up @@ -82,12 +84,7 @@ import {
shouldEnableThinkingByDefault,
type ThinkingConfig,
} from './utils/thinking.js'

// Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time
/* eslint-disable @typescript-eslint/no-require-imports */
const messageSelector =
(): typeof import('src/components/MessageSelector.js') =>
require('src/components/MessageSelector.js')
import { selectableUserMessagesFilter } from './utils/messageFilters.js'

import {
localCommandOutputToSDKAssistantMessage,
Expand Down Expand Up @@ -360,7 +357,7 @@ export class QueryEngine {
isNonInteractiveSession: true,
customSystemPrompt,
appendSystemPrompt,
agentDefinitions: { activeAgents: agents, allAgents: [] },
agentDefinitions: { activeAgents: agents, allAgents: agents },
theme: resolveThemeSetting(getGlobalConfig().theme),
maxBudgetUsd,
},
Expand Down Expand Up @@ -469,7 +466,7 @@ export class QueryEngine {
(msg.type === 'user' &&
!msg.isMeta && // Skip synthetic caveat messages
!msg.toolUseResult && // Skip tool results (they'll be acked from query)
messageSelector().selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.)
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 : []
Expand Down Expand Up @@ -509,7 +506,7 @@ export class QueryEngine {
customSystemPrompt,
appendSystemPrompt,
theme: resolveThemeSetting(getGlobalConfig().theme),
agentDefinitions: { activeAgents: agents, allAgents: [] },
agentDefinitions: { activeAgents: agents, allAgents: agents },
maxBudgetUsd,
},
getAppState,
Expand Down Expand Up @@ -641,7 +638,7 @@ export class QueryEngine {

if (fileHistoryEnabled() && persistSession) {
messagesFromUserInput
.filter(messageSelector().selectableUserMessagesFilter)
.filter(selectableUserMessagesFilter)
.forEach(message => {
void fileHistoryMakeSnapshot(
(updater: (prev: FileHistoryState) => FileHistoryState) => {
Expand Down Expand Up @@ -1022,10 +1019,11 @@ export class QueryEngine {
SYNTHETIC_OUTPUT_TOOL_NAME,
)
const callsThisQuery = currentCalls - initialStructuredOutputCalls
const maxRetries = parseInt(
const parsed = parseInt(
process.env.MAX_STRUCTURED_OUTPUT_RETRIES || '5',
10,
)
const maxRetries = Number.isNaN(parsed) ? 5 : parsed
if (callsThisQuery >= maxRetries) {
if (persistSession) {
if (
Expand Down Expand Up @@ -1177,6 +1175,105 @@ export class QueryEngine {
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
}
Expand All @@ -1188,6 +1285,14 @@ export class QueryEngine {
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
}
}

/**
Expand Down
98 changes: 86 additions & 12 deletions src/bootstrap/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,28 +428,82 @@ function getInitialState(): State {
// AND ESPECIALLY HERE
const STATE: State = getInitialState()

/**
* Per-query SDK context for AsyncLocalStorage-based isolation.
* When set, overrides global STATE reads for the current async context.
*
* **Runtime Requirement:** Uses Node.js `async_hooks.AsyncLocalStorage`.
* Not available in browsers or non-Node JavaScript environments.
* SDK consumers must run in a Node.js runtime (Node.js 12.17.0+ or 14.0.0+).
*/
type SdkContext = {
sessionId: SessionId
sessionProjectDir: string | null
cwd: string
originalCwd: string
parentSessionId?: SessionId
}

import { AsyncLocalStorage } from 'async_hooks'

const sdkContextStorage = new AsyncLocalStorage<SdkContext>()

/**
* Run a function with an SDK-specific context that overrides global state.
* All reads of sessionId, sessionProjectDir, cwd, originalCwd within fn
* return context-scoped values instead of global STATE.
*
* **Node.js Only:** Requires AsyncLocalStorage from async_hooks module.
* This function will throw if called in a non-Node environment where
* async_hooks is not available.
*/
export function runWithSdkContext<T>(context: SdkContext, fn: () => T): T {
return sdkContextStorage.run(context, fn)
}

function getSdkContext(): SdkContext | undefined {
return sdkContextStorage.getStore()
}

export function getSessionId(): SessionId {
return STATE.sessionId
const ctx = getSdkContext()
return ctx?.sessionId ?? STATE.sessionId
}

export function regenerateSessionId(
options: { setCurrentAsParent?: boolean } = {},
): SessionId {
const ctx = getSdkContext()
const currentSessionId = ctx?.sessionId ?? STATE.sessionId
if (options.setCurrentAsParent) {
STATE.parentSessionId = STATE.sessionId
if (ctx) {
ctx.parentSessionId = currentSessionId
} else {
STATE.parentSessionId = currentSessionId
}
}
// Drop the outgoing session's plan-slug entry so the Map doesn't
// accumulate stale keys. Callers that need to carry the slug across
// (REPL.tsx clearContext) read it before calling clearConversation.
STATE.planSlugCache.delete(STATE.sessionId)
STATE.planSlugCache.delete(currentSessionId)
// Regenerated sessions live in the current project: reset projectDir to
// null so getTranscriptPath() derives from originalCwd.
STATE.sessionId = randomUUID() as SessionId
STATE.sessionProjectDir = null
return STATE.sessionId
const newId = randomUUID() as SessionId
if (ctx) {
ctx.sessionId = newId
ctx.sessionProjectDir = null
} else {
STATE.sessionId = newId
STATE.sessionProjectDir = null
}
return newId
}

export function getParentSessionId(): SessionId | undefined {
const ctx = getSdkContext()
if (ctx) {
return ctx.parentSessionId
}
return STATE.parentSessionId
}

Expand All @@ -469,12 +523,19 @@ export function switchSession(
sessionId: SessionId,
projectDir: string | null = null,
): void {
const ctx = getSdkContext()
const currentSessionId = ctx?.sessionId ?? STATE.sessionId
// Drop the outgoing session's plan-slug entry so the Map stays bounded
// across repeated /resume. Only the current session's slug is ever read
// (plans.ts getPlanSlug defaults to getSessionId()).
STATE.planSlugCache.delete(STATE.sessionId)
STATE.sessionId = sessionId
STATE.sessionProjectDir = projectDir
STATE.planSlugCache.delete(currentSessionId)
if (ctx) {
ctx.sessionId = sessionId
ctx.sessionProjectDir = projectDir
} else {
STATE.sessionId = sessionId
STATE.sessionProjectDir = projectDir
}
sessionSwitched.emit(sessionId)
}

Expand All @@ -494,11 +555,13 @@ export const onSessionSwitch = sessionSwitched.subscribe
* originalCwd). See `switchSession()`.
*/
export function getSessionProjectDir(): string | null {
return STATE.sessionProjectDir
const ctx = getSdkContext()
return ctx?.sessionProjectDir ?? STATE.sessionProjectDir
}

export function getOriginalCwd(): string {
return STATE.originalCwd
const ctx = getSdkContext()
return ctx?.originalCwd ?? STATE.originalCwd
}

/**
Expand All @@ -513,6 +576,11 @@ export function getProjectRoot(): string {
}

export function setOriginalCwd(cwd: string): void {
const ctx = getSdkContext()
if (ctx) {
ctx.originalCwd = cwd.normalize('NFC')
return
}
STATE.originalCwd = cwd.normalize('NFC')
}

Expand All @@ -525,10 +593,16 @@ export function setProjectRoot(cwd: string): void {
}

export function getCwdState(): string {
return STATE.cwd
const ctx = getSdkContext()
return ctx?.cwd ?? STATE.cwd
}

export function setCwdState(cwd: string): void {
const ctx = getSdkContext()
if (ctx) {
ctx.cwd = cwd.normalize('NFC')
return
}
STATE.cwd = cwd.normalize('NFC')
}

Expand Down
Loading
Loading