@margay/ccl-core 1.5.0-alpha.4 → 1.5.1-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/cli.js +108954 -103254
  2. package/dist/sdk/background.d.ts +48 -0
  3. package/dist/sdk/background.js +87 -0
  4. package/dist/sdk/backgroundRunTypes.d.ts +101 -0
  5. package/dist/sdk/backgroundRunTypes.js +1 -0
  6. package/dist/sdk/cancellation.d.ts +15 -0
  7. package/dist/sdk/cancellation.js +18 -0
  8. package/dist/sdk/commandAdapter.d.ts +77 -0
  9. package/dist/sdk/commandAdapter.js +180 -0
  10. package/dist/sdk/commandProcess.d.ts +27 -0
  11. package/dist/sdk/commandProcess.js +130 -0
  12. package/dist/sdk/executionGuidance.d.ts +7 -0
  13. package/dist/sdk/executionGuidance.js +37 -0
  14. package/dist/sdk/index.d.ts +14 -0
  15. package/dist/sdk/index.js +9 -0
  16. package/dist/sdk/mcpServer.d.ts +9 -5
  17. package/dist/sdk/mcpServer.js +51 -9
  18. package/dist/sdk/operations/command.d.ts +24 -0
  19. package/dist/sdk/operations/command.js +48 -0
  20. package/dist/sdk/operations/host.d.ts +3 -0
  21. package/dist/sdk/operations/host.js +376 -0
  22. package/dist/sdk/operations/json.d.ts +6 -0
  23. package/dist/sdk/operations/json.js +24 -0
  24. package/dist/sdk/operations/reference.d.ts +15 -0
  25. package/dist/sdk/operations/reference.js +19 -0
  26. package/dist/sdk/operations/store.d.ts +26 -0
  27. package/dist/sdk/operations/store.js +300 -0
  28. package/dist/sdk/operations/types.d.ts +171 -0
  29. package/dist/sdk/operations/types.js +8 -0
  30. package/dist/sdk/pendingApprovalIdentity.d.ts +35 -0
  31. package/dist/sdk/pendingApprovalIdentity.js +27 -0
  32. package/dist/sdk/permissionDelegation.d.ts +19 -0
  33. package/dist/sdk/permissionDelegation.js +11 -0
  34. package/dist/sdk/query.d.ts +3 -1
  35. package/dist/sdk/query.js +142 -30
  36. package/dist/sdk/queryHandle.js +28 -8
  37. package/dist/sdk/queryLifecycle.d.ts +19 -0
  38. package/dist/sdk/queryLifecycle.js +93 -0
  39. package/dist/sdk/queryLifecycleTypes.d.ts +160 -0
  40. package/dist/sdk/queryLifecycleTypes.js +47 -0
  41. package/dist/sdk/scheduledOccurrenceTypes.d.ts +103 -0
  42. package/dist/sdk/scheduledOccurrenceTypes.js +12 -0
  43. package/dist/sdk/types.d.ts +21 -3
  44. package/docs/background-runs.md +229 -0
  45. package/docs/command-tool-recovery.md +62 -0
  46. package/docs/dev-quickstart.md +29 -7
  47. package/docs/error-recovery-usage.md +18 -0
  48. package/docs/hook-input-provenance.md +77 -0
  49. package/docs/index.md +2 -0
  50. package/docs/mcp-tool-recovery.md +82 -0
  51. package/docs/releases/1.5.0-alpha.5.md +26 -0
  52. package/docs/releases/1.5.0-alpha.6.md +15 -0
  53. package/docs/releases/1.5.1-alpha.1.md +11 -0
  54. package/docs/releases/1.5.1-alpha.2.md +17 -0
  55. package/docs/releases/1.5.1-alpha.3.md +23 -0
  56. package/docs/releases/1.5.1-alpha.md +22 -0
  57. package/docs/runtime-resources.md +6 -3
  58. package/examples/background-runs/agents/background-reviewer.md +6 -0
  59. package/examples/background-runs/agents/background-worker.md +6 -0
  60. package/examples/background-runs/worker-reviewer.mjs +42 -0
  61. package/examples/command-recovery/durable-server.mjs +44 -0
  62. package/examples/command-recovery/receipt-command.mjs +40 -0
  63. package/package.json +17 -2
  64. package/sdk/API.md +206 -1
  65. package/sdk/README.md +6 -0
package/dist/sdk/query.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { approvalInputDigest, pendingApprovalIdentity, pendingApprovalSuspensionResponseSchema, pendingApprovalIdentitySchema, pendingApprovalConflictSchema, samePendingApproval } from './pendingApprovalIdentity.js';
3
+ import { QueryLifecycleObservationSchema } from './queryLifecycleTypes.js';
4
+ import { readOperationReference } from './operations/reference.js';
1
5
  import { sanitizeRequestProgress } from './requestProgress.js';
2
6
  /**
3
7
  * ccl-sdk query() — the programmatic headless entry (issue P0-1) with the
@@ -30,6 +34,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, sym
30
34
  import { createHash } from 'crypto';
31
35
  import { describeError, errorDetailsList, sanitizeErrorEnvelope, sanitizeErrorText, sanitizeRecoveryDiagnostic, sanitizeRecoveryOutcome } from './errorDiagnostics.js';
32
36
  import { interactionResponseSchema } from './interactionResponseSchema.js';
37
+ import { hostCancellationSchema } from './cancellation.js';
33
38
  import { tmpdir } from 'os';
34
39
  import { dirname, join, resolve } from 'path';
35
40
  import { fileURLToPath } from 'url';
@@ -73,8 +78,17 @@ export function buildCliArgs(options) {
73
78
  args.push('--max-turns', String(options.maxTurns));
74
79
  if (options.maxBudgetUsd !== undefined)
75
80
  args.push('--max-budget-usd', String(options.maxBudgetUsd));
76
- if (options.permissionMode)
77
- args.push('--permission-mode', options.permissionMode);
81
+ const permissionMode = options.permissionDelegation?.mode ?? options.permissionMode;
82
+ if (permissionMode)
83
+ args.push('--permission-mode', permissionMode);
84
+ if (options.permissionCallbackMode)
85
+ args.push('--permission-callback-mode', options.permissionCallbackMode);
86
+ if (options.permissionDelegation)
87
+ args.push('--require-permission-delegation');
88
+ if (options.pendingDecision)
89
+ args.push('--resume-pending-approval');
90
+ if (options.pendingDecision?.expectedApproval)
91
+ args.push('--strict-pending-resume');
78
92
  for (const dir of options.pluginDirs ?? [])
79
93
  args.push('--plugin-dir', dir);
80
94
  if (options.addDirs?.length)
@@ -183,6 +197,7 @@ export async function decideFailClosed(callback, toolName, input, context, timeo
183
197
  const deny = (message, failClosedReason) => ({
184
198
  kind: 'decision',
185
199
  decision: { behavior: 'deny', message, toolUseID: context.toolUseId,
200
+ cancellation: { origin: 'host', reason: timedOut ? 'timeout' : 'callback_error' },
186
201
  ...(toolName === 'AskUserQuestion' ? { interaction: { outcome: timedOut ? 'timed_out' : 'interaction_unavailable' } } : {}),
187
202
  },
188
203
  failClosedReason,
@@ -226,6 +241,10 @@ export async function decideFailClosed(callback, toolName, input, context, timeo
226
241
  };
227
242
  }
228
243
  case 'deny': {
244
+ const cancellation = hostCancellationSchema.safeParse(raw.cancellation);
245
+ if (raw.cancellation !== undefined && !cancellation.success) {
246
+ return deny('Cancellation provenance was invalid; no user choice was inferred.', 'unrecognized_result');
247
+ }
229
248
  const interaction = interactionResponseSchema.safeParse(raw.interaction);
230
249
  if (raw.interaction !== undefined && !interaction.success) {
231
250
  return deny('Interaction response was invalid; no user choice was inferred.', 'unrecognized_result');
@@ -237,6 +256,8 @@ export async function decideFailClosed(callback, toolName, input, context, timeo
237
256
  message: sanitizeErrorText(raw.message ?? 'Denied by host'),
238
257
  ...(toolName === 'AskUserQuestion' && interaction.success ? { interaction: interaction.data } : {}),
239
258
  ...(raw.interrupt !== undefined && { interrupt: raw.interrupt }),
259
+ ...(cancellation.success ? { cancellation: cancellation.data } : raw.interrupt
260
+ ? { cancellation: { origin: 'host', reason: 'permission_denied' } } : {}),
240
261
  toolUseID: context.toolUseId,
241
262
  },
242
263
  };
@@ -282,6 +303,9 @@ export async function* queryWithTransport(prompt, options, transport) {
282
303
  let turn = 0;
283
304
  let sawResult = false;
284
305
  let sessionEnded = false;
306
+ let suspension;
307
+ let suspensionTimer;
308
+ let suspensionTimedOut = false;
285
309
  const streamingInput = isAsyncInput(prompt);
286
310
  const inputState = { finished: false };
287
311
  let resolveProvenanceSupport;
@@ -310,11 +334,27 @@ export async function* queryWithTransport(prompt, options, transport) {
310
334
  if (!options.resume) {
311
335
  throw new Error('ccl-sdk: pendingDecision requires options.resume');
312
336
  }
313
- pendingApproval = await store.loadPendingApproval(options.resume);
314
- if (!pendingApproval) {
315
- throw new Error(`ccl-sdk: no pending approval recorded for session ${options.resume}`);
337
+ try {
338
+ pendingApproval = await store.loadPendingApproval(options.resume);
339
+ if (!pendingApproval)
340
+ throw new Error(`ccl-sdk: no pending approval recorded for session ${options.resume}`);
341
+ if (pendingApproval.suspension && pendingApproval.suspension !== 'confirmed')
342
+ throw new Error('permission_suspension_unconfirmed: the CLI did not confirm a durable suspension; execution status must be checked before resuming');
343
+ if (pendingDecision.expectedApproval) {
344
+ const expected = pendingApprovalIdentitySchema.parse(pendingDecision.expectedApproval);
345
+ if (!samePendingApproval(expected, pendingApproval) || expected.sessionId !== options.resume)
346
+ throw new Error('resume_approval_mismatch: saved approval does not match the decision identity');
347
+ if (pendingDecision.behavior === 'allow' && pendingDecision.updatedInput && approvalInputDigest(pendingDecision.updatedInput) !== expected.inputDigest)
348
+ throw new Error('resume_approval_mismatch: strict approval cannot substitute different input');
349
+ pendingDecision = { ...pendingDecision, expectedApproval: expected };
350
+ }
351
+ }
352
+ catch (error) {
353
+ await transport.close();
354
+ throw error;
316
355
  }
317
356
  }
357
+ const strictApprovalEvidence = pendingDecision?.expectedApproval ? pendingApproval : null;
318
358
  const onAbort = () => { resolveProvenanceSupport(false); void transport.close(); };
319
359
  options.signal?.addEventListener('abort', onAbort, { once: true });
320
360
  if (options.signal?.aborted)
@@ -342,6 +382,9 @@ export async function* queryWithTransport(prompt, options, transport) {
342
382
  request_id: initId,
343
383
  request: {
344
384
  subtype: 'initialize',
385
+ ...(pendingApproval ? { pendingResumeApproval: { approval: pendingApprovalIdentity(pendingApproval), confirmed: pendingApproval.suspension === 'confirmed' } } : {}),
386
+ ...(options.permissionDelegation ? { permissionDelegation: options.permissionDelegation } : {}),
387
+ ...(pendingDecision?.expectedApproval ? { strictPendingApproval: pendingDecision.expectedApproval } : {}),
345
388
  ...(options.systemPrompt !== undefined && {
346
389
  systemPrompt: options.systemPrompt,
347
390
  }),
@@ -385,7 +428,7 @@ export async function* queryWithTransport(prompt, options, transport) {
385
428
  await runHook(hooks.onUserPromptSubmit && (() => hooks.onUserPromptSubmit({ prompt: text, sessionId: lastSessionId })));
386
429
  })
387
430
  : transport.read();
388
- for await (const rawMessage of messages) {
431
+ permissionWire: for await (const rawMessage of messages) {
389
432
  let attribution = rawMessage.input_uuid === null
390
433
  ? { origin: 'background' } : typeof rawMessage.input_uuid === 'string'
391
434
  ? { origin: 'user', turnId: rawMessage.input_uuid }
@@ -416,6 +459,13 @@ export async function* queryWithTransport(prompt, options, transport) {
416
459
  yield { ...attribution, type: 'raw', message };
417
460
  switch (message.type) {
418
461
  case 'system': {
462
+ if (message.subtype === 'query_lifecycle') {
463
+ const parsed = QueryLifecycleObservationSchema.safeParse(message.lifecycle);
464
+ if (parsed.success && parsed.data.record.session_id === message.session_id && parsed.data.record.event_id === message.uuid)
465
+ yield {
466
+ ...attribution, type: 'query_lifecycle', lifecycle: parsed.data, sessionId: parsed.data.record.session_id,
467
+ };
468
+ }
419
469
  if (message.subtype === 'request_progress' && progress)
420
470
  yield { ...attribution,
421
471
  type: 'request_progress', progress,
@@ -463,6 +513,7 @@ export async function* queryWithTransport(prompt, options, transport) {
463
513
  for (const block of contentBlocks(message.message)) {
464
514
  if (block.type === 'tool_result') {
465
515
  const toolUseId = String(block.tool_use_id ?? '');
516
+ const operationReference = readOperationReference(message.operation_reference, toolUseId);
466
517
  const isError = block.is_error === true;
467
518
  const resultContent = isError
468
519
  ? typeof block.content === 'string' ? sanitizeErrorText(block.content)
@@ -473,6 +524,7 @@ export async function* queryWithTransport(prompt, options, transport) {
473
524
  type: 'tool_result',
474
525
  toolUseId,
475
526
  content: resultContent,
527
+ ...(operationReference ? { operationReference } : {}),
476
528
  isError,
477
529
  sessionId,
478
530
  };
@@ -481,6 +533,7 @@ export async function* queryWithTransport(prompt, options, transport) {
481
533
  toolUseId,
482
534
  toolName: toolNamesById.get(toolUseId),
483
535
  content: resultContent,
536
+ ...(operationReference ? { operationReference } : {}),
484
537
  isError,
485
538
  sessionId,
486
539
  })));
@@ -491,6 +544,10 @@ export async function* queryWithTransport(prompt, options, transport) {
491
544
  break;
492
545
  }
493
546
  case 'control_request': {
547
+ // Suspension stops the whole query; never answer another concurrent
548
+ // tool while waiting for the targeted CLI acknowledgement.
549
+ if (suspension)
550
+ break;
494
551
  const requestId = String(message.request_id ?? '');
495
552
  const request = (message.request ?? {});
496
553
  // slice sdk-in-process-mcp-server: route MCP tool calls to host in-process servers.
@@ -535,9 +592,14 @@ export async function* queryWithTransport(prompt, options, transport) {
535
592
  agentId,
536
593
  };
537
594
  // Resume path: settle the recorded approval on its matching call.
538
- // Match by toolName — on allow the tool runs with the RECORDED
539
- // input (or the host's explicit updatedInput), never the model's
540
- // re-generated one, so what the owner approved is what executes.
595
+ // Strict callers require the same tool and structured input. Legacy
596
+ // callers retain name-only matching and recorded-input substitution.
597
+ if (pendingDecision?.expectedApproval && (toolName !== pendingDecision.expectedApproval.toolName || approvalInputDigest(input) !== pendingDecision.expectedApproval.inputDigest)) {
598
+ yield { ...attribution, type: 'error', code: 'resume_approval_mismatch', fatal: true, message: 'Regenerated tool/input differs from the original approved invocation' };
599
+ // Unwind the read iterator before finally awaits close; otherwise
600
+ // unread stdout can keep the child's close event waiting forever.
601
+ return;
602
+ }
541
603
  if (pendingDecision && pendingApproval?.toolName === toolName) {
542
604
  const decision = pendingDecision.behavior === 'allow'
543
605
  ? {
@@ -620,28 +682,16 @@ export async function* queryWithTransport(prompt, options, transport) {
620
682
  toolName,
621
683
  input,
622
684
  createdAt: new Date().toISOString(),
685
+ suspension: 'requested',
623
686
  };
624
687
  await store.savePendingApproval(record);
625
- // Deliberately NO control_response: the tool must not run and
626
- // the model must not see a denial. Terminating here leaves the
627
- // transcript ending at the unresolved tool_use, which the
628
- // resume path drops and re-drives.
629
- yield { ...attribution,
630
- type: 'suspended',
631
- sessionId,
632
- requestId,
633
- toolUseId,
634
- toolName,
635
- input,
636
- };
637
- sessionEnded = true;
638
- await runHook(hooks.onSessionEnd &&
639
- (() => hooks.onSessionEnd({
640
- sessionId,
641
- subtype: 'suspended',
642
- isError: false,
643
- })));
644
- return;
688
+ // A saved SDK record alone does not prove the CLI stopped without
689
+ // converting EOF into a denial. Ask it to freeze and persist first.
690
+ suspension = { requestId: randomUUID(), record, attribution, confirmed: false };
691
+ suspensionTimer = setTimeout(() => { suspensionTimedOut = true; void transport.close(); }, 10000);
692
+ await transport.write({ type: 'control_request', request_id: suspension.requestId,
693
+ request: { subtype: 'suspend_permission', approval: pendingApprovalIdentity(record) } });
694
+ break;
645
695
  }
646
696
  await transport.write({
647
697
  type: 'control_response',
@@ -657,6 +707,7 @@ export async function* queryWithTransport(prompt, options, transport) {
657
707
  toolUseId,
658
708
  behavior: outcome.decision.behavior,
659
709
  ...(outcome.decision.behavior === 'deny' && outcome.decision.interaction ? { interaction: outcome.decision.interaction } : {}),
710
+ ...(outcome.decision.behavior === 'deny' && outcome.decision.cancellation ? { cancellation: outcome.decision.cancellation } : {}),
660
711
  source: 'callback',
661
712
  ...(outcome.failClosedReason && {
662
713
  failClosedReason: outcome.failClosedReason,
@@ -667,6 +718,20 @@ export async function* queryWithTransport(prompt, options, transport) {
667
718
  case 'control_response': {
668
719
  const response = (message.response ?? {});
669
720
  const requestId = String(response.request_id ?? '');
721
+ if (suspension?.requestId === requestId) {
722
+ const ack = pendingApprovalSuspensionResponseSchema.safeParse(response.response);
723
+ if (response.subtype !== 'success' || !ack.success || !samePendingApproval(ack.data.approval, suspension.record)) {
724
+ yield { ...suspension.attribution, type: 'error', code: 'permission_suspension_failed', fatal: true,
725
+ message: `CLI did not confirm pending approval suspension: ${String(response.error ?? 'invalid acknowledgement')}` };
726
+ return;
727
+ }
728
+ suspension.record = { ...suspension.record, suspension: 'confirmed' };
729
+ await store.savePendingApproval(suspension.record);
730
+ suspension.confirmed = true;
731
+ if (suspensionTimer)
732
+ clearTimeout(suspensionTimer);
733
+ break permissionWire;
734
+ }
670
735
  if (ownRequestIds.has(requestId)) {
671
736
  ownRequestIds.delete(requestId);
672
737
  const capability = response.response?.input_provenance;
@@ -683,6 +748,34 @@ export async function* queryWithTransport(prompt, options, transport) {
683
748
  break;
684
749
  }
685
750
  case 'result': {
751
+ if (suspension && !suspension.confirmed) {
752
+ yield { ...suspension.attribution, type: 'error', code: 'permission_suspension_failed', fatal: true,
753
+ message: 'CLI ended its turn before confirming permission suspension; inspect execution status' };
754
+ return;
755
+ }
756
+ const conflict = pendingApprovalConflictSchema.safeParse(message.permission_resume_conflict);
757
+ if (strictApprovalEvidence && conflict.success && samePendingApproval(conflict.data.approval, strictApprovalEvidence) && message.session_id === strictApprovalEvidence.sessionId) {
758
+ if (conflict.data.originalNotExecuted) {
759
+ // The CLI guard proves this invocation never reached execution. Restore
760
+ // evidence removed on response; never overwrite a newer pending request.
761
+ const current = await store.loadPendingApproval(strictApprovalEvidence.sessionId);
762
+ if (!current)
763
+ await store.savePendingApproval(strictApprovalEvidence);
764
+ }
765
+ yield { ...attribution, type: 'error', code: conflict.data.code, fatal: true,
766
+ message: errorDetailsList(message.errors).join('; ') || 'Strict approval conflict before execution' };
767
+ // Unwind the read iterator before finally awaits close; otherwise
768
+ // unread stdout can keep the child's close event waiting forever.
769
+ return;
770
+ }
771
+ if (pendingDecision?.expectedApproval) {
772
+ yield { ...attribution, type: 'error', code: 'resume_approval_mismatch', fatal: true,
773
+ message: 'Strict resume ended without settling the original approval',
774
+ };
775
+ // Unwind the read iterator before finally awaits close; otherwise
776
+ // unread stdout can keep the child's close event waiting forever.
777
+ return;
778
+ }
686
779
  sawResult = true;
687
780
  const sessionId = message.session_id;
688
781
  const usage = message.usage;
@@ -756,6 +849,22 @@ export async function* queryWithTransport(prompt, options, transport) {
756
849
  if (sawResult && !streamingInput)
757
850
  break;
758
851
  }
852
+ if (suspension) {
853
+ if (!suspension.confirmed) {
854
+ yield { ...suspension.attribution, type: 'error', code: 'permission_suspension_failed', fatal: true,
855
+ message: suspensionTimedOut ? 'Timed out waiting for CLI permission suspension acknowledgement' : 'CLI exited without confirming permission suspension' };
856
+ return;
857
+ }
858
+ // The read iterator has unwound. Stop the original process before exposing
859
+ // a resumable event, so a consumer can immediately resume in a fresh CLI.
860
+ await transport.close();
861
+ const record = suspension.record;
862
+ yield { ...suspension.attribution, type: 'suspended', sessionId: record.sessionId,
863
+ requestId: record.requestId, toolUseId: record.toolUseId, toolName: record.toolName, input: record.input };
864
+ sessionEnded = true;
865
+ await runHook(hooks.onSessionEnd && (() => hooks.onSessionEnd({ sessionId: record.sessionId, subtype: 'suspended', isError: false })));
866
+ return;
867
+ }
759
868
  if (streamingInput && inputState.finished) {
760
869
  await runHook(hooks.onSessionEnd && (() => hooks.onSessionEnd({
761
870
  sessionId: lastSessionId, subtype: 'input_complete', isError: false,
@@ -783,6 +892,8 @@ export async function* queryWithTransport(prompt, options, transport) {
783
892
  }
784
893
  }
785
894
  finally {
895
+ if (suspensionTimer)
896
+ clearTimeout(suspensionTimer);
786
897
  resolveProvenanceSupport(false);
787
898
  options.signal?.removeEventListener('abort', onAbort);
788
899
  await sdkMcpRegistry?.close();
@@ -858,7 +969,8 @@ export function query(prompt, options = {}) {
858
969
  *
859
970
  * On allow, the suspended tool executes with exactly the recorded input (or
860
971
  * decision.updatedInput if the owner amended it); on deny the model receives
861
- * the denial and the session continues.
972
+ * the denial and the session continues. With expectedApproval, both the saved
973
+ * identity and regenerated input must match; substitution is not permitted.
862
974
  */
863
975
  export function resumeWithDecision(sessionId, decision, options = {}) {
864
976
  const merged = {
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { queryWithTransport, spawnForOptions } from './query.js';
27
27
  import { isAsyncInput } from './queryInput.js';
28
+ import { hostCancellationSchema } from './cancellation.js';
28
29
  let requestCounter = 0;
29
30
  const nextRequestId = () => `ccl-sdk-req-${++requestCounter}-${Math.random().toString(36).slice(2, 10)}`;
30
31
  class QueryHandle {
@@ -81,14 +82,15 @@ class QueryHandle {
81
82
  // The init promise is settled internally; hosts that never ask for the
82
83
  // initialization result must not see an unhandled rejection.
83
84
  this.initPromise.catch(() => { });
84
- void transport.exited.then(() => this.cancelPermission?.(), () => this.cancelPermission?.());
85
+ const retired = () => this.cancelPermission?.({ origin: 'host', reason: 'connection_closed' });
86
+ void transport.exited.then(retired, retired);
85
87
  const callback = options.canUseTool;
86
88
  void this.pump(prompt, callback ? {
87
89
  ...options,
88
90
  canUseTool: async (...args) => {
89
91
  let cancel;
90
92
  const cancelled = new Promise(resolve => {
91
- cancel = () => resolve({ behavior: 'deny', message: 'Permission wait cancelled by host', interrupt: true });
93
+ cancel = cancellation => resolve({ behavior: 'deny', message: 'Permission wait cancelled by host', interrupt: true, cancellation });
92
94
  });
93
95
  this.cancelPermission = cancel;
94
96
  try {
@@ -220,9 +222,12 @@ class QueryHandle {
220
222
  if (response.subtype === 'error') {
221
223
  entry.reject(new Error(String(response.error ?? 'control request failed')));
222
224
  }
223
- else {
225
+ else if (response.subtype === 'success') {
224
226
  entry.resolve(response.response);
225
227
  }
228
+ else {
229
+ entry.reject(new Error('ccl-sdk: invalid control acknowledgement'));
230
+ }
226
231
  }
227
232
  trackInitialize(requestId) {
228
233
  this.pending.set(requestId, {
@@ -278,19 +283,34 @@ class QueryHandle {
278
283
  return Promise.reject(new Error(this.endReason));
279
284
  const requestId = nextRequestId();
280
285
  return new Promise((resolve, reject) => {
281
- this.pending.set(requestId, { resolve, reject });
286
+ const timer = opts.timeoutMs ? setTimeout(() => {
287
+ const entry = this.pending.get(requestId);
288
+ this.pending.delete(requestId);
289
+ entry?.reject(new Error('ccl-sdk: interrupt acknowledgement timed out; stop outcome is unknown'));
290
+ }, opts.timeoutMs) : undefined;
291
+ this.pending.set(requestId, {
292
+ resolve: value => { if (timer)
293
+ clearTimeout(timer); resolve(value); },
294
+ reject: error => { if (timer)
295
+ clearTimeout(timer); reject(error); },
296
+ });
282
297
  this.transport
283
298
  .write({ type: 'control_request', request_id: requestId, request })
284
299
  .catch(error => {
285
300
  if (this.pending.delete(requestId)) {
301
+ if (timer)
302
+ clearTimeout(timer);
286
303
  reject(error instanceof Error ? error : new Error(String(error)));
287
304
  }
288
305
  });
289
306
  });
290
307
  }
291
- interrupt() {
292
- this.cancelPermission?.();
293
- return this.controlRequest({ subtype: 'interrupt' });
308
+ interrupt(cancellation = { origin: 'host', reason: 'cancelled' }) {
309
+ const parsed = hostCancellationSchema.safeParse(cancellation);
310
+ if (!parsed.success)
311
+ return Promise.reject(new Error('ccl-sdk: invalid cancellation provenance'));
312
+ this.cancelPermission?.(parsed.data);
313
+ return this.controlRequest({ subtype: 'interrupt', cancellation: parsed.data }, { timeoutMs: 10000 });
294
314
  }
295
315
  setPermissionMode(mode) {
296
316
  return this.controlRequest({ subtype: 'set_permission_mode', mode });
@@ -372,7 +392,7 @@ class QueryHandle {
372
392
  return (await this.initializationResult()).account;
373
393
  }
374
394
  async close() {
375
- this.cancelPermission?.();
395
+ this.cancelPermission?.({ origin: 'host', reason: 'connection_closed' });
376
396
  this.markDead('ccl-sdk: query closed');
377
397
  await this.transport.close();
378
398
  }
@@ -0,0 +1,19 @@
1
+ import { type QueryLifecycleRecord } from './queryLifecycleTypes.js';
2
+ export type QueryLifecycleRead = {
3
+ state: 'found';
4
+ completeness: 'complete' | 'incomplete';
5
+ records: QueryLifecycleRecord[];
6
+ terminal?: QueryLifecycleRecord;
7
+ } | {
8
+ state: 'not_found';
9
+ } | {
10
+ state: 'unavailable';
11
+ reason: 'file_unavailable' | 'invalid_record' | 'truncated_tail' | 'record_too_large' | 'conflicting_events';
12
+ };
13
+ /** Read one caller-specified file. Absence is never proof that a tool did not run.
14
+ * No session discovery, model invocation, daemon startup or repair is performed. */
15
+ export declare function readQueryLifecycle(input: {
16
+ transcriptPath: string;
17
+ sessionId: string;
18
+ queryId: string;
19
+ }): Promise<QueryLifecycleRead>;
@@ -0,0 +1,93 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { QueryLifecycleSchema } from './queryLifecycleTypes.js';
3
+ /** Read one caller-specified file. Absence is never proof that a tool did not run.
4
+ * No session discovery, model invocation, daemon startup or repair is performed. */
5
+ export async function readQueryLifecycle(input) {
6
+ const records = [];
7
+ const events = new Map();
8
+ let failure;
9
+ let pending = '';
10
+ const stream = createReadStream(input.transcriptPath, { encoding: 'utf8', highWaterMark: 64 * 1024 });
11
+ const consume = (line) => {
12
+ if (!line.trim())
13
+ return;
14
+ let raw;
15
+ try {
16
+ raw = JSON.parse(line);
17
+ }
18
+ catch {
19
+ failure = { state: 'unavailable', reason: 'invalid_record' };
20
+ return;
21
+ }
22
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
23
+ failure = { state: 'unavailable', reason: 'invalid_record' };
24
+ return;
25
+ }
26
+ const entry = raw;
27
+ if (entry.type !== 'query_lifecycle')
28
+ return;
29
+ if (entry.record?.session_id !== input.sessionId || entry.record?.query_id !== input.queryId)
30
+ return;
31
+ const parsed = QueryLifecycleSchema.safeParse(entry.record);
32
+ if (!parsed.success) {
33
+ failure = { state: 'unavailable', reason: 'invalid_record' };
34
+ return;
35
+ }
36
+ const record = parsed.data;
37
+ const serialized = JSON.stringify(record);
38
+ const duplicate = events.get(record.event_id);
39
+ if (duplicate !== undefined) {
40
+ if (duplicate !== serialized)
41
+ failure = { state: 'unavailable', reason: 'conflicting_events' };
42
+ return;
43
+ }
44
+ events.set(record.event_id, serialized);
45
+ records.push(record);
46
+ };
47
+ try {
48
+ for await (const chunk of stream) {
49
+ pending += chunk;
50
+ let index;
51
+ while ((index = pending.indexOf('\n')) !== -1) {
52
+ if (index > 32 * 1024 * 1024)
53
+ return { state: 'unavailable', reason: 'record_too_large' };
54
+ consume(pending.slice(0, index));
55
+ if (failure)
56
+ return failure;
57
+ pending = pending.slice(index + 1);
58
+ }
59
+ // Protect against an unbounded unterminated line, including ordinary
60
+ // conversation records. Large files remain streamed rather than loaded.
61
+ if (pending.length > 32 * 1024 * 1024)
62
+ return { state: 'unavailable', reason: 'record_too_large' };
63
+ }
64
+ if (pending.trim())
65
+ return { state: 'unavailable', reason: 'truncated_tail' };
66
+ }
67
+ catch {
68
+ return { state: 'unavailable', reason: 'file_unavailable' };
69
+ }
70
+ finally {
71
+ stream.destroy();
72
+ }
73
+ if (!records.length)
74
+ return { state: 'not_found' };
75
+ let previous;
76
+ let completeSequence = records[0].phase === 'start';
77
+ for (const record of records) {
78
+ if (previous) {
79
+ if (previous.phase === 'terminal' || record.sequence <= previous.sequence || record.phase === 'start'
80
+ || record.scope !== previous.scope || record.agent_id !== previous.agent_id
81
+ || previous.accepted_inputs.some(p => !record.accepted_inputs.some(n => n.message_id === p.message_id && JSON.stringify(n) === JSON.stringify(p)))
82
+ || previous.input_message_ids.some(id => !record.input_message_ids.includes(id))) {
83
+ return { state: 'unavailable', reason: 'conflicting_events' };
84
+ }
85
+ if (record.sequence !== previous.sequence + 1)
86
+ completeSequence = false;
87
+ }
88
+ previous = record;
89
+ }
90
+ const terminal = previous?.phase === 'terminal' ? previous : undefined;
91
+ return { state: 'found', completeness: terminal && completeSequence ? 'complete' : 'incomplete', records,
92
+ ...(terminal && completeSequence ? { terminal } : {}) };
93
+ }