@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.
- package/dist/cli.js +108954 -103254
- package/dist/sdk/background.d.ts +48 -0
- package/dist/sdk/background.js +87 -0
- package/dist/sdk/backgroundRunTypes.d.ts +101 -0
- package/dist/sdk/backgroundRunTypes.js +1 -0
- package/dist/sdk/cancellation.d.ts +15 -0
- package/dist/sdk/cancellation.js +18 -0
- package/dist/sdk/commandAdapter.d.ts +77 -0
- package/dist/sdk/commandAdapter.js +180 -0
- package/dist/sdk/commandProcess.d.ts +27 -0
- package/dist/sdk/commandProcess.js +130 -0
- package/dist/sdk/executionGuidance.d.ts +7 -0
- package/dist/sdk/executionGuidance.js +37 -0
- package/dist/sdk/index.d.ts +14 -0
- package/dist/sdk/index.js +9 -0
- package/dist/sdk/mcpServer.d.ts +9 -5
- package/dist/sdk/mcpServer.js +51 -9
- package/dist/sdk/operations/command.d.ts +24 -0
- package/dist/sdk/operations/command.js +48 -0
- package/dist/sdk/operations/host.d.ts +3 -0
- package/dist/sdk/operations/host.js +376 -0
- package/dist/sdk/operations/json.d.ts +6 -0
- package/dist/sdk/operations/json.js +24 -0
- package/dist/sdk/operations/reference.d.ts +15 -0
- package/dist/sdk/operations/reference.js +19 -0
- package/dist/sdk/operations/store.d.ts +26 -0
- package/dist/sdk/operations/store.js +300 -0
- package/dist/sdk/operations/types.d.ts +171 -0
- package/dist/sdk/operations/types.js +8 -0
- package/dist/sdk/pendingApprovalIdentity.d.ts +35 -0
- package/dist/sdk/pendingApprovalIdentity.js +27 -0
- package/dist/sdk/permissionDelegation.d.ts +19 -0
- package/dist/sdk/permissionDelegation.js +11 -0
- package/dist/sdk/query.d.ts +3 -1
- package/dist/sdk/query.js +142 -30
- package/dist/sdk/queryHandle.js +28 -8
- package/dist/sdk/queryLifecycle.d.ts +19 -0
- package/dist/sdk/queryLifecycle.js +93 -0
- package/dist/sdk/queryLifecycleTypes.d.ts +160 -0
- package/dist/sdk/queryLifecycleTypes.js +47 -0
- package/dist/sdk/scheduledOccurrenceTypes.d.ts +103 -0
- package/dist/sdk/scheduledOccurrenceTypes.js +12 -0
- package/dist/sdk/types.d.ts +21 -3
- package/docs/background-runs.md +229 -0
- package/docs/command-tool-recovery.md +62 -0
- package/docs/dev-quickstart.md +29 -7
- package/docs/error-recovery-usage.md +18 -0
- package/docs/hook-input-provenance.md +77 -0
- package/docs/index.md +2 -0
- package/docs/mcp-tool-recovery.md +82 -0
- package/docs/releases/1.5.0-alpha.5.md +26 -0
- package/docs/releases/1.5.0-alpha.6.md +15 -0
- package/docs/releases/1.5.1-alpha.1.md +11 -0
- package/docs/releases/1.5.1-alpha.2.md +17 -0
- package/docs/releases/1.5.1-alpha.3.md +23 -0
- package/docs/releases/1.5.1-alpha.md +22 -0
- package/docs/runtime-resources.md +6 -3
- package/examples/background-runs/agents/background-reviewer.md +6 -0
- package/examples/background-runs/agents/background-worker.md +6 -0
- package/examples/background-runs/worker-reviewer.mjs +42 -0
- package/examples/command-recovery/durable-server.mjs +44 -0
- package/examples/command-recovery/receipt-command.mjs +40 -0
- package/package.json +17 -2
- package/sdk/API.md +206 -1
- 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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
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
|
-
//
|
|
626
|
-
//
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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 = {
|
package/dist/sdk/queryHandle.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
+
}
|