mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-05-02 15:22:30 +00:00
a46b31c3ec
* 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.
* 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
* test(sdk): add sequential timeout-then-host-response race condition tests
Adds two tests addressing reviewer request for proof that host response
after SDK timeout is safely handled with no double-resolve or leaked listener:
1. Integration test: stale host resolve called after timeout deny —
verifies no error, no mutation, map cleanup
2. Unit test: raw resolve called exactly once when timeout wins —
directly proves createOnceOnlyResolve prevents second execution
* fix: restore openclaude.json comment in REPL.tsx
Reviewer caught that the comment was incorrectly changed to
~/.claude.json during merge — project has already migrated to
~/.openclaude.json.
* fix(sdk): register pending permission before emitting onPermissionRequest
The previous code emitted onPermissionRequest before calling
registerPendingPermission, so a host responding synchronously from
the callback would find an empty map and its response was lost.
Swap the order so registration happens first.
Adds a regression test for the synchronous host response path.
* fix(sdk): make state setters context-aware for SDK isolation
When running inside runWithSdkContext(), setter functions (regenerateSessionId,
switchSession, setCwdState, setOriginalCwd) now write to the AsyncLocalStorage
context instead of global STATE. This prevents cross-session state leakage in
multi-session SDK scenarios.
Reads were already context-aware; this completes the isolation by making writes
consistent. Outside of SDK context, behavior is unchanged — all writes go to
global STATE as before.
* test(sdk): add context-aware state isolation tests
Tests verify that setters within runWithSdkContext() write to the SDK
context (not global STATE) and that parallel async contexts do not leak
state between sessions. Covers setCwdState, setOriginalCwd,
regenerateSessionId, switchSession, and an end-to-end parallel session
scenario.
* fix(sdk): selective tool schema cache invalidation for multi-engine isolation
Replace global clearToolSchemaCache() in QueryEngine.updateTools() with
selective invalidation that only removes cache entries for tools no longer
in the tool set. This preserves cached schemas for tools that remain,
avoiding unnecessary recomputation for concurrent QueryEngine instances
in multi-session SDK scenarios.
New function invalidateRemovedToolSchemas() handles both simple tool name
keys and schema-variant keys (format: "toolName:{...schemaJSON...}").
* docs(sdk): address PR2 non-blocking documentation and logging issues
- Document request_id vs tool_use_id relationship in shared.ts
(request_id for response correlation, tool_use_id for tracking)
- Add injectable SDKLogger interface to permissions.ts, replacing
direct console.warn calls with logger.warn (hosts can control noise)
- Document Node.js-only AsyncLocalStorage requirement in state.ts
(requires Node.js 12.17.0+ or 14.0.0+)
- Clarify env-mutex is host utility (SDK doesn't mutate process.env)
* fix(sdk): handle throwing onPermissionRequest and fix permission request shape
- Wrap onPermissionRequest in try-catch to clean up pending resolver on throw
- Add uuid and session_id to permission_request message to match SDK schema
- Add regression tests for throwing callback and message shape validation
* fix(sdk): use explicit no-session placeholder for standalone permission prompts
- Add NO_SESSION_PLACEHOLDER constant ('no-session') for permission requests
- Update SDKPermissionRequestMessage doc to explain session_id semantics
- Replace empty string fallback with explicit placeholder
- Add test verifying placeholder behavior when sessionId omitted
* docs(sdk): add example code to permission denial warning
Include canUseTool example in warning message to improve developer
experience and make SDK usage more discoverable for new users.
* fix(sdk): scope parentSessionId to SDK context for parallel isolation
regenerateSessionId({ setCurrentAsParent: true }) was writing to the
process-global STATE.parentSessionId even inside runWithSdkContext(),
allowing one SDK context to overwrite another's parent-session metadata.
Add parentSessionId to the SdkContext type and update both
regenerateSessionId and getParentSessionId to read/write from the
active context when one exists, using an explicit if-else pattern
rather than ?? to avoid undefined fallback leaking across contexts.
The non-SDK CLI path (no active context) continues to use STATE
directly, preserving existing behavior.
---------
Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
/**
|
|
* Shared types, helpers, and mutex for the SDK modules.
|
|
*
|
|
* This module has no sibling imports — it is the foundation for
|
|
* sessions.ts, permissions.ts, query.ts, and v2.ts.
|
|
*/
|
|
|
|
import type { UUID } from 'crypto'
|
|
import type {
|
|
SDKMessage as GeneratedSDKMessage,
|
|
SDKUserMessage as GeneratedSDKUserMessage,
|
|
} from './coreTypes.generated.js'
|
|
import { validateUuid } from '../../utils/sessionStoragePortable.js'
|
|
|
|
// ============================================================================
|
|
// Session ID validation
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Validate sessionId is a proper UUID to prevent path traversal.
|
|
* Throws if invalid.
|
|
*/
|
|
export function assertValidSessionId(sessionId: string): void {
|
|
if (!validateUuid(sessionId)) {
|
|
throw new Error(`Invalid session ID: ${sessionId}`)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Environment mutation mutex for parallel query safety
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Global mutex for process.env mutations.
|
|
* Prevents race conditions when multiple queries run in parallel.
|
|
*
|
|
* **Note:** The SDK itself does not directly mutate process.env. This mutex
|
|
* is provided as a utility for SDK hosts who need to modify environment
|
|
* variables during parallel query execution (e.g., setting API keys per-query).
|
|
* Hosts must opt-in to using this mutex — there is no enforcement mechanism.
|
|
*
|
|
* Example usage:
|
|
* ```typescript
|
|
* const result = await acquireEnvMutex({ timeoutMs: 1000 })
|
|
* if (result.acquired) {
|
|
* try {
|
|
* process.env.MY_API_KEY = 'key-for-this-query'
|
|
* // ... perform query ...
|
|
* } finally {
|
|
* releaseEnvMutex()
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
const envMutationQueue: Array<() => void> = []
|
|
let envMutationLocked = false
|
|
|
|
export interface MutexAcquireOptions {
|
|
/** Maximum time to wait for mutex in milliseconds. Default: no timeout (wait forever). */
|
|
timeoutMs?: number
|
|
}
|
|
|
|
export interface MutexAcquireResult {
|
|
/** Whether the mutex was acquired successfully. */
|
|
acquired: boolean
|
|
/** Reason for failure if not acquired. */
|
|
reason?: 'timeout'
|
|
}
|
|
|
|
export async function acquireEnvMutex(options?: MutexAcquireOptions): Promise<MutexAcquireResult> {
|
|
if (!envMutationLocked) {
|
|
envMutationLocked = true
|
|
return { acquired: true }
|
|
}
|
|
|
|
if (options?.timeoutMs === undefined) {
|
|
// No timeout - wait forever (original behavior for backward compatibility)
|
|
return new Promise(resolve => {
|
|
envMutationQueue.push(() => resolve({ acquired: true }))
|
|
})
|
|
}
|
|
|
|
// With timeout - race between queue and timeout
|
|
return new Promise(resolve => {
|
|
let resolved = false
|
|
let callback: () => void
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true
|
|
// Remove ourselves from the queue to prevent orphaned callback
|
|
const index = envMutationQueue.indexOf(callback)
|
|
if (index !== -1) {
|
|
envMutationQueue.splice(index, 1)
|
|
}
|
|
resolve({ acquired: false, reason: 'timeout' })
|
|
}
|
|
}, options.timeoutMs)
|
|
|
|
callback = () => {
|
|
if (!resolved) {
|
|
resolved = true
|
|
clearTimeout(timeoutId)
|
|
resolve({ acquired: true })
|
|
}
|
|
}
|
|
|
|
envMutationQueue.push(callback)
|
|
})
|
|
}
|
|
|
|
export function releaseEnvMutex(): void {
|
|
if (envMutationQueue.length > 0) {
|
|
const next = envMutationQueue.shift()
|
|
if (next) {
|
|
try {
|
|
next()
|
|
} catch {
|
|
// If callback throws, ensure mutex is unlocked so next caller can acquire
|
|
// The error is intentionally not propagated - callback errors should not
|
|
// block the mutex system. Callers should handle their own errors.
|
|
envMutationLocked = false
|
|
}
|
|
}
|
|
} else {
|
|
envMutationLocked = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset mutex state for testing purposes only.
|
|
* Do not use in production code.
|
|
* @internal
|
|
*/
|
|
export function resetEnvMutexForTesting(): void {
|
|
envMutationQueue.length = 0
|
|
envMutationLocked = false
|
|
}
|
|
|
|
// ============================================================================
|
|
// SDK Types — snake_case public interface
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Permission request message emitted when a tool needs permission approval.
|
|
* Hosts can respond via respondToPermission() using the request_id.
|
|
*
|
|
* **ID Relationship:**
|
|
* - `request_id`: UUID generated per permission request, used as correlation ID
|
|
* for respondToPermission(). Passed to onPermissionRequest callback for hosts
|
|
* to identify which request they're responding to.
|
|
* - `tool_use_id`: Identifier for the specific tool use instance, passed from
|
|
* canUseTool(). Used internally for pending permission tracking and queue
|
|
* filtering. Multiple permission requests for the same tool use are rare but
|
|
* possible (e.g., retry after timeout).
|
|
* - `session_id`: SDK session identifier. When 'no-session', indicates a
|
|
* standalone permission prompt outside an SDK session flow (e.g., direct
|
|
* createExternalCanUseTool usage without session context).
|
|
* - `uuid`: Message UUID for stream correlation and transcript persistence.
|
|
*
|
|
* Hosts typically use `request_id` for responding; `tool_use_id` is useful
|
|
* for tracking state or correlating with tool_use events in the message stream.
|
|
* `session_id` enables correlation with SDK session lifecycle events.
|
|
*/
|
|
export type SDKPermissionRequestMessage = {
|
|
type: 'permission_request'
|
|
request_id: string
|
|
tool_name: string
|
|
tool_use_id: string
|
|
input: Record<string, unknown>
|
|
uuid: string
|
|
session_id: string
|
|
}
|
|
|
|
/**
|
|
* Message emitted when a permission request times out without a response.
|
|
* Hosts can detect timeouts by checking `type === 'permission_timeout'`
|
|
* in their `for await` loop. The `tool_use_id` matches the original
|
|
* permission_request, allowing correlation.
|
|
*
|
|
* Note: `request_id` is not included in timeout messages since the request
|
|
* is no longer pending — hosts cannot respond to timed-out requests.
|
|
*/
|
|
export type SDKPermissionTimeoutMessage = {
|
|
type: 'permission_timeout'
|
|
tool_name: string
|
|
tool_use_id: string
|
|
timed_out_after_ms: number
|
|
}
|
|
|
|
/**
|
|
* A message emitted by the query engine during a conversation.
|
|
* Re-exports the full generated type from coreTypes.generated.ts.
|
|
*/
|
|
export type SDKMessage = GeneratedSDKMessage | SDKPermissionTimeoutMessage
|
|
|
|
/**
|
|
* A user message fed into query() via AsyncIterable.
|
|
* Re-exports the full generated type from coreTypes.generated.ts.
|
|
*/
|
|
export type SDKUserMessage = GeneratedSDKUserMessage
|
|
|
|
/**
|
|
* Map an internal Message object to an SDKMessage.
|
|
* Internal messages have a different shape from SDK types — this function
|
|
* performs the conversion instead of relying on unsafe casts.
|
|
*
|
|
* Validates that the message is a non-null object and has a valid type field.
|
|
* Returns a message with type='unknown' if type is missing or invalid.
|
|
*/
|
|
export function mapMessageToSDK(msg: Record<string, unknown>): SDKMessage {
|
|
// Validate input is a non-null object
|
|
if (msg === null || typeof msg !== 'object') {
|
|
throw new TypeError('mapMessageToSDK: expected non-null object')
|
|
}
|
|
|
|
// Validate type field is a string (if present)
|
|
const typeValue = msg.type
|
|
if (typeValue !== undefined && typeof typeValue !== 'string') {
|
|
throw new TypeError(`mapMessageToSDK: 'type' field must be string, got ${typeof typeValue}`)
|
|
}
|
|
|
|
// Internal messages from QueryEngine already use the SDK field naming
|
|
// convention (snake_case: parent_tool_use_id, session_id, etc.).
|
|
// We spread all fields through and let the discriminated-union type
|
|
// narrow via the `type` field.
|
|
return {
|
|
...msg,
|
|
type: (typeValue as string) ?? 'unknown',
|
|
} as SDKMessage
|
|
}
|
|
|
|
/**
|
|
* Session metadata returned by listSessions and getSessionInfo.
|
|
* Uses snake_case field names matching the public SDK contract.
|
|
*/
|
|
export type SDKSessionInfo = {
|
|
session_id: string
|
|
summary: string
|
|
last_modified: number
|
|
file_size?: number
|
|
custom_title?: string
|
|
first_prompt?: string
|
|
git_branch?: string
|
|
cwd?: string
|
|
tag?: string
|
|
created_at?: number
|
|
}
|
|
|
|
/** Options for listSessions. */
|
|
export type ListSessionsOptions = {
|
|
/** Project directory. When omitted, returns sessions across all projects. */
|
|
dir?: string
|
|
/** Maximum number of sessions to return. */
|
|
limit?: number
|
|
/** Number of sessions to skip (pagination). */
|
|
offset?: number
|
|
/** Include git worktree sessions (default true). */
|
|
includeWorktrees?: boolean
|
|
}
|
|
|
|
/** Options for getSessionInfo. */
|
|
export type GetSessionInfoOptions = {
|
|
/** Project directory. When omitted, searches all project directories. */
|
|
dir?: string
|
|
}
|
|
|
|
/** Options for getSessionMessages. */
|
|
export type GetSessionMessagesOptions = {
|
|
/** Project directory. When omitted, searches all project directories. */
|
|
dir?: string
|
|
/** Maximum number of messages to return. */
|
|
limit?: number
|
|
/** Number of messages to skip (pagination). */
|
|
offset?: number
|
|
/** Include system messages in the output. Default false. */
|
|
includeSystemMessages?: boolean
|
|
}
|
|
|
|
/** Options for renameSession and tagSession. */
|
|
export type SessionMutationOptions = {
|
|
/** Project directory. When omitted, searches all project directories. */
|
|
dir?: string
|
|
}
|
|
|
|
/** Options for forkSession. */
|
|
export type ForkSessionOptions = {
|
|
/** Project directory. When omitted, searches all project directories. */
|
|
dir?: string
|
|
/** Fork up to (and including) this message UUID. */
|
|
upToMessageId?: string
|
|
/** Title for the forked session. */
|
|
title?: string
|
|
}
|
|
|
|
/** Result of forkSession. */
|
|
export type ForkSessionResult = {
|
|
/** UUID of the newly created forked session. */
|
|
session_id: string
|
|
}
|
|
|
|
/**
|
|
* A single message in a session conversation.
|
|
* Returned by getSessionMessages.
|
|
*/
|
|
export type SessionMessage = {
|
|
role: 'user' | 'assistant' | 'system'
|
|
content: unknown
|
|
timestamp?: string
|
|
uuid?: string
|
|
parent_uuid?: string | null
|
|
[key: string]: unknown
|
|
}
|
|
|
|
/**
|
|
* Permission mode for the query.
|
|
* Controls how tool permissions are handled.
|
|
*/
|
|
export type QueryPermissionMode =
|
|
| 'default'
|
|
| 'plan'
|
|
| 'auto-accept'
|
|
| 'bypass-permissions'
|
|
| 'bypassPermissions'
|
|
| 'acceptEdits'
|
|
|
|
/**
|
|
* Callback type for canUseTool permission checks.
|
|
* Shared between QueryOptions and SDKSessionOptions.
|
|
*/
|
|
export type CanUseToolCallback = (
|
|
name: string,
|
|
input: unknown,
|
|
options?: { toolUseID?: string },
|
|
) => Promise<{ behavior: 'allow' | 'deny'; message?: string; updatedInput?: unknown }>
|
|
|
|
// ============================================================================
|
|
// Internal types shared across modules
|
|
// ============================================================================
|
|
|
|
/**
|
|
* JSONL line types used by getSessionMessages and forkSession.
|
|
* @internal
|
|
*/
|
|
export type JsonlEntry = {
|
|
type: string
|
|
uuid?: string
|
|
parentUuid?: string | null
|
|
sessionId?: string
|
|
timestamp?: string
|
|
message?: {
|
|
role?: string
|
|
content?: unknown
|
|
[key: string]: unknown
|
|
}
|
|
isSidechain?: boolean
|
|
[key: string]: unknown
|
|
}
|