feat: SDK Core — Permission System, Async Context, and Engine Extensions - #951
Conversation
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
…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.
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).
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
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
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.
Covers snakeToCamel, camelToSnake, mapKeysToCamel, mapKeysToSnake including nested objects, arrays, null/undefined, and round-trips.
…solve 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.
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
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.
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.
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.
…rror 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
gnanam1990
left a comment
There was a problem hiding this comment.
Pulled the branch — 70 SDK tests pass locally, CI green, no openclaude red flags introduced (no tengu_, no new network calls, no Anthropic fingerprints).
I'm not approving this in a single pass though. ~1.6k lines across 15 files touching permission handling, AsyncLocalStorage isolation, mutex acquisition with timeouts, and race-condition-safe promise resolution is too much surface for one reviewer — and the failure modes (permission bypass, cross-session state leak, unresolved promises) are exactly the kind that pass tests but bite in production.
Two requests:
-
Could you tag @kevincodex1 or @anandh8x for a second review? This needs more than one set of eyes by policy for a stack-of-3 SDK runtime PR.
-
The permission
createDefaultCanUseTooldeny-by-default is the right call, but I want to walk through thecreatePermissionTarget()once-only-resolve flow againstacquireEnvMutextimeout interaction more carefully. Could you point me at a specific test that exercises: host responds after the SDK timeout has already fired and called the deny path? Want to confirm there's no double-resolve / leaked listener path.
Will do a focused second pass once a second reviewer is on board and you've pointed at the test for #2.
…ests 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
Re #1 — I've requested a second reviewer. Re #2 — Here's the test you asked for:
Test 1: Integration-level (host response after timeout is safely ignored)
Test 2: Unit-level (timeout-deny-then-host-allow: raw resolve called exactly once)
The two tests are complementary: Test 1 proves the integration flow is safe, Test 2 proves why it's safe at the mechanism level. For the |
|
lets have a look into this @Vasanthdev2004 @techbrewboss @devNull-bootloader |
| // empty to non-empty, not on every length change -- otherwise a render loop | ||
| // (concurrent onQuery thrashing, etc.) spams saveGlobalConfig, which hits | ||
| // ELOCKED under concurrent sessions and falls back to unlocked writes. | ||
| // That write storm is the primary trigger for ~/.openclaude.json corruption |
There was a problem hiding this comment.
why this change we are moving away from claude.json and will be using openclaude.json
There was a problem hiding this comment.
That was a stale merge-conflict
resolution. The comment should reference
~/.openclaude.json as you noted. Fixed in c725c48.
Reviewer caught that the comment was incorrectly changed to ~/.claude.json during merge — project has already migrated to ~/.openclaude.json.
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Thanks for the detailed follow-up. I did a targeted re-review of the current head, focused on the SDK permission flow and the tests you pointed to.
Verdict: Needs changes
Blocking issue:
createExternalCanUseTool()emitsonPermissionRequest(...)before it registers the pending resolver withpermissionTarget.registerPendingPermission(toolUseID). That means a host that responds synchronously/immediately from the permission request callback can lose the response becausependingPermissionPromptsdoes not contain the tool use yet. The request then waits until timeout and denies/falls back even though the host allowed it.
Minimal repro against current head c725c48:
bun --eval "const m = await import('./src/entrypoints/sdk/permissions.ts'); const target = m.createPermissionTarget(); const canUseTool = m.createExternalCanUseTool(undefined, async () => ({ behavior: 'deny', message: 'fallback' }), target, (message) => { const pending = target.pendingPermissionPrompts.get(message.tool_use_id); if (pending) pending.resolve({ behavior: 'allow' }); }, undefined, 5); const result = await canUseTool({ name: 'TestTool' }, {}, {}, {}, 'sync-response-id', undefined); console.log(JSON.stringify(result));"Current output:
{"behavior":"deny","message":"fallback"}Expected behavior would be allow. The fix should be to register the pending permission before emitting onPermissionRequest, then race the already-created pending promise against the timeout. Please add a regression test for the synchronous/immediate host response path, since the existing tests cover late response and timeout safety but not this registration-order case.
What I checked:
- Current head
c725c48 tests/sdk/permissions.test.ts, especially the timeout/late-response testssrc/entrypoints/sdk/permissions.tsrequest/registration orderbun test tests/sdk/permissions.test.ts tests/sdk/shared-utils.test.ts tests/sdk/casing.test.tsbun run build
The SDK shape is moving in the right direction, but this one is a real permission-flow blocker.
…uest 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.
Thanks for the thorough follow-up. Fixed in d64a269: registerPendingPermission now runs before onPermissionRequest is emitted, so a host responding synchronously finds the entry immediately. Added a regression test (permissions.test.ts — "synchronous host response from onPermissionRequest is received") that asserts pending is defined inside the callback and resolves with allow as expected. |
|
Took a deeper look into this.
Blocking Issues
Non-blocking Issues / Recommendations
Security scan summary
Dependency check
Suggested next steps
|
|
I did another pass against current head Blocking: throwing After Minimal repro on current head: bun --eval "const m = await import('./src/entrypoints/sdk/permissions.ts'); const target = m.createPermissionTarget(); const canUseTool = m.createExternalCanUseTool(undefined, async () => ({ behavior: 'deny', message: 'fallback' }), target, () => { throw new Error('host boom') }, undefined, 5); try { await canUseTool({ name: 'TestTool' }, {}, {}, {}, 'throw-id', undefined); } catch (e) { console.log('threw=' + e.message); } console.log('pending=' + target.pendingPermissionPrompts.has('throw-id'));"Current output: Since Permission request shape mismatch The permission request object emitted by That means a host or future stream path validating against |
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.
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.
…solation
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...}").
- 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)
Thank you for the thorough review @devNull-bootloader . I've addressed all issues raised: Blocking Issue: STATE Write Isolation ✅ FixedProblem: Fix (commit Tests (commit
Non-Blocking Issues ✅ Addressed1. Tool Schema Cache Global Clear (commit
|
| Commit | Description |
|---|---|
380fab3 |
fix(sdk): make state setters context-aware for SDK isolation |
b2e5981 |
test(sdk): add context-aware state isolation tests |
2abd87b |
fix(sdk): selective tool schema cache invalidation |
543c4e1 |
docs(sdk): address non-blocking documentation and logging issues |
Please re-review when you have time. Thank you.
…est 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
@techbrewboss Thanks for the thorough review. Both blocking issues have been addressed. Issue 1: Throwing Fixed. The Verification with your minimal repro now shows:
Regression test added: Issue 2: Permission request shape mismatch Fixed. Changes made:
Regression test added: Verification summary:
Commit: Please re-review when you have time. Thank you. |
…on 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
Include canUseTool example in warning message to improve developer experience and make SDK usage more discoverable for new users.
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Thanks for the follow-up fixes. I did a targeted re-review of current head 69029f2.
Verdict: Needs changes
What looks fixed:
- The synchronous
onPermissionRequestresponse race is fixed. The pending resolver is registered before the callback is emitted, and the regression test passes. - Throwing
onPermissionRequestnow cleans up the pending resolver and denies cleanly. permission_requestnow includesuuidandsession_id, and the no-session placeholder is clearer than an empty string.- The focused SDK tests and build pass locally.
Remaining blocker:
- SDK context isolation still leaks through
parentSessionId.runWithSdkContext()now scopessessionId,sessionProjectDir,cwd, andoriginalCwd, butregenerateSessionId({ setCurrentAsParent: true })still writesSTATE.parentSessionIdglobally, andgetParentSessionId()always reads the global value. That means one SDK context can overwrite parent-session metadata for another context.
Minimal repro on current head:
bun --eval "const s = await import('./src/bootstrap/state.ts'); s.runWithSdkContext({ sessionId: '11111111-1111-4111-8111-111111111111', sessionProjectDir: null, cwd: 'C:/a', originalCwd: 'C:/a' }, () => { s.regenerateSessionId({ setCurrentAsParent: true }); }); const afterA = s.getParentSessionId(); s.runWithSdkContext({ sessionId: '22222222-2222-4222-8222-222222222222', sessionProjectDir: null, cwd: 'C:/b', originalCwd: 'C:/b' }, () => { s.regenerateSessionId({ setCurrentAsParent: true }); }); const afterB = s.getParentSessionId(); console.log(JSON.stringify({ afterA, afterB }));"Current output:
{"afterA":"11111111-1111-4111-8111-111111111111","afterB":"22222222-2222-4222-8222-222222222222"}That second context mutates the process-global parent session state. Since the stated goal is SDK-parallel isolation, parentSessionId should either be included in the SDK context and read/written context-locally, or the PR should explicitly prove/document that SDK-isolated execution cannot hit the parent-session path.
Verification run locally:
bun test tests/sdk/permissions.test.ts tests/sdk/shared-utils.test.ts tests/sdk/casing.test.ts tests/sdk/sdk-context-isolation.test.ts tests/sdk/tool-schema-cache.test.tspassed 75/75bun run buildpassed
Happy to re-review once the parent-session state path is scoped or otherwise ruled out.
|
Thanks for the quick fixes. I re-reviewed current head I also ran the focused SDK tests against the PR head and they pass locally: Other than the remaining |
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.
@Vasanthdev2004 Thanks for the thorough review and the clean repro — that made the fix straightforward.
What changed
Why
|
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Thanks for the follow-up. I did a targeted re-review of current head eaea430, focused on the SDK context-isolation blocker I raised earlier plus the permission-flow fixes already discussed above.
Verdict: Approve-ready from my side
What I checked:
parentSessionIdis now included in the SDK context and read with an explicit active-context check, so an SDK context that has no parent session does not fall through to the global parent session.regenerateSessionId({ setCurrentAsParent: true })now writes parent-session state into the active SDK context instead of process-globalSTATE.- The previous synchronous permission response and throwing
onPermissionRequestpaths remain covered by tests. - The exact repro from my previous review now prints
{}, confirming the global parent-session state is not mutated by the SDK contexts.
Verification run locally:
- parent-session repro command passed with
{}output bun test tests/sdk/permissions.test.ts tests/sdk/shared-utils.test.ts tests/sdk/casing.test.ts tests/sdk/sdk-context-isolation.test.ts tests/sdk/tool-schema-cache.test.tspassed 79/79bun run buildpassed
I do not see a remaining blocker from my side on the current head. If another maintainer still has a separate SDK-surface concern, we should respect that, but my requested-change item is resolved.
|
@kevincodex1 @gnanam1990 Would you mind taking a look at this when you get a chance? Thanks! |
gnanam1990
left a comment
There was a problem hiding this comment.
Thanks for the iteration depth here — the back-and-forth with @techbrewboss and @devNull-bootloader has substantially hardened this PR. Did a focused re-review against eaea430:
Verified locally
- Pulled the branch,
bun install, ranbun test tests/sdk→ 95 pass / 0 fail - Walked through the cumulative deltas since my last comment:
registerPendingPermission()now runs beforeonPermissionRequest()is emitted (693b112) — fixes the synchronous-host-response race- Throwing
onPermissionRequestcleans up cleanly (e380af7) permission_requestcarriesuuid+session_id/no-sessionplaceholder (4b38af6)parentSessionIdis now per-SDK-context with explicit if-else (no??undefined fallback) — eaea430 looks right, and the parallel-context test is exactly the right shape
No openclaude red flags
- No
tengu_*, noUSER_TYPE === 'ant', no new outbound network calls in 3P paths, no Anthropic fingerprints, no telemetry deps added.
Why approving despite scope
This started as a 1.6k-line PR I wasn't comfortable single-passing. With two additional reviewers (@techbrewboss, @devNull-bootloader) running independent passes and every blocker landed in fix-up commits with regression tests, the safety margin is now where it needs to be.
LGTM. Recommend @kevincodex1 also gives this a sanity-check before merge given the SDK-runtime surface — happy to do a final pass after that if anything new comes up. 🚀
|
this looks good to me. merging now. |
…ons (Twigpine#951) * 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>
…ons (Twigpine#951) * 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>
…ons (Twigpine#951) * 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>
PR 2: SDK Core — Permission System, Async Context Isolation, and Engine Extensions
Summary
Adds the SDK's core runtime infrastructure — permission handling with external resolution support, AsyncLocalStorage-based context isolation for parallel SDK queries, and QueryEngine extensions for dynamic injection. Includes snake_case ↔ camelCase key mapping utilities for the SDK boundary layer. These modules form the glue between SDK consumers and the CLI's internal permission/runtime systems.
What changed
src/entrypoints/sdk/casing.tssnakeToCamel,camelToSnake,mapKeysToCamel,mapKeysToSnake) — handles the naming convention conversion at the SDK boundary where internal runtime uses snake_case and public SDK API uses camelCasesrc/entrypoints/sdk/shared.tsassertValidSessionId), environment mutex with optional timeout for parallel query safety (acquireEnvMutex,releaseEnvMutex), SDK type definitions (SDKPermissionRequestMessage,SDKPermissionTimeoutMessage,SDKSessionInfo, etc.), andmapMessageToSDK()with runtime validationsrc/entrypoints/sdk/permissions.tsbuildPermissionContext()maps SDK permission modes to internal modes,createExternalCanUseTool()supports external permission resolution via timeout + host callback,createPermissionTarget()factory for race-condition-safe promise resolution,createDefaultCanUseTool()implements secure-by-default denial,connectSdkMcpServers()for MCP server connection from SDK options with config validationsrc/QueryEngine.tsinjectMessages()for session fork/resume,injectAgents()for async agent loading,updateTools()for dynamic permission mode changes (transactional validation),setThinkingConfig()for thinking token budget control, fixedagentDefinitions.allAgentsassignment, replaced lazy MessageSelector import with directmessageFiltersimportsrc/bootstrap/state.tsrunWithSdkContext()overrides global STATE reads (sessionId, cwd, originalCwd, sessionProjectDir) for the current async execution context, enabling parallel SDK queries without cross-session contaminationsrc/tools.tsfilter(Boolean)on tool arrays, null-safeisEnabled()checks, prevents crash if lazy getters return null/undefined during initialization timing edge casessrc/commands.tsmeetsAvailabilityRequirement()accepts nullable Command, defensiveformatDescriptionWithSource()handles missing description,.filter(Boolean)on login/logout commands arraysrc/utils/messageFilters.tsselectableUserMessagesFilter()andmessagesAfterAreOnlySynthetic()moved to standalone utility module for SDK reuse without React/ink dependencysrc/components/MessageSelector.tsxmessageFilters.ts, imports from new modulesrc/screens/REPL.tsxmessageFilters.tstests/sdk/casing.test.ts__proto__)tests/sdk/permissions.test.tscreatePermissionTarget()factory validationtests/sdk/shared-utils.test.tsWhy it changed
The SDK needs to integrate with the CLI's existing permission and runtime systems while maintaining isolation for parallel query execution. The permission system allows SDK consumers to either provide a synchronous
canUseToolcallback or handle permission requests asynchronously via timeout + host response pattern. AsyncLocalStorage enables per-query context that overrides global state without modifying the CLI's singleton-based architecture. The QueryEngine extensions support SDK-specific workflows like session forking/resume and dynamic permission mode switching. Casing utilities bridge the snake_case internal naming (JSONL files, session storage) with camelCase SDK API convention.Impact
src/entrypoints/sdk/canUseTooloronPermissionRequestcallbackTesting
bun test tests/sdk/— 70 pass, 0 fail (132 expect calls)bun run build— passesbun run smoke— not affectedNotes
createPermissionTarget()factory applies onceOnlyResolve wrapper at registration time (not at timeout), ensuring both timeout handler and host response use the same wrapped resolve — prevents "promise already resolved" errorsScopedMcpServerConfigschema (deferred — would require importing Zod at runtime)