@margay/ccl-core 1.5.0-alpha.0 → 1.5.1-alpha.2

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 (94) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +95197 -74935
  3. package/dist/sdk/background.d.ts +48 -0
  4. package/dist/sdk/background.js +87 -0
  5. package/dist/sdk/backgroundRunTypes.d.ts +101 -0
  6. package/dist/sdk/backgroundRunTypes.js +1 -0
  7. package/dist/sdk/commandAdapter.d.ts +77 -0
  8. package/dist/sdk/commandAdapter.js +180 -0
  9. package/dist/sdk/commandProcess.d.ts +27 -0
  10. package/dist/sdk/commandProcess.js +130 -0
  11. package/dist/sdk/executionGuidance.d.ts +7 -0
  12. package/dist/sdk/executionGuidance.js +37 -0
  13. package/dist/sdk/index.d.ts +16 -1
  14. package/dist/sdk/index.js +10 -0
  15. package/dist/sdk/inputProvenance.d.ts +13 -0
  16. package/dist/sdk/inputProvenance.js +26 -0
  17. package/dist/sdk/mcpServer.d.ts +9 -5
  18. package/dist/sdk/mcpServer.js +51 -9
  19. package/dist/sdk/operations/command.d.ts +24 -0
  20. package/dist/sdk/operations/command.js +48 -0
  21. package/dist/sdk/operations/host.d.ts +3 -0
  22. package/dist/sdk/operations/host.js +376 -0
  23. package/dist/sdk/operations/json.d.ts +6 -0
  24. package/dist/sdk/operations/json.js +24 -0
  25. package/dist/sdk/operations/reference.d.ts +15 -0
  26. package/dist/sdk/operations/reference.js +19 -0
  27. package/dist/sdk/operations/store.d.ts +26 -0
  28. package/dist/sdk/operations/store.js +300 -0
  29. package/dist/sdk/operations/types.d.ts +171 -0
  30. package/dist/sdk/operations/types.js +8 -0
  31. package/dist/sdk/pendingApprovalIdentity.d.ts +35 -0
  32. package/dist/sdk/pendingApprovalIdentity.js +27 -0
  33. package/dist/sdk/permissionDelegation.d.ts +19 -0
  34. package/dist/sdk/permissionDelegation.js +11 -0
  35. package/dist/sdk/query.d.ts +4 -2
  36. package/dist/sdk/query.js +236 -56
  37. package/dist/sdk/queryInput.js +7 -0
  38. package/dist/sdk/queryLifecycle.d.ts +19 -0
  39. package/dist/sdk/queryLifecycle.js +93 -0
  40. package/dist/sdk/queryLifecycleTypes.d.ts +160 -0
  41. package/dist/sdk/queryLifecycleTypes.js +47 -0
  42. package/dist/sdk/runtimeResources.d.ts +197 -0
  43. package/dist/sdk/runtimeResources.js +36 -0
  44. package/dist/sdk/scheduledOccurrenceTypes.d.ts +103 -0
  45. package/dist/sdk/scheduledOccurrenceTypes.js +12 -0
  46. package/dist/sdk/session.d.ts +4 -3
  47. package/dist/sdk/session.js +19 -14
  48. package/dist/sdk/types.d.ts +21 -2
  49. package/docs/README.md +5 -0
  50. package/docs/background-runs.md +229 -0
  51. package/docs/ccl-duo-agent.md +1 -4
  52. package/docs/ccl-ide-usage.md +7 -70
  53. package/docs/cli-installation.md +37 -0
  54. package/docs/command-tool-recovery.md +62 -0
  55. package/docs/dev-quickstart.md +29 -7
  56. package/docs/error-recovery-usage.md +30 -2
  57. package/docs/features/business-facts.md +2 -4
  58. package/docs/hook-input-provenance.md +120 -0
  59. package/docs/index.md +30 -96
  60. package/docs/local-analytics-quickstart.md +11 -28
  61. package/docs/mcp-tool-recovery.md +82 -0
  62. package/docs/operations-client-integration.md +1 -7
  63. package/docs/peer-development-review.md +0 -1
  64. package/docs/releases/1.5.0-alpha.3.md +17 -0
  65. package/docs/releases/1.5.0-alpha.4.md +16 -0
  66. package/docs/releases/1.5.0-alpha.5.md +26 -0
  67. package/docs/releases/1.5.0-alpha.6.md +15 -0
  68. package/docs/releases/1.5.1-alpha.1.md +11 -0
  69. package/docs/releases/1.5.1-alpha.2.md +17 -0
  70. package/docs/releases/1.5.1-alpha.md +22 -0
  71. package/docs/runtime-resources.md +137 -0
  72. package/docs/self-hosted-product-events.md +6 -35
  73. package/docs/shared-skill-resources.md +50 -0
  74. package/examples/background-runs/agents/background-reviewer.md +6 -0
  75. package/examples/background-runs/agents/background-worker.md +6 -0
  76. package/examples/background-runs/worker-reviewer.mjs +42 -0
  77. package/examples/command-recovery/durable-server.mjs +44 -0
  78. package/examples/command-recovery/receipt-command.mjs +40 -0
  79. package/examples/runtime-resources/catalog.ts +68 -0
  80. package/package.json +25 -14
  81. package/sdk/API.md +236 -1
  82. package/sdk/README.md +6 -0
  83. package/docs/agent-owned-error-recovery-ux-2026-10-04.md +0 -146
  84. package/docs/analytics-capabilities-2026-10-08.md +0 -223
  85. package/docs/releases/1.4.5-rc.1.md +0 -39
  86. package/docs/releases/1.4.5-rc.10.md +0 -50
  87. package/docs/releases/1.4.5-rc.2.md +0 -26
  88. package/docs/releases/1.4.5-rc.3.md +0 -9
  89. package/docs/releases/1.4.5-rc.6.md +0 -11
  90. package/docs/releases/1.4.5-rc.7.md +0 -14
  91. package/docs/releases/1.4.5-rc.8.md +0 -18
  92. package/docs/releases/1.4.5-rc.9.md +0 -22
  93. package/docs/releases/1.4.6-beta.0.md +0 -54
  94. package/docs/releases/1.5.0-alpha.0.md +0 -44
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
@@ -26,7 +30,8 @@ import { sanitizeRequestProgress } from './requestProgress.js';
26
30
  * the model re-generated. Deny works symmetrically. One-shot: the record
27
31
  * is deleted after it is applied.
28
32
  */
29
- import { existsSync, mkdtempSync, symlinkSync } from 'fs';
33
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs';
34
+ import { createHash } from 'crypto';
30
35
  import { describeError, errorDetailsList, sanitizeErrorEnvelope, sanitizeErrorText, sanitizeRecoveryDiagnostic, sanitizeRecoveryOutcome } from './errorDiagnostics.js';
31
36
  import { interactionResponseSchema } from './interactionResponseSchema.js';
32
37
  import { tmpdir } from 'os';
@@ -36,6 +41,7 @@ import { SdkMcpRegistry } from './mcpServer.js';
36
41
  import { fileSessionStore } from './store.js';
37
42
  import { spawnCliTransport } from './transport.js';
38
43
  import { isAsyncInput, normalizeUserInput, readWithInput } from './queryInput.js';
44
+ import { isInternalInput } from './inputProvenance.js';
39
45
  const DEFAULT_CAN_USE_TOOL_TIMEOUT_MS = 300_000;
40
46
  /** Builds the CLI argv for a query. Exported for unit tests. */
41
47
  export function buildCliArgs(options) {
@@ -71,8 +77,17 @@ export function buildCliArgs(options) {
71
77
  args.push('--max-turns', String(options.maxTurns));
72
78
  if (options.maxBudgetUsd !== undefined)
73
79
  args.push('--max-budget-usd', String(options.maxBudgetUsd));
74
- if (options.permissionMode)
75
- args.push('--permission-mode', options.permissionMode);
80
+ const permissionMode = options.permissionDelegation?.mode ?? options.permissionMode;
81
+ if (permissionMode)
82
+ args.push('--permission-mode', permissionMode);
83
+ if (options.permissionCallbackMode)
84
+ args.push('--permission-callback-mode', options.permissionCallbackMode);
85
+ if (options.permissionDelegation)
86
+ args.push('--require-permission-delegation');
87
+ if (options.pendingDecision)
88
+ args.push('--resume-pending-approval');
89
+ if (options.pendingDecision?.expectedApproval)
90
+ args.push('--strict-pending-resume');
76
91
  for (const dir of options.pluginDirs ?? [])
77
92
  args.push('--plugin-dir', dir);
78
93
  if (options.addDirs?.length)
@@ -119,18 +134,40 @@ export function buildCliEnv(options) {
119
134
  /**
120
135
  * Wraps each skills dir as an ephemeral plugin (a temp dir whose `skills`
121
136
  * entry symlinks to the user's dir) so the CLI loads it via --plugin-dir,
122
- * its cwd-independent skill channel (P1-5). Exported for unit tests.
137
+ * its cwd-independent skill channel (P1-5). Direct callers own the returned
138
+ * directories; managed transports dispose only their own wrappers.
123
139
  */
124
140
  export function prepareSkillsWrappers(skillsDirs) {
125
- return skillsDirs.map(dir => {
126
- const target = resolve(dir);
127
- if (!existsSync(target)) {
128
- throw new Error(`ccl-sdk: skills dir does not exist: ${target}`);
141
+ // Validate every input before allocating. Canonical roots keep identity stable
142
+ // across temporary wrappers and pin a published version before CLI discovery.
143
+ const targets = [...new Set(skillsDirs.map(dir => {
144
+ const target = resolve(dir);
145
+ if (!existsSync(target)) {
146
+ throw new Error(`ccl-sdk: skills dir does not exist: ${target}`);
147
+ }
148
+ const canonical = realpathSync(target);
149
+ if (!statSync(canonical).isDirectory()) {
150
+ throw new Error(`ccl-sdk: skills path is not a directory: ${target}`);
151
+ }
152
+ return canonical;
153
+ }))];
154
+ const wrappers = [];
155
+ try {
156
+ for (const target of targets) {
157
+ const wrapper = mkdtempSync(join(tmpdir(), 'ccl-sdk-skills-'));
158
+ wrappers.push(wrapper);
159
+ mkdirSync(join(wrapper, '.claude-plugin'));
160
+ const name = `ccl-sdk-skills-${createHash('sha256').update(target).digest('hex')}`;
161
+ writeFileSync(join(wrapper, '.claude-plugin', 'plugin.json'), JSON.stringify({ name, skills: ['./skills'] }));
162
+ symlinkSync(target, join(wrapper, 'skills'), 'dir');
129
163
  }
130
- const wrapper = mkdtempSync(join(tmpdir(), 'ccl-sdk-skills-'));
131
- symlinkSync(target, join(wrapper, 'skills'), 'dir');
132
- return wrapper;
133
- });
164
+ return wrappers;
165
+ }
166
+ catch (error) {
167
+ for (const wrapper of wrappers)
168
+ rmSync(wrapper, { recursive: true, force: true });
169
+ throw error;
170
+ }
134
171
  }
135
172
  function resolveCliPath(options) {
136
173
  if (options.cliPath)
@@ -258,8 +295,30 @@ export async function* queryWithTransport(prompt, options, transport) {
258
295
  let turn = 0;
259
296
  let sawResult = false;
260
297
  let sessionEnded = false;
298
+ let suspension;
299
+ let suspensionTimer;
300
+ let suspensionTimedOut = false;
261
301
  const streamingInput = isAsyncInput(prompt);
262
302
  const inputState = { finished: false };
303
+ let resolveProvenanceSupport;
304
+ const provenanceSupport = new Promise(resolve => { resolveProvenanceSupport = resolve; });
305
+ async function* negotiatedInputs(source) {
306
+ for await (const message of source) {
307
+ if (message?.input_provenance !== undefined || message?.isSynthetic === true) {
308
+ let timer;
309
+ try {
310
+ const supported = await Promise.race([provenanceSupport, new Promise(resolve => { timer = setTimeout(() => resolve(false), 30000); })]);
311
+ if (!supported)
312
+ throw new Error('CCL input_provenance v1 is unavailable; upgrade the CLI or bind a compatible runtime before submitting internal input');
313
+ }
314
+ finally {
315
+ if (timer)
316
+ clearTimeout(timer);
317
+ }
318
+ }
319
+ yield message;
320
+ }
321
+ }
263
322
  // resumeWithDecision path: the recorded approval this run must settle.
264
323
  let pendingApproval = null;
265
324
  let pendingDecision = options.pendingDecision;
@@ -267,12 +326,28 @@ export async function* queryWithTransport(prompt, options, transport) {
267
326
  if (!options.resume) {
268
327
  throw new Error('ccl-sdk: pendingDecision requires options.resume');
269
328
  }
270
- pendingApproval = await store.loadPendingApproval(options.resume);
271
- if (!pendingApproval) {
272
- throw new Error(`ccl-sdk: no pending approval recorded for session ${options.resume}`);
329
+ try {
330
+ pendingApproval = await store.loadPendingApproval(options.resume);
331
+ if (!pendingApproval)
332
+ throw new Error(`ccl-sdk: no pending approval recorded for session ${options.resume}`);
333
+ if (pendingApproval.suspension && pendingApproval.suspension !== 'confirmed')
334
+ throw new Error('permission_suspension_unconfirmed: the CLI did not confirm a durable suspension; execution status must be checked before resuming');
335
+ if (pendingDecision.expectedApproval) {
336
+ const expected = pendingApprovalIdentitySchema.parse(pendingDecision.expectedApproval);
337
+ if (!samePendingApproval(expected, pendingApproval) || expected.sessionId !== options.resume)
338
+ throw new Error('resume_approval_mismatch: saved approval does not match the decision identity');
339
+ if (pendingDecision.behavior === 'allow' && pendingDecision.updatedInput && approvalInputDigest(pendingDecision.updatedInput) !== expected.inputDigest)
340
+ throw new Error('resume_approval_mismatch: strict approval cannot substitute different input');
341
+ pendingDecision = { ...pendingDecision, expectedApproval: expected };
342
+ }
343
+ }
344
+ catch (error) {
345
+ await transport.close();
346
+ throw error;
273
347
  }
274
348
  }
275
- const onAbort = () => void transport.close();
349
+ const strictApprovalEvidence = pendingDecision?.expectedApproval ? pendingApproval : null;
350
+ const onAbort = () => { resolveProvenanceSupport(false); void transport.close(); };
276
351
  options.signal?.addEventListener('abort', onAbort, { once: true });
277
352
  if (options.signal?.aborted)
278
353
  onAbort();
@@ -299,6 +374,9 @@ export async function* queryWithTransport(prompt, options, transport) {
299
374
  request_id: initId,
300
375
  request: {
301
376
  subtype: 'initialize',
377
+ ...(pendingApproval ? { pendingResumeApproval: { approval: pendingApprovalIdentity(pendingApproval), confirmed: pendingApproval.suspension === 'confirmed' } } : {}),
378
+ ...(options.permissionDelegation ? { permissionDelegation: options.permissionDelegation } : {}),
379
+ ...(pendingDecision?.expectedApproval ? { strictPendingApproval: pendingDecision.expectedApproval } : {}),
302
380
  ...(options.systemPrompt !== undefined && {
303
381
  systemPrompt: options.systemPrompt,
304
382
  }),
@@ -334,14 +412,15 @@ export async function* queryWithTransport(prompt, options, transport) {
334
412
  // slice sdk-observability-hooks-usage (G5): fire onSessionStart once, on the first session_id.
335
413
  let sessionStarted = false;
336
414
  const messages = streamingInput
337
- ? readWithInput(prompt, transport, inputState, async (message) => {
415
+ ? readWithInput(negotiatedInputs(prompt), transport, inputState, async (message) => {
338
416
  sawResult = false;
339
417
  const text = typeof message.message.content === 'string' ? message.message.content
340
418
  : 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 })));
419
+ if (!isInternalInput(message))
420
+ await runHook(hooks.onUserPromptSubmit && (() => hooks.onUserPromptSubmit({ prompt: text, sessionId: lastSessionId })));
342
421
  })
343
422
  : transport.read();
344
- for await (const rawMessage of messages) {
423
+ permissionWire: for await (const rawMessage of messages) {
345
424
  let attribution = rawMessage.input_uuid === null
346
425
  ? { origin: 'background' } : typeof rawMessage.input_uuid === 'string'
347
426
  ? { origin: 'user', turnId: rawMessage.input_uuid }
@@ -372,6 +451,13 @@ export async function* queryWithTransport(prompt, options, transport) {
372
451
  yield { ...attribution, type: 'raw', message };
373
452
  switch (message.type) {
374
453
  case 'system': {
454
+ if (message.subtype === 'query_lifecycle') {
455
+ const parsed = QueryLifecycleObservationSchema.safeParse(message.lifecycle);
456
+ if (parsed.success && parsed.data.record.session_id === message.session_id && parsed.data.record.event_id === message.uuid)
457
+ yield {
458
+ ...attribution, type: 'query_lifecycle', lifecycle: parsed.data, sessionId: parsed.data.record.session_id,
459
+ };
460
+ }
375
461
  if (message.subtype === 'request_progress' && progress)
376
462
  yield { ...attribution,
377
463
  type: 'request_progress', progress,
@@ -419,6 +505,7 @@ export async function* queryWithTransport(prompt, options, transport) {
419
505
  for (const block of contentBlocks(message.message)) {
420
506
  if (block.type === 'tool_result') {
421
507
  const toolUseId = String(block.tool_use_id ?? '');
508
+ const operationReference = readOperationReference(message.operation_reference, toolUseId);
422
509
  const isError = block.is_error === true;
423
510
  const resultContent = isError
424
511
  ? typeof block.content === 'string' ? sanitizeErrorText(block.content)
@@ -429,6 +516,7 @@ export async function* queryWithTransport(prompt, options, transport) {
429
516
  type: 'tool_result',
430
517
  toolUseId,
431
518
  content: resultContent,
519
+ ...(operationReference ? { operationReference } : {}),
432
520
  isError,
433
521
  sessionId,
434
522
  };
@@ -437,6 +525,7 @@ export async function* queryWithTransport(prompt, options, transport) {
437
525
  toolUseId,
438
526
  toolName: toolNamesById.get(toolUseId),
439
527
  content: resultContent,
528
+ ...(operationReference ? { operationReference } : {}),
440
529
  isError,
441
530
  sessionId,
442
531
  })));
@@ -447,6 +536,10 @@ export async function* queryWithTransport(prompt, options, transport) {
447
536
  break;
448
537
  }
449
538
  case 'control_request': {
539
+ // Suspension stops the whole query; never answer another concurrent
540
+ // tool while waiting for the targeted CLI acknowledgement.
541
+ if (suspension)
542
+ break;
450
543
  const requestId = String(message.request_id ?? '');
451
544
  const request = (message.request ?? {});
452
545
  // slice sdk-in-process-mcp-server: route MCP tool calls to host in-process servers.
@@ -491,9 +584,14 @@ export async function* queryWithTransport(prompt, options, transport) {
491
584
  agentId,
492
585
  };
493
586
  // Resume path: settle the recorded approval on its matching call.
494
- // Match by toolName — on allow the tool runs with the RECORDED
495
- // input (or the host's explicit updatedInput), never the model's
496
- // re-generated one, so what the owner approved is what executes.
587
+ // Strict callers require the same tool and structured input. Legacy
588
+ // callers retain name-only matching and recorded-input substitution.
589
+ if (pendingDecision?.expectedApproval && (toolName !== pendingDecision.expectedApproval.toolName || approvalInputDigest(input) !== pendingDecision.expectedApproval.inputDigest)) {
590
+ yield { ...attribution, type: 'error', code: 'resume_approval_mismatch', fatal: true, message: 'Regenerated tool/input differs from the original approved invocation' };
591
+ // Unwind the read iterator before finally awaits close; otherwise
592
+ // unread stdout can keep the child's close event waiting forever.
593
+ return;
594
+ }
497
595
  if (pendingDecision && pendingApproval?.toolName === toolName) {
498
596
  const decision = pendingDecision.behavior === 'allow'
499
597
  ? {
@@ -576,28 +674,16 @@ export async function* queryWithTransport(prompt, options, transport) {
576
674
  toolName,
577
675
  input,
578
676
  createdAt: new Date().toISOString(),
677
+ suspension: 'requested',
579
678
  };
580
679
  await store.savePendingApproval(record);
581
- // Deliberately NO control_response: the tool must not run and
582
- // the model must not see a denial. Terminating here leaves the
583
- // transcript ending at the unresolved tool_use, which the
584
- // resume path drops and re-drives.
585
- yield { ...attribution,
586
- type: 'suspended',
587
- sessionId,
588
- requestId,
589
- toolUseId,
590
- toolName,
591
- input,
592
- };
593
- sessionEnded = true;
594
- await runHook(hooks.onSessionEnd &&
595
- (() => hooks.onSessionEnd({
596
- sessionId,
597
- subtype: 'suspended',
598
- isError: false,
599
- })));
600
- return;
680
+ // A saved SDK record alone does not prove the CLI stopped without
681
+ // converting EOF into a denial. Ask it to freeze and persist first.
682
+ suspension = { requestId: randomUUID(), record, attribution, confirmed: false };
683
+ suspensionTimer = setTimeout(() => { suspensionTimedOut = true; void transport.close(); }, 10000);
684
+ await transport.write({ type: 'control_request', request_id: suspension.requestId,
685
+ request: { subtype: 'suspend_permission', approval: pendingApprovalIdentity(record) } });
686
+ break;
601
687
  }
602
688
  await transport.write({
603
689
  type: 'control_response',
@@ -623,8 +709,24 @@ export async function* queryWithTransport(prompt, options, transport) {
623
709
  case 'control_response': {
624
710
  const response = (message.response ?? {});
625
711
  const requestId = String(response.request_id ?? '');
712
+ if (suspension?.requestId === requestId) {
713
+ const ack = pendingApprovalSuspensionResponseSchema.safeParse(response.response);
714
+ if (response.subtype !== 'success' || !ack.success || !samePendingApproval(ack.data.approval, suspension.record)) {
715
+ yield { ...suspension.attribution, type: 'error', code: 'permission_suspension_failed', fatal: true,
716
+ message: `CLI did not confirm pending approval suspension: ${String(response.error ?? 'invalid acknowledgement')}` };
717
+ return;
718
+ }
719
+ suspension.record = { ...suspension.record, suspension: 'confirmed' };
720
+ await store.savePendingApproval(suspension.record);
721
+ suspension.confirmed = true;
722
+ if (suspensionTimer)
723
+ clearTimeout(suspensionTimer);
724
+ break permissionWire;
725
+ }
626
726
  if (ownRequestIds.has(requestId)) {
627
727
  ownRequestIds.delete(requestId);
728
+ const capability = response.response?.input_provenance;
729
+ resolveProvenanceSupport(response.subtype !== 'error' && capability?.versions?.includes(1) === true);
628
730
  if (response.subtype === 'error') {
629
731
  yield { ...attribution,
630
732
  type: 'error',
@@ -637,6 +739,34 @@ export async function* queryWithTransport(prompt, options, transport) {
637
739
  break;
638
740
  }
639
741
  case 'result': {
742
+ if (suspension && !suspension.confirmed) {
743
+ yield { ...suspension.attribution, type: 'error', code: 'permission_suspension_failed', fatal: true,
744
+ message: 'CLI ended its turn before confirming permission suspension; inspect execution status' };
745
+ return;
746
+ }
747
+ const conflict = pendingApprovalConflictSchema.safeParse(message.permission_resume_conflict);
748
+ if (strictApprovalEvidence && conflict.success && samePendingApproval(conflict.data.approval, strictApprovalEvidence) && message.session_id === strictApprovalEvidence.sessionId) {
749
+ if (conflict.data.originalNotExecuted) {
750
+ // The CLI guard proves this invocation never reached execution. Restore
751
+ // evidence removed on response; never overwrite a newer pending request.
752
+ const current = await store.loadPendingApproval(strictApprovalEvidence.sessionId);
753
+ if (!current)
754
+ await store.savePendingApproval(strictApprovalEvidence);
755
+ }
756
+ yield { ...attribution, type: 'error', code: conflict.data.code, fatal: true,
757
+ message: errorDetailsList(message.errors).join('; ') || 'Strict approval conflict before execution' };
758
+ // Unwind the read iterator before finally awaits close; otherwise
759
+ // unread stdout can keep the child's close event waiting forever.
760
+ return;
761
+ }
762
+ if (pendingDecision?.expectedApproval) {
763
+ yield { ...attribution, type: 'error', code: 'resume_approval_mismatch', fatal: true,
764
+ message: 'Strict resume ended without settling the original approval',
765
+ };
766
+ // Unwind the read iterator before finally awaits close; otherwise
767
+ // unread stdout can keep the child's close event waiting forever.
768
+ return;
769
+ }
640
770
  sawResult = true;
641
771
  const sessionId = message.session_id;
642
772
  const usage = message.usage;
@@ -710,6 +840,22 @@ export async function* queryWithTransport(prompt, options, transport) {
710
840
  if (sawResult && !streamingInput)
711
841
  break;
712
842
  }
843
+ if (suspension) {
844
+ if (!suspension.confirmed) {
845
+ yield { ...suspension.attribution, type: 'error', code: 'permission_suspension_failed', fatal: true,
846
+ message: suspensionTimedOut ? 'Timed out waiting for CLI permission suspension acknowledgement' : 'CLI exited without confirming permission suspension' };
847
+ return;
848
+ }
849
+ // The read iterator has unwound. Stop the original process before exposing
850
+ // a resumable event, so a consumer can immediately resume in a fresh CLI.
851
+ await transport.close();
852
+ const record = suspension.record;
853
+ yield { ...suspension.attribution, type: 'suspended', sessionId: record.sessionId,
854
+ requestId: record.requestId, toolUseId: record.toolUseId, toolName: record.toolName, input: record.input };
855
+ sessionEnded = true;
856
+ await runHook(hooks.onSessionEnd && (() => hooks.onSessionEnd({ sessionId: record.sessionId, subtype: 'suspended', isError: false })));
857
+ return;
858
+ }
713
859
  if (streamingInput && inputState.finished) {
714
860
  await runHook(hooks.onSessionEnd && (() => hooks.onSessionEnd({
715
861
  sessionId: lastSessionId, subtype: 'input_complete', isError: false,
@@ -737,6 +883,9 @@ export async function* queryWithTransport(prompt, options, transport) {
737
883
  }
738
884
  }
739
885
  finally {
886
+ if (suspensionTimer)
887
+ clearTimeout(suspensionTimer);
888
+ resolveProvenanceSupport(false);
740
889
  options.signal?.removeEventListener('abort', onAbort);
741
890
  await sdkMcpRegistry?.close();
742
891
  await transport.close();
@@ -744,26 +893,56 @@ export async function* queryWithTransport(prompt, options, transport) {
744
893
  }
745
894
  /** Spawns the managed CLI subprocess for a set of options. Shared with the P1 Query handle. */
746
895
  export function spawnForOptions(options) {
896
+ const wrappers = options.skillsDirs?.length ? prepareSkillsWrappers(options.skillsDirs) : [];
747
897
  const pluginDirs = [
748
898
  ...(options.pluginDirs ?? []),
749
- ...(options.skillsDirs?.length ? prepareSkillsWrappers(options.skillsDirs) : []),
899
+ ...wrappers,
750
900
  ];
751
901
  const effective = { ...options, pluginDirs };
752
- return spawnCliTransport({
753
- onDiagnostic: options.onDiagnostic ?? (line => {
754
- if (line.startsWith('[business records]')) {
902
+ const cleanup = () => {
903
+ for (const wrapper of wrappers) {
904
+ try {
905
+ rmSync(wrapper, { recursive: true, force: true });
906
+ }
907
+ catch (error) {
755
908
  try {
756
- process.stderr.write(line + '\n');
909
+ options.onDiagnostic?.(`ccl-sdk: could not remove skill wrapper: ${String(error)}`);
757
910
  }
758
- catch { /* diagnostics must not fail the query */ }
911
+ catch { /* cleanup must not mask a transport error */ }
759
912
  }
760
- }),
761
- execPath: options.execPath ?? process.execPath,
762
- cliPath: resolveCliPath(options),
763
- args: buildCliArgs(effective),
764
- cwd: options.cwd,
765
- env: buildCliEnv(effective),
766
- });
913
+ }
914
+ };
915
+ try {
916
+ const transport = spawnCliTransport({
917
+ onDiagnostic: options.onDiagnostic ?? (line => {
918
+ if (line.startsWith('[business records]') || line.startsWith('[memory]')) {
919
+ try {
920
+ process.stderr.write(line + '\n');
921
+ }
922
+ catch { /* diagnostics must not fail the query */ }
923
+ }
924
+ }),
925
+ execPath: options.execPath ?? process.execPath,
926
+ cliPath: resolveCliPath(options),
927
+ args: buildCliArgs(effective),
928
+ cwd: options.cwd,
929
+ env: buildCliEnv(effective),
930
+ });
931
+ void transport.exited.then(cleanup, cleanup);
932
+ let closing;
933
+ return {
934
+ ...transport,
935
+ close() {
936
+ // Concurrent Session/Query shutdowns share completion. A second close
937
+ // must not remove wrappers while the first is still stopping the CLI.
938
+ return closing ??= transport.close().finally(cleanup);
939
+ },
940
+ };
941
+ }
942
+ catch (error) {
943
+ cleanup();
944
+ throw error;
945
+ }
767
946
  }
768
947
  /**
769
948
  * Run one prompt against a CCL agent session and stream JSON-serializable
@@ -781,7 +960,8 @@ export function query(prompt, options = {}) {
781
960
  *
782
961
  * On allow, the suspended tool executes with exactly the recorded input (or
783
962
  * decision.updatedInput if the owner amended it); on deny the model receives
784
- * the denial and the session continues.
963
+ * the denial and the session continues. With expectedApproval, both the saved
964
+ * identity and regenerated input must match; substitution is not permitted.
785
965
  */
786
966
  export function resumeWithDecision(sessionId, decision, options = {}) {
787
967
  const merged = {
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { parseInputProvenance } from './inputProvenance.js';
2
3
  export function isAsyncInput(value) {
3
4
  return value !== null && typeof value === 'object' &&
4
5
  typeof value[Symbol.asyncIterator] === 'function';
@@ -17,9 +18,15 @@ export function normalizeUserInput(value) {
17
18
  if (input.uuid !== undefined && (typeof input.uuid !== 'string' || !input.uuid)) {
18
19
  throw new TypeError('ccl-sdk: user message uuid must be a nonempty string');
19
20
  }
21
+ if (input.isSynthetic !== undefined && typeof input.isSynthetic !== 'boolean')
22
+ throw new TypeError('isSynthetic must be a boolean');
23
+ if (input.isSynthetic && input.input_provenance?.actor === 'human')
24
+ throw new TypeError('Synthetic input cannot claim a human actor');
20
25
  return {
21
26
  type: 'user', uuid: input.uuid ?? randomUUID(),
22
27
  message: { role: 'user', content }, parent_tool_use_id: null,
28
+ ...(input.isSynthetic !== undefined ? { isSynthetic: input.isSynthetic } : {}),
29
+ ...(input.input_provenance !== undefined ? { input_provenance: parseInputProvenance(input.input_provenance) } : {}),
23
30
  };
24
31
  }
25
32
  /**
@@ -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
+ }