@margay/ccl-core 1.4.5-rc.9 → 1.5.0-alpha.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.
@@ -7,8 +7,10 @@
7
7
  */
8
8
  export { buildCliArgs, buildCliEnv, decideFailClosed, getSessionUsage, prepareSkillsWrappers, queryWithTransport, resumeWithDecision, } from './query.js';
9
9
  export { query, queryHandleWithTransport } from './queryHandle.js';
10
+ export { createSession, Session, type SessionOptions, type SessionControls, type Turn, type TurnResult } from './session.js';
10
11
  export { createSdkMcpServer, tool, SdkMcpRegistry, type SdkMcpToolDefinition, type McpSdkServerConfigWithInstance, } from './mcpServer.js';
11
12
  export { defaultStoreDir, fileSessionStore } from './store.js';
12
13
  export { spawnCliTransport } from './transport.js';
13
14
  export type { RecoveryDiagnostic } from './errorDiagnostics.js';
14
- export type { AccumulatedUsage, CanUseToolContext, CanUseToolFn, CanUseToolResult, InitializationResult, LifecycleHooks, PendingApproval, PendingDecision, ProviderConfig, Query, QueryOptions, SDKEvent, SessionStore, Transport, Usage, WireMessage, } from './types.js';
15
+ export type { GatewayRequestPhase, GatewayRequestProgress } from './requestProgress.js';
16
+ export type { AccumulatedUsage, CanUseToolContext, CanUseToolFn, CanUseToolResult, InitializationResult, LifecycleHooks, PendingApproval, PendingDecision, ProviderConfig, Query, QueryOptions, SDKEvent, SDKUserMessage, SessionStore, SessionLifecycleEvent, Transport, Usage, WireMessage, } from './types.js';
package/dist/sdk/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export { buildCliArgs, buildCliEnv, decideFailClosed, getSessionUsage, prepareSkillsWrappers, queryWithTransport, resumeWithDecision, } from './query.js';
9
9
  export { query, queryHandleWithTransport } from './queryHandle.js';
10
+ export { createSession, Session } from './session.js';
10
11
  export { createSdkMcpServer, tool, SdkMcpRegistry, } from './mcpServer.js';
11
12
  export { defaultStoreDir, fileSessionStore } from './store.js';
12
13
  export { spawnCliTransport } from './transport.js';
@@ -1,32 +1,5 @@
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
1
  import { type InteractionResponse } from './interactionResponseSchema.js';
29
- import type { CanUseToolFn, PendingDecision, QueryOptions, SDKEvent, SessionStore, Transport } from './types.js';
2
+ import type { CanUseToolFn, PendingDecision, QueryOptions, SDKEvent, SDKUserMessage, SessionStore, Transport } from './types.js';
30
3
  /** Builds the CLI argv for a query. Exported for unit tests. */
31
4
  export declare function buildCliArgs(options: QueryOptions): string[];
32
5
  /** Builds subprocess env additions. Exported for unit tests. */
@@ -68,7 +41,7 @@ export declare function decideFailClosed(callback: CanUseToolFn, toolName: strin
68
41
  * Core loop, transport-injectable for tests. Production callers use query()
69
42
  * or resumeWithDecision().
70
43
  */
71
- export declare function queryWithTransport(prompt: string | null, options: QueryOptions, transport: Transport): AsyncGenerator<SDKEvent>;
44
+ export declare function queryWithTransport(prompt: string | null | AsyncIterable<SDKUserMessage>, options: QueryOptions, transport: Transport): AsyncGenerator<SDKEvent>;
72
45
  /** Spawns the managed CLI subprocess for a set of options. Shared with the P1 Query handle. */
73
46
  export declare function spawnForOptions(options: QueryOptions): Transport;
74
47
  /**
package/dist/sdk/query.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { sanitizeRequestProgress } from './requestProgress.js';
1
2
  /**
2
3
  * ccl-sdk query() — the programmatic headless entry (issue P0-1) with the
3
4
  * canUseTool permission callback (P0-2 including pending suspend/resume),
@@ -34,6 +35,7 @@ import { fileURLToPath } from 'url';
34
35
  import { SdkMcpRegistry } from './mcpServer.js';
35
36
  import { fileSessionStore } from './store.js';
36
37
  import { spawnCliTransport } from './transport.js';
38
+ import { isAsyncInput, normalizeUserInput, readWithInput } from './queryInput.js';
37
39
  const DEFAULT_CAN_USE_TOOL_TIMEOUT_MS = 300_000;
38
40
  /** Builds the CLI argv for a query. Exported for unit tests. */
39
41
  export function buildCliArgs(options) {
@@ -89,6 +91,8 @@ export function buildCliArgs(options) {
89
91
  args.push('--include-partial-messages');
90
92
  if (options.includeErrorDiagnostics)
91
93
  args.push('--include-error-diagnostics');
94
+ if (options.includeRequestProgress)
95
+ args.push('--include-request-progress');
92
96
  // slice sdk-input-external-mcp (G2): external MCP servers via --mcp-config (JSON string).
93
97
  if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
94
98
  args.push('--mcp-config', JSON.stringify({ mcpServers: options.mcpServers }));
@@ -249,10 +253,13 @@ export async function* queryWithTransport(prompt, options, transport) {
249
253
  const hooks = options.hooks ?? {};
250
254
  const ownRequestIds = new Set();
251
255
  const toolNamesById = new Map();
256
+ const toolOrigins = new Map();
252
257
  let lastSessionId = options.sessionId ?? options.resume;
253
258
  let turn = 0;
254
259
  let sawResult = false;
255
260
  let sessionEnded = false;
261
+ const streamingInput = isAsyncInput(prompt);
262
+ const inputState = { finished: false };
256
263
  // resumeWithDecision path: the recorded approval this run must settle.
257
264
  let pendingApproval = null;
258
265
  let pendingDecision = options.pendingDecision;
@@ -267,6 +274,8 @@ export async function* queryWithTransport(prompt, options, transport) {
267
274
  }
268
275
  const onAbort = () => void transport.close();
269
276
  options.signal?.addEventListener('abort', onAbort, { once: true });
277
+ if (options.signal?.aborted)
278
+ onAbort();
270
279
  // slice sdk-in-process-mcp-server: host-defined in-process MCP servers, bridged to the subprocess
271
280
  // agent over the control protocol. The registry replies to each `mcp_message` control_request with
272
281
  // its server's JSONRPC response ({ mcp_response }). null when the host passed no sdkMcpServers.
@@ -307,28 +316,50 @@ export async function* queryWithTransport(prompt, options, transport) {
307
316
  });
308
317
  // On pending-decision resume the CLI auto-continues the interrupted turn
309
318
  // (CLAUDE_CODE_RESUME_INTERRUPTED_TURN); a prompt would double-drive it.
310
- if (prompt !== null && !pendingDecision) {
319
+ if (typeof prompt === 'string' && !pendingDecision) {
311
320
  // slice sdk-input-external-mcp (G6): multimodal input — host-provided content blocks replace
312
321
  // the plain-text content when set; otherwise the string prompt is a single text block.
313
322
  const userContent = options.promptContentBlocks && options.promptContentBlocks.length > 0
314
323
  ? options.promptContentBlocks
315
324
  : [{ type: 'text', text: prompt }];
316
- await transport.write({
325
+ await transport.write(normalizeUserInput({
317
326
  type: 'user',
318
327
  message: { role: 'user', content: userContent },
319
328
  parent_tool_use_id: null,
320
- });
329
+ }));
321
330
  // slice sdk-observability-hooks-usage (G5): the host's prompt was submitted to the agent.
322
331
  await runHook(hooks.onUserPromptSubmit &&
323
332
  (() => hooks.onUserPromptSubmit({ prompt, sessionId: lastSessionId })));
324
333
  }
325
334
  // slice sdk-observability-hooks-usage (G5): fire onSessionStart once, on the first session_id.
326
335
  let sessionStarted = false;
327
- for await (const rawMessage of transport.read()) {
336
+ const messages = streamingInput
337
+ ? readWithInput(prompt, transport, inputState, async (message) => {
338
+ sawResult = false;
339
+ const text = typeof message.message.content === 'string' ? message.message.content
340
+ : message.message.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
341
+ await runHook(hooks.onUserPromptSubmit && (() => hooks.onUserPromptSubmit({ prompt: text, sessionId: lastSessionId })));
342
+ })
343
+ : transport.read();
344
+ for await (const rawMessage of messages) {
345
+ let attribution = rawMessage.input_uuid === null
346
+ ? { origin: 'background' } : typeof rawMessage.input_uuid === 'string'
347
+ ? { origin: 'user', turnId: rawMessage.input_uuid }
348
+ : streamingInput && inputState.turnId ? { turnId: inputState.turnId } : {};
328
349
  // Keep ordinary raw passthrough unchanged. Diagnostic payloads are an
329
350
  // untrusted error boundary even when a host asks for raw wire events.
330
- const message = rawMessage.type === 'system' && rawMessage.subtype === 'error_diagnostic'
331
- ? sanitizeErrorEnvelope(rawMessage) : rawMessage;
351
+ if (rawMessage.type === 'system' && rawMessage.subtype === 'request_progress' && !options.includeRequestProgress)
352
+ continue;
353
+ const progress = rawMessage.type === 'system' && rawMessage.subtype === 'request_progress'
354
+ ? sanitizeRequestProgress(rawMessage.progress) : undefined;
355
+ if (rawMessage.type === 'system' && rawMessage.subtype === 'request_progress' && !progress)
356
+ continue;
357
+ const message = progress
358
+ ? { type: 'system', subtype: 'request_progress', progress,
359
+ ...(typeof rawMessage.session_id === 'string' ? { session_id: rawMessage.session_id } : {}),
360
+ ...(typeof rawMessage.uuid === 'string' ? { uuid: rawMessage.uuid } : {}) }
361
+ : rawMessage.type === 'system' && rawMessage.subtype === 'error_diagnostic'
362
+ ? sanitizeErrorEnvelope(rawMessage) : rawMessage;
332
363
  if (typeof message.session_id === 'string') {
333
364
  lastSessionId = message.session_id;
334
365
  if (!sessionStarted) {
@@ -338,13 +369,18 @@ export async function* queryWithTransport(prompt, options, transport) {
338
369
  }
339
370
  }
340
371
  if (options.includeRaw)
341
- yield { type: 'raw', message };
372
+ yield { ...attribution, type: 'raw', message };
342
373
  switch (message.type) {
343
374
  case 'system': {
375
+ if (message.subtype === 'request_progress' && progress)
376
+ yield { ...attribution,
377
+ type: 'request_progress', progress,
378
+ sessionId: typeof message.session_id === 'string' ? message.session_id : undefined,
379
+ };
344
380
  if (message.subtype === 'error_diagnostic') {
345
381
  const diagnostic = sanitizeRecoveryDiagnostic(message.diagnostic);
346
382
  if (diagnostic)
347
- yield {
383
+ yield { ...attribution,
348
384
  type: 'error_diagnostic', diagnostic,
349
385
  sessionId: typeof message.session_id === 'string' ? message.session_id : undefined,
350
386
  };
@@ -355,19 +391,19 @@ export async function* queryWithTransport(prompt, options, transport) {
355
391
  const sessionId = message.session_id;
356
392
  for (const block of contentBlocks(message.message)) {
357
393
  if (block.type === 'text' && typeof block.text === 'string') {
358
- yield { type: 'assistant_text', text: block.text, sessionId };
394
+ yield { ...attribution, type: 'assistant_text', text: block.text, sessionId };
359
395
  }
360
396
  else if (block.type === 'tool_use') {
361
397
  const toolUseId = String(block.id ?? '');
362
398
  const toolName = String(block.name ?? '');
363
399
  toolNamesById.set(toolUseId, toolName);
364
- yield {
400
+ toolOrigins.set(toolUseId, attribution);
401
+ yield { ...attribution,
365
402
  type: 'tool_use',
366
403
  toolName,
367
404
  input: (block.input ?? {}),
368
405
  toolUseId,
369
- sessionId,
370
- };
406
+ sessionId, };
371
407
  }
372
408
  }
373
409
  turn += 1;
@@ -389,7 +425,7 @@ export async function* queryWithTransport(prompt, options, transport) {
389
425
  : Array.isArray(block.content) ? block.content.map(part => part?.type === 'text' && typeof part.text === 'string' ? { ...part, text: sanitizeErrorText(part.text) } : part)
390
426
  : describeError(block.content)
391
427
  : block.content;
392
- yield {
428
+ yield { ...attribution,
393
429
  type: 'tool_result',
394
430
  toolUseId,
395
431
  content: resultContent,
@@ -404,6 +440,8 @@ export async function* queryWithTransport(prompt, options, transport) {
404
440
  isError,
405
441
  sessionId,
406
442
  })));
443
+ toolOrigins.delete(toolUseId);
444
+ toolNamesById.delete(toolUseId);
407
445
  }
408
446
  }
409
447
  break;
@@ -442,8 +480,9 @@ export async function* queryWithTransport(prompt, options, transport) {
442
480
  const toolName = String(request.tool_name ?? '');
443
481
  const input = (request.input ?? {});
444
482
  const toolUseId = String(request.tool_use_id ?? '');
483
+ attribution = toolOrigins.get(toolUseId) ?? attribution;
445
484
  const agentId = typeof request.agent_id === 'string' ? request.agent_id : undefined;
446
- yield {
485
+ yield { ...attribution,
447
486
  type: 'permission_request',
448
487
  requestId,
449
488
  toolName,
@@ -478,7 +517,7 @@ export async function* queryWithTransport(prompt, options, transport) {
478
517
  await store.deletePendingApproval(pendingApproval.sessionId);
479
518
  pendingDecision = undefined;
480
519
  pendingApproval = null;
481
- yield {
520
+ yield { ...attribution,
482
521
  type: 'permission_resolved',
483
522
  requestId,
484
523
  toolUseId,
@@ -515,13 +554,13 @@ export async function* queryWithTransport(prompt, options, transport) {
515
554
  },
516
555
  },
517
556
  });
518
- yield {
557
+ yield { ...attribution,
519
558
  type: 'error',
520
559
  message: 'pending approval requested before any session_id was observed',
521
560
  fatal: false,
522
561
  code: 'pending_without_session',
523
562
  };
524
- yield {
563
+ yield { ...attribution,
525
564
  type: 'permission_resolved',
526
565
  requestId,
527
566
  toolUseId,
@@ -543,7 +582,7 @@ export async function* queryWithTransport(prompt, options, transport) {
543
582
  // the model must not see a denial. Terminating here leaves the
544
583
  // transcript ending at the unresolved tool_use, which the
545
584
  // resume path drops and re-drives.
546
- yield {
585
+ yield { ...attribution,
547
586
  type: 'suspended',
548
587
  sessionId,
549
588
  requestId,
@@ -568,7 +607,7 @@ export async function* queryWithTransport(prompt, options, transport) {
568
607
  response: outcome.decision,
569
608
  },
570
609
  });
571
- yield {
610
+ yield { ...attribution,
572
611
  type: 'permission_resolved',
573
612
  requestId,
574
613
  toolUseId,
@@ -587,7 +626,7 @@ export async function* queryWithTransport(prompt, options, transport) {
587
626
  if (ownRequestIds.has(requestId)) {
588
627
  ownRequestIds.delete(requestId);
589
628
  if (response.subtype === 'error') {
590
- yield {
629
+ yield { ...attribution,
591
630
  type: 'error',
592
631
  message: `initialize failed: ${String(response.error)}`,
593
632
  fatal: false,
@@ -613,8 +652,9 @@ export async function* queryWithTransport(prompt, options, transport) {
613
652
  // Metering must not break the conversation stream.
614
653
  }
615
654
  }
616
- yield {
655
+ yield { ...attribution,
617
656
  type: 'result',
657
+ ...(streamingInput ? { turnId: inputState.resultTurnId } : {}),
618
658
  subtype: String(message.subtype ?? 'unknown'),
619
659
  usage,
620
660
  totalCostUsd,
@@ -634,14 +674,21 @@ export async function* queryWithTransport(prompt, options, transport) {
634
674
  structuredOutput: message
635
675
  .structured_output,
636
676
  };
637
- sessionEnded = true;
638
- await runHook(hooks.onSessionEnd &&
639
- (() => hooks.onSessionEnd({
640
- sessionId,
641
- subtype: String(message.subtype ?? 'unknown'),
642
- isError: message.is_error === true,
643
- usage,
644
- })));
677
+ for (const [id, origin] of toolOrigins) {
678
+ if (origin.turnId === attribution.turnId && origin.origin === attribution.origin) {
679
+ toolOrigins.delete(id);
680
+ toolNamesById.delete(id);
681
+ }
682
+ }
683
+ sessionEnded = !streamingInput;
684
+ if (!streamingInput)
685
+ await runHook(hooks.onSessionEnd &&
686
+ (() => hooks.onSessionEnd({
687
+ sessionId,
688
+ subtype: String(message.subtype ?? 'unknown'),
689
+ isError: message.is_error === true,
690
+ usage,
691
+ })));
645
692
  break;
646
693
  }
647
694
  case 'stream_event': {
@@ -649,7 +696,7 @@ export async function* queryWithTransport(prompt, options, transport) {
649
696
  // (token-level delta). Only arrives when includePartialMessages passed the flag.
650
697
  const streamEvent = message.event;
651
698
  if (streamEvent && typeof streamEvent.type === 'string') {
652
- yield {
699
+ yield { ...attribution,
653
700
  type: 'stream_event',
654
701
  event: streamEvent,
655
702
  sessionId: message.session_id,
@@ -660,10 +707,15 @@ export async function* queryWithTransport(prompt, options, transport) {
660
707
  default:
661
708
  break;
662
709
  }
663
- if (sawResult)
710
+ if (sawResult && !streamingInput)
664
711
  break;
665
712
  }
666
- if (!sawResult) {
713
+ if (streamingInput && inputState.finished) {
714
+ await runHook(hooks.onSessionEnd && (() => hooks.onSessionEnd({
715
+ sessionId: lastSessionId, subtype: 'input_complete', isError: false,
716
+ })));
717
+ }
718
+ else if (!sawResult || streamingInput) {
667
719
  const code = await transport.exited;
668
720
  const aborted = options.signal?.aborted === true;
669
721
  yield {
@@ -23,19 +23,19 @@
23
23
  * supportedAgents()/accountInfo() serve it, and reinitialize() sends a new
24
24
  * initialize (fresh request_id) and refreshes the cache.
25
25
  */
26
- import type { Query, QueryOptions, Transport } from './types.js';
26
+ import type { Query, QueryOptions, SDKUserMessage, Transport } from './types.js';
27
27
  /**
28
28
  * Test-injection seam for the P1 Query handle, mirroring queryWithTransport.
29
29
  * Production callers use query().
30
30
  */
31
- export declare function queryHandleWithTransport(prompt: string | null, options: QueryOptions, transport: Transport): Query;
31
+ export declare function queryHandleWithTransport(prompt: string | null | AsyncIterable<SDKUserMessage>, options: QueryOptions, transport: Transport): Query;
32
32
  /** @deprecated Use the object form query({ prompt, options }) instead. */
33
- export declare function query(prompt: string | AsyncIterable<unknown>, options?: QueryOptions): Query;
33
+ export declare function query(prompt: string | AsyncIterable<SDKUserMessage>, options?: QueryOptions): Query;
34
34
  /**
35
35
  * Run one prompt against a CCL agent session and return a Query handle: the
36
36
  * async stream of SDKEvents plus the control methods of the wire protocol.
37
37
  */
38
38
  export declare function query(params: {
39
- prompt: string;
39
+ prompt: string | AsyncIterable<SDKUserMessage>;
40
40
  options?: QueryOptions;
41
41
  }): Query;
@@ -24,6 +24,7 @@
24
24
  * initialize (fresh request_id) and refreshes the cache.
25
25
  */
26
26
  import { queryWithTransport, spawnForOptions } from './query.js';
27
+ import { isAsyncInput } from './queryInput.js';
27
28
  let requestCounter = 0;
28
29
  const nextRequestId = () => `ccl-sdk-req-${++requestCounter}-${Math.random().toString(36).slice(2, 10)}`;
29
30
  class QueryHandle {
@@ -66,7 +67,10 @@ class QueryHandle {
66
67
  initPromise;
67
68
  initResolve;
68
69
  initReject;
70
+ streamingInput;
71
+ cancelPermission;
69
72
  constructor(prompt, options, transport) {
73
+ this.streamingInput = isAsyncInput(prompt);
70
74
  this.transport = transport;
71
75
  this.options = options;
72
76
  this.innerRead = transport.read()[Symbol.asyncIterator]();
@@ -77,16 +81,41 @@ class QueryHandle {
77
81
  // The init promise is settled internally; hosts that never ask for the
78
82
  // initialization result must not see an unhandled rejection.
79
83
  this.initPromise.catch(() => { });
80
- void this.pump(prompt, options);
84
+ void transport.exited.then(() => this.cancelPermission?.(), () => this.cancelPermission?.());
85
+ const callback = options.canUseTool;
86
+ void this.pump(prompt, callback ? {
87
+ ...options,
88
+ canUseTool: async (...args) => {
89
+ let cancel;
90
+ const cancelled = new Promise(resolve => {
91
+ cancel = () => resolve({ behavior: 'deny', message: 'Permission wait cancelled by host', interrupt: true });
92
+ });
93
+ this.cancelPermission = cancel;
94
+ try {
95
+ return await Promise.race([Promise.resolve().then(() => callback(...args)), cancelled]);
96
+ }
97
+ finally {
98
+ if (this.cancelPermission === cancel)
99
+ this.cancelPermission = undefined;
100
+ }
101
+ },
102
+ } : options);
81
103
  }
82
104
  // ------------------------------------------------------------- event pump
83
105
  async pump(prompt, options) {
84
106
  try {
85
107
  for await (const event of queryWithTransport(prompt, options, this.tap())) {
108
+ // Long-lived streams must not retain unlimited output when the host
109
+ // stops reading. Fail explicitly rather than dropping agent events.
110
+ if (this.streamingInput && this.eventQueue.length >= 1024) {
111
+ await this.transport.close();
112
+ throw new Error('ccl-sdk: streamed query event buffer exceeded 1024 events; consume events as they arrive');
113
+ }
86
114
  this.eventQueue.push(event);
87
115
  if (event.type === 'result') {
88
116
  this.resultDelivered = true;
89
- this.markEnded('ccl-sdk: query has ended (result delivered)');
117
+ if (!this.streamingInput)
118
+ this.markEnded('ccl-sdk: query has ended (result delivered)');
90
119
  }
91
120
  this.notifyEventWaiters();
92
121
  }
@@ -99,7 +128,7 @@ class QueryHandle {
99
128
  // Normal completion (result) is a SOFT end — the child stays alive for
100
129
  // post-run control requests until close(). Every other exit (stream
101
130
  // ended, suspended, error) really closed the transport: hard end.
102
- if (!this.resultDelivered) {
131
+ if (!this.resultDelivered || this.streamingInput) {
103
132
  this.markDead('ccl-sdk: query has ended (transport closed)');
104
133
  }
105
134
  this.notifyEventWaiters();
@@ -130,7 +159,7 @@ class QueryHandle {
130
159
  // requests after the result, which is what makes the post-run
131
160
  // rewind_files round-trip possible. Abort/error paths (no result)
132
161
  // close for real, exactly as before.
133
- if (this.resultDelivered && !this.transportDead)
162
+ if (this.resultDelivered && !this.transportDead && !this.streamingInput)
134
163
  return;
135
164
  await inner.close();
136
165
  },
@@ -153,7 +182,8 @@ class QueryHandle {
153
182
  // The inner event loop stopped consuming (result delivered / early
154
183
  // return). Keep observing control_responses on the SAME iterator so
155
184
  // post-run control requests (rewind_files) still settle.
156
- this.startResidualReader();
185
+ if (!this.streamingInput)
186
+ this.startResidualReader();
157
187
  }
158
188
  }
159
189
  /** Post-loop observer: drains the shared read iterator after the inner
@@ -259,6 +289,7 @@ class QueryHandle {
259
289
  });
260
290
  }
261
291
  interrupt() {
292
+ this.cancelPermission?.();
262
293
  return this.controlRequest({ subtype: 'interrupt' });
263
294
  }
264
295
  setPermissionMode(mode) {
@@ -341,6 +372,7 @@ class QueryHandle {
341
372
  return (await this.initializationResult()).account;
342
373
  }
343
374
  async close() {
375
+ this.cancelPermission?.();
344
376
  this.markDead('ccl-sdk: query closed');
345
377
  await this.transport.close();
346
378
  }
@@ -400,15 +432,18 @@ export function queryHandleWithTransport(prompt, options, transport) {
400
432
  return new QueryHandle(prompt, options, transport);
401
433
  }
402
434
  export function query(promptOrParams, positionalOptions) {
403
- const positional = typeof promptOrParams === 'string' ||
404
- (typeof promptOrParams === 'object' &&
405
- promptOrParams !== null &&
406
- Symbol.asyncIterator in promptOrParams);
435
+ const positional = typeof promptOrParams === 'string' || isAsyncInput(promptOrParams);
407
436
  const prompt = positional
408
437
  ? promptOrParams
409
438
  : promptOrParams.prompt;
410
439
  const options = (positional
411
440
  ? positionalOptions
412
441
  : promptOrParams.options) ?? {};
442
+ if (typeof prompt !== 'string' && !isAsyncInput(prompt)) {
443
+ throw new TypeError('ccl-sdk: prompt must be a string or an AsyncIterable of user messages');
444
+ }
445
+ if (isAsyncInput(prompt) && options.pendingDecision) {
446
+ throw new TypeError('ccl-sdk: use resumeWithDecision to resolve a suspended approval before streaming new input');
447
+ }
413
448
  return queryHandleWithTransport(prompt, options, spawnForOptions(options));
414
449
  }
@@ -0,0 +1,14 @@
1
+ import type { SDKUserMessage, Transport, WireMessage } from './types.js';
2
+ export declare function isAsyncInput(value: unknown): value is AsyncIterable<SDKUserMessage>;
3
+ /** Validate at the public input boundary, before sending anything to the CLI. */
4
+ export declare function normalizeUserInput(value: unknown): SDKUserMessage;
5
+ export type InputState = {
6
+ finished: boolean;
7
+ turnId?: string;
8
+ resultTurnId?: string;
9
+ };
10
+ /**
11
+ * One input is in flight at a time. Keep reading controls while the producer
12
+ * waits, without reading ahead into an unbounded queue of future prompts.
13
+ */
14
+ export declare function readWithInput(input: AsyncIterable<SDKUserMessage>, transport: Transport, state: InputState, submitted: (message: SDKUserMessage) => Promise<void>): AsyncGenerator<WireMessage>;
@@ -0,0 +1,82 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export function isAsyncInput(value) {
3
+ return value !== null && typeof value === 'object' &&
4
+ typeof value[Symbol.asyncIterator] === 'function';
5
+ }
6
+ /** Validate at the public input boundary, before sending anything to the CLI. */
7
+ export function normalizeUserInput(value) {
8
+ const input = value;
9
+ if (!input || input.type !== 'user' || input.message?.role !== 'user') {
10
+ throw new TypeError('ccl-sdk: streamed input must be a user message with message.role="user"');
11
+ }
12
+ const content = input.message.content;
13
+ if (typeof content !== 'string' && !(Array.isArray(content) && content.every(block => block !== null && typeof block === 'object' && typeof block.type === 'string' &&
14
+ (block.type !== 'text' || typeof block.text === 'string')))) {
15
+ throw new TypeError('ccl-sdk: user message content must be text or content blocks');
16
+ }
17
+ if (input.uuid !== undefined && (typeof input.uuid !== 'string' || !input.uuid)) {
18
+ throw new TypeError('ccl-sdk: user message uuid must be a nonempty string');
19
+ }
20
+ return {
21
+ type: 'user', uuid: input.uuid ?? randomUUID(),
22
+ message: { role: 'user', content }, parent_tool_use_id: null,
23
+ };
24
+ }
25
+ /**
26
+ * One input is in flight at a time. Keep reading controls while the producer
27
+ * waits, without reading ahead into an unbounded queue of future prompts.
28
+ */
29
+ export async function* readWithInput(input, transport, state, submitted) {
30
+ const source = input[Symbol.asyncIterator]();
31
+ const wire = transport.read()[Symbol.asyncIterator]();
32
+ const pull = () => Promise.resolve().then(() => source.next()).then(value => ({ kind: 'input', value }), error => ({ kind: 'input-error', error }));
33
+ let pendingInput = pull();
34
+ let pendingWire;
35
+ try {
36
+ for (;;) {
37
+ pendingWire ??= wire.next().then(value => ({ kind: 'wire', value }));
38
+ const next = await Promise.race(pendingInput ? [pendingWire, pendingInput] : [pendingWire]);
39
+ if (next.kind === 'input-error')
40
+ throw next.error;
41
+ if (next.kind === 'input') {
42
+ pendingInput = undefined;
43
+ if (next.value.done) {
44
+ state.finished = true;
45
+ return;
46
+ }
47
+ const message = normalizeUserInput(next.value.value);
48
+ state.turnId = message.uuid;
49
+ await transport.write(message);
50
+ await submitted(message);
51
+ }
52
+ else {
53
+ pendingWire = undefined;
54
+ if (next.value.done)
55
+ return;
56
+ const message = next.value.value;
57
+ const resultId = Object.prototype.hasOwnProperty.call(message, 'input_uuid')
58
+ ? typeof message.input_uuid === 'string' ? message.input_uuid : undefined
59
+ : state.turnId; // Compatibility with CLIs predating explicit attribution.
60
+ const completesInput = message.type === 'result' && state.turnId !== undefined && resultId === state.turnId;
61
+ if (message.type === 'result')
62
+ state.resultTurnId = resultId;
63
+ yield next.value.value;
64
+ if (next.value.value.type === 'result') {
65
+ if (completesInput)
66
+ state.turnId = undefined;
67
+ // Scheduled work can finish while the host is already waiting for
68
+ // the next user input. Replacing that pull loses its eventual value
69
+ // and strands a Session turn behind another source.next().
70
+ if (!state.turnId)
71
+ pendingInput ??= pull();
72
+ }
73
+ }
74
+ }
75
+ }
76
+ finally {
77
+ // A producer may be waiting on user code forever. Request cancellation,
78
+ // but never let an uncooperative iterator prevent subprocess cleanup.
79
+ void Promise.resolve().then(() => source.return?.()).catch(() => { });
80
+ void Promise.resolve().then(() => wire.return?.()).catch(() => { });
81
+ }
82
+ }
@@ -0,0 +1,14 @@
1
+ /** Nonterminal gateway status. It is not model output or permission to retry. */
2
+ export type GatewayRequestPhase = 'preparing' | 'connecting' | 'sending' | 'waiting_response' | 'response_headers' | 'retry_wait' | 'streaming' | 'finished';
3
+ export type GatewayRequestProgress = {
4
+ requestId: string;
5
+ phase: GatewayRequestPhase;
6
+ attempt: number;
7
+ elapsedMs: number;
8
+ /** Age of the last effective model event, never of a status notification. */
9
+ idleMs: number;
10
+ slow: boolean;
11
+ };
12
+ /** Allow-listed projection at the child-process boundary; never forwards arbitrary text. */
13
+ export declare function sanitizeRequestProgress(value: unknown): GatewayRequestProgress | undefined;
14
+ export declare function formatRequestProgress(event: GatewayRequestProgress): string | null;
@@ -0,0 +1,26 @@
1
+ const phases = new Set(['preparing', 'connecting', 'sending', 'waiting_response', 'response_headers', 'retry_wait', 'streaming', 'finished']);
2
+ /** Allow-listed projection at the child-process boundary; never forwards arbitrary text. */
3
+ export function sanitizeRequestProgress(value) {
4
+ if (!value || typeof value !== 'object')
5
+ return;
6
+ const v = value;
7
+ if (typeof v.requestId !== 'string' || !/^[a-zA-Z0-9_.:-]{1,128}$/.test(v.requestId) ||
8
+ !phases.has(v.phase) || typeof v.slow !== 'boolean')
9
+ return;
10
+ for (const name of ['attempt', 'elapsedMs', 'idleMs']) {
11
+ if (!Number.isSafeInteger(v[name]) || v[name] < 0)
12
+ return;
13
+ }
14
+ return { requestId: v.requestId, phase: v.phase, attempt: v.attempt,
15
+ elapsedMs: v.elapsedMs, idleMs: v.idleMs, slow: v.slow };
16
+ }
17
+ export function formatRequestProgress(event) {
18
+ if (event.phase === 'finished' || (event.phase === 'streaming' && !event.slow))
19
+ return null;
20
+ const labels = {
21
+ preparing: 'Preparing request', connecting: 'Connecting to gateway', sending: 'Sending request',
22
+ waiting_response: 'Waiting for gateway response', response_headers: 'Reading gateway response',
23
+ retry_wait: 'Waiting to retry gateway request', streaming: 'Waiting for model progress',
24
+ };
25
+ return `${labels[event.phase]}${event.slow ? ` (${Math.floor(event.idleMs / 1000)}s without model progress; Esc to cancel)` : ''}`;
26
+ }