@margay/ccl-core 1.4.5-rc.9 → 1.4.6-beta.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, 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, 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 }));
@@ -253,6 +257,8 @@ export async function* queryWithTransport(prompt, options, transport) {
253
257
  let turn = 0;
254
258
  let sawResult = false;
255
259
  let sessionEnded = false;
260
+ const streamingInput = isAsyncInput(prompt);
261
+ const inputState = { finished: false };
256
262
  // resumeWithDecision path: the recorded approval this run must settle.
257
263
  let pendingApproval = null;
258
264
  let pendingDecision = options.pendingDecision;
@@ -267,6 +273,8 @@ export async function* queryWithTransport(prompt, options, transport) {
267
273
  }
268
274
  const onAbort = () => void transport.close();
269
275
  options.signal?.addEventListener('abort', onAbort, { once: true });
276
+ if (options.signal?.aborted)
277
+ onAbort();
270
278
  // slice sdk-in-process-mcp-server: host-defined in-process MCP servers, bridged to the subprocess
271
279
  // agent over the control protocol. The registry replies to each `mcp_message` control_request with
272
280
  // its server's JSONRPC response ({ mcp_response }). null when the host passed no sdkMcpServers.
@@ -307,7 +315,7 @@ export async function* queryWithTransport(prompt, options, transport) {
307
315
  });
308
316
  // On pending-decision resume the CLI auto-continues the interrupted turn
309
317
  // (CLAUDE_CODE_RESUME_INTERRUPTED_TURN); a prompt would double-drive it.
310
- if (prompt !== null && !pendingDecision) {
318
+ if (typeof prompt === 'string' && !pendingDecision) {
311
319
  // slice sdk-input-external-mcp (G6): multimodal input — host-provided content blocks replace
312
320
  // the plain-text content when set; otherwise the string prompt is a single text block.
313
321
  const userContent = options.promptContentBlocks && options.promptContentBlocks.length > 0
@@ -324,11 +332,29 @@ export async function* queryWithTransport(prompt, options, transport) {
324
332
  }
325
333
  // slice sdk-observability-hooks-usage (G5): fire onSessionStart once, on the first session_id.
326
334
  let sessionStarted = false;
327
- for await (const rawMessage of transport.read()) {
335
+ const messages = streamingInput
336
+ ? readWithInput(prompt, transport, inputState, async (message) => {
337
+ sawResult = false;
338
+ const text = typeof message.message.content === 'string' ? message.message.content
339
+ : message.message.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
340
+ await runHook(hooks.onUserPromptSubmit && (() => hooks.onUserPromptSubmit({ prompt: text, sessionId: lastSessionId })));
341
+ })
342
+ : transport.read();
343
+ for await (const rawMessage of messages) {
328
344
  // Keep ordinary raw passthrough unchanged. Diagnostic payloads are an
329
345
  // 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;
346
+ if (rawMessage.type === 'system' && rawMessage.subtype === 'request_progress' && !options.includeRequestProgress)
347
+ continue;
348
+ const progress = rawMessage.type === 'system' && rawMessage.subtype === 'request_progress'
349
+ ? sanitizeRequestProgress(rawMessage.progress) : undefined;
350
+ if (rawMessage.type === 'system' && rawMessage.subtype === 'request_progress' && !progress)
351
+ continue;
352
+ const message = progress
353
+ ? { type: 'system', subtype: 'request_progress', progress,
354
+ ...(typeof rawMessage.session_id === 'string' ? { session_id: rawMessage.session_id } : {}),
355
+ ...(typeof rawMessage.uuid === 'string' ? { uuid: rawMessage.uuid } : {}) }
356
+ : rawMessage.type === 'system' && rawMessage.subtype === 'error_diagnostic'
357
+ ? sanitizeErrorEnvelope(rawMessage) : rawMessage;
332
358
  if (typeof message.session_id === 'string') {
333
359
  lastSessionId = message.session_id;
334
360
  if (!sessionStarted) {
@@ -341,6 +367,11 @@ export async function* queryWithTransport(prompt, options, transport) {
341
367
  yield { type: 'raw', message };
342
368
  switch (message.type) {
343
369
  case 'system': {
370
+ if (message.subtype === 'request_progress' && progress)
371
+ yield {
372
+ type: 'request_progress', progress,
373
+ sessionId: typeof message.session_id === 'string' ? message.session_id : undefined,
374
+ };
344
375
  if (message.subtype === 'error_diagnostic') {
345
376
  const diagnostic = sanitizeRecoveryDiagnostic(message.diagnostic);
346
377
  if (diagnostic)
@@ -615,6 +646,7 @@ export async function* queryWithTransport(prompt, options, transport) {
615
646
  }
616
647
  yield {
617
648
  type: 'result',
649
+ ...(streamingInput ? { turnId: inputState.turnId } : {}),
618
650
  subtype: String(message.subtype ?? 'unknown'),
619
651
  usage,
620
652
  totalCostUsd,
@@ -634,14 +666,15 @@ export async function* queryWithTransport(prompt, options, transport) {
634
666
  structuredOutput: message
635
667
  .structured_output,
636
668
  };
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
- })));
669
+ sessionEnded = !streamingInput;
670
+ if (!streamingInput)
671
+ await runHook(hooks.onSessionEnd &&
672
+ (() => hooks.onSessionEnd({
673
+ sessionId,
674
+ subtype: String(message.subtype ?? 'unknown'),
675
+ isError: message.is_error === true,
676
+ usage,
677
+ })));
645
678
  break;
646
679
  }
647
680
  case 'stream_event': {
@@ -660,10 +693,15 @@ export async function* queryWithTransport(prompt, options, transport) {
660
693
  default:
661
694
  break;
662
695
  }
663
- if (sawResult)
696
+ if (sawResult && !streamingInput)
664
697
  break;
665
698
  }
666
- if (!sawResult) {
699
+ if (streamingInput && inputState.finished) {
700
+ await runHook(hooks.onSessionEnd && (() => hooks.onSessionEnd({
701
+ sessionId: lastSessionId, subtype: 'input_complete', isError: false,
702
+ })));
703
+ }
704
+ else if (!sawResult || streamingInput) {
667
705
  const code = await transport.exited;
668
706
  const aborted = options.signal?.aborted === true;
669
707
  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,13 @@
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
+ };
9
+ /**
10
+ * One input is in flight at a time. Keep reading controls while the producer
11
+ * waits, without reading ahead into an unbounded queue of future prompts.
12
+ */
13
+ export declare function readWithInput(input: AsyncIterable<SDKUserMessage>, transport: Transport, state: InputState, submitted: (message: SDKUserMessage) => Promise<void>): AsyncGenerator<WireMessage>;
@@ -0,0 +1,70 @@
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
+ yield next.value.value;
57
+ if (next.value.value.type === 'result') {
58
+ state.turnId = undefined;
59
+ pendingInput = pull();
60
+ }
61
+ }
62
+ }
63
+ }
64
+ finally {
65
+ // A producer may be waiting on user code forever. Request cancellation,
66
+ // but never let an uncooperative iterator prevent subprocess cleanup.
67
+ void Promise.resolve().then(() => source.return?.()).catch(() => { });
68
+ void Promise.resolve().then(() => wire.return?.()).catch(() => { });
69
+ }
70
+ }
@@ -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
+ }
@@ -0,0 +1,48 @@
1
+ import type { Query, QueryOptions, SDKEvent, SDKUserMessage, Transport } from './types.js';
2
+ export type TurnResult = Extract<SDKEvent, {
3
+ type: 'result';
4
+ }>;
5
+ export type SessionControls = Omit<Query, keyof AsyncGenerator<SDKEvent, void, unknown>>;
6
+ export type SessionOptions = QueryOptions & {
7
+ /** Maximum accepted but not yet completed turns, including the active turn. */
8
+ maxPendingTurns?: number;
9
+ /** Events are delivered as they arrive; Session does not retain an event history. */
10
+ onEvent?: (event: SDKEvent) => void;
11
+ };
12
+ export interface Turn {
13
+ readonly id: string;
14
+ readonly result: Promise<TurnResult>;
15
+ cancel(): Promise<void>;
16
+ }
17
+ /** One CLI process, sequential turns, and a bounded host-side input queue. */
18
+ export declare class Session {
19
+ readonly pid: number | undefined;
20
+ /** Existing SDK controls, including initialization, model and task controls. */
21
+ readonly controls: SessionControls;
22
+ private readonly handle;
23
+ private readonly limit;
24
+ private readonly options;
25
+ private queue;
26
+ private active?;
27
+ private wake?;
28
+ private accepting;
29
+ private closed;
30
+ private drainWaiters;
31
+ private readonly pumping;
32
+ constructor(options?: SessionOptions, transport?: Transport);
33
+ prompt(content: SDKUserMessage['message']['content']): Turn;
34
+ followUp(content: SDKUserMessage['message']['content']): Turn;
35
+ /** Interrupt the active turn; correction runs before queued follow-ups. */
36
+ steer(content: SDKUserMessage['message']['content']): Turn;
37
+ private enqueue;
38
+ private inputs;
39
+ private consume;
40
+ private fail;
41
+ private notifyDrained;
42
+ interrupt(): Promise<void>;
43
+ private cancel;
44
+ /** Stop accepting inputs and drain accepted turns, keeping controls alive. */
45
+ endInput(): Promise<void>;
46
+ close(): Promise<void>;
47
+ }
48
+ export declare function createSession(options?: SessionOptions): Session;