@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.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/examples/01-basic-query.js +38 -0
- package/examples/02-bug-fixer.js +57 -0
- package/examples/03-custom-system-prompt.js +41 -0
- package/examples/04-read-only-agent.js +38 -0
- package/examples/05-find-todos.js +37 -0
- package/examples/06-session-resume.js +70 -0
- package/examples/07-hooks-pretooluse.js +74 -0
- package/examples/08-hooks-posttooluse-audit.js +66 -0
- package/examples/09-hooks-block-etc.js +68 -0
- package/examples/10-hooks-redirect-sandbox.js +70 -0
- package/examples/11-subagents.js +54 -0
- package/examples/12-mcp-stdio.js +48 -0
- package/examples/13-mcp-http.js +54 -0
- package/examples/14-custom-tool.js +84 -0
- package/examples/15-custom-tool-unit-converter.js +132 -0
- package/examples/16-mcp-github.js +71 -0
- package/examples/17-session-store-postgres.js +78 -0
- package/examples/18-session-store-redis.js +65 -0
- package/examples/19-session-store-s3.js +67 -0
- package/examples/20-session-list.js +72 -0
- package/examples/21-hooks-notification-slack.js +78 -0
- package/examples/22-hooks-webhook-posttooluse.js +78 -0
- package/examples/23-hooks-subagent-tracker.js +59 -0
- package/examples/24-v2-session-api.js +62 -0
- package/examples/README.md +95 -0
- package/examples/basic.js +240 -0
- package/examples/smoke-test.js +296 -0
- package/package.json +52 -0
- package/src/ast-tools.js +182 -0
- package/src/index.js +70 -0
- package/src/instrumenter.js +2921 -0
- package/src/loader.js +306 -0
- package/src/locate.js +296 -0
- 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
|
+
}
|