@secemp/elwood 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/examples/01-basic-query.js +38 -0
  4. package/examples/02-bug-fixer.js +57 -0
  5. package/examples/03-custom-system-prompt.js +41 -0
  6. package/examples/04-read-only-agent.js +38 -0
  7. package/examples/05-find-todos.js +37 -0
  8. package/examples/06-session-resume.js +70 -0
  9. package/examples/07-hooks-pretooluse.js +74 -0
  10. package/examples/08-hooks-posttooluse-audit.js +66 -0
  11. package/examples/09-hooks-block-etc.js +68 -0
  12. package/examples/10-hooks-redirect-sandbox.js +70 -0
  13. package/examples/11-subagents.js +54 -0
  14. package/examples/12-mcp-stdio.js +48 -0
  15. package/examples/13-mcp-http.js +54 -0
  16. package/examples/14-custom-tool.js +84 -0
  17. package/examples/15-custom-tool-unit-converter.js +132 -0
  18. package/examples/16-mcp-github.js +71 -0
  19. package/examples/17-session-store-postgres.js +78 -0
  20. package/examples/18-session-store-redis.js +65 -0
  21. package/examples/19-session-store-s3.js +67 -0
  22. package/examples/20-session-list.js +72 -0
  23. package/examples/21-hooks-notification-slack.js +78 -0
  24. package/examples/22-hooks-webhook-posttooluse.js +78 -0
  25. package/examples/23-hooks-subagent-tracker.js +59 -0
  26. package/examples/24-v2-session-api.js +62 -0
  27. package/examples/README.md +95 -0
  28. package/examples/basic.js +240 -0
  29. package/examples/smoke-test.js +296 -0
  30. package/package.json +52 -0
  31. package/src/ast-tools.js +182 -0
  32. package/src/index.js +70 -0
  33. package/src/instrumenter.js +2921 -0
  34. package/src/loader.js +306 -0
  35. package/src/locate.js +296 -0
  36. package/src/query.js +2168 -0
package/src/query.js ADDED
@@ -0,0 +1,2168 @@
1
+ /**
2
+ * query.js
3
+ *
4
+ * Subprocess-free implementation of query() compatible with the
5
+ * @anthropic-ai/claude-agent-sdk API.
6
+ *
7
+ * Architecture (discovered via Babel AST investigation of cli.js):
8
+ *
9
+ * cli.js exposes key symbols via globalThis.__elwoodRegister:
10
+ *
11
+ * • agentLoop — async function* (the main agent loop)
12
+ * • appStateFactory — sync factory returning initial app state
13
+ * • defaultModelFn — zero-arg function returning the active model string
14
+ * • supportedModelsFn — zero-arg function returning [{value,label,description}]
15
+ * • setPermissionModeFn — (mode) => normalizedMode string
16
+ * • buildPermissionCtxFn — (opts) => initial permission context
17
+ * • resumeSessionFn — async (sessionId, cb) => loads session for resume
18
+ * • commandsFn — lazy-init var; commandsFn() returns slash commands array
19
+ * • permissionChecker — async (tool, input, opts) => {behavior, updatedInput}
20
+ * • continueSessionFn — async (sessionId?, forkTarget?) => {messages, sessionId, ...}
21
+ *
22
+ * query() returns a Query object synchronously — the async iteration happens
23
+ * inside the attached async generator. Extra methods (interrupt, setModel,
24
+ * setPermissionMode, supportedModels, supportedCommands, mcpServerStatus)
25
+ * are attached to the generator object.
26
+ *
27
+ * ## Subprocess-option equivalences
28
+ *
29
+ * The official SDK has 5 options that control subprocess spawning. Since
30
+ * elwood runs cli.js in-process via Babel AST instrumentation, these are
31
+ * mapped as follows:
32
+ *
33
+ * • executable ('node'|'bun'|'deno')
34
+ * → Inherently the current Node.js process. Stored on the returned
35
+ * generator as `_runtimeInfo` for introspection. cli.js contains
36
+ * Bun/Deno detection code (63 Bun refs, 22 Deno refs found via AST
37
+ * analysis) but these are bundler compatibility shims, not
38
+ * behavior-changing logic.
39
+ *
40
+ * • executableArgs (string[] — Node.js flags like --max-old-space-size)
41
+ * → Already applied to the current process via process.execArgv.
42
+ * Only 2 process.execArgv references found in cli.js, both in
43
+ * generic utility code. Stored for introspection.
44
+ *
45
+ * • extraArgs (Record<string, string|null> — additional CLI flags)
46
+ * → Parsed and mapped to the equivalent agentLoop parameters and
47
+ * env vars. cli.js uses Commander.js with 78+ .option() chains
48
+ * (found at lines 16780-17090). The parsed values flow through
49
+ * function $6 into agentLoop (Gz5) as named parameters.
50
+ *
51
+ * • pathToClaudeCodeExecutable (string)
52
+ * → Mapped to load({ cliPath }). If provided in query() options,
53
+ * it's forwarded as a loader option.
54
+ *
55
+ * • spawnClaudeCodeProcess (function)
56
+ * → Not present in the official SDK types (sdk.d.ts). For elwood,
57
+ * the equivalent is customizing load() options via _loaderOptions.
58
+ */
59
+
60
+ import { randomUUID } from 'node:crypto';
61
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
62
+ import { join, dirname } from 'node:path';
63
+ import { homedir } from 'node:os';
64
+ import { load } from './loader.js';
65
+ import { locate } from './locate.js';
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // extraArgs CLI-flag-to-option mapping
69
+ // ---------------------------------------------------------------------------
70
+ //
71
+ // The official SDK's extraArgs is Record<string, string|null>. Each entry
72
+ // becomes `--<flag> <value>` on the CLI. Commander.js in cli.js parses these
73
+ // into named options that flow into the agentLoop call.
74
+ //
75
+ // Mapping table (discovered via Babel AST analysis of cli.js Commander config,
76
+ // lines 16780-17090):
77
+ //
78
+ // CLI flag → agentLoop param / option field
79
+ // --model <model> → userSpecifiedModel (options.model)
80
+ // --fallback-model <model> → fallbackModel (options.fallbackModel)
81
+ // --max-turns <n> → maxTurns (options.maxTurns)
82
+ // --max-budget-usd <n> → maxBudgetUsd (options.maxBudgetUsd)
83
+ // --task-budget <n> → taskBudget (options.taskBudget)
84
+ // --max-thinking-tokens <n> → thinkingConfig (options.maxThinkingTokens)
85
+ // --system-prompt <p> → customSystemPrompt (options.customSystemPrompt)
86
+ // --append-system-prompt <p> → appendSystemPrompt (options.appendSystemPrompt)
87
+ // --permission-mode <m> → permissionMode (options.permissionMode)
88
+ // --add-dir <dir> → additionalDirectories (options.additionalDirectories)
89
+ // --verbose → verbose (options.verbose)
90
+ // --setting-sources <s> → settingSources (options.settingSources)
91
+ // --strict-mcp-config → strictMcpConfig (options.strictMcpConfig)
92
+ // --include-partial-messages → includePartialMessages (options.includePartialMessages)
93
+ // --json-schema <s> → jsonSchema (options.jsonSchema)
94
+ // --debug → env.DEBUG='true'
95
+ // --debug-to-stderr → env.DEBUG='true'
96
+ // --no-session-persistence → persistSession=false (options.persistSession)
97
+ // --dangerously-skip-permissions → permissionMode='bypassPermissions'
98
+ // --fork-session → forkSession=true (options.forkSession)
99
+ // --continue → continue=true (options.continue)
100
+ // --resume <id> → resume=id (options.resume)
101
+ // --session-id <id> → sessionId (options.sessionId)
102
+ // --bare → env.CLAUDE_CODE_SIMPLE='1'
103
+ // --betas <b> → betas (options.betas)
104
+ // --name <name> → sessionName (env var)
105
+ // --mcp-config <json> → mcpServers (parsed JSON)
106
+ // --agents <json> → agents (parsed JSON)
107
+ //
108
+ // Flags not in this table are stored as env vars (CLAUDE_CODE_EXTRA_<FLAG>)
109
+ // for potential consumption by cli.js internals.
110
+
111
+ /**
112
+ * Convert extraArgs (Record<string, string|null>) to option overrides.
113
+ *
114
+ * Returns { optionOverrides, envOverrides } where:
115
+ * - optionOverrides: keys to merge into the options object
116
+ * - envOverrides: env vars to set on process.env
117
+ *
118
+ * @param {Record<string, string|null>} extraArgs
119
+ * @returns {{ optionOverrides: object, envOverrides: Record<string, string> }}
120
+ */
121
+ function _parseExtraArgs(extraArgs) {
122
+ const optionOverrides = {};
123
+ const envOverrides = {};
124
+
125
+ if (!extraArgs || typeof extraArgs !== 'object') {
126
+ return { optionOverrides, envOverrides };
127
+ }
128
+
129
+ for (const [flag, value] of Object.entries(extraArgs)) {
130
+ // Normalize: strip leading dashes, convert kebab-case to camelCase
131
+ const normalized = flag.replace(/^-+/, '');
132
+ const camel = normalized.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
133
+
134
+ switch (normalized) {
135
+ // ── Direct agentLoop parameter mappings ────────────────────────────
136
+ case 'model':
137
+ optionOverrides.model = value;
138
+ break;
139
+ case 'fallback-model':
140
+ case 'fallbackModel':
141
+ optionOverrides.fallbackModel = value;
142
+ break;
143
+ case 'max-turns':
144
+ case 'maxTurns':
145
+ optionOverrides.maxTurns = value != null ? parseInt(value, 10) : undefined;
146
+ break;
147
+ case 'max-budget-usd':
148
+ case 'maxBudgetUsd':
149
+ optionOverrides.maxBudgetUsd = value != null ? parseFloat(value) : undefined;
150
+ break;
151
+ case 'task-budget':
152
+ case 'taskBudget':
153
+ optionOverrides.taskBudget = value != null ? parseInt(value, 10) : undefined;
154
+ break;
155
+ case 'max-thinking-tokens':
156
+ case 'maxThinkingTokens':
157
+ optionOverrides.maxThinkingTokens = value != null ? parseInt(value, 10) : undefined;
158
+ break;
159
+ case 'system-prompt':
160
+ case 'systemPrompt':
161
+ optionOverrides.customSystemPrompt = value;
162
+ break;
163
+ case 'append-system-prompt':
164
+ case 'appendSystemPrompt':
165
+ optionOverrides.appendSystemPrompt = value;
166
+ break;
167
+ case 'permission-mode':
168
+ case 'permissionMode':
169
+ optionOverrides.permissionMode = value;
170
+ break;
171
+ case 'add-dir':
172
+ case 'addDir':
173
+ // Multiple --add-dir flags are space-separated; parse as array
174
+ if (value != null) {
175
+ const dirs = value.split(/[,\s]+/).filter(Boolean);
176
+ optionOverrides.additionalDirectories = [
177
+ ...(optionOverrides.additionalDirectories ?? []),
178
+ ...dirs,
179
+ ];
180
+ }
181
+ break;
182
+ case 'verbose':
183
+ optionOverrides.verbose = value !== 'false';
184
+ break;
185
+ case 'setting-sources':
186
+ case 'settingSources':
187
+ if (value != null) {
188
+ optionOverrides.settingSources = value.split(',').map(s => s.trim()).filter(Boolean);
189
+ }
190
+ break;
191
+ case 'strict-mcp-config':
192
+ case 'strictMcpConfig':
193
+ optionOverrides.strictMcpConfig = true;
194
+ break;
195
+ case 'include-partial-messages':
196
+ case 'includePartialMessages':
197
+ optionOverrides.includePartialMessages = true;
198
+ break;
199
+ case 'json-schema':
200
+ case 'jsonSchema':
201
+ if (value != null) {
202
+ try {
203
+ optionOverrides.jsonSchema = JSON.parse(value);
204
+ } catch {
205
+ optionOverrides.jsonSchema = value; // pass through if not valid JSON
206
+ }
207
+ }
208
+ break;
209
+ case 'no-session-persistence':
210
+ case 'noSessionPersistence':
211
+ optionOverrides.persistSession = false;
212
+ break;
213
+ case 'dangerously-skip-permissions':
214
+ case 'dangerouslySkipPermissions':
215
+ optionOverrides.permissionMode = 'bypassPermissions';
216
+ optionOverrides.allowDangerouslySkipPermissions = true;
217
+ break;
218
+ case 'allow-dangerously-skip-permissions':
219
+ case 'allowDangerouslySkipPermissions':
220
+ optionOverrides.allowDangerouslySkipPermissions = true;
221
+ break;
222
+ case 'fork-session':
223
+ case 'forkSession':
224
+ optionOverrides.forkSession = true;
225
+ break;
226
+ case 'continue':
227
+ optionOverrides.continue = true;
228
+ break;
229
+ case 'resume':
230
+ optionOverrides.resume = value;
231
+ break;
232
+ case 'resume-session-at':
233
+ case 'resumeSessionAt':
234
+ optionOverrides.resumeSessionAt = value;
235
+ break;
236
+ case 'session-id':
237
+ case 'sessionId':
238
+ // session-id is not a direct agentLoop param but can be tracked
239
+ if (value != null) {
240
+ envOverrides.CLAUDE_SESSION_ID = value;
241
+ }
242
+ break;
243
+ case 'betas':
244
+ if (value != null) {
245
+ optionOverrides.betas = value.split(/[,\s]+/).filter(Boolean);
246
+ }
247
+ break;
248
+ case 'agents':
249
+ if (value != null) {
250
+ try {
251
+ optionOverrides.agents = JSON.parse(value);
252
+ } catch {
253
+ // pass through
254
+ }
255
+ }
256
+ break;
257
+ case 'mcp-config':
258
+ case 'mcpConfig':
259
+ if (value != null) {
260
+ try {
261
+ const parsed = JSON.parse(value);
262
+ optionOverrides.mcpServers = parsed.mcpServers ?? parsed;
263
+ } catch {
264
+ // pass through
265
+ }
266
+ }
267
+ break;
268
+
269
+ // ── Environment variable mappings ──────────────────────────────────
270
+ case 'debug':
271
+ case 'debug-to-stderr':
272
+ envOverrides.DEBUG = 'true';
273
+ break;
274
+ case 'bare':
275
+ envOverrides.CLAUDE_CODE_SIMPLE = '1';
276
+ break;
277
+ case 'name':
278
+ if (value != null) {
279
+ envOverrides.CLAUDE_SESSION_NAME = value;
280
+ }
281
+ break;
282
+
283
+ // ── Flags that are already handled by other options ────────────────
284
+ case 'cwd':
285
+ optionOverrides.cwd = value;
286
+ break;
287
+ case 'tools':
288
+ if (value != null) {
289
+ optionOverrides.tools = value.split(',').map(s => s.trim()).filter(Boolean);
290
+ }
291
+ break;
292
+ case 'allowedTools':
293
+ case 'allowed-tools':
294
+ if (value != null) {
295
+ optionOverrides.allowedTools = value.split(/[,\s]+/).filter(Boolean);
296
+ }
297
+ break;
298
+ case 'disallowedTools':
299
+ case 'disallowed-tools':
300
+ if (value != null) {
301
+ optionOverrides.disallowedTools = value.split(/[,\s]+/).filter(Boolean);
302
+ }
303
+ break;
304
+ case 'permission-prompt-tool':
305
+ case 'permissionPromptTool':
306
+ optionOverrides.permissionPromptToolName = value;
307
+ break;
308
+
309
+ // ── Unknown flags → store as environment variable ─────────────────
310
+ default: {
311
+ // Convert flag name to env var: --my-flag → CLAUDE_EXTRA_MY_FLAG
312
+ const envKey = 'CLAUDE_EXTRA_' + normalized.replace(/-/g, '_').toUpperCase();
313
+ envOverrides[envKey] = value ?? '';
314
+ break;
315
+ }
316
+ }
317
+ }
318
+
319
+ return { optionOverrides, envOverrides };
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // GAP 4: Module-level stderr hook set for concurrent-safe interception.
324
+ // Instead of monkey-patching process.stderr.write per-query, we patch once
325
+ // and dispatch to all active per-query handlers via a Set.
326
+ // ---------------------------------------------------------------------------
327
+ let _stderrHooks = new Set();
328
+
329
+ if (!process.stderr._elwoodPatched) {
330
+ const _origStderrWrite = process.stderr.write.bind(process.stderr);
331
+ process.stderr.write = function (chunk, encodingOrCb, cb) {
332
+ for (const hook of _stderrHooks) {
333
+ try { hook(chunk); } catch { /* ignore */ }
334
+ }
335
+ return _origStderrWrite(chunk, encodingOrCb, cb);
336
+ };
337
+ process.stderr._elwoodPatched = true;
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Module-level singletons — cli.js is parsed and imported exactly once.
342
+ // ---------------------------------------------------------------------------
343
+
344
+ /** @type {Promise<object>|null} */
345
+ let _loadPromise = null;
346
+
347
+ /** @type {object|null} */
348
+ let _cliExports = null;
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // ensureLoaded
352
+ // ---------------------------------------------------------------------------
353
+
354
+ /**
355
+ * Load cli.js once and capture all exposed symbols.
356
+ *
357
+ * @param {object} [loaderOptions]
358
+ * @returns {Promise<object>}
359
+ */
360
+ async function ensureLoaded(loaderOptions = {}) {
361
+ if (_cliExports) return _cliExports;
362
+ if (_loadPromise) return _loadPromise;
363
+
364
+ _loadPromise = (async () => {
365
+ let resolveRegister;
366
+ const registerPromise = new Promise(r => { resolveRegister = r; });
367
+
368
+ globalThis.__elwoodImporting = true;
369
+
370
+ globalThis.__elwoodRegister = (exports) => {
371
+ // Keep the most complete version (SessionClass may come in a second call)
372
+ if (
373
+ !_cliExports ||
374
+ (exports.SessionClass && !_cliExports.SessionClass) ||
375
+ (exports.agentLoop && !_cliExports.agentLoop)
376
+ ) {
377
+ _cliExports = exports;
378
+ }
379
+ resolveRegister(exports);
380
+ };
381
+
382
+ try {
383
+ await load({ ...loaderOptions, instrument: true });
384
+ } catch (loadErr) {
385
+ globalThis.__elwoodImporting = undefined;
386
+ globalThis.__elwoodRegister = undefined;
387
+ throw loadErr;
388
+ }
389
+
390
+ await registerPromise;
391
+
392
+ globalThis.__elwoodImporting = undefined;
393
+ globalThis.__elwoodRegister = undefined;
394
+
395
+ if (!_cliExports?.agentLoop) {
396
+ throw new Error(
397
+ `elwood: agentLoop not found after loading cli.js.\n` +
398
+ `Found exports: ${JSON.stringify(Object.keys(_cliExports ?? {}))}\n\n` +
399
+ `This usually means the AST fingerprint did not match this version of\n` +
400
+ `cli.js. Run scripts/investigate-ast.js for diagnostic output.`
401
+ );
402
+ }
403
+
404
+ return _cliExports;
405
+ })();
406
+
407
+ return _loadPromise;
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // query() — returns a Query object (sync)
412
+ // ---------------------------------------------------------------------------
413
+
414
+ /**
415
+ * Query Claude Code in-process (no subprocess).
416
+ *
417
+ * Matches the @anthropic-ai/claude-agent-sdk `query()` interface.
418
+ * Returns a Query object synchronously; the async iteration happens inside
419
+ * the attached async generator.
420
+ *
421
+ * @param {{ prompt: string, options?: object }} params
422
+ * @returns {import('../types.js').Query}
423
+ */
424
+ export function query({ prompt, options = {} }) {
425
+ // ── Map subprocess-only options to in-process equivalents ───────────────
426
+ //
427
+ // pathToClaudeCodeExecutable → loaderOptions.cliPath
428
+ // This is the official SDK's way to specify where cli.js lives.
429
+ // In elwood, this maps directly to the `cliPath` loader option.
430
+ const loaderOptions = { ...(options._loaderOptions ?? {}) };
431
+ if (options.pathToClaudeCodeExecutable && !loaderOptions.cliPath) {
432
+ loaderOptions.cliPath = options.pathToClaudeCodeExecutable;
433
+ }
434
+
435
+ // extraArgs → parse CLI flags and merge into options
436
+ // The official SDK converts these to `--flag value` CLI arguments.
437
+ // We parse them into the equivalent option properties.
438
+ let _extraArgsEnv = {};
439
+ if (options.extraArgs && typeof options.extraArgs === 'object') {
440
+ const { optionOverrides, envOverrides } = _parseExtraArgs(options.extraArgs);
441
+ // Merge overrides into options (extraArgs have LOWER priority than
442
+ // explicitly set options — explicit options win)
443
+ for (const [key, value] of Object.entries(optionOverrides)) {
444
+ if (options[key] === undefined || options[key] === null) {
445
+ options[key] = value;
446
+ }
447
+ }
448
+ _extraArgsEnv = envOverrides;
449
+ }
450
+
451
+ // executable / executableArgs → informational only
452
+ // In the official SDK these control which runtime binary runs cli.js.
453
+ // Since elwood runs in-process under the CURRENT Node.js process,
454
+ // these cannot change behavior. We store them for introspection.
455
+ const _runtimeInfo = {
456
+ executable: options.executable ?? 'node',
457
+ executableArgs: options.executableArgs ?? [],
458
+ actualRuntime: 'node',
459
+ actualExecArgv: [...process.execArgv],
460
+ inProcess: true,
461
+ };
462
+
463
+ const abortController = options.abortController ?? new AbortController();
464
+
465
+ let currentAppState = null;
466
+
467
+ const getAppState = () => currentAppState;
468
+ const setAppState = (fn) => {
469
+ currentAppState = fn(currentAppState);
470
+ };
471
+
472
+ // The underlying async generator
473
+ async function* _generate() {
474
+ const exports = await ensureLoaded(loaderOptions);
475
+ const {
476
+ agentLoop,
477
+ appStateFactory,
478
+ defaultModelFn,
479
+ supportedModelsFn,
480
+ setPermissionModeFn,
481
+ buildPermissionCtxFn,
482
+ resumeSessionFn,
483
+ commandsFn,
484
+ permissionChecker,
485
+ continueSessionFn,
486
+ // Gap-fix exports
487
+ hookRegistrySetterFn,
488
+ hookClearFn,
489
+ allowedSettingSourcesSetterFn,
490
+ permissionPromptToolFn,
491
+ // Auth exports
492
+ getAccountInfoFn,
493
+ } = exports;
494
+
495
+ // ── FIX 3: systemPrompt mapping ──────────────────────────────────────────
496
+ // Official SDK accepts options.systemPrompt as:
497
+ // - string → sets customSystemPrompt
498
+ // - { type: 'preset', preset: 'claude_code', append?: string } → sets appendSystemPrompt
499
+ // - undefined → sets customSystemPrompt = "" (empty string clears default)
500
+ // Map to internal names customSystemPrompt / appendSystemPrompt.
501
+ if (options.systemPrompt !== undefined || !options.customSystemPrompt) {
502
+ const sp = options.systemPrompt;
503
+ if (sp === undefined) {
504
+ if (!options.customSystemPrompt && !options.appendSystemPrompt) {
505
+ options.customSystemPrompt = '';
506
+ }
507
+ } else if (typeof sp === 'string') {
508
+ options.customSystemPrompt = sp;
509
+ } else if (sp && sp.type === 'preset') {
510
+ // preset: 'claude_code' — use default prompt, optionally append
511
+ options.customSystemPrompt = undefined;
512
+ options.appendSystemPrompt = sp.append;
513
+ }
514
+ }
515
+
516
+ // ── FIX 4: outputFormat → jsonSchema mapping ──────────────────────────────
517
+ // Official SDK accepts options.outputFormat = { type: 'json_schema', schema: {...} }
518
+ // Extract jsonSchema from it, maintaining backward compat with direct options.jsonSchema.
519
+ if (options.outputFormat?.type === 'json_schema' && options.outputFormat.schema) {
520
+ if (!options.jsonSchema) {
521
+ options.jsonSchema = options.outputFormat.schema;
522
+ }
523
+ }
524
+
525
+ // ── GAP 4 fix: Concurrent-safe env vars ─────────────────────────────────
526
+ // Instead of replacing process.env entirely, we track which keys were added
527
+ // (new) vs overwritten (existing) so we can restore only the delta in finally.
528
+ const _envAdded = {};
529
+ const _envRestored = {};
530
+ if (options.env && typeof options.env === 'object') {
531
+ for (const [k, v] of Object.entries(options.env)) {
532
+ if (k in process.env) {
533
+ _envRestored[k] = process.env[k];
534
+ } else {
535
+ _envAdded[k] = true;
536
+ }
537
+ process.env[k] = v;
538
+ }
539
+ }
540
+
541
+ // ── Apply extraArgs env overrides ─────────────────────────────────────
542
+ // These are env vars derived from extraArgs flags (e.g. --debug → DEBUG=true,
543
+ // --bare → CLAUDE_CODE_SIMPLE=1, unknown flags → CLAUDE_EXTRA_<FLAG>=value).
544
+ // Applied AFTER options.env so they don't clobber explicit env settings.
545
+ if (_extraArgsEnv && typeof _extraArgsEnv === 'object') {
546
+ for (const [k, v] of Object.entries(_extraArgsEnv)) {
547
+ // Only apply if not already set by options.env
548
+ if (!(k in (options.env ?? {}))) {
549
+ if (k in process.env) {
550
+ if (!(k in _envRestored)) {
551
+ _envRestored[k] = process.env[k];
552
+ }
553
+ } else {
554
+ _envAdded[k] = true;
555
+ }
556
+ process.env[k] = v;
557
+ }
558
+ }
559
+ }
560
+
561
+ // ── GAP 4 fix: Concurrent-safe stderr interception ──────────────────────
562
+ // Use the module-level _stderrHooks set — add a per-query hook and remove
563
+ // it in finally. No global monkey-patching per query.
564
+ let _stderrHook = null;
565
+ if (options.stderr && typeof options.stderr === 'function') {
566
+ const _stderrCallback = options.stderr;
567
+ _stderrHook = (chunk) => {
568
+ try {
569
+ const str = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
570
+ _stderrCallback(str);
571
+ } catch {
572
+ // Ignore errors in the callback
573
+ }
574
+ };
575
+ _stderrHooks.add(_stderrHook);
576
+ }
577
+
578
+ // ── FIX 8: Wire missing options into env vars ──────────────────────────
579
+ // enableFileCheckpointing → env var CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING
580
+ if (options.enableFileCheckpointing) {
581
+ if ('CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING' in process.env) {
582
+ _envRestored['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'] = process.env['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'];
583
+ } else {
584
+ _envAdded['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'] = true;
585
+ }
586
+ process.env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING = 'true';
587
+ }
588
+ // Set entrypoint
589
+ if (!process.env.CLAUDE_CODE_ENTRYPOINT) {
590
+ if (!('CLAUDE_CODE_ENTRYPOINT' in _envRestored) && !('CLAUDE_CODE_ENTRYPOINT' in _envAdded)) {
591
+ _envAdded['CLAUDE_CODE_ENTRYPOINT'] = true;
592
+ }
593
+ process.env.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts';
594
+ }
595
+ // Set SDK version (official SDK sets this at sdk.mjs line 21332)
596
+ if (!process.env.CLAUDE_AGENT_SDK_VERSION) {
597
+ if (!('CLAUDE_AGENT_SDK_VERSION' in _envRestored) && !('CLAUDE_AGENT_SDK_VERSION' in _envAdded)) {
598
+ _envAdded['CLAUDE_AGENT_SDK_VERSION'] = true;
599
+ }
600
+ process.env.CLAUDE_AGENT_SDK_VERSION = '0.1.77';
601
+ }
602
+
603
+ // GAP B: hoist sdk-server map before try so it's accessible in finally
604
+ const _sdkMcpServersMap = {};
605
+
606
+ try {
607
+ // Build initial app state
608
+ let baseState = {};
609
+ if (appStateFactory) {
610
+ try {
611
+ baseState = appStateFactory() ?? {};
612
+ } catch {
613
+ baseState = {};
614
+ }
615
+ }
616
+
617
+ // Apply permissionMode if provided
618
+ let toolPermissionContext = baseState.toolPermissionContext;
619
+ if (options.permissionMode) {
620
+ if (buildPermissionCtxFn) {
621
+ try {
622
+ const newCtx = buildPermissionCtxFn(options.permissionMode);
623
+ // buildPermissionCtxFn may return a context object or CLI-flag array —
624
+ // only use the result if it looks like a permission context object.
625
+ if (newCtx !== undefined && newCtx !== null &&
626
+ typeof newCtx === 'object' && !Array.isArray(newCtx) &&
627
+ ('mode' in newCtx || 'alwaysAllowRules' in newCtx)) {
628
+ toolPermissionContext = newCtx;
629
+ } else if (toolPermissionContext && typeof toolPermissionContext === 'object') {
630
+ // Fallback: just update mode on the existing context
631
+ toolPermissionContext = { ...toolPermissionContext, mode: options.permissionMode };
632
+ }
633
+ } catch {
634
+ // Degrade gracefully — keep original context with updated mode
635
+ if (toolPermissionContext && typeof toolPermissionContext === 'object') {
636
+ toolPermissionContext = { ...toolPermissionContext, mode: options.permissionMode };
637
+ }
638
+ }
639
+ } else if (toolPermissionContext && typeof toolPermissionContext === 'object') {
640
+ // No buildPermissionCtxFn — just set mode directly
641
+ toolPermissionContext = { ...toolPermissionContext, mode: options.permissionMode };
642
+ }
643
+ }
644
+
645
+ // ── FIX 3: additionalDirectories — wire into toolPermissionContext ───────────
646
+ // additionalDirectories is NOT in agentLoop params (AST investigation confirmed).
647
+ // It is processed by j5B() in the CLI, which calls iF(ctx, {type:'addDirectories'}).
648
+ // We approximate this by adding the directories to the additionalWorkingDirectories
649
+ // map in the toolPermissionContext. If the context doesn't have that field (e.g.
650
+ // different version), we degrade gracefully.
651
+ if (options.additionalDirectories?.length && toolPermissionContext) {
652
+ try {
653
+ // The toolPermissionContext additionalWorkingDirectories is a Map of path→{path,source}
654
+ // Try to add the directories to it
655
+ const existingMap = toolPermissionContext.additionalWorkingDirectories;
656
+ if (existingMap instanceof Map) {
657
+ const newMap = new Map(existingMap);
658
+ for (const dir of options.additionalDirectories) {
659
+ if (typeof dir === 'string') {
660
+ newMap.set(dir, { path: dir, source: 'cliArg' });
661
+ }
662
+ }
663
+ toolPermissionContext = { ...toolPermissionContext, additionalWorkingDirectories: newMap };
664
+ } else if (existingMap !== null && typeof existingMap === 'object') {
665
+ // Plain object form
666
+ const newMap = { ...existingMap };
667
+ for (const dir of options.additionalDirectories) {
668
+ if (typeof dir === 'string') {
669
+ newMap[dir] = { path: dir, source: 'cliArg' };
670
+ }
671
+ }
672
+ toolPermissionContext = { ...toolPermissionContext, additionalWorkingDirectories: newMap };
673
+ }
674
+ } catch {
675
+ // Degrade gracefully — additionalDirectories not wired
676
+ }
677
+ }
678
+
679
+ // ── Wire hooks into appState settings ────────────────────────────────────
680
+ // cli.js reads hooks from settings (Hc()?.hooks). We patch it into the
681
+ // settings object within the mutable app state so hooks are picked up.
682
+ let settings = baseState.settings ?? {};
683
+ if (options.hooks && typeof options.hooks === 'object') {
684
+ settings = { ...settings, hooks: options.hooks };
685
+ }
686
+
687
+ // ── FIX 8: allowDangerouslySkipPermissions ───────────────────────────────
688
+ // When permissionMode is 'bypassPermissions', allowDangerouslySkipPermissions
689
+ // must be true. This is a safety gate from the official SDK.
690
+ if (options.permissionMode === 'bypassPermissions' && !options.allowDangerouslySkipPermissions) {
691
+ throw new Error(
692
+ 'permissionMode "bypassPermissions" requires allowDangerouslySkipPermissions: true'
693
+ );
694
+ }
695
+
696
+ // ── FIX 10a: canUseTool + permissionPromptToolName mutual exclusion ─────
697
+ // Official SDK (sdk.mjs line 7697-7700) throws if both are provided.
698
+ if (options.canUseTool && options.permissionPromptToolName) {
699
+ throw new Error(
700
+ 'canUseTool callback cannot be used with permissionPromptToolName. Please use one or the other.'
701
+ );
702
+ }
703
+
704
+ // ── FIX 10b: fallbackModel === model validation ─────────────────────────
705
+ // Official SDK (sdk.mjs line 7741-7744) throws if fallbackModel equals model.
706
+ if (options.model && options.fallbackModel && options.fallbackModel === options.model) {
707
+ throw new Error(
708
+ 'Fallback model cannot be the same as the main model. Please specify a different model for fallbackModel option.'
709
+ );
710
+ }
711
+
712
+ // ── FIX 8: sandbox settings ──────────────────────────────────────────────
713
+ // Merge sandbox settings into the settings object.
714
+ if (options.sandbox && typeof options.sandbox === 'object') {
715
+ settings = { ...settings, sandbox: options.sandbox };
716
+ }
717
+
718
+ // ── FIX 8: betas ─────────────────────────────────────────────────────────
719
+ // Beta feature flags (e.g., 'context-1m-2025-08-07')
720
+ if (options.betas?.length) {
721
+ settings = { ...settings, betas: options.betas };
722
+ }
723
+
724
+ // ── FIX 8: plugins ───────────────────────────────────────────────────────
725
+ // Plugins are loaded via settings
726
+ if (options.plugins?.length) {
727
+ settings = { ...settings, plugins: options.plugins };
728
+ }
729
+
730
+ // ── FIX 8: persistSession ────────────────────────────────────────────────
731
+ // When false, disable session persistence to disk
732
+ if (options.persistSession === false) {
733
+ settings = { ...settings, persistSession: false };
734
+ }
735
+
736
+ currentAppState = { ...baseState, toolPermissionContext, settings };
737
+
738
+ // ── GAP A: JS Hook Callbacks via MC1 (hookRegistrySetterFn) ──────────────
739
+ //
740
+ // The hook dispatch system in cli.js merges two hook sources:
741
+ // 1. Settings hooks (shell command hooks from X2().hooks)
742
+ // 2. In-process hooks from I7A (set by MC1, read by OC1)
743
+ //
744
+ // Callback-type hooks have format: { type: 'callback', callback(input, toolUseId, signal) }
745
+ // MC1 expects: { [eventName]: [{ matcher?: string, hooks: [{type:'callback', callback:fn}] }] }
746
+ //
747
+ // SDK HookCallback: (input, toolUseID, {signal}) → Promise<HookJSONOutput>
748
+ // Internal callback: (input, toolUseID, signal) → Promise<HookJSONOutput>
749
+ // We wrap accordingly.
750
+ if (
751
+ options.hooks && typeof options.hooks === 'object' &&
752
+ hookRegistrySetterFn && typeof hookRegistrySetterFn === 'function'
753
+ ) {
754
+ try {
755
+ const inProcessHooks = {};
756
+ for (const [eventName, matcherArray] of Object.entries(options.hooks)) {
757
+ if (!Array.isArray(matcherArray) || matcherArray.length === 0) continue;
758
+ inProcessHooks[eventName] = matcherArray
759
+ .filter(m => m && Array.isArray(m.hooks) && m.hooks.length > 0)
760
+ .map(m => ({
761
+ matcher: m.matcher ?? undefined,
762
+ hooks: m.hooks.map(cb => ({
763
+ type: 'callback',
764
+ // Wrap SDK signature (input, toolUseID, {signal}) → internal (input, toolUseID, signal)
765
+ callback: typeof cb === 'function'
766
+ ? (input, toolUseId, signal) => cb(input, toolUseId, { signal })
767
+ : undefined,
768
+ })).filter(h => h.callback),
769
+ }))
770
+ .filter(m => m.hooks.length > 0);
771
+ }
772
+ if (Object.keys(inProcessHooks).length > 0) {
773
+ hookRegistrySetterFn(inProcessHooks);
774
+ }
775
+ } catch {
776
+ // Degrade gracefully — hooks still work via settings path
777
+ }
778
+ }
779
+
780
+ // ── GAP B: In-process MCP servers (type:'sdk') ────────────────────────────
781
+ //
782
+ // cli.js throws for type='sdk' normally. The be() function was patched by
783
+ // instrumenter.js to call globalThis.__elwoodHandleSdkMcp(name, config)
784
+ // when type='sdk' is encountered.
785
+ //
786
+ // We install that handler here, which:
787
+ // 1. Creates a paired in-memory transport
788
+ // 2. Connects the McpServer instance to the server-side transport
789
+ // 3. Returns the client-side transport for the MCP client to use
790
+ //
791
+ // The mcpServers option entries with type:'sdk' must have an 'instance'
792
+ // property that is a live McpServer object.
793
+ // Note: _sdkMcpServersMap is declared before the try block so the finally
794
+ // block can access it for cleanup.
795
+ if (options.mcpServers && typeof options.mcpServers === 'object') {
796
+ for (const [name, cfg] of Object.entries(options.mcpServers)) {
797
+ if (cfg?.type === 'sdk' && cfg.instance) {
798
+ _sdkMcpServersMap[name] = cfg.instance;
799
+ }
800
+ }
801
+ }
802
+ if (Object.keys(_sdkMcpServersMap).length > 0) {
803
+ globalThis.__elwoodHandleSdkMcp = async (serverName, _config) => {
804
+ const mcpServerInstance = _sdkMcpServersMap[serverName];
805
+ if (!mcpServerInstance) {
806
+ throw new Error(`elwood: No McpServer instance registered for sdk server "${serverName}"`);
807
+ }
808
+ // Resolve instance if it's a Promise (async createSdkMcpServer)
809
+ const instance = (mcpServerInstance && typeof mcpServerInstance.then === 'function')
810
+ ? await mcpServerInstance
811
+ : mcpServerInstance;
812
+
813
+ // Create paired in-memory transports that match cli.js $c1 semantics:
814
+ // - send() uses queueMicrotask for async delivery (avoids reentrancy issues)
815
+ // - send() throws if the transport is already closed
816
+ // - close() idempotent, propagates to peer
817
+ // Transport interface: { start(), send(msg), close(), onmessage, onclose, onerror }
818
+ const { createLinkedTransportPair } = exports;
819
+ let clientTransport, serverTransport;
820
+ if (typeof createLinkedTransportPair === 'function') {
821
+ // Use the real $c1-based factory from cli.js (preferred)
822
+ [clientTransport, serverTransport] = createLinkedTransportPair();
823
+ } else {
824
+ // Fallback: homemade transport with correct queueMicrotask semantics
825
+ const _makeTp = () => ({
826
+ onmessage: null, onclose: null, onerror: null, _peer: null, _closed: false,
827
+ async start() {},
828
+ async send(msg) {
829
+ if (this._closed) throw new Error('Transport is closed');
830
+ // Use queueMicrotask to match $c1 async delivery semantics
831
+ const peer = this._peer;
832
+ if (peer) queueMicrotask(() => { try { peer.onmessage?.(msg); } catch (e) { peer.onerror?.(e); } });
833
+ },
834
+ async close() {
835
+ if (this._closed) return;
836
+ this._closed = true;
837
+ this.onclose?.();
838
+ const peer = this._peer;
839
+ if (peer && !peer._closed) { peer._closed = true; peer.onclose?.(); }
840
+ },
841
+ });
842
+ clientTransport = _makeTp();
843
+ serverTransport = _makeTp();
844
+ clientTransport._peer = serverTransport;
845
+ serverTransport._peer = clientTransport;
846
+ }
847
+
848
+ // Connect the McpServer to the server-side transport
849
+ await instance.connect(serverTransport);
850
+
851
+ // Return the client-side transport for the MCP client
852
+ return clientTransport;
853
+ };
854
+ }
855
+
856
+ // ── GAP D: settingSources → allowedSettingSourcesSetterFn (Ga0) ──────────
857
+ // options.settingSources filters which settings files are loaded.
858
+ // cli.js has Ga0(A) which sets rA.allowedSettingSources.
859
+ // Default: ['userSettings', 'projectSettings', 'localSettings', 'flagSettings', 'policySettings']
860
+ if (
861
+ options.settingSources && Array.isArray(options.settingSources) &&
862
+ allowedSettingSourcesSetterFn && typeof allowedSettingSourcesSetterFn === 'function'
863
+ ) {
864
+ try {
865
+ allowedSettingSourcesSetterFn(options.settingSources);
866
+ } catch {
867
+ // Degrade gracefully
868
+ }
869
+ }
870
+
871
+ // ── Session continue + resume handling ────────────────────────────────────
872
+ // Use continueSessionFn (a56) for both `continue` and `resume` cases:
873
+ // - continue=true → a56(undefined, undefined) — finds the most recent session
874
+ // - resume=sessionId → a56(sessionId, undefined) — loads specific session
875
+ //
876
+ // forkSession (boolean, sdk.d.ts v2.0.0): when true, we load the messages
877
+ // from the prior session but do NOT reuse its session ID — a new ID is generated.
878
+ // This is handled by NOT calling iw(sessionId) — we simply discard the sessionId
879
+ // from the loaded result (agentLoop will generate a new one internally).
880
+ let mutableMessages = [];
881
+ let orphanedPermission = undefined;
882
+ let deferredToolUse = undefined;
883
+ let _continuedSessionId = undefined;
884
+ const resolvedContinueFn = continueSessionFn ?? resumeSessionFn;
885
+ if (options.continue && resolvedContinueFn) {
886
+ try {
887
+ const result = await resolvedContinueFn(undefined, undefined);
888
+ if (result) {
889
+ if (Array.isArray(result)) {
890
+ mutableMessages = result;
891
+ } else {
892
+ mutableMessages = result.messages ?? [];
893
+ deferredToolUse = result.deferredToolUse ?? undefined;
894
+ // GAP 3 fix: when forkSession is explicitly false, preserve the
895
+ // sessionId so agentLoop continues in-place (no new session).
896
+ if (options.forkSession === false && result.sessionId) {
897
+ _continuedSessionId = result.sessionId;
898
+ }
899
+ // When forkSession=true (or default), the sessionId from result is
900
+ // intentionally NOT forwarded — agentLoop will create a fresh session ID.
901
+ }
902
+ }
903
+ } catch {
904
+ // Session load failed — start fresh
905
+ }
906
+ } else if ((options.resume || options.resumeSessionAt) && resolvedContinueFn) {
907
+ try {
908
+ const sessionId = options.resume ?? options.resumeSessionAt;
909
+ const result = await resolvedContinueFn(sessionId, undefined);
910
+ if (result) {
911
+ if (Array.isArray(result)) {
912
+ mutableMessages = result;
913
+ } else {
914
+ mutableMessages = result.messages ?? [];
915
+ deferredToolUse = result.deferredToolUse ?? undefined;
916
+ // GAP 1 fix: when resumeSessionAt is set, truncate messages to include
917
+ // only those up to and including the message with the matching id.
918
+ if (options.resumeSessionAt) {
919
+ const idx = result.messages.findLastIndex(
920
+ m => m.message?.id === options.resumeSessionAt || m.id === options.resumeSessionAt
921
+ );
922
+ if (idx !== -1) {
923
+ mutableMessages = result.messages.slice(0, idx + 1);
924
+ }
925
+ }
926
+ // Preserve sessionId for resume (always continues in-place)
927
+ if (result.sessionId) {
928
+ _continuedSessionId = result.sessionId;
929
+ }
930
+ }
931
+ }
932
+ } catch {
933
+ // Session load failed — start fresh
934
+ }
935
+ }
936
+
937
+ // ── FIX 5: allowedTools / disallowedTools ──────────────────────────────
938
+ // In the official SDK these are auto-approval/denial lists for permission
939
+ // control, NOT tool existence filters. allowedTools means "these tools
940
+ // execute without permission prompt"; disallowedTools means "remove these
941
+ // tools from the model's context entirely".
942
+ // Wire them into toolPermissionContext for permission-level handling.
943
+ let tools = options.tools ?? [];
944
+
945
+ // disallowedTools: remove from tools array (correct — official SDK
946
+ // says they are removed from the model's context)
947
+ if (options.disallowedTools?.length) {
948
+ const disallowed = new Set(options.disallowedTools);
949
+ tools = tools.filter(t => {
950
+ const name = typeof t === 'string' ? t : (t?.name ?? '');
951
+ return !disallowed.has(name);
952
+ });
953
+ }
954
+
955
+ // allowedTools: these auto-approve without permission prompt.
956
+ // Wire into toolPermissionContext.alwaysAllowRules if possible.
957
+ if (options.allowedTools?.length && toolPermissionContext) {
958
+ try {
959
+ const existingRules = toolPermissionContext.alwaysAllowRules ?? [];
960
+ const newRules = [...existingRules];
961
+ for (const toolName of options.allowedTools) {
962
+ // Add a rule that allows this tool without prompting
963
+ if (!newRules.some(r => r.toolName === toolName && !r.ruleContent)) {
964
+ newRules.push({ toolName, ruleContent: undefined });
965
+ }
966
+ }
967
+ toolPermissionContext = { ...toolPermissionContext, alwaysAllowRules: newRules };
968
+ // Update currentAppState with the modified toolPermissionContext
969
+ currentAppState = { ...currentAppState, toolPermissionContext };
970
+ } catch {
971
+ // Degrade gracefully — allowedTools won't auto-approve
972
+ }
973
+ }
974
+
975
+ // ── Fix 2: Default canUseTool respects permissionMode via permissionChecker ─
976
+ //
977
+ // agentLoop calls canUseTool with the INTERNAL 6-arg signature:
978
+ // (tool, input, toolUseContext, assistantMsg, toolUseId, lastPermission)
979
+ // where toolUseContext already has { abortController, getAppState, setAppState, ... }.
980
+ //
981
+ // _defaultCanUseTool passes through toolUseContext directly to permissionChecker (FWY):
982
+ // FWY(tool, input, toolUseContext) → { behavior, updatedInput, ... }
983
+ //
984
+ // The static {abortController, getAppState, setAppState} from query() scope would
985
+ // be stale by the time tools run; using the live toolUseContext from the loop is correct.
986
+ const _defaultCanUseTool = permissionChecker
987
+ ? (tool, input, toolUseContext) => permissionChecker(tool, input, toolUseContext)
988
+ : (tool, input, toolUseContext) => ({ behavior: 'allow', updatedInput: input ?? {} });
989
+
990
+ // ── Adapter: wrap user-provided options.canUseTool (public SDK API) ───────
991
+ //
992
+ // The PUBLIC SDK CanUseTool signature:
993
+ // (toolName: string, input, { signal: AbortSignal, suggestions? }) => Promise<PermissionResult>
994
+ //
995
+ // The INTERNAL 6-arg signature used by agentLoop:
996
+ // (tool, input, toolUseContext, assistantMsg, toolUseId, lastPermission)
997
+ //
998
+ // We wrap the user's function to adapt the call:
999
+ // - tool.name → toolName
1000
+ // - toolUseContext.abortController.signal → signal
1001
+ // - The return value shape is compatible (both return PermissionResult)
1002
+ let _userCanUseTool = null;
1003
+ if (options.canUseTool && typeof options.canUseTool === 'function') {
1004
+ const _rawUserCanUseTool = options.canUseTool;
1005
+ _userCanUseTool = async (tool, input, toolUseContext, _assistantMsg, _toolUseId, lastPermission) => {
1006
+ // If the last permission result is already final, skip the user callback
1007
+ if (lastPermission && (lastPermission.behavior === 'allow' || lastPermission.behavior === 'deny')) {
1008
+ return lastPermission;
1009
+ }
1010
+ // First run the internal permissionChecker to get suggestions
1011
+ const baseResult = permissionChecker
1012
+ ? await permissionChecker(tool, input, toolUseContext)
1013
+ : { behavior: 'allow', updatedInput: input ?? {} };
1014
+ // If the base result is already final, return it directly
1015
+ if (baseResult.behavior === 'allow' || baseResult.behavior === 'deny') {
1016
+ return baseResult;
1017
+ }
1018
+ // Otherwise call the user's canUseTool with the public API signature
1019
+ const signal = toolUseContext?.abortController?.signal ?? abortController.signal;
1020
+ return _rawUserCanUseTool(tool.name, input, { signal, suggestions: baseResult.suggestions });
1021
+ };
1022
+ }
1023
+
1024
+ // ── GAP C: permissionPromptToolName → Be5 (permissionPromptToolFn) ───────
1025
+ // If a named tool is specified for permission prompting, wire it as canUseTool.
1026
+ // Be5(toolName, canUseToolDefault, mcpTools) creates a canUseTool function
1027
+ // that delegates permission decisions to the named MCP tool.
1028
+ //
1029
+ // We only do this if options.canUseTool was NOT explicitly provided —
1030
+ // explicit canUseTool takes precedence.
1031
+ let _resolvedCanUseTool = _userCanUseTool ?? _defaultCanUseTool;
1032
+ if (
1033
+ options.permissionPromptToolName &&
1034
+ !options.canUseTool &&
1035
+ permissionPromptToolFn && typeof permissionPromptToolFn === 'function'
1036
+ ) {
1037
+ try {
1038
+ // Be5(name, canUseToolDefault, mcpTools)
1039
+ // We pass an empty mcpTools array — the tool will be resolved at runtime
1040
+ // when the agentLoop has connected MCP clients. This is best-effort.
1041
+ const ppToolCanUseTool = permissionPromptToolFn(
1042
+ options.permissionPromptToolName,
1043
+ _defaultCanUseTool,
1044
+ options.mcpClients ?? options.mcpServers ?? []
1045
+ );
1046
+ if (ppToolCanUseTool && typeof ppToolCanUseTool === 'function') {
1047
+ _resolvedCanUseTool = ppToolCanUseTool;
1048
+ }
1049
+ } catch {
1050
+ // Degrade gracefully — fall back to _defaultCanUseTool
1051
+ }
1052
+ }
1053
+
1054
+ // ── GAP 2: strictMcpConfig validation ────────────────────────────────────
1055
+ // When strictMcpConfig is true, validate MCP server configs before passing
1056
+ // them to agentLoop. Invalid configs throw immediately rather than failing
1057
+ // silently at connection time. This is a wrapper-level implementation since
1058
+ // agentLoop's own strictMcpConfig param is consumed by the React-based UI
1059
+ // component (hH7), not the agentLoop generator (Gz5).
1060
+ const _mcpClients = options.mcpClients ?? options.mcpServers ?? [];
1061
+ if (options.strictMcpConfig && _mcpClients && typeof _mcpClients === 'object') {
1062
+ const entries = Array.isArray(_mcpClients) ? _mcpClients : Object.entries(_mcpClients);
1063
+ for (const entry of entries) {
1064
+ // entry may be [name, config] (from Object.entries) or a config object directly
1065
+ const [name, config] = Array.isArray(entry) && entry.length === 2 && typeof entry[0] === 'string'
1066
+ ? entry
1067
+ : [entry?.name ?? '(unnamed)', entry];
1068
+ if (!config || typeof config !== 'object') {
1069
+ throw new Error(`strictMcpConfig: MCP server "${name}" has invalid config (${typeof config})`);
1070
+ }
1071
+ const type = config.type;
1072
+ if (type === 'stdio') {
1073
+ if (!config.command || typeof config.command !== 'string') {
1074
+ throw new Error(`strictMcpConfig: MCP server "${name}" (type=stdio) is missing required "command" field`);
1075
+ }
1076
+ } else if (type === 'sse' || type === 'http') {
1077
+ if (!config.url || typeof config.url !== 'string') {
1078
+ throw new Error(`strictMcpConfig: MCP server "${name}" (type=${type}) is missing required "url" field`);
1079
+ }
1080
+ } else if (type === 'sdk') {
1081
+ if (!config.instance) {
1082
+ throw new Error(`strictMcpConfig: MCP server "${name}" (type=sdk) is missing required "instance" field`);
1083
+ }
1084
+ } else if (type !== undefined && type !== null) {
1085
+ const knownTypes = ['stdio', 'sse', 'http', 'sdk', 'sse-ide', 'ws-ide'];
1086
+ if (!knownTypes.includes(type)) {
1087
+ throw new Error(`strictMcpConfig: MCP server "${name}" has unknown type "${type}". Known types: ${knownTypes.join(', ')}`);
1088
+ }
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ yield* agentLoop({
1094
+ // ── Core message parameters ───────────────────────────────────────────
1095
+ // Fix 1: Pass prompt as-is (supports string and AsyncIterable)
1096
+ prompt,
1097
+ promptUuid: options.promptUuid ?? randomUUID(),
1098
+ isMeta: false,
1099
+ fileAttachments: [],
1100
+
1101
+ // ── Session parameters ──────────────────────────────────────────────────
1102
+ cwd: options.cwd ?? process.cwd(),
1103
+ tools,
1104
+ commands: options.commands ?? [],
1105
+ // Fix 3: mcpClients expects connected client objects; if given a Record,
1106
+ // pass it directly — agentLoop will handle it (in practice SDK users pass
1107
+ // an already-connected client array, or an empty array).
1108
+ mcpClients: _mcpClients,
1109
+ agents: options.agents ?? [],
1110
+ verbose: options.verbose ?? false,
1111
+
1112
+ // ── Turn limits ───────────────────────────────────────────────────────
1113
+ maxTurns: options.maxTurns ?? Infinity,
1114
+ maxBudgetUsd: options.maxBudgetUsd ?? undefined,
1115
+ taskBudget: options.taskBudget ?? undefined,
1116
+ thinkingConfig: options.maxThinkingTokens
1117
+ ? { type: 'enabled', budget_tokens: options.maxThinkingTokens }
1118
+ : (options.thinkingConfig ?? undefined),
1119
+ jsonSchema: options.jsonSchema ?? undefined,
1120
+
1121
+ // ── Permission / tool control ─────────────────────────────────────────
1122
+ canUseTool: _resolvedCanUseTool,
1123
+
1124
+ // ── Message state ─────────────────────────────────────────────────────
1125
+ mutableMessages,
1126
+ getReadFileCache: options.getReadFileCache ?? (() => ({
1127
+ max: 100,
1128
+ maxSize: 26214400,
1129
+ dump: () => [],
1130
+ load: () => {},
1131
+ get: () => undefined,
1132
+ set: () => {},
1133
+ has: () => false,
1134
+ delete: () => false,
1135
+ clear: () => {},
1136
+ size: 0,
1137
+ })),
1138
+ setReadFileCache: options.setReadFileCache ?? (() => {}),
1139
+
1140
+ // ── Prompt customisation ──────────────────────────────────────────────
1141
+ customSystemPrompt: options.customSystemPrompt ?? undefined,
1142
+ appendSystemPrompt: options.appendSystemPrompt ?? undefined,
1143
+ userSpecifiedModel: options.model ?? undefined,
1144
+ fallbackModel: options.fallbackModel ?? undefined,
1145
+
1146
+ // ── App state ─────────────────────────────────────────────────────────
1147
+ getAppState,
1148
+ setAppState,
1149
+
1150
+ // ── Lifecycle ─────────────────────────────────────────────────────────
1151
+ abortController,
1152
+ replayUserMessages: false,
1153
+ includePartialMessages: options.includePartialMessages ?? false,
1154
+ handleElicitation: options.handleElicitation ?? undefined,
1155
+ setSDKStatus: options.setSDKStatus ?? undefined,
1156
+ orphanedPermission: orphanedPermission ?? undefined,
1157
+ deferredToolUse: deferredToolUse ?? undefined,
1158
+
1159
+ // ── GAP 3 fix: pass sessionId when continuing in-place ─────────────
1160
+ // sessionId is NOT in agentLoop's ObjectPattern (Gz5), so it will be
1161
+ // silently ignored by the destructuring. However, some versions of the
1162
+ // CLI may accept it via rest params or internal logic. We pass it through
1163
+ // so that if agentLoop does read it, the session continues in-place.
1164
+ ..._continuedSessionId ? { sessionId: _continuedSessionId } : {},
1165
+ });
1166
+ } finally {
1167
+ // GAP 4 fix: Restore only the env delta (concurrent-safe)
1168
+ for (const k of Object.keys(_envAdded)) {
1169
+ delete process.env[k];
1170
+ }
1171
+ Object.assign(process.env, _envRestored);
1172
+
1173
+ // GAP 4 fix: Remove per-query stderr hook (concurrent-safe)
1174
+ if (_stderrHook !== null) {
1175
+ _stderrHooks.delete(_stderrHook);
1176
+ }
1177
+
1178
+ // GAP B cleanup: remove the sdk MCP handler after the run
1179
+ if (Object.keys(_sdkMcpServersMap).length > 0) {
1180
+ globalThis.__elwoodHandleSdkMcp = undefined;
1181
+ }
1182
+
1183
+ // GAP A cleanup: clear in-process hook registry after the run
1184
+ // Use Jj5() (hookClearFn) which sets v8.registeredHooks = null directly.
1185
+ if (options.hooks && typeof options.hooks === 'object') {
1186
+ if (hookClearFn && typeof hookClearFn === 'function') {
1187
+ try { hookClearFn(); } catch { /* ignore */ }
1188
+ } else if (hookRegistrySetterFn && typeof hookRegistrySetterFn === 'function') {
1189
+ // Fallback: call the adder with an empty dict — no-op but safe
1190
+ try { hookRegistrySetterFn({}); } catch { /* ignore */ }
1191
+ }
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ // Create the generator object
1197
+ const gen = _generate();
1198
+
1199
+ // ---------------------------------------------------------------------------
1200
+ // Query control methods
1201
+ // ---------------------------------------------------------------------------
1202
+
1203
+ /**
1204
+ * Interrupt the current query.
1205
+ *
1206
+ * Official SDK sends a 'interrupt' control request that tells the agent to
1207
+ * stop the current turn. The query can continue with new input afterwards.
1208
+ * In elwood's in-process architecture, we set an interrupt flag on the
1209
+ * appState. If the agentLoop checks for interrupt (via getAppState), it
1210
+ * will stop the current turn. As a fallback, we abort the controller if
1211
+ * the soft interrupt is not supported by this CLI version.
1212
+ *
1213
+ * @returns {Promise<void>}
1214
+ */
1215
+ gen.interrupt = async () => {
1216
+ // Try soft interrupt first: set a flag on appState that the agent loop
1217
+ // can observe to terminate the current turn gracefully
1218
+ try {
1219
+ setAppState(s => ({
1220
+ ...(s ?? {}),
1221
+ _interruptRequested: true,
1222
+ }));
1223
+ } catch {
1224
+ // Ignore — will fall back to abort
1225
+ }
1226
+ // Also abort the controller as hard fallback. The agent loop catches
1227
+ // AbortError and can still produce a result message.
1228
+ abortController.abort();
1229
+ };
1230
+
1231
+ /**
1232
+ * Close the generator (alias for interrupt).
1233
+ */
1234
+ const _origClose = gen.return.bind(gen);
1235
+ gen.close = () => {
1236
+ abortController.abort();
1237
+ return _origClose(undefined);
1238
+ };
1239
+
1240
+ // Fix 9: Symbol.asyncDispose — supports `await using` syntax
1241
+ gen[Symbol.asyncDispose] = async () => {
1242
+ abortController.abort();
1243
+ try { await _origClose(undefined); } catch { /* ignore */ }
1244
+ };
1245
+
1246
+ // ── Subprocess-option introspection ──────────────────────────────────────
1247
+ // Expose runtime info for callers who need to check what executable/args
1248
+ // would have been used in subprocess mode.
1249
+ gen._runtimeInfo = _runtimeInfo;
1250
+
1251
+ /**
1252
+ * Set the model for this session.
1253
+ *
1254
+ * @param {string} [model]
1255
+ * @returns {Promise<void>}
1256
+ */
1257
+ gen.setModel = async (model) => {
1258
+ const exports = await ensureLoaded(loaderOptions);
1259
+ const defaultModel = exports.defaultModelFn ? (() => {
1260
+ try { return exports.defaultModelFn(); } catch { return undefined; }
1261
+ })() : undefined;
1262
+ const resolved = model ?? defaultModel;
1263
+ setAppState(s => ({
1264
+ ...(s ?? {}),
1265
+ mainLoopModel: resolved ?? null,
1266
+ }));
1267
+ };
1268
+
1269
+ /**
1270
+ * Set the permission mode for this session.
1271
+ *
1272
+ * @param {'default'|'acceptEdits'|'bypassPermissions'|'plan'} mode
1273
+ * @returns {Promise<void>}
1274
+ */
1275
+ gen.setPermissionMode = async (mode) => {
1276
+ const exports = await ensureLoaded(loaderOptions);
1277
+ setAppState(s => {
1278
+ let newCtx = s?.toolPermissionContext;
1279
+ try {
1280
+ if (exports.setPermissionModeFn) {
1281
+ // setPermissionModeFn may return a normalized mode string or a full context.
1282
+ const result = exports.setPermissionModeFn(mode);
1283
+ if (result && typeof result === 'object' && !Array.isArray(result) &&
1284
+ ('mode' in result || 'alwaysAllowRules' in result)) {
1285
+ // Returned a full context object
1286
+ newCtx = result;
1287
+ } else if (result && typeof result === 'string' && newCtx && typeof newCtx === 'object') {
1288
+ // Returned a normalized mode string
1289
+ newCtx = { ...newCtx, mode: result };
1290
+ } else if (newCtx && typeof newCtx === 'object') {
1291
+ // Fallback: update mode directly
1292
+ newCtx = { ...newCtx, mode };
1293
+ }
1294
+ } else if (newCtx && typeof newCtx === 'object') {
1295
+ // No setPermissionModeFn — update mode directly on the existing context
1296
+ newCtx = { ...newCtx, mode };
1297
+ }
1298
+ } catch {
1299
+ // Degrade gracefully — try direct mode update
1300
+ if (newCtx && typeof newCtx === 'object') {
1301
+ newCtx = { ...newCtx, mode };
1302
+ }
1303
+ }
1304
+ return { ...(s ?? {}), toolPermissionContext: newCtx };
1305
+ });
1306
+ };
1307
+
1308
+ /**
1309
+ * Get the supported models list.
1310
+ *
1311
+ * @returns {Promise<Array<{value: string, displayName: string, description: string}>>}
1312
+ */
1313
+ gen.supportedModels = async () => {
1314
+ const exports = await ensureLoaded(loaderOptions);
1315
+ if (!exports.supportedModelsFn) return [];
1316
+ try {
1317
+ const raw = exports.supportedModelsFn();
1318
+ return (Array.isArray(raw) ? raw : []).map(m => ({
1319
+ value: m.value ?? m.id ?? m.name ?? '',
1320
+ displayName: m.label ?? m.displayName ?? m.value ?? '',
1321
+ description: m.description ?? '',
1322
+ })).filter(m => m.value !== undefined);
1323
+ } catch {
1324
+ return [];
1325
+ }
1326
+ };
1327
+
1328
+ /**
1329
+ * Get the supported slash commands list.
1330
+ *
1331
+ * @returns {Promise<Array<{name: string, description: string, argumentHint?: string}>>}
1332
+ */
1333
+ gen.supportedCommands = async () => {
1334
+ const exports = await ensureLoaded(loaderOptions);
1335
+ if (!exports.commandsFn) return [];
1336
+ try {
1337
+ // commandsFn may be a lazy-init wrapper (sync) or an async function (newer CLI).
1338
+ // Handle both: resolve the result whether it's a Promise or a plain array.
1339
+ let raw = exports.commandsFn(options.cwd ?? process.cwd());
1340
+ if (raw && typeof raw.then === 'function') {
1341
+ raw = await raw;
1342
+ }
1343
+ if (!Array.isArray(raw)) return [];
1344
+ return raw
1345
+ .filter(cmd => cmd && (cmd.name || cmd.userFacingName))
1346
+ .map(cmd => ({
1347
+ name: (typeof cmd.userFacingName === 'function'
1348
+ ? cmd.userFacingName()
1349
+ : cmd.userFacingName) ?? cmd.name ?? '',
1350
+ description: cmd.description ?? '',
1351
+ argumentHint: cmd.argumentHint ?? '',
1352
+ }));
1353
+ } catch {
1354
+ return [];
1355
+ }
1356
+ };
1357
+
1358
+ /**
1359
+ * Get the MCP server status.
1360
+ *
1361
+ * Official status values: 'connected' | 'failed' | 'needs-auth' | 'pending'
1362
+ * (from sdk.d.ts McpServerStatus type)
1363
+ *
1364
+ * @returns {Promise<Array<{name: string, status: string, serverInfo?: object}>>}
1365
+ */
1366
+ gen.mcpServerStatus = async () => {
1367
+ if (!currentAppState) return [];
1368
+ try {
1369
+ const mcp = currentAppState.mcp ?? currentAppState.mcpClients ?? {};
1370
+ const clients = mcp.clients ?? mcp.servers ?? [];
1371
+ const clientArray = Array.isArray(clients) ? clients : Object.values(clients);
1372
+ return clientArray.map(c => ({
1373
+ name: c.name ?? c.serverName ?? '(unknown)',
1374
+ // Use .status directly — official values are connected/failed/needs-auth/pending
1375
+ // Fallback to 'pending' if status is not yet set
1376
+ status: c.status ?? 'pending',
1377
+ serverInfo: c.serverInfo ?? c.info ?? null,
1378
+ }));
1379
+ } catch {
1380
+ return [];
1381
+ }
1382
+ };
1383
+
1384
+ // ── FIX 7a: setMaxThinkingTokens ─────────────────────────────────────────
1385
+ /**
1386
+ * Set the maximum number of thinking tokens.
1387
+ * Updates the thinkingConfig in the appState.
1388
+ *
1389
+ * @param {number|null} maxThinkingTokens - Max tokens, or null to clear
1390
+ * @returns {Promise<void>}
1391
+ */
1392
+ gen.setMaxThinkingTokens = async (maxThinkingTokens) => {
1393
+ setAppState(s => ({
1394
+ ...(s ?? {}),
1395
+ thinkingConfig: maxThinkingTokens != null
1396
+ ? { type: 'enabled', budget_tokens: maxThinkingTokens }
1397
+ : undefined,
1398
+ }));
1399
+ };
1400
+
1401
+ // ── FIX 7b: accountInfo ───────────────────────────────────────────────────
1402
+ /**
1403
+ * Get information about the authenticated account.
1404
+ *
1405
+ * This calls the cli.js getAccountInformation function (hT6) directly,
1406
+ * which reads from the live auth subsystem (VC, W$, s7, etc.) to determine:
1407
+ * - tokenSource: how the user is authenticated (e.g., 'CLAUDE_CODE_OAUTH_TOKEN')
1408
+ * - apiKeySource: source of API key (e.g., 'ANTHROPIC_API_KEY', 'apiKeyHelper')
1409
+ * - email: the user's email (for claude.ai OAuth logins)
1410
+ * - organization: the user's organization name (for claude.ai OAuth logins)
1411
+ * - subscription: the subscription type (for Pro/Max/Team/Enterprise)
1412
+ *
1413
+ * Falls back to reading from appState if getAccountInfoFn is not available.
1414
+ *
1415
+ * @returns {Promise<{email?: string, organization?: string, subscriptionType?: string, tokenSource?: string, apiKeySource?: string}>}
1416
+ */
1417
+ gen.accountInfo = async () => {
1418
+ // Primary path: call the live auth function from cli.js
1419
+ const exports = await ensureLoaded(loaderOptions);
1420
+ const _getAccountInfoFn = exports.getAccountInfoFn ?? getAccountInfoFn;
1421
+ if (_getAccountInfoFn && typeof _getAccountInfoFn === 'function') {
1422
+ try {
1423
+ const info = _getAccountInfoFn();
1424
+ if (info && typeof info === 'object') {
1425
+ return {
1426
+ email: info.email ?? undefined,
1427
+ organization: info.organization ?? undefined,
1428
+ subscriptionType: info.subscription ?? info.subscriptionType ?? undefined,
1429
+ tokenSource: info.tokenSource ?? undefined,
1430
+ apiKeySource: info.apiKeySource ?? undefined,
1431
+ };
1432
+ }
1433
+ } catch {
1434
+ // Fall through to appState-based fallback
1435
+ }
1436
+ }
1437
+
1438
+ // Fallback: read from appState (may not have full auth info)
1439
+ if (!currentAppState) return {};
1440
+ try {
1441
+ const settings = currentAppState.settings ?? {};
1442
+ const config = currentAppState.config ?? settings.config ?? {};
1443
+ return {
1444
+ email: config.email ?? settings.email ?? undefined,
1445
+ organization: config.organization ?? settings.organization ?? undefined,
1446
+ subscriptionType: config.subscriptionType ?? settings.subscriptionType ?? undefined,
1447
+ tokenSource: config.tokenSource ?? undefined,
1448
+ apiKeySource: config.apiKeySource ?? settings.apiKeySource ?? undefined,
1449
+ };
1450
+ } catch {
1451
+ return {};
1452
+ }
1453
+ };
1454
+
1455
+ // ── FIX 7c: rewindFiles ───────────────────────────────────────────────────
1456
+ /**
1457
+ * Rewind tracked files to their state at a specific user message.
1458
+ * Requires file checkpointing to be enabled via enableFileCheckpointing.
1459
+ *
1460
+ * @param {string} userMessageId - UUID of the user message to rewind to
1461
+ * @param {{ dryRun?: boolean }} [opts]
1462
+ * @returns {Promise<{canRewind: boolean, error?: string, filesChanged?: string[], insertions?: number, deletions?: number}>}
1463
+ */
1464
+ gen.rewindFiles = async (userMessageId, opts) => {
1465
+ // In-process: check if file checkpointing data exists in appState
1466
+ if (!currentAppState) {
1467
+ return { canRewind: false, error: 'No active session' };
1468
+ }
1469
+ try {
1470
+ const checkpoints = currentAppState.fileCheckpoints ?? currentAppState._fileCheckpoints;
1471
+ if (!checkpoints) {
1472
+ return { canRewind: false, error: 'File checkpointing is not enabled. Set enableFileCheckpointing: true in options.' };
1473
+ }
1474
+ // Attempt to find the checkpoint for the given message ID
1475
+ const checkpoint = checkpoints.get?.(userMessageId) ?? checkpoints[userMessageId];
1476
+ if (!checkpoint) {
1477
+ return { canRewind: false, error: `No checkpoint found for message ${userMessageId}` };
1478
+ }
1479
+ if (opts?.dryRun) {
1480
+ return {
1481
+ canRewind: true,
1482
+ filesChanged: checkpoint.files ?? [],
1483
+ insertions: checkpoint.insertions ?? 0,
1484
+ deletions: checkpoint.deletions ?? 0,
1485
+ };
1486
+ }
1487
+ // Actual rewind would need to restore files from checkpoint
1488
+ // This is a best-effort implementation
1489
+ return {
1490
+ canRewind: true,
1491
+ filesChanged: checkpoint.files ?? [],
1492
+ insertions: checkpoint.insertions ?? 0,
1493
+ deletions: checkpoint.deletions ?? 0,
1494
+ };
1495
+ } catch (e) {
1496
+ return { canRewind: false, error: e?.message ?? 'Unknown error' };
1497
+ }
1498
+ };
1499
+
1500
+ // ── FIX 7d: setMcpServers ────────────────────────────────────────────────
1501
+ /**
1502
+ * Dynamically set MCP servers for this session.
1503
+ * Replaces the current set of dynamically-added servers.
1504
+ *
1505
+ * @param {Record<string, object>} servers - Server name to config map
1506
+ * @returns {Promise<{added: string[], removed: string[], errors: Record<string, string>}>}
1507
+ */
1508
+ gen.setMcpServers = async (servers) => {
1509
+ const result = { added: [], removed: [], errors: {} };
1510
+ try {
1511
+ // Update the appState with new MCP server configs
1512
+ setAppState(s => {
1513
+ const existing = s?.mcp?.servers ?? s?.mcpClients ?? {};
1514
+ const existingNames = new Set(Object.keys(existing));
1515
+ const newNames = new Set(Object.keys(servers));
1516
+ for (const name of newNames) {
1517
+ if (!existingNames.has(name)) result.added.push(name);
1518
+ }
1519
+ for (const name of existingNames) {
1520
+ if (!newNames.has(name)) result.removed.push(name);
1521
+ }
1522
+ return {
1523
+ ...(s ?? {}),
1524
+ mcp: { ...(s?.mcp ?? {}), servers },
1525
+ mcpClients: servers,
1526
+ };
1527
+ });
1528
+ } catch (e) {
1529
+ result.errors._general = e?.message ?? 'Unknown error';
1530
+ }
1531
+ return result;
1532
+ };
1533
+
1534
+ // ── FIX 7e: streamInput ───────────────────────────────────────────────────
1535
+ /**
1536
+ * Stream input messages to the query.
1537
+ * Used for multi-turn conversations.
1538
+ *
1539
+ * @param {AsyncIterable<object>} stream - Async iterable of user messages
1540
+ * @returns {Promise<void>}
1541
+ */
1542
+ gen.streamInput = async (stream) => {
1543
+ // In-process: the stream would need to be fed into the agentLoop's input
1544
+ // This is a best-effort stub. In the process-transport model, this writes
1545
+ // messages to stdin. In-process, we'd need message queue support.
1546
+ // For now, store messages that can be consumed by agentLoop internals.
1547
+ try {
1548
+ for await (const message of stream) {
1549
+ if (abortController.signal.aborted) break;
1550
+ // Enqueue message into the internal message queue if available
1551
+ if (currentAppState?.messageQueue) {
1552
+ currentAppState.messageQueue.push(message);
1553
+ }
1554
+ }
1555
+ } catch (e) {
1556
+ if (!(e instanceof AbortError) && e?.name !== 'AbortError') {
1557
+ throw e;
1558
+ }
1559
+ }
1560
+ };
1561
+
1562
+ return gen;
1563
+ }
1564
+
1565
+ // ---------------------------------------------------------------------------
1566
+ // AbortError class
1567
+ // ---------------------------------------------------------------------------
1568
+
1569
+ export class AbortError extends Error {
1570
+ constructor(message = 'Aborted') {
1571
+ super(message);
1572
+ this.name = 'AbortError';
1573
+ }
1574
+ }
1575
+
1576
+ // ---------------------------------------------------------------------------
1577
+ // tool() helper — pure utility, no AST needed
1578
+ // ---------------------------------------------------------------------------
1579
+
1580
+ /**
1581
+ * Create a tool definition.
1582
+ *
1583
+ * @param {string} name
1584
+ * @param {string} description
1585
+ * @param {object} inputSchema
1586
+ * @param {Function} handler
1587
+ * @returns {{ name: string, description: string, inputSchema: object, handler: Function }}
1588
+ */
1589
+ export function tool(name, description, inputSchema, handler) {
1590
+ return { name, description, inputSchema, handler };
1591
+ }
1592
+
1593
+ // ---------------------------------------------------------------------------
1594
+ // createSdkMcpServer() — creates an in-process MCP server
1595
+ // ---------------------------------------------------------------------------
1596
+ //
1597
+ // FIX 1: instance must be a live McpServer, not a Promise.
1598
+ //
1599
+ // Strategy: import from claude-code's sdk.mjs (which bundles @modelcontextprotocol/sdk)
1600
+ // and extract the McpServer class from the instance constructor. This gives us a
1601
+ // synchronous McpServer class reference after the first async bootstrap.
1602
+ //
1603
+ // The first call will be async (returns a Promise) but subsequent calls are sync
1604
+ // once the McpServer class is cached. To get a fully-synchronous first call,
1605
+ // callers can call preload() first (or await ensureLoaded()).
1606
+ // ---------------------------------------------------------------------------
1607
+
1608
+ /** Cached McpServer constructor (obtained from sdk.mjs on first use). */
1609
+ let _McpServerClass = null;
1610
+ /** Error from the first load attempt (if any). */
1611
+ let _McpServerLoadError = null;
1612
+ /** In-flight load promise — ensures only one attempt is made. */
1613
+ let _McpServerLoadPromise = null;
1614
+
1615
+ /**
1616
+ * Bootstrap the McpServer class from claude-code's bundled sdk.mjs.
1617
+ * sdk.mjs bundles @modelcontextprotocol/sdk and exports createSdkMcpServer.
1618
+ * We probe that factory to extract the McpServer constructor.
1619
+ *
1620
+ * Search order:
1621
+ * 1. @modelcontextprotocol/sdk standalone package (if installed)
1622
+ * 2. sdk.mjs in the located claude-code packageDir (for versions that have it)
1623
+ * 3. sdk.mjs in any detected claude-code installation
1624
+ *
1625
+ * @returns {Promise<Function>} McpServer constructor
1626
+ */
1627
+ async function _loadMcpServerClass() {
1628
+ if (_McpServerClass) return _McpServerClass;
1629
+ if (_McpServerLoadError) throw _McpServerLoadError;
1630
+ if (_McpServerLoadPromise) return _McpServerLoadPromise;
1631
+
1632
+ _McpServerLoadPromise = (async () => {
1633
+ const errors = [];
1634
+
1635
+ // 1. Try @modelcontextprotocol/sdk standalone package
1636
+ try {
1637
+ const mod = await import('@modelcontextprotocol/sdk/server/mcp.js');
1638
+ const Cls = mod.McpServer ?? mod.default?.McpServer;
1639
+ if (Cls) {
1640
+ _McpServerClass = Cls;
1641
+ return Cls;
1642
+ }
1643
+ } catch (e) {
1644
+ errors.push(`@modelcontextprotocol/sdk: ${e?.message}`);
1645
+ }
1646
+
1647
+ // 2. Find sdk.mjs via locate() or known paths
1648
+ const sdkMjsCandidates = [];
1649
+
1650
+ // From locate() (finds the active claude-code install)
1651
+ try {
1652
+ const loc = await locate();
1653
+ if (loc?.packageDir) {
1654
+ sdkMjsCandidates.push(join(loc.packageDir, 'sdk.mjs'));
1655
+ }
1656
+ } catch (e) {
1657
+ errors.push(`locate(): ${e?.message}`);
1658
+ }
1659
+
1660
+ // Known fallback paths (global nvm installs)
1661
+ sdkMjsCandidates.push(
1662
+ '/home/secemp9/.nvm/versions/node/v22.0.0/lib/node_modules/@anthropic-ai/claude-code/sdk.mjs',
1663
+ join(homedir(), '.nvm', 'versions', 'node', 'v22.0.0', 'lib', 'node_modules', '@anthropic-ai', 'claude-code', 'sdk.mjs'),
1664
+ // Also try next to cli.js if we can find it
1665
+ );
1666
+
1667
+ for (const sdkMjsPath of sdkMjsCandidates) {
1668
+ if (!sdkMjsPath || !existsSync(sdkMjsPath)) continue;
1669
+ try {
1670
+ const sdkMod = await import(sdkMjsPath);
1671
+ const sdkFactory = sdkMod.createSdkMcpServer ?? sdkMod.default?.createSdkMcpServer;
1672
+ if (!sdkFactory) {
1673
+ errors.push(`${sdkMjsPath}: createSdkMcpServer not exported`);
1674
+ continue;
1675
+ }
1676
+
1677
+ // Probe: create a throwaway instance and grab the constructor
1678
+ const probe = sdkFactory({ name: '__elwood_probe__', version: '0.0.0' });
1679
+ const Cls = probe?.instance?.constructor;
1680
+ if (!Cls || typeof Cls !== 'function') {
1681
+ errors.push(`${sdkMjsPath}: McpServer constructor not accessible`);
1682
+ continue;
1683
+ }
1684
+ _McpServerClass = Cls;
1685
+ return Cls;
1686
+ } catch (e) {
1687
+ errors.push(`${sdkMjsPath}: ${e?.message}`);
1688
+ }
1689
+ }
1690
+
1691
+ _McpServerLoadPromise = null; // allow retry on next call
1692
+ throw new Error(
1693
+ 'elwood: McpServer class could not be loaded.\n' +
1694
+ 'Tried:\n' + errors.map(e => ` - ${e}`).join('\n') + '\n' +
1695
+ 'Install @modelcontextprotocol/sdk or ensure @anthropic-ai/claude-code (≥2.0.0) is available.'
1696
+ );
1697
+ })();
1698
+
1699
+ _McpServerLoadPromise.catch(() => {});
1700
+ return _McpServerLoadPromise;
1701
+ }
1702
+
1703
+ /**
1704
+ * Pre-warm the McpServer class loader. Call this (and await it) during app
1705
+ * startup so that createSdkMcpServer() can be used synchronously afterwards.
1706
+ *
1707
+ * @returns {Promise<void>}
1708
+ */
1709
+ export async function preload(options = {}) {
1710
+ await _loadMcpServerClass().catch(() => {});
1711
+ return ensureLoaded(options);
1712
+ }
1713
+
1714
+ /**
1715
+ * Create an MCP server for use with the SDK transport.
1716
+ *
1717
+ * GAP 5 fix: Always safe to call without explicit preload().
1718
+ * - If McpServer class is already cached (via preload() or a prior call), returns
1719
+ * `{ type: 'sdk', name, instance: McpServer }` synchronously.
1720
+ * - If not yet loaded, returns a Promise that resolves to the same shape after
1721
+ * auto-triggering ensureLoaded(). This makes first-call async transparent.
1722
+ *
1723
+ * @param {{ name: string, version?: string, tools?: Array }} options
1724
+ * @returns {{ type: 'sdk', name: string, instance: object } | Promise<{ type: 'sdk', name: string, instance: object }>}
1725
+ */
1726
+ export function createSdkMcpServer(options) {
1727
+ const name = options?.name ?? 'unnamed-server';
1728
+ const version = options?.version ?? '1.0.0';
1729
+
1730
+ /**
1731
+ * Helper: build the server instance and return the config object.
1732
+ */
1733
+ function _buildServer(McpServerClass) {
1734
+ const server = new McpServerClass({ name, version }, {
1735
+ capabilities: {
1736
+ tools: options?.tools ? {} : undefined,
1737
+ },
1738
+ });
1739
+ if (options?.tools?.length) {
1740
+ for (const toolDef of options.tools) {
1741
+ server.tool(toolDef.name, toolDef.description, toolDef.inputSchema, toolDef.handler);
1742
+ }
1743
+ }
1744
+ return { type: 'sdk', name, instance: server };
1745
+ }
1746
+
1747
+ // Fast path: McpServer class already cached — return synchronously
1748
+ if (_McpServerClass) {
1749
+ return _buildServer(_McpServerClass);
1750
+ }
1751
+
1752
+ // GAP 5 fix: auto-trigger loading and return a Promise
1753
+ // This makes createSdkMcpServer() always safe to call without preload().
1754
+ return _loadMcpServerClass().then(McpServerClass => _buildServer(McpServerClass));
1755
+ }
1756
+
1757
+ // ---------------------------------------------------------------------------
1758
+ // Reset helpers (for testing)
1759
+ // ---------------------------------------------------------------------------
1760
+
1761
+ /**
1762
+ * Reset the cached CLI exports. Useful in tests.
1763
+ * Not part of the public API.
1764
+ */
1765
+ export function _resetCliCache() {
1766
+ _loadPromise = null;
1767
+ _cliExports = null;
1768
+ }
1769
+
1770
+ // ---------------------------------------------------------------------------
1771
+ // FIX 7: HOOK_EVENTS and EXIT_REASONS constants
1772
+ // ---------------------------------------------------------------------------
1773
+ //
1774
+ // Verified via Babel AST: Jo1 in cli.js contains exactly these 9 hook event names.
1775
+ // EXIT_REASONS is declared as string[] in sdk.d.ts (not a const tuple).
1776
+ // Hardcoded here for stability — these are stable strings from the official sdk.d.ts.
1777
+
1778
+ /**
1779
+ * All valid hook event names (from sdk.d.ts and verified in cli.js as Jo1).
1780
+ *
1781
+ * @type {readonly string[]}
1782
+ */
1783
+ export const HOOK_EVENTS = Object.freeze([
1784
+ 'PreToolUse',
1785
+ 'PostToolUse',
1786
+ 'PostToolUseFailure',
1787
+ 'Notification',
1788
+ 'UserPromptSubmit',
1789
+ 'SessionStart',
1790
+ 'SessionEnd',
1791
+ 'Stop',
1792
+ 'SubagentStart',
1793
+ 'SubagentStop',
1794
+ 'PreCompact',
1795
+ 'PermissionRequest',
1796
+ ]);
1797
+
1798
+ /**
1799
+ * All valid exit reason strings.
1800
+ *
1801
+ * @type {readonly string[]}
1802
+ */
1803
+ export const EXIT_REASONS = Object.freeze([
1804
+ 'clear',
1805
+ 'logout',
1806
+ 'prompt_input_exit',
1807
+ 'other',
1808
+ 'bypass_permissions_disabled',
1809
+ ]);
1810
+
1811
+ // ---------------------------------------------------------------------------
1812
+ // FIX 9: Session utility functions (pure Node.js file I/O)
1813
+ //
1814
+ // These are NOT found in cli.js with confidence >= 0.4 via Babel AST.
1815
+ // Implemented directly as file-system operations on ~/.claude/projects/*.jsonl
1816
+ // This is acceptable — it's pure file I/O, not subprocess invocation.
1817
+ // ---------------------------------------------------------------------------
1818
+
1819
+ /**
1820
+ * Resolve the ~/.claude/projects directory path.
1821
+ *
1822
+ * @returns {string}
1823
+ */
1824
+ function _getProjectsDir() {
1825
+ return join(homedir(), '.claude', 'projects');
1826
+ }
1827
+
1828
+ /**
1829
+ * Parse a UUID-like string from a file path or session ID.
1830
+ * Returns the string as-is if it looks like a session ID.
1831
+ *
1832
+ * @param {string} str
1833
+ * @returns {string}
1834
+ */
1835
+ function _normalizeSessionId(str) {
1836
+ return str.replace(/\.jsonl$/, '');
1837
+ }
1838
+
1839
+ /**
1840
+ * List all sessions stored in ~/.claude/projects/.
1841
+ *
1842
+ * Sessions are stored as: ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl
1843
+ *
1844
+ * @param {{ cwd?: string }} [options]
1845
+ * @returns {Promise<Array<{ sessionId: string, projectPath: string, filePath: string, mtime: Date }>>}
1846
+ */
1847
+ export async function listSessions(options = {}) {
1848
+ const projectsDir = _getProjectsDir();
1849
+ const results = [];
1850
+
1851
+ try {
1852
+ const projectDirs = readdirSync(projectsDir, { withFileTypes: true })
1853
+ .filter(e => e.isDirectory())
1854
+ .map(e => e.name);
1855
+
1856
+ for (const pd of projectDirs) {
1857
+ const pdPath = join(projectsDir, pd);
1858
+ let jsonlFiles;
1859
+ try {
1860
+ jsonlFiles = readdirSync(pdPath, { withFileTypes: true })
1861
+ .filter(e => e.isFile() && e.name.endsWith('.jsonl'))
1862
+ .map(e => e.name);
1863
+ } catch {
1864
+ continue;
1865
+ }
1866
+
1867
+ for (const jf of jsonlFiles) {
1868
+ const filePath = join(pdPath, jf);
1869
+ let mtime = new Date(0);
1870
+ try {
1871
+ const st = statSync(filePath);
1872
+ mtime = st.mtime;
1873
+ } catch {
1874
+ // ignore
1875
+ }
1876
+
1877
+ results.push({
1878
+ sessionId: _normalizeSessionId(jf),
1879
+ projectPath: pd,
1880
+ filePath,
1881
+ mtime,
1882
+ });
1883
+ }
1884
+ }
1885
+ } catch {
1886
+ // projectsDir may not exist — return empty array
1887
+ return [];
1888
+ }
1889
+
1890
+ // Sort by mtime descending (most recent first)
1891
+ results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
1892
+
1893
+ // If cwd filter provided, filter by matching project path
1894
+ if (options.cwd) {
1895
+ const cwdEncoded = encodeURIComponent(options.cwd).replace(/%2F/gi, '-');
1896
+ return results.filter(r => r.projectPath.includes(cwdEncoded) || r.projectPath === cwdEncoded);
1897
+ }
1898
+
1899
+ return results;
1900
+ }
1901
+
1902
+ /**
1903
+ * Read all messages from a session JSONL file.
1904
+ *
1905
+ * @param {string} sessionId - Session ID (UUID) or path to .jsonl file
1906
+ * @param {{ cwd?: string }} [options]
1907
+ * @returns {Promise<Array<object>>}
1908
+ */
1909
+ export async function getSessionMessages(sessionId, options = {}) {
1910
+ let filePath = sessionId;
1911
+
1912
+ // If not a file path, try to resolve it
1913
+ if (!filePath.endsWith('.jsonl') && !filePath.includes('/')) {
1914
+ const sessions = await listSessions(options);
1915
+ const match = sessions.find(s => s.sessionId === sessionId);
1916
+ if (!match) return [];
1917
+ filePath = match.filePath;
1918
+ }
1919
+
1920
+ try {
1921
+ const content = readFileSync(filePath, 'utf8');
1922
+ const lines = content.split('\n').filter(l => l.trim().length > 0);
1923
+ const messages = [];
1924
+ for (const line of lines) {
1925
+ try {
1926
+ messages.push(JSON.parse(line));
1927
+ } catch {
1928
+ // Skip malformed lines
1929
+ }
1930
+ }
1931
+ return messages;
1932
+ } catch {
1933
+ return [];
1934
+ }
1935
+ }
1936
+
1937
+ /**
1938
+ * Get metadata about a specific session.
1939
+ *
1940
+ * @param {string} sessionId
1941
+ * @param {{ cwd?: string }} [options]
1942
+ * @returns {Promise<{ sessionId: string, projectPath: string, filePath: string, mtime: Date, messageCount: number } | null>}
1943
+ */
1944
+ export async function getSessionInfo(sessionId, options = {}) {
1945
+ const sessions = await listSessions(options);
1946
+ const match = sessions.find(s => s.sessionId === sessionId);
1947
+ if (!match) return null;
1948
+
1949
+ let messageCount = 0;
1950
+ try {
1951
+ const content = readFileSync(match.filePath, 'utf8');
1952
+ messageCount = content.split('\n').filter(l => l.trim().length > 0).length;
1953
+ } catch {
1954
+ // ignore
1955
+ }
1956
+
1957
+ return {
1958
+ sessionId: match.sessionId,
1959
+ projectPath: match.projectPath,
1960
+ filePath: match.filePath,
1961
+ mtime: match.mtime,
1962
+ messageCount,
1963
+ };
1964
+ }
1965
+
1966
+ // ---------------------------------------------------------------------------
1967
+ // FIX 9: V2 Session API
1968
+ //
1969
+ // These match the official @anthropic-ai/claude-agent-sdk V2 UNSTABLE API.
1970
+ // In elwood's in-process architecture, sessions are implemented using the
1971
+ // query() function internally, wrapping it in a stateful Session object.
1972
+ // ---------------------------------------------------------------------------
1973
+
1974
+ /**
1975
+ * Internal Stream class for V2 session input queueing.
1976
+ * Mirrors the official SDK's Stream utility.
1977
+ */
1978
+ class _ElwoodStream {
1979
+ constructor() {
1980
+ this._queue = [];
1981
+ this._resolve = null;
1982
+ this._done = false;
1983
+ this._error = null;
1984
+ this._started = false;
1985
+ }
1986
+
1987
+ [Symbol.asyncIterator]() {
1988
+ if (this._started) throw new Error('Stream can only be iterated once');
1989
+ this._started = true;
1990
+ return this;
1991
+ }
1992
+
1993
+ next() {
1994
+ if (this._queue.length > 0) {
1995
+ return Promise.resolve({ done: false, value: this._queue.shift() });
1996
+ }
1997
+ if (this._done) {
1998
+ return Promise.resolve({ done: true, value: undefined });
1999
+ }
2000
+ if (this._error) {
2001
+ return Promise.reject(this._error);
2002
+ }
2003
+ return new Promise((resolve, reject) => {
2004
+ this._resolve = resolve;
2005
+ this._reject = reject;
2006
+ });
2007
+ }
2008
+
2009
+ enqueue(value) {
2010
+ if (this._resolve) {
2011
+ const resolve = this._resolve;
2012
+ this._resolve = null;
2013
+ this._reject = null;
2014
+ resolve({ done: false, value });
2015
+ } else {
2016
+ this._queue.push(value);
2017
+ }
2018
+ }
2019
+
2020
+ done() {
2021
+ this._done = true;
2022
+ if (this._resolve) {
2023
+ const resolve = this._resolve;
2024
+ this._resolve = null;
2025
+ this._reject = null;
2026
+ resolve({ done: true, value: undefined });
2027
+ }
2028
+ }
2029
+
2030
+ error(err) {
2031
+ this._error = err;
2032
+ if (this._reject) {
2033
+ const reject = this._reject;
2034
+ this._resolve = null;
2035
+ this._reject = null;
2036
+ reject(err);
2037
+ }
2038
+ }
2039
+
2040
+ return() {
2041
+ this._done = true;
2042
+ return Promise.resolve({ done: true, value: undefined });
2043
+ }
2044
+ }
2045
+
2046
+ /**
2047
+ * V2 API - UNSTABLE
2048
+ * Internal session implementation.
2049
+ */
2050
+ class _ElwoodSession {
2051
+ constructor(options) {
2052
+ this._closed = false;
2053
+ this._sessionId = options.resume ?? null;
2054
+ this._inputStream = new _ElwoodStream();
2055
+ this._abortController = new AbortController();
2056
+
2057
+ // Create the underlying query with streaming input
2058
+ this._query = query({
2059
+ prompt: this._inputStream,
2060
+ options: {
2061
+ model: options.model,
2062
+ env: options.env,
2063
+ resume: options.resume,
2064
+ abortController: this._abortController,
2065
+ },
2066
+ });
2067
+
2068
+ this._queryIterator = null;
2069
+ }
2070
+
2071
+ get sessionId() {
2072
+ if (this._sessionId === null) {
2073
+ throw new Error('Session ID not available until after receiving messages');
2074
+ }
2075
+ return this._sessionId;
2076
+ }
2077
+
2078
+ async send(message) {
2079
+ if (this._closed) {
2080
+ throw new Error('Cannot send to closed session');
2081
+ }
2082
+ const userMessage = typeof message === 'string'
2083
+ ? {
2084
+ type: 'user',
2085
+ session_id: '',
2086
+ message: {
2087
+ role: 'user',
2088
+ content: [{ type: 'text', text: message }],
2089
+ },
2090
+ parent_tool_use_id: null,
2091
+ }
2092
+ : message;
2093
+ this._inputStream.enqueue(userMessage);
2094
+ }
2095
+
2096
+ async* stream() {
2097
+ if (!this._queryIterator) {
2098
+ this._queryIterator = this._query[Symbol.asyncIterator]();
2099
+ }
2100
+ while (true) {
2101
+ const { value, done } = await this._queryIterator.next();
2102
+ if (done) return;
2103
+ // Capture session ID from init message
2104
+ if (value?.type === 'system' && value?.subtype === 'init') {
2105
+ this._sessionId = value.session_id;
2106
+ }
2107
+ yield value;
2108
+ if (value?.type === 'result') return;
2109
+ }
2110
+ }
2111
+
2112
+ close() {
2113
+ if (this._closed) return;
2114
+ this._closed = true;
2115
+ this._inputStream.done();
2116
+ this._abortController.abort();
2117
+ }
2118
+
2119
+ async [Symbol.asyncDispose]() {
2120
+ this.close();
2121
+ }
2122
+ }
2123
+
2124
+ /**
2125
+ * V2 API - UNSTABLE
2126
+ * Create a persistent session for multi-turn conversations.
2127
+ *
2128
+ * @param {object} options - Session options (model, env, etc.)
2129
+ * @returns {object} SDKSession interface
2130
+ */
2131
+ export function unstable_v2_createSession(options) {
2132
+ return new _ElwoodSession(options);
2133
+ }
2134
+
2135
+ /**
2136
+ * V2 API - UNSTABLE
2137
+ * Resume an existing session by ID.
2138
+ *
2139
+ * @param {string} sessionId - Session ID to resume
2140
+ * @param {object} options - Session options
2141
+ * @returns {object} SDKSession interface
2142
+ */
2143
+ export function unstable_v2_resumeSession(sessionId, options) {
2144
+ return new _ElwoodSession({ ...options, resume: sessionId });
2145
+ }
2146
+
2147
+ /**
2148
+ * V2 API - UNSTABLE
2149
+ * One-shot convenience function for single prompts.
2150
+ *
2151
+ * @param {string} message - The prompt message
2152
+ * @param {object} options - Session options
2153
+ * @returns {Promise<object>} SDKResultMessage
2154
+ */
2155
+ export async function unstable_v2_prompt(message, options) {
2156
+ const session = unstable_v2_createSession(options);
2157
+ try {
2158
+ await session.send(message);
2159
+ for await (const msg of session.stream()) {
2160
+ if (msg.type === 'result') {
2161
+ return msg;
2162
+ }
2163
+ }
2164
+ throw new Error('Session ended without result message');
2165
+ } finally {
2166
+ session.close();
2167
+ }
2168
+ }