@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.
- package/README.md +1 -1
- package/dist/cli.js +95197 -74935
- 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/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 +16 -1
- package/dist/sdk/index.js +10 -0
- package/dist/sdk/inputProvenance.d.ts +13 -0
- package/dist/sdk/inputProvenance.js +26 -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 +4 -2
- package/dist/sdk/query.js +236 -56
- package/dist/sdk/queryInput.js +7 -0
- 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/runtimeResources.d.ts +197 -0
- package/dist/sdk/runtimeResources.js +36 -0
- package/dist/sdk/scheduledOccurrenceTypes.d.ts +103 -0
- package/dist/sdk/scheduledOccurrenceTypes.js +12 -0
- package/dist/sdk/session.d.ts +4 -3
- package/dist/sdk/session.js +19 -14
- package/dist/sdk/types.d.ts +21 -2
- package/docs/README.md +5 -0
- package/docs/background-runs.md +229 -0
- package/docs/ccl-duo-agent.md +1 -4
- package/docs/ccl-ide-usage.md +7 -70
- package/docs/cli-installation.md +37 -0
- package/docs/command-tool-recovery.md +62 -0
- package/docs/dev-quickstart.md +29 -7
- package/docs/error-recovery-usage.md +30 -2
- package/docs/features/business-facts.md +2 -4
- package/docs/hook-input-provenance.md +120 -0
- package/docs/index.md +30 -96
- package/docs/local-analytics-quickstart.md +11 -28
- package/docs/mcp-tool-recovery.md +82 -0
- package/docs/operations-client-integration.md +1 -7
- package/docs/peer-development-review.md +0 -1
- package/docs/releases/1.5.0-alpha.3.md +17 -0
- package/docs/releases/1.5.0-alpha.4.md +16 -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.md +22 -0
- package/docs/runtime-resources.md +137 -0
- package/docs/self-hosted-product-events.md +6 -35
- package/docs/shared-skill-resources.md +50 -0
- 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/examples/runtime-resources/catalog.ts +68 -0
- package/package.json +25 -14
- package/sdk/API.md +236 -1
- package/sdk/README.md +6 -0
- package/docs/agent-owned-error-recovery-ux-2026-10-04.md +0 -146
- package/docs/analytics-capabilities-2026-10-08.md +0 -223
- package/docs/releases/1.4.5-rc.1.md +0 -39
- package/docs/releases/1.4.5-rc.10.md +0 -50
- package/docs/releases/1.4.5-rc.2.md +0 -26
- package/docs/releases/1.4.5-rc.3.md +0 -9
- package/docs/releases/1.4.5-rc.6.md +0 -11
- package/docs/releases/1.4.5-rc.7.md +0 -14
- package/docs/releases/1.4.5-rc.8.md +0 -18
- package/docs/releases/1.4.5-rc.9.md +0 -22
- package/docs/releases/1.4.6-beta.0.md +0 -54
- 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
|
-
|
|
75
|
-
|
|
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).
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
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
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
...
|
|
899
|
+
...wrappers,
|
|
750
900
|
];
|
|
751
901
|
const effective = { ...options, pluginDirs };
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
909
|
+
options.onDiagnostic?.(`ccl-sdk: could not remove skill wrapper: ${String(error)}`);
|
|
757
910
|
}
|
|
758
|
-
catch { /*
|
|
911
|
+
catch { /* cleanup must not mask a transport error */ }
|
|
759
912
|
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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 = {
|
package/dist/sdk/queryInput.js
CHANGED
|
@@ -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
|
+
}
|