@margay/ccl-core 1.3.5-rc.6

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.
@@ -0,0 +1,697 @@
1
+ /**
2
+ * ccl-sdk query() — the programmatic headless entry (issue P0-1) with the
3
+ * canUseTool permission callback (P0-2 including pending suspend/resume),
4
+ * session store integration (P0-3), provider mapping (P0-4), skills dirs
5
+ * (P1-5), lifecycle hooks (P1-6), and usage accumulation (P1-7).
6
+ *
7
+ * Architecture: the SDK is the HOST side of the stream-json control protocol
8
+ * that cli/print.ts already implements. We run the CLI as a managed
9
+ * subprocess (explicitly allowed by the issue's "进程内(或受管子进程)"),
10
+ * which keeps every user-visible CLI behavior — tools, skills, hooks, session
11
+ * persistence, gateway routing — byte-identical between CLI and SDK use.
12
+ *
13
+ * Pending suspend/resume mechanism (P0-2, zero CLI changes):
14
+ * - Suspend: on a `pending` callback result we persist the approval record,
15
+ * terminate the subprocess WITHOUT answering the can_use_tool request, and
16
+ * end the stream with a `suspended` event. The transcript on disk ends at
17
+ * the assistant tool_use whose tool never ran.
18
+ * - Resume: resumeWithDecision() relaunches with --resume <sessionId> and
19
+ * CLAUDE_CODE_RESUME_INTERRUPTED_TURN=1. The CLI's resume path drops the
20
+ * unresolved tool_use (filterUnresolvedToolUses) and auto-continues the
21
+ * interrupted turn (cli/print.ts:1195), so the model re-issues the tool
22
+ * call; the SDK intercepts the matching can_use_tool request and applies
23
+ * the recorded decision — on allow, with EXACTLY the approved input (the
24
+ * recorded pending input, or the host's updatedInput), regardless of what
25
+ * the model re-generated. Deny works symmetrically. One-shot: the record
26
+ * is deleted after it is applied.
27
+ */
28
+ import { existsSync, mkdtempSync, symlinkSync } from 'fs';
29
+ import { tmpdir } from 'os';
30
+ import { dirname, join, resolve } from 'path';
31
+ import { fileURLToPath } from 'url';
32
+ import { SdkMcpRegistry } from './mcpServer.js';
33
+ import { fileSessionStore } from './store.js';
34
+ import { spawnCliTransport } from './transport.js';
35
+ const DEFAULT_CAN_USE_TOOL_TIMEOUT_MS = 300_000;
36
+ /** Builds the CLI argv for a query. Exported for unit tests. */
37
+ export function buildCliArgs(options) {
38
+ const args = [
39
+ '--print',
40
+ '--verbose',
41
+ '--output-format',
42
+ 'stream-json',
43
+ '--input-format',
44
+ 'stream-json',
45
+ ];
46
+ if (options.canUseTool || options.pendingDecision)
47
+ args.push('--permission-prompt-tool', 'stdio');
48
+ if (options.model)
49
+ args.push('--model', options.model);
50
+ if (options.provider?.type === 'anthropic') {
51
+ if (options.provider.baseUrl)
52
+ args.push('--base-url', options.provider.baseUrl);
53
+ if (options.provider.apiKey)
54
+ args.push('--api-key', options.provider.apiKey);
55
+ }
56
+ if (options.allowedTools?.length)
57
+ args.push('--allowed-tools', options.allowedTools.join(','));
58
+ if (options.disallowedTools?.length)
59
+ args.push('--disallowed-tools', options.disallowedTools.join(','));
60
+ if (options.sessionId)
61
+ args.push('--session-id', options.sessionId);
62
+ if (options.resume)
63
+ args.push('--resume', options.resume);
64
+ if (options.forkSession)
65
+ args.push('--fork-session');
66
+ if (options.maxTurns !== undefined)
67
+ args.push('--max-turns', String(options.maxTurns));
68
+ if (options.maxBudgetUsd !== undefined)
69
+ args.push('--max-budget-usd', String(options.maxBudgetUsd));
70
+ if (options.permissionMode)
71
+ args.push('--permission-mode', options.permissionMode);
72
+ for (const dir of options.pluginDirs ?? [])
73
+ args.push('--plugin-dir', dir);
74
+ if (options.addDirs?.length)
75
+ args.push('--add-dir', ...options.addDirs);
76
+ if (options.settings)
77
+ args.push('--settings', options.settings);
78
+ if (options.agents)
79
+ args.push('--agents', options.agents);
80
+ if (options.persistSession === false)
81
+ args.push('--no-session-persistence');
82
+ if (options.cwd)
83
+ args.push('--cwd', options.cwd);
84
+ // slice sdk-token-streaming (G3): opt-in token-level streaming — the CLI then emits stream_event
85
+ // (Anthropic raw message stream) deltas. Default off keeps the minimal event set unchanged.
86
+ if (options.includePartialMessages)
87
+ args.push('--include-partial-messages');
88
+ // slice sdk-input-external-mcp (G2): external MCP servers via --mcp-config (JSON string).
89
+ if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
90
+ args.push('--mcp-config', JSON.stringify({ mcpServers: options.mcpServers }));
91
+ }
92
+ if (options.extraArgs?.length)
93
+ args.push(...options.extraArgs);
94
+ return args;
95
+ }
96
+ /** Builds subprocess env additions. Exported for unit tests. */
97
+ export function buildCliEnv(options) {
98
+ const env = { ...options.env };
99
+ if (options.provider?.type === 'openai-compatible') {
100
+ env.CCL_GATEWAY_URL = options.provider.baseUrl;
101
+ if (options.provider.apiKey)
102
+ env.CCL_GATEWAY_KEY = options.provider.apiKey;
103
+ }
104
+ if (options.pendingDecision) {
105
+ // Auto-continue the interrupted turn on resume (cli/print.ts:1195) so
106
+ // the model re-issues the suspended tool call without a new user prompt.
107
+ env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = '1';
108
+ }
109
+ return env;
110
+ }
111
+ /**
112
+ * Wraps each skills dir as an ephemeral plugin (a temp dir whose `skills`
113
+ * entry symlinks to the user's dir) so the CLI loads it via --plugin-dir,
114
+ * its cwd-independent skill channel (P1-5). Exported for unit tests.
115
+ */
116
+ export function prepareSkillsWrappers(skillsDirs) {
117
+ return skillsDirs.map(dir => {
118
+ const target = resolve(dir);
119
+ if (!existsSync(target)) {
120
+ throw new Error(`ccl-sdk: skills dir does not exist: ${target}`);
121
+ }
122
+ const wrapper = mkdtempSync(join(tmpdir(), 'ccl-sdk-skills-'));
123
+ symlinkSync(target, join(wrapper, 'skills'), 'dir');
124
+ return wrapper;
125
+ });
126
+ }
127
+ function resolveCliPath(options) {
128
+ if (options.cliPath)
129
+ return options.cliPath;
130
+ if (process.env.CCL_SDK_CLI_PATH)
131
+ return process.env.CCL_SDK_CLI_PATH;
132
+ // Packaged layout: <pkg>/dist/sdk/query.js or <pkg>/sdk/query.ts, both
133
+ // resolve to the sibling <pkg>/dist/cli.js.
134
+ const here = dirname(fileURLToPath(import.meta.url));
135
+ for (const candidate of [
136
+ join(here, '..', 'cli.js'),
137
+ join(here, '..', 'dist', 'cli.js'),
138
+ ]) {
139
+ if (existsSync(candidate))
140
+ return candidate;
141
+ }
142
+ throw new Error('ccl-sdk: cannot locate the CCL CLI. Pass options.cliPath or set CCL_SDK_CLI_PATH.');
143
+ }
144
+ /**
145
+ * Fail-closed wrapper around the host's canUseTool callback (issue P0-2):
146
+ * throw, timeout, or an unrecognized return value all become deny, and the
147
+ * tool does not execute. `pending` is surfaced to the caller for suspend.
148
+ */
149
+ export async function decideFailClosed(callback, toolName, input, context, timeoutMs) {
150
+ const deny = (message, failClosedReason) => ({
151
+ kind: 'decision',
152
+ decision: { behavior: 'deny', message, toolUseID: context.toolUseId },
153
+ failClosedReason,
154
+ });
155
+ let raw;
156
+ let timer;
157
+ try {
158
+ const callbackPromise = Promise.resolve(callback(toolName, input, context));
159
+ raw =
160
+ timeoutMs > 0
161
+ ? await Promise.race([
162
+ callbackPromise,
163
+ new Promise((_, reject) => {
164
+ timer = setTimeout(() => reject(new Error(`canUseTool timed out after ${timeoutMs}ms`)), timeoutMs);
165
+ }),
166
+ ])
167
+ : await callbackPromise;
168
+ }
169
+ catch (error) {
170
+ return deny(`Denied (fail-closed): canUseTool callback failed: ${error instanceof Error ? error.message : String(error)}`, 'callback_error');
171
+ }
172
+ finally {
173
+ if (timer)
174
+ clearTimeout(timer);
175
+ }
176
+ if (!raw || typeof raw !== 'object' || typeof raw.behavior !== 'string') {
177
+ return deny('Denied (fail-closed): canUseTool returned an unrecognized value', 'unrecognized_result');
178
+ }
179
+ switch (raw.behavior) {
180
+ case 'allow': {
181
+ const updated = raw.updatedInput && Object.keys(raw.updatedInput).length > 0
182
+ ? raw.updatedInput
183
+ : input;
184
+ return {
185
+ kind: 'decision',
186
+ decision: {
187
+ behavior: 'allow',
188
+ updatedInput: updated,
189
+ toolUseID: context.toolUseId,
190
+ },
191
+ };
192
+ }
193
+ case 'deny':
194
+ return {
195
+ kind: 'decision',
196
+ decision: {
197
+ behavior: 'deny',
198
+ message: raw.message ?? 'Denied by host',
199
+ ...(raw.interrupt !== undefined && { interrupt: raw.interrupt }),
200
+ toolUseID: context.toolUseId,
201
+ },
202
+ };
203
+ case 'pending':
204
+ return { kind: 'pending' };
205
+ default:
206
+ return deny(`Denied (fail-closed): unknown behavior '${raw.behavior}'`, 'unrecognized_result');
207
+ }
208
+ }
209
+ function contentBlocks(message) {
210
+ if (typeof message !== 'object' || message === null)
211
+ return [];
212
+ const content = message.content;
213
+ if (typeof content === 'string')
214
+ return [{ type: 'text', text: content }];
215
+ if (!Array.isArray(content))
216
+ return [];
217
+ return content.filter((b) => typeof b === 'object' && b !== null);
218
+ }
219
+ async function runHook(fn) {
220
+ if (!fn)
221
+ return;
222
+ try {
223
+ await fn();
224
+ }
225
+ catch {
226
+ // P1-6 contract: hooks are observability, never control flow.
227
+ }
228
+ }
229
+ /**
230
+ * Core loop, transport-injectable for tests. Production callers use query()
231
+ * or resumeWithDecision().
232
+ */
233
+ export async function* queryWithTransport(prompt, options, transport) {
234
+ const timeoutMs = options.canUseToolTimeoutMs ?? DEFAULT_CAN_USE_TOOL_TIMEOUT_MS;
235
+ const store = options.store ?? fileSessionStore();
236
+ const hooks = options.hooks ?? {};
237
+ const ownRequestIds = new Set();
238
+ const toolNamesById = new Map();
239
+ let lastSessionId = options.sessionId ?? options.resume;
240
+ let turn = 0;
241
+ let sawResult = false;
242
+ let sessionEnded = false;
243
+ // resumeWithDecision path: the recorded approval this run must settle.
244
+ let pendingApproval = null;
245
+ let pendingDecision = options.pendingDecision;
246
+ if (pendingDecision) {
247
+ if (!options.resume) {
248
+ throw new Error('ccl-sdk: pendingDecision requires options.resume');
249
+ }
250
+ pendingApproval = await store.loadPendingApproval(options.resume);
251
+ if (!pendingApproval) {
252
+ throw new Error(`ccl-sdk: no pending approval recorded for session ${options.resume}`);
253
+ }
254
+ }
255
+ const onAbort = () => void transport.close();
256
+ options.signal?.addEventListener('abort', onAbort, { once: true });
257
+ // slice sdk-in-process-mcp-server: host-defined in-process MCP servers, bridged to the subprocess
258
+ // agent over the control protocol. The registry replies to each `mcp_message` control_request with
259
+ // its server's JSONRPC response ({ mcp_response }). null when the host passed no sdkMcpServers.
260
+ const sdkMcpRegistry = options.sdkMcpServers && options.sdkMcpServers.length > 0
261
+ ? await SdkMcpRegistry.create(options.sdkMcpServers, (requestId, mcpResponse) => {
262
+ void transport.write({
263
+ type: 'control_response',
264
+ response: {
265
+ subtype: 'success',
266
+ request_id: requestId,
267
+ response: { mcp_response: mcpResponse },
268
+ },
269
+ });
270
+ })
271
+ : null;
272
+ try {
273
+ const initId = `ccl-sdk-init-${Date.now().toString(36)}`;
274
+ ownRequestIds.add(initId);
275
+ await transport.write({
276
+ type: 'control_request',
277
+ request_id: initId,
278
+ request: {
279
+ subtype: 'initialize',
280
+ ...(options.systemPrompt !== undefined && {
281
+ systemPrompt: options.systemPrompt,
282
+ }),
283
+ ...(options.appendSystemPrompt !== undefined && {
284
+ appendSystemPrompt: options.appendSystemPrompt,
285
+ }),
286
+ ...(sdkMcpRegistry && sdkMcpRegistry.names.length > 0
287
+ ? { sdkMcpServers: sdkMcpRegistry.names }
288
+ : {}),
289
+ // slice sdk-json-schema-output (G4): structured-output schema over the control channel
290
+ // (print.ts reads request.jsonSchema at initialize), not argv, to avoid ARG_MAX.
291
+ ...(options.jsonSchema ? { jsonSchema: options.jsonSchema } : {}),
292
+ },
293
+ });
294
+ // On pending-decision resume the CLI auto-continues the interrupted turn
295
+ // (CLAUDE_CODE_RESUME_INTERRUPTED_TURN); a prompt would double-drive it.
296
+ if (prompt !== null && !pendingDecision) {
297
+ // slice sdk-input-external-mcp (G6): multimodal input — host-provided content blocks replace
298
+ // the plain-text content when set; otherwise the string prompt is a single text block.
299
+ const userContent = options.promptContentBlocks && options.promptContentBlocks.length > 0
300
+ ? options.promptContentBlocks
301
+ : [{ type: 'text', text: prompt }];
302
+ await transport.write({
303
+ type: 'user',
304
+ message: { role: 'user', content: userContent },
305
+ parent_tool_use_id: null,
306
+ });
307
+ // slice sdk-observability-hooks-usage (G5): the host's prompt was submitted to the agent.
308
+ await runHook(hooks.onUserPromptSubmit &&
309
+ (() => hooks.onUserPromptSubmit({ prompt, sessionId: lastSessionId })));
310
+ }
311
+ // slice sdk-observability-hooks-usage (G5): fire onSessionStart once, on the first session_id.
312
+ let sessionStarted = false;
313
+ for await (const message of transport.read()) {
314
+ if (typeof message.session_id === 'string') {
315
+ lastSessionId = message.session_id;
316
+ if (!sessionStarted) {
317
+ sessionStarted = true;
318
+ await runHook(hooks.onSessionStart &&
319
+ (() => hooks.onSessionStart({ sessionId: message.session_id })));
320
+ }
321
+ }
322
+ if (options.includeRaw)
323
+ yield { type: 'raw', message };
324
+ switch (message.type) {
325
+ case 'assistant': {
326
+ const sessionId = message.session_id;
327
+ for (const block of contentBlocks(message.message)) {
328
+ if (block.type === 'text' && typeof block.text === 'string') {
329
+ yield { type: 'assistant_text', text: block.text, sessionId };
330
+ }
331
+ else if (block.type === 'tool_use') {
332
+ const toolUseId = String(block.id ?? '');
333
+ const toolName = String(block.name ?? '');
334
+ toolNamesById.set(toolUseId, toolName);
335
+ yield {
336
+ type: 'tool_use',
337
+ toolName,
338
+ input: (block.input ?? {}),
339
+ toolUseId,
340
+ sessionId,
341
+ };
342
+ }
343
+ }
344
+ turn += 1;
345
+ // slice sdk-observability-hooks-usage (G7): surface this turn's token usage from the
346
+ // assistant message when present.
347
+ const turnUsage = message.message?.usage;
348
+ await runHook(hooks.onTurnEnd &&
349
+ (() => hooks.onTurnEnd({ turn, sessionId, usage: turnUsage })));
350
+ break;
351
+ }
352
+ case 'user': {
353
+ const sessionId = message.session_id;
354
+ for (const block of contentBlocks(message.message)) {
355
+ if (block.type === 'tool_result') {
356
+ const toolUseId = String(block.tool_use_id ?? '');
357
+ const isError = block.is_error === true;
358
+ yield {
359
+ type: 'tool_result',
360
+ toolUseId,
361
+ content: block.content,
362
+ isError,
363
+ sessionId,
364
+ };
365
+ await runHook(hooks.onToolUse &&
366
+ (() => hooks.onToolUse({
367
+ toolUseId,
368
+ toolName: toolNamesById.get(toolUseId),
369
+ content: block.content,
370
+ isError,
371
+ sessionId,
372
+ })));
373
+ }
374
+ }
375
+ break;
376
+ }
377
+ case 'control_request': {
378
+ const requestId = String(message.request_id ?? '');
379
+ const request = (message.request ?? {});
380
+ // slice sdk-in-process-mcp-server: route MCP tool calls to host in-process servers.
381
+ if (request.subtype === 'mcp_message') {
382
+ if (sdkMcpRegistry) {
383
+ sdkMcpRegistry.handle(String(request.server_name ?? ''), request.message, requestId);
384
+ }
385
+ else {
386
+ await transport.write({
387
+ type: 'control_response',
388
+ response: {
389
+ subtype: 'error',
390
+ request_id: requestId,
391
+ error: 'ccl-sdk received mcp_message but no sdkMcpServers configured',
392
+ },
393
+ });
394
+ }
395
+ break;
396
+ }
397
+ if (request.subtype !== 'can_use_tool') {
398
+ await transport.write({
399
+ type: 'control_response',
400
+ response: {
401
+ subtype: 'error',
402
+ request_id: requestId,
403
+ error: `ccl-sdk does not support control_request subtype '${String(request.subtype)}'`,
404
+ },
405
+ });
406
+ break;
407
+ }
408
+ const toolName = String(request.tool_name ?? '');
409
+ const input = (request.input ?? {});
410
+ const toolUseId = String(request.tool_use_id ?? '');
411
+ const agentId = typeof request.agent_id === 'string' ? request.agent_id : undefined;
412
+ yield {
413
+ type: 'permission_request',
414
+ requestId,
415
+ toolName,
416
+ input,
417
+ toolUseId,
418
+ agentId,
419
+ };
420
+ // Resume path: settle the recorded approval on its matching call.
421
+ // Match by toolName — on allow the tool runs with the RECORDED
422
+ // input (or the host's explicit updatedInput), never the model's
423
+ // re-generated one, so what the owner approved is what executes.
424
+ if (pendingDecision && pendingApproval?.toolName === toolName) {
425
+ const decision = pendingDecision.behavior === 'allow'
426
+ ? {
427
+ behavior: 'allow',
428
+ updatedInput: pendingDecision.updatedInput ?? pendingApproval.input,
429
+ toolUseID: toolUseId,
430
+ }
431
+ : {
432
+ behavior: 'deny',
433
+ message: pendingDecision.message ?? 'Denied by owner',
434
+ toolUseID: toolUseId,
435
+ };
436
+ await transport.write({
437
+ type: 'control_response',
438
+ response: {
439
+ subtype: 'success',
440
+ request_id: requestId,
441
+ response: decision,
442
+ },
443
+ });
444
+ await store.deletePendingApproval(pendingApproval.sessionId);
445
+ pendingDecision = undefined;
446
+ pendingApproval = null;
447
+ yield {
448
+ type: 'permission_resolved',
449
+ requestId,
450
+ toolUseId,
451
+ behavior: decision.behavior,
452
+ source: 'pending_decision',
453
+ };
454
+ break;
455
+ }
456
+ const outcome = options.canUseTool
457
+ ? await decideFailClosed(options.canUseTool, toolName, input, { toolUseId, agentId }, timeoutMs)
458
+ : {
459
+ kind: 'decision',
460
+ decision: {
461
+ behavior: 'deny',
462
+ message: 'Denied (fail-closed): no canUseTool callback configured',
463
+ toolUseID: toolUseId,
464
+ },
465
+ failClosedReason: 'no_callback',
466
+ };
467
+ if (outcome.kind === 'pending') {
468
+ const sessionId = lastSessionId;
469
+ if (!sessionId) {
470
+ // Cannot resume without a session identity — fail closed.
471
+ await transport.write({
472
+ type: 'control_response',
473
+ response: {
474
+ subtype: 'success',
475
+ request_id: requestId,
476
+ response: {
477
+ behavior: 'deny',
478
+ message: 'Denied (fail-closed): pending requires a known session_id',
479
+ toolUseID: toolUseId,
480
+ },
481
+ },
482
+ });
483
+ yield {
484
+ type: 'error',
485
+ message: 'pending approval requested before any session_id was observed',
486
+ fatal: false,
487
+ code: 'pending_without_session',
488
+ };
489
+ yield {
490
+ type: 'permission_resolved',
491
+ requestId,
492
+ toolUseId,
493
+ behavior: 'deny',
494
+ failClosedReason: 'pending_without_session',
495
+ };
496
+ break;
497
+ }
498
+ const record = {
499
+ sessionId,
500
+ requestId,
501
+ toolUseId,
502
+ toolName,
503
+ input,
504
+ createdAt: new Date().toISOString(),
505
+ };
506
+ await store.savePendingApproval(record);
507
+ // Deliberately NO control_response: the tool must not run and
508
+ // the model must not see a denial. Terminating here leaves the
509
+ // transcript ending at the unresolved tool_use, which the
510
+ // resume path drops and re-drives.
511
+ yield {
512
+ type: 'suspended',
513
+ sessionId,
514
+ requestId,
515
+ toolUseId,
516
+ toolName,
517
+ input,
518
+ };
519
+ sessionEnded = true;
520
+ await runHook(hooks.onSessionEnd &&
521
+ (() => hooks.onSessionEnd({
522
+ sessionId,
523
+ subtype: 'suspended',
524
+ isError: false,
525
+ })));
526
+ return;
527
+ }
528
+ await transport.write({
529
+ type: 'control_response',
530
+ response: {
531
+ subtype: 'success',
532
+ request_id: requestId,
533
+ response: outcome.decision,
534
+ },
535
+ });
536
+ yield {
537
+ type: 'permission_resolved',
538
+ requestId,
539
+ toolUseId,
540
+ behavior: outcome.decision.behavior,
541
+ source: 'callback',
542
+ ...(outcome.failClosedReason && {
543
+ failClosedReason: outcome.failClosedReason,
544
+ }),
545
+ };
546
+ break;
547
+ }
548
+ case 'control_response': {
549
+ const response = (message.response ?? {});
550
+ const requestId = String(response.request_id ?? '');
551
+ if (ownRequestIds.has(requestId)) {
552
+ ownRequestIds.delete(requestId);
553
+ if (response.subtype === 'error') {
554
+ yield {
555
+ type: 'error',
556
+ message: `initialize failed: ${String(response.error)}`,
557
+ fatal: false,
558
+ code: 'initialize_error',
559
+ };
560
+ }
561
+ }
562
+ break;
563
+ }
564
+ case 'result': {
565
+ sawResult = true;
566
+ const sessionId = message.session_id;
567
+ const usage = message.usage;
568
+ const totalCostUsd = typeof message.total_cost_usd === 'number'
569
+ ? message.total_cost_usd
570
+ : undefined;
571
+ if (sessionId && usage) {
572
+ // P1-7: per-session accumulation, queryable via getSessionUsage.
573
+ try {
574
+ await store.addUsage(sessionId, usage, totalCostUsd);
575
+ }
576
+ catch {
577
+ // Metering must not break the conversation stream.
578
+ }
579
+ }
580
+ yield {
581
+ type: 'result',
582
+ subtype: String(message.subtype ?? 'unknown'),
583
+ usage,
584
+ totalCostUsd,
585
+ numTurns: typeof message.num_turns === 'number' ? message.num_turns : undefined,
586
+ durationMs: typeof message.duration_ms === 'number'
587
+ ? message.duration_ms
588
+ : undefined,
589
+ isError: message.is_error === true,
590
+ sessionId,
591
+ result: typeof message.result === 'string' ? message.result : undefined,
592
+ // slice sdk-json-schema-output (G4): surface the CLI's schema-validated structured
593
+ // output (present only when the query ran with jsonSchema).
594
+ structuredOutput: message
595
+ .structured_output,
596
+ };
597
+ sessionEnded = true;
598
+ await runHook(hooks.onSessionEnd &&
599
+ (() => hooks.onSessionEnd({
600
+ sessionId,
601
+ subtype: String(message.subtype ?? 'unknown'),
602
+ isError: message.is_error === true,
603
+ usage,
604
+ })));
605
+ break;
606
+ }
607
+ case 'stream_event': {
608
+ // slice sdk-token-streaming (G3): forward one Anthropic raw message stream event
609
+ // (token-level delta). Only arrives when includePartialMessages passed the flag.
610
+ const streamEvent = message.event;
611
+ if (streamEvent && typeof streamEvent.type === 'string') {
612
+ yield {
613
+ type: 'stream_event',
614
+ event: streamEvent,
615
+ sessionId: message.session_id,
616
+ };
617
+ }
618
+ break;
619
+ }
620
+ default:
621
+ break;
622
+ }
623
+ if (sawResult)
624
+ break;
625
+ }
626
+ if (!sawResult) {
627
+ const code = await transport.exited;
628
+ const aborted = options.signal?.aborted === true;
629
+ yield {
630
+ type: 'error',
631
+ message: aborted
632
+ ? 'query aborted by host signal'
633
+ : `CCL subprocess ended without a result event (exit code ${code})`,
634
+ fatal: true,
635
+ code: aborted ? 'aborted' : 'subprocess_exit',
636
+ };
637
+ if (!sessionEnded) {
638
+ await runHook(hooks.onSessionEnd &&
639
+ (() => hooks.onSessionEnd({
640
+ sessionId: lastSessionId,
641
+ subtype: aborted ? 'aborted' : 'subprocess_exit',
642
+ isError: true,
643
+ })));
644
+ }
645
+ }
646
+ }
647
+ finally {
648
+ options.signal?.removeEventListener('abort', onAbort);
649
+ await sdkMcpRegistry?.close();
650
+ await transport.close();
651
+ }
652
+ }
653
+ /** Spawns the managed CLI subprocess for a set of options. Shared with the P1 Query handle. */
654
+ export function spawnForOptions(options) {
655
+ const pluginDirs = [
656
+ ...(options.pluginDirs ?? []),
657
+ ...(options.skillsDirs?.length ? prepareSkillsWrappers(options.skillsDirs) : []),
658
+ ];
659
+ const effective = { ...options, pluginDirs };
660
+ return spawnCliTransport({
661
+ execPath: options.execPath ?? process.execPath,
662
+ cliPath: resolveCliPath(options),
663
+ args: buildCliArgs(effective),
664
+ cwd: options.cwd,
665
+ env: buildCliEnv(effective),
666
+ });
667
+ }
668
+ /**
669
+ * Run one prompt against a CCL agent session and stream JSON-serializable
670
+ * events. See QueryOptions for session resume, permission callback, model,
671
+ * provider, and tool configuration.
672
+ */
673
+ export function query(prompt, options = {}) {
674
+ return queryWithTransport(prompt, options, spawnForOptions(options));
675
+ }
676
+ /**
677
+ * Resume a session suspended by a `pending` permission result and apply the
678
+ * owner's decision (issue P0-2 acceptance path). Cross-process: the pending
679
+ * record lives in the session store, the conversation in the CLI's
680
+ * transcript store — nothing is held in memory between suspend and resume.
681
+ *
682
+ * On allow, the suspended tool executes with exactly the recorded input (or
683
+ * decision.updatedInput if the owner amended it); on deny the model receives
684
+ * the denial and the session continues.
685
+ */
686
+ export function resumeWithDecision(sessionId, decision, options = {}) {
687
+ const merged = {
688
+ ...options,
689
+ resume: sessionId,
690
+ pendingDecision: decision,
691
+ };
692
+ return queryWithTransport(null, merged, spawnForOptions(merged));
693
+ }
694
+ /** P1-7: accumulated usage for a session, across all queries that ran it. */
695
+ export async function getSessionUsage(sessionId, store = fileSessionStore()) {
696
+ return store.getUsage(sessionId);
697
+ }