@margay/ccl-core 1.5.0-alpha.4 → 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 (61) hide show
  1. package/dist/cli.js +69586 -64065
  2. package/dist/sdk/background.d.ts +48 -0
  3. package/dist/sdk/background.js +87 -0
  4. package/dist/sdk/backgroundRunTypes.d.ts +101 -0
  5. package/dist/sdk/backgroundRunTypes.js +1 -0
  6. package/dist/sdk/commandAdapter.d.ts +77 -0
  7. package/dist/sdk/commandAdapter.js +180 -0
  8. package/dist/sdk/commandProcess.d.ts +27 -0
  9. package/dist/sdk/commandProcess.js +130 -0
  10. package/dist/sdk/executionGuidance.d.ts +7 -0
  11. package/dist/sdk/executionGuidance.js +37 -0
  12. package/dist/sdk/index.d.ts +13 -0
  13. package/dist/sdk/index.js +9 -0
  14. package/dist/sdk/mcpServer.d.ts +9 -5
  15. package/dist/sdk/mcpServer.js +51 -9
  16. package/dist/sdk/operations/command.d.ts +24 -0
  17. package/dist/sdk/operations/command.js +48 -0
  18. package/dist/sdk/operations/host.d.ts +3 -0
  19. package/dist/sdk/operations/host.js +376 -0
  20. package/dist/sdk/operations/json.d.ts +6 -0
  21. package/dist/sdk/operations/json.js +24 -0
  22. package/dist/sdk/operations/reference.d.ts +15 -0
  23. package/dist/sdk/operations/reference.js +19 -0
  24. package/dist/sdk/operations/store.d.ts +26 -0
  25. package/dist/sdk/operations/store.js +300 -0
  26. package/dist/sdk/operations/types.d.ts +171 -0
  27. package/dist/sdk/operations/types.js +8 -0
  28. package/dist/sdk/pendingApprovalIdentity.d.ts +35 -0
  29. package/dist/sdk/pendingApprovalIdentity.js +27 -0
  30. package/dist/sdk/permissionDelegation.d.ts +19 -0
  31. package/dist/sdk/permissionDelegation.js +11 -0
  32. package/dist/sdk/query.d.ts +2 -1
  33. package/dist/sdk/query.js +133 -30
  34. package/dist/sdk/queryLifecycle.d.ts +19 -0
  35. package/dist/sdk/queryLifecycle.js +93 -0
  36. package/dist/sdk/queryLifecycleTypes.d.ts +160 -0
  37. package/dist/sdk/queryLifecycleTypes.js +47 -0
  38. package/dist/sdk/scheduledOccurrenceTypes.d.ts +103 -0
  39. package/dist/sdk/scheduledOccurrenceTypes.js +12 -0
  40. package/dist/sdk/types.d.ts +17 -2
  41. package/docs/background-runs.md +229 -0
  42. package/docs/command-tool-recovery.md +62 -0
  43. package/docs/dev-quickstart.md +29 -7
  44. package/docs/error-recovery-usage.md +18 -0
  45. package/docs/hook-input-provenance.md +77 -0
  46. package/docs/index.md +2 -0
  47. package/docs/mcp-tool-recovery.md +82 -0
  48. package/docs/releases/1.5.0-alpha.5.md +26 -0
  49. package/docs/releases/1.5.0-alpha.6.md +15 -0
  50. package/docs/releases/1.5.1-alpha.1.md +11 -0
  51. package/docs/releases/1.5.1-alpha.2.md +17 -0
  52. package/docs/releases/1.5.1-alpha.md +22 -0
  53. package/docs/runtime-resources.md +6 -3
  54. package/examples/background-runs/agents/background-reviewer.md +6 -0
  55. package/examples/background-runs/agents/background-worker.md +6 -0
  56. package/examples/background-runs/worker-reviewer.mjs +42 -0
  57. package/examples/command-recovery/durable-server.mjs +44 -0
  58. package/examples/command-recovery/receipt-command.mjs +40 -0
  59. package/package.json +16 -2
  60. package/sdk/API.md +146 -0
  61. 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
@@ -73,8 +77,17 @@ export function buildCliArgs(options) {
73
77
  args.push('--max-turns', String(options.maxTurns));
74
78
  if (options.maxBudgetUsd !== undefined)
75
79
  args.push('--max-budget-usd', String(options.maxBudgetUsd));
76
- if (options.permissionMode)
77
- 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');
78
91
  for (const dir of options.pluginDirs ?? [])
79
92
  args.push('--plugin-dir', dir);
80
93
  if (options.addDirs?.length)
@@ -282,6 +295,9 @@ export async function* queryWithTransport(prompt, options, transport) {
282
295
  let turn = 0;
283
296
  let sawResult = false;
284
297
  let sessionEnded = false;
298
+ let suspension;
299
+ let suspensionTimer;
300
+ let suspensionTimedOut = false;
285
301
  const streamingInput = isAsyncInput(prompt);
286
302
  const inputState = { finished: false };
287
303
  let resolveProvenanceSupport;
@@ -310,11 +326,27 @@ export async function* queryWithTransport(prompt, options, transport) {
310
326
  if (!options.resume) {
311
327
  throw new Error('ccl-sdk: pendingDecision requires options.resume');
312
328
  }
313
- pendingApproval = await store.loadPendingApproval(options.resume);
314
- if (!pendingApproval) {
315
- 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;
316
347
  }
317
348
  }
349
+ const strictApprovalEvidence = pendingDecision?.expectedApproval ? pendingApproval : null;
318
350
  const onAbort = () => { resolveProvenanceSupport(false); void transport.close(); };
319
351
  options.signal?.addEventListener('abort', onAbort, { once: true });
320
352
  if (options.signal?.aborted)
@@ -342,6 +374,9 @@ export async function* queryWithTransport(prompt, options, transport) {
342
374
  request_id: initId,
343
375
  request: {
344
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 } : {}),
345
380
  ...(options.systemPrompt !== undefined && {
346
381
  systemPrompt: options.systemPrompt,
347
382
  }),
@@ -385,7 +420,7 @@ export async function* queryWithTransport(prompt, options, transport) {
385
420
  await runHook(hooks.onUserPromptSubmit && (() => hooks.onUserPromptSubmit({ prompt: text, sessionId: lastSessionId })));
386
421
  })
387
422
  : transport.read();
388
- for await (const rawMessage of messages) {
423
+ permissionWire: for await (const rawMessage of messages) {
389
424
  let attribution = rawMessage.input_uuid === null
390
425
  ? { origin: 'background' } : typeof rawMessage.input_uuid === 'string'
391
426
  ? { origin: 'user', turnId: rawMessage.input_uuid }
@@ -416,6 +451,13 @@ export async function* queryWithTransport(prompt, options, transport) {
416
451
  yield { ...attribution, type: 'raw', message };
417
452
  switch (message.type) {
418
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
+ }
419
461
  if (message.subtype === 'request_progress' && progress)
420
462
  yield { ...attribution,
421
463
  type: 'request_progress', progress,
@@ -463,6 +505,7 @@ export async function* queryWithTransport(prompt, options, transport) {
463
505
  for (const block of contentBlocks(message.message)) {
464
506
  if (block.type === 'tool_result') {
465
507
  const toolUseId = String(block.tool_use_id ?? '');
508
+ const operationReference = readOperationReference(message.operation_reference, toolUseId);
466
509
  const isError = block.is_error === true;
467
510
  const resultContent = isError
468
511
  ? typeof block.content === 'string' ? sanitizeErrorText(block.content)
@@ -473,6 +516,7 @@ export async function* queryWithTransport(prompt, options, transport) {
473
516
  type: 'tool_result',
474
517
  toolUseId,
475
518
  content: resultContent,
519
+ ...(operationReference ? { operationReference } : {}),
476
520
  isError,
477
521
  sessionId,
478
522
  };
@@ -481,6 +525,7 @@ export async function* queryWithTransport(prompt, options, transport) {
481
525
  toolUseId,
482
526
  toolName: toolNamesById.get(toolUseId),
483
527
  content: resultContent,
528
+ ...(operationReference ? { operationReference } : {}),
484
529
  isError,
485
530
  sessionId,
486
531
  })));
@@ -491,6 +536,10 @@ export async function* queryWithTransport(prompt, options, transport) {
491
536
  break;
492
537
  }
493
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;
494
543
  const requestId = String(message.request_id ?? '');
495
544
  const request = (message.request ?? {});
496
545
  // slice sdk-in-process-mcp-server: route MCP tool calls to host in-process servers.
@@ -535,9 +584,14 @@ export async function* queryWithTransport(prompt, options, transport) {
535
584
  agentId,
536
585
  };
537
586
  // Resume path: settle the recorded approval on its matching call.
538
- // Match by toolName — on allow the tool runs with the RECORDED
539
- // input (or the host's explicit updatedInput), never the model's
540
- // re-generated one, so what the owner approved is what executes.
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
+ }
541
595
  if (pendingDecision && pendingApproval?.toolName === toolName) {
542
596
  const decision = pendingDecision.behavior === 'allow'
543
597
  ? {
@@ -620,28 +674,16 @@ export async function* queryWithTransport(prompt, options, transport) {
620
674
  toolName,
621
675
  input,
622
676
  createdAt: new Date().toISOString(),
677
+ suspension: 'requested',
623
678
  };
624
679
  await store.savePendingApproval(record);
625
- // Deliberately NO control_response: the tool must not run and
626
- // the model must not see a denial. Terminating here leaves the
627
- // transcript ending at the unresolved tool_use, which the
628
- // resume path drops and re-drives.
629
- yield { ...attribution,
630
- type: 'suspended',
631
- sessionId,
632
- requestId,
633
- toolUseId,
634
- toolName,
635
- input,
636
- };
637
- sessionEnded = true;
638
- await runHook(hooks.onSessionEnd &&
639
- (() => hooks.onSessionEnd({
640
- sessionId,
641
- subtype: 'suspended',
642
- isError: false,
643
- })));
644
- return;
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;
645
687
  }
646
688
  await transport.write({
647
689
  type: 'control_response',
@@ -667,6 +709,20 @@ export async function* queryWithTransport(prompt, options, transport) {
667
709
  case 'control_response': {
668
710
  const response = (message.response ?? {});
669
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
+ }
670
726
  if (ownRequestIds.has(requestId)) {
671
727
  ownRequestIds.delete(requestId);
672
728
  const capability = response.response?.input_provenance;
@@ -683,6 +739,34 @@ export async function* queryWithTransport(prompt, options, transport) {
683
739
  break;
684
740
  }
685
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
+ }
686
770
  sawResult = true;
687
771
  const sessionId = message.session_id;
688
772
  const usage = message.usage;
@@ -756,6 +840,22 @@ export async function* queryWithTransport(prompt, options, transport) {
756
840
  if (sawResult && !streamingInput)
757
841
  break;
758
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
+ }
759
859
  if (streamingInput && inputState.finished) {
760
860
  await runHook(hooks.onSessionEnd && (() => hooks.onSessionEnd({
761
861
  sessionId: lastSessionId, subtype: 'input_complete', isError: false,
@@ -783,6 +883,8 @@ export async function* queryWithTransport(prompt, options, transport) {
783
883
  }
784
884
  }
785
885
  finally {
886
+ if (suspensionTimer)
887
+ clearTimeout(suspensionTimer);
786
888
  resolveProvenanceSupport(false);
787
889
  options.signal?.removeEventListener('abort', onAbort);
788
890
  await sdkMcpRegistry?.close();
@@ -858,7 +960,8 @@ export function query(prompt, options = {}) {
858
960
  *
859
961
  * On allow, the suspended tool executes with exactly the recorded input (or
860
962
  * decision.updatedInput if the owner amended it); on deny the model receives
861
- * 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.
862
965
  */
863
966
  export function resumeWithDecision(sessionId, decision, options = {}) {
864
967
  const merged = {
@@ -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
+ }
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod/v4';
2
+ /** Host observations of one query loop. Neither an authorization nor a business result. */
3
+ export declare const QueryLifecycleSchema: z.ZodObject<{
4
+ version: z.ZodLiteral<1>;
5
+ event_id: z.ZodString;
6
+ query_id: z.ZodString;
7
+ session_id: z.ZodString;
8
+ sequence: z.ZodNumber;
9
+ timestamp: z.ZodString;
10
+ scope: z.ZodEnum<{
11
+ main: "main";
12
+ subagent: "subagent";
13
+ auxiliary: "auxiliary";
14
+ }>;
15
+ agent_id: z.ZodNullable<z.ZodString>;
16
+ phase: z.ZodEnum<{
17
+ start: "start";
18
+ stop_attempt: "stop_attempt";
19
+ stop_failure: "stop_failure";
20
+ terminal: "terminal";
21
+ }>;
22
+ attempt: z.ZodNumber;
23
+ accepted_inputs: z.ZodArray<z.ZodObject<{
24
+ message_id: z.ZodString;
25
+ origin: z.ZodString;
26
+ provenance: z.ZodOptional<z.ZodObject<{
27
+ version: z.ZodLiteral<1>;
28
+ actor: z.ZodEnum<{
29
+ unknown: "unknown";
30
+ agent: "agent";
31
+ human: "human";
32
+ host: "host";
33
+ }>;
34
+ source: z.ZodString;
35
+ session_id: z.ZodOptional<z.ZodString>;
36
+ request_id: z.ZodOptional<z.ZodString>;
37
+ }, z.core.$strip>>;
38
+ }, z.core.$strip>>;
39
+ input_message_ids: z.ZodArray<z.ZodString>;
40
+ input_association: z.ZodEnum<{
41
+ unknown: "unknown";
42
+ known: "known";
43
+ }>;
44
+ last_assistant_message_id: z.ZodNullable<z.ZodString>;
45
+ status: z.ZodNullable<z.ZodEnum<{
46
+ completed: "completed";
47
+ failed: "failed";
48
+ cancelled: "cancelled";
49
+ interrupted: "interrupted";
50
+ stopped: "stopped";
51
+ }>>;
52
+ terminal_reason: z.ZodNullable<z.ZodString>;
53
+ }, z.core.$strip>;
54
+ export type QueryLifecycleRecord = z.infer<typeof QueryLifecycleSchema>;
55
+ export type QueryLifecycleWrite = {
56
+ state: 'saved';
57
+ } | {
58
+ state: 'unavailable';
59
+ reason: 'persistence_disabled' | 'no_transcript';
60
+ } | {
61
+ state: 'failed';
62
+ reason: 'write_failed';
63
+ } | {
64
+ state: 'unknown';
65
+ reason: 'write_timeout';
66
+ };
67
+ export declare const QueryLifecycleWriteSchema: z.ZodUnion<readonly [z.ZodObject<{
68
+ state: z.ZodLiteral<"saved">;
69
+ }, z.core.$strip>, z.ZodObject<{
70
+ state: z.ZodLiteral<"unavailable">;
71
+ reason: z.ZodEnum<{
72
+ persistence_disabled: "persistence_disabled";
73
+ no_transcript: "no_transcript";
74
+ }>;
75
+ }, z.core.$strip>, z.ZodObject<{
76
+ state: z.ZodLiteral<"failed">;
77
+ reason: z.ZodLiteral<"write_failed">;
78
+ }, z.core.$strip>, z.ZodObject<{
79
+ state: z.ZodLiteral<"unknown">;
80
+ reason: z.ZodLiteral<"write_timeout">;
81
+ }, z.core.$strip>]>;
82
+ export type QueryLifecycleObservation = {
83
+ record: QueryLifecycleRecord;
84
+ persistence: QueryLifecycleWrite;
85
+ transcript_path: string | null;
86
+ };
87
+ export declare const QueryLifecycleObservationSchema: z.ZodObject<{
88
+ record: z.ZodObject<{
89
+ version: z.ZodLiteral<1>;
90
+ event_id: z.ZodString;
91
+ query_id: z.ZodString;
92
+ session_id: z.ZodString;
93
+ sequence: z.ZodNumber;
94
+ timestamp: z.ZodString;
95
+ scope: z.ZodEnum<{
96
+ main: "main";
97
+ subagent: "subagent";
98
+ auxiliary: "auxiliary";
99
+ }>;
100
+ agent_id: z.ZodNullable<z.ZodString>;
101
+ phase: z.ZodEnum<{
102
+ start: "start";
103
+ stop_attempt: "stop_attempt";
104
+ stop_failure: "stop_failure";
105
+ terminal: "terminal";
106
+ }>;
107
+ attempt: z.ZodNumber;
108
+ accepted_inputs: z.ZodArray<z.ZodObject<{
109
+ message_id: z.ZodString;
110
+ origin: z.ZodString;
111
+ provenance: z.ZodOptional<z.ZodObject<{
112
+ version: z.ZodLiteral<1>;
113
+ actor: z.ZodEnum<{
114
+ unknown: "unknown";
115
+ agent: "agent";
116
+ human: "human";
117
+ host: "host";
118
+ }>;
119
+ source: z.ZodString;
120
+ session_id: z.ZodOptional<z.ZodString>;
121
+ request_id: z.ZodOptional<z.ZodString>;
122
+ }, z.core.$strip>>;
123
+ }, z.core.$strip>>;
124
+ input_message_ids: z.ZodArray<z.ZodString>;
125
+ input_association: z.ZodEnum<{
126
+ unknown: "unknown";
127
+ known: "known";
128
+ }>;
129
+ last_assistant_message_id: z.ZodNullable<z.ZodString>;
130
+ status: z.ZodNullable<z.ZodEnum<{
131
+ completed: "completed";
132
+ failed: "failed";
133
+ cancelled: "cancelled";
134
+ interrupted: "interrupted";
135
+ stopped: "stopped";
136
+ }>>;
137
+ terminal_reason: z.ZodNullable<z.ZodString>;
138
+ }, z.core.$strip>;
139
+ persistence: z.ZodUnion<readonly [z.ZodObject<{
140
+ state: z.ZodLiteral<"saved">;
141
+ }, z.core.$strip>, z.ZodObject<{
142
+ state: z.ZodLiteral<"unavailable">;
143
+ reason: z.ZodEnum<{
144
+ persistence_disabled: "persistence_disabled";
145
+ no_transcript: "no_transcript";
146
+ }>;
147
+ }, z.core.$strip>, z.ZodObject<{
148
+ state: z.ZodLiteral<"failed">;
149
+ reason: z.ZodLiteral<"write_failed">;
150
+ }, z.core.$strip>, z.ZodObject<{
151
+ state: z.ZodLiteral<"unknown">;
152
+ reason: z.ZodLiteral<"write_timeout">;
153
+ }, z.core.$strip>]>;
154
+ transcript_path: z.ZodNullable<z.ZodString>;
155
+ }, z.core.$strip>;
156
+ /** Metadata entry deliberately has no conversation uuid/parentUuid chain fields. */
157
+ export type QueryLifecycleEntry = {
158
+ type: 'query_lifecycle';
159
+ record: QueryLifecycleRecord;
160
+ };
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod/v4';
2
+ const identity = z.string().min(1).max(256);
3
+ const provenance = z.object({
4
+ version: z.literal(1), actor: z.enum(['human', 'host', 'agent', 'unknown']),
5
+ source: z.string(), session_id: z.string().optional(), request_id: z.string().optional(),
6
+ });
7
+ /** Host observations of one query loop. Neither an authorization nor a business result. */
8
+ export const QueryLifecycleSchema = z.object({
9
+ version: z.literal(1), event_id: identity, query_id: identity, session_id: identity,
10
+ sequence: z.number().int().nonnegative(), timestamp: z.string(),
11
+ scope: z.enum(['main', 'subagent', 'auxiliary']), agent_id: identity.nullable(),
12
+ phase: z.enum(['start', 'stop_attempt', 'stop_failure', 'terminal']),
13
+ attempt: z.number().int().nonnegative(),
14
+ accepted_inputs: z.array(z.object({ message_id: identity, origin: z.string(), provenance: provenance.optional() })),
15
+ // Handed to the host model adapter, not proof of server receipt or understanding.
16
+ input_message_ids: z.array(identity), input_association: z.enum(['known', 'unknown']),
17
+ last_assistant_message_id: identity.nullable(),
18
+ status: z.enum(['completed', 'failed', 'cancelled', 'stopped', 'interrupted']).nullable(),
19
+ terminal_reason: z.string().nullable(),
20
+ }).superRefine((event, ctx) => {
21
+ const invalid = (message) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
22
+ if ((event.phase === 'terminal') !== (event.status !== null))
23
+ invalid('Only terminal events have a status');
24
+ if (event.phase !== 'terminal' && event.terminal_reason !== null)
25
+ invalid('Only terminal events have a terminal reason');
26
+ const accepted = event.accepted_inputs.map(input => input.message_id);
27
+ if (new Set(accepted).size !== accepted.length)
28
+ invalid('Duplicate accepted input');
29
+ if (new Set(event.input_message_ids).size !== event.input_message_ids.length)
30
+ invalid('Duplicate dispatched input');
31
+ if (event.input_message_ids.some(id => !accepted.includes(id)))
32
+ invalid('Dispatched input was not accepted');
33
+ if (event.phase === 'start' && (event.sequence !== 0 || event.attempt !== 0 || event.input_message_ids.length !== 0))
34
+ invalid('Invalid query start');
35
+ if (event.scope === 'subagent' && event.agent_id === null)
36
+ invalid('Subagent identity missing');
37
+ });
38
+ export const QueryLifecycleWriteSchema = z.union([
39
+ z.object({ state: z.literal('saved') }),
40
+ z.object({ state: z.literal('unavailable'), reason: z.enum(['persistence_disabled', 'no_transcript']) }),
41
+ z.object({ state: z.literal('failed'), reason: z.literal('write_failed') }),
42
+ z.object({ state: z.literal('unknown'), reason: z.literal('write_timeout') }),
43
+ ]);
44
+ export const QueryLifecycleObservationSchema = z.object({
45
+ record: QueryLifecycleSchema, persistence: QueryLifecycleWriteSchema,
46
+ transcript_path: z.string().nullable(),
47
+ });