chainlesschain 0.166.135 → 0.166.138

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.
@@ -48,6 +48,29 @@ function positive(value, integer = false) {
48
48
  return integer ? Math.floor(number) : number;
49
49
  }
50
50
 
51
+ function invalidProfileField(field) {
52
+ const error = new Error(`Invalid background launch profile field: ${field}`);
53
+ error.code = "BACKGROUND_LAUNCH_PROFILE_INVALID";
54
+ return error;
55
+ }
56
+
57
+ function policyBudget(value, field) {
58
+ const text = String(value).trim();
59
+ const cost = field.toLowerCase().includes("cost");
60
+ const number = Number(value);
61
+ if (
62
+ value == null ||
63
+ !(cost ? /^\+?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i : /^\+?\d+$/).test(
64
+ text,
65
+ ) ||
66
+ !Number.isFinite(number) ||
67
+ number <= 0 ||
68
+ (!cost && !Number.isSafeInteger(number))
69
+ )
70
+ throw invalidProfileField(`budget.${field}`);
71
+ return number;
72
+ }
73
+
51
74
  function sortedStrings(values, { split = false, pathsFrom = null } = {}) {
52
75
  const raw = Array.isArray(values) ? values : values ? [values] : [];
53
76
  const expanded = split
@@ -356,6 +379,17 @@ export function captureBackgroundLaunchProfile({
356
379
  index++;
357
380
  return next;
358
381
  };
382
+ const takePolicy = (allowEmpty = false) => {
383
+ const next = inline !== null ? inline : args[index + 1];
384
+ if (
385
+ next == null ||
386
+ (inline === null && next.startsWith("--")) ||
387
+ (!allowEmpty && next.trim() === "")
388
+ )
389
+ throw invalidProfileField(`argv.${name}`);
390
+ if (inline === null) index++;
391
+ return next;
392
+ };
359
393
  switch (name) {
360
394
  case "--provider":
361
395
  profile.llm.provider = scalar(take());
@@ -376,14 +410,17 @@ export function captureBackgroundLaunchProfile({
376
410
  fallbackModels.push(...orderedStrings(take(), { split: true }));
377
411
  break;
378
412
  case "--allowed-tools":
379
- profile.tools.allowed = sortedStrings(take(), { split: true });
413
+ profile.tools.allowed = sortedStrings(takePolicy(true), {
414
+ split: true,
415
+ });
380
416
  break;
381
417
  case "--disallowed-tools":
382
- profile.tools.disallowed = sortedStrings(take(), { split: true });
418
+ profile.tools.disallowed = sortedStrings(takePolicy(true), {
419
+ split: true,
420
+ });
383
421
  break;
384
422
  case "--permission-mode": {
385
- const mode = scalar(take());
386
- profile.permission.mode = PERMISSION_MODES.has(mode) ? mode : "default";
423
+ profile.permission.mode = scalar(takePolicy());
387
424
  break;
388
425
  }
389
426
  case "--allow-dangerous-bypass":
@@ -395,7 +432,7 @@ export function captureBackgroundLaunchProfile({
395
432
  profile.permission.unattended = true;
396
433
  break;
397
434
  case "--unattended-allow":
398
- profile.permission.unattendedAllow = sortedStrings(take(), {
435
+ profile.permission.unattendedAllow = sortedStrings(takePolicy(true), {
399
436
  split: true,
400
437
  });
401
438
  break;
@@ -413,8 +450,7 @@ export function captureBackgroundLaunchProfile({
413
450
  profile.sandbox.image = scalar(take(true));
414
451
  break;
415
452
  case "--sandbox-mode": {
416
- const mode = scalar(take());
417
- profile.sandbox.mode = SANDBOX_MODES.has(mode) ? mode : null;
453
+ profile.sandbox.mode = scalar(takePolicy());
418
454
  break;
419
455
  }
420
456
  case "--sandbox-network":
@@ -427,6 +463,12 @@ export function captureBackgroundLaunchProfile({
427
463
  profile.mcp.disabled = true;
428
464
  profile.runtime.noMcpAutoConnect = true;
429
465
  break;
466
+ case "--no-mcp-auto-connect":
467
+ profile.runtime.noMcpAutoConnect = true;
468
+ break;
469
+ case "--no-plugins":
470
+ profile.plugins.disabled = true;
471
+ break;
430
472
  case "--strict-mcp-config":
431
473
  profile.mcp.strict = true;
432
474
  break;
@@ -461,28 +503,43 @@ export function captureBackgroundLaunchProfile({
461
503
  addDirs.push(take());
462
504
  break;
463
505
  case "--max-turns":
464
- profile.budget.maxTurns = positive(take(), true);
506
+ profile.budget.maxTurns = policyBudget(takePolicy(), "maxTurns");
465
507
  break;
466
508
  case "--max-budget-usd":
467
- profile.budget.maxCostUsd = positive(take());
509
+ profile.budget.maxCostUsd = policyBudget(takePolicy(), "maxCostUsd");
468
510
  break;
469
511
  case "--thinking-budget":
470
- profile.budget.thinkingTokens = positive(take(), true);
512
+ profile.budget.thinkingTokens = policyBudget(
513
+ takePolicy(),
514
+ "thinkingTokens",
515
+ );
471
516
  break;
472
517
  case "--max-rewakes":
473
- profile.budget.maxRewakes = positive(take(), true);
518
+ profile.budget.maxRewakes = policyBudget(takePolicy(), "maxRewakes");
474
519
  break;
475
520
  case "--max-outer-turns":
476
- profile.budget.maxOuterTurns = positive(take(), true);
521
+ profile.budget.maxOuterTurns = policyBudget(
522
+ takePolicy(),
523
+ "maxOuterTurns",
524
+ );
477
525
  break;
478
526
  case "--goal-max-tokens":
479
- profile.budget.goalMaxTokens = positive(take(), true);
527
+ profile.budget.goalMaxTokens = policyBudget(
528
+ takePolicy(),
529
+ "goalMaxTokens",
530
+ );
480
531
  break;
481
532
  case "--goal-max-cost":
482
- profile.budget.goalMaxCostUsd = positive(take());
533
+ profile.budget.goalMaxCostUsd = policyBudget(
534
+ takePolicy(),
535
+ "goalMaxCostUsd",
536
+ );
483
537
  break;
484
538
  case "--goal-max-time":
485
- profile.budget.goalMaxTimeMs = positive(take(), true);
539
+ profile.budget.goalMaxTimeMs = policyBudget(
540
+ takePolicy(),
541
+ "goalMaxTimeMs",
542
+ );
486
543
  break;
487
544
  case "--safe-mode":
488
545
  profile.runtime.safeMode = true;
@@ -667,12 +724,70 @@ export function normalizeBackgroundLaunchProfile(input) {
667
724
  );
668
725
  }
669
726
  const rejectField = (field) => {
670
- const error = new Error(
671
- `Invalid background launch profile field: ${field}`,
672
- );
673
- error.code = "BACKGROUND_LAUNCH_PROFILE_INVALID";
674
- throw error;
727
+ throw invalidProfileField(field);
675
728
  };
729
+ for (const group of [
730
+ "llm",
731
+ "tools",
732
+ "permission",
733
+ "sandbox",
734
+ "mcp",
735
+ "plugins",
736
+ "runtime",
737
+ "budget",
738
+ ])
739
+ if (
740
+ input[group] != null &&
741
+ (typeof input[group] !== "object" || Array.isArray(input[group]))
742
+ )
743
+ rejectField(group);
744
+ for (const [group, fields] of Object.entries({
745
+ permission: [
746
+ "dangerousBypass",
747
+ "unattended",
748
+ "interactiveApprovals",
749
+ "remoteControl",
750
+ ],
751
+ sandbox: ["enabled", "network"],
752
+ mcp: ["disabled", "strict", "project"],
753
+ plugins: ["disabled"],
754
+ runtime: [
755
+ "safeMode",
756
+ "bare",
757
+ "managedCheckpoint",
758
+ "noProjectMemory",
759
+ "noFileRefs",
760
+ "noSlashMacros",
761
+ "noRecallMemory",
762
+ "noMcpAutoConnect",
763
+ "autoRewake",
764
+ "autoPin",
765
+ "noStream",
766
+ "noParkOnExit",
767
+ ],
768
+ }))
769
+ for (const field of fields)
770
+ if (
771
+ input[group]?.[field] !== undefined &&
772
+ typeof input[group][field] !== "boolean"
773
+ )
774
+ rejectField(`${group}.${field}`);
775
+ for (const field of ["ide", "pdh", "jetbrains"])
776
+ if (input.mcp?.[field] != null && !TRI_STATES.has(input.mcp[field]))
777
+ rejectField(`mcp.${field}`);
778
+ for (const [group, fields] of Object.entries({
779
+ tools: ["allowed", "disallowed"],
780
+ permission: ["unattendedAllow"],
781
+ }))
782
+ for (const field of fields) {
783
+ const value = input[group]?.[field];
784
+ if (
785
+ value != null &&
786
+ (!Array.isArray(value) ||
787
+ value.some((item) => typeof item !== "string" || !item.trim()))
788
+ )
789
+ rejectField(`${group}.${field}`);
790
+ }
676
791
  // A persisted policy must not silently fall back to a weaker default.
677
792
  if (
678
793
  input.permission?.mode != null &&
@@ -692,14 +807,8 @@ export function normalizeBackgroundLaunchProfile(input) {
692
807
  "goalMaxTimeMs",
693
808
  ]) {
694
809
  const value = input.budget?.[field];
695
- if (value == null || value === "") continue;
696
- const number = Number(value);
697
- if (
698
- !Number.isFinite(number) ||
699
- number <= 0 ||
700
- (!field.toLowerCase().includes("cost") && !Number.isSafeInteger(number))
701
- )
702
- rejectField(`budget.${field}`);
810
+ if (value == null) continue;
811
+ policyBudget(value, field);
703
812
  }
704
813
  const cwd = canonicalPath(input.workspace?.cwd || process.cwd());
705
814
  const safeBase = sanitizeBackgroundBaseUrl(input.llm?.baseUrl);
@@ -1020,6 +1129,11 @@ export function buildArgvFromBackgroundLaunchProfile(profile) {
1020
1129
  pushFlag(argv, "--sandbox-network", p.sandbox.network);
1021
1130
  pushValue(argv, "--mcp-config", p.mcp.configFile);
1022
1131
  pushFlag(argv, "--no-mcp", p.mcp.disabled);
1132
+ pushFlag(
1133
+ argv,
1134
+ "--no-mcp-auto-connect",
1135
+ p.runtime.noMcpAutoConnect && !p.mcp.disabled,
1136
+ );
1023
1137
  pushFlag(argv, "--strict-mcp-config", p.mcp.strict);
1024
1138
  pushFlag(argv, "--project-mcp", p.mcp.project);
1025
1139
  for (const [key, value] of [
@@ -1032,6 +1146,7 @@ export function buildArgvFromBackgroundLaunchProfile(profile) {
1032
1146
  }
1033
1147
  pushValue(argv, "--settings", p.settings.file);
1034
1148
  pushValue(argv, "--bundle", p.plugins.bundle);
1149
+ pushFlag(argv, "--no-plugins", p.plugins.disabled && !p.runtime.bare);
1035
1150
  for (const dir of p.workspace.addDirs) pushValue(argv, "--add-dir", dir);
1036
1151
  pushValue(argv, "--max-turns", p.budget.maxTurns);
1037
1152
  pushValue(argv, "--max-budget-usd", p.budget.maxCostUsd);
@@ -2,9 +2,11 @@
2
2
  export const CODING_TASK_QUALITY_GUIDANCE = `Coding task quality:
3
3
  - Read the actual implementation and its exported API before writing a caller or test. Verify parameter order, return shapes, fixture requirements and the delegated owner of each lifecycle transition.
4
4
  - Keep progress explanations to one or two sentences. Resolve uncertainty with targeted reads, reuse unchanged source already inspected, and put code directly in write/edit arguments.
5
+ - Omitted or undelivered source has not been inspected. For an incomplete source excerpt, select a complete enclosing statement or container; a larger range or whole file within the tool limits may be necessary. Do not keep halving the same omitted page. For oversized output, select bounded fields or sections. If safe source remains unavailable, report that limitation without claiming completion.
5
6
  - Resolve each relative import from the destination file's directory. Read back completed files and fix newDiagnostics. A successful write proves persistence, not test execution or acceptance.
6
7
  - Map every acceptance requirement to a fixture, real production call path and observable assertion. Positive and negative controls must reach the same boundary with unrelated prerequisites valid. Avoid mocks that bypass it; identify which assertion would fail without that boundary.
7
8
  - Derive case allocation from the user's requirements. A test-count cap does not waive coverage: merge redundant cases to cover gaps within the cap, authorized scope and remaining budget. Report unresolved gaps as incomplete.
9
+ - Keep test fixtures and assertions within the runner's memory and time limits. For large binary payloads, assert lengths and use Buffer.equals or a digest for content checks; avoid deep matchers that enumerate every byte or print the entire payload on failure. Check boundary rejection before allocating a large body when the contract permits it.
8
10
  - For races, control the intended transition and complete the current worker/request when testing cancellation or replacement. An earlier unrelated rejection does not prove the target guard. Derive expectations independently from the contract or authoritative state, not by calling the function under test.
9
11
  - Trace rejection before asserting its aftermath: it may precede registration or persistence. Inspect whether state stays unchanged, a failed record exists or an event is emitted. Compare independently recorded identities and provenance; equal text does not prove ownership.
10
12
  - Inspect persistence projections before comparing histories. Host notices, lineage and imports may accompany user/assistant messages; verify their provenance separately. Report defects that cannot be repaired within the permitted scope instead of inventing passing behavior.
@@ -54,12 +54,17 @@ function compactStats(event, messages) {
54
54
 
55
55
  /** SessionContextPort backed by the CLI's verified, anchored JSONL transcript. */
56
56
  export class JsonlSessionContextPort {
57
- constructor({ sessionId, allowedSinks = ["*"] } = {}) {
57
+ constructor({
58
+ sessionId,
59
+ allowedSinks = ["*"],
60
+ requiredUserMessages = [],
61
+ } = {}) {
58
62
  if (typeof sessionId !== "string" || !sessionId.trim()) {
59
63
  throw new TypeError("JsonlSessionContextPort requires sessionId");
60
64
  }
61
65
  this.sessionId = sessionId.trim();
62
66
  this.allowedSinks = [...allowedSinks];
67
+ this.requiredUserMessages = requiredUserMessages;
63
68
  this.ownedHeadAdvances = new Map();
64
69
  }
65
70
 
@@ -113,16 +118,20 @@ export class JsonlSessionContextPort {
113
118
  memoryRevision = revision;
114
119
  }
115
120
  },
116
- finish: (authority) => ({
117
- sessionId,
118
- head: authority.headHash,
119
- memoryRevision,
120
- items: messagesToContextItems(authority.readMessages(), {
121
+ finish: (authority) => {
122
+ const messages = authority.readMessages();
123
+ return {
121
124
  sessionId,
122
- allowedSinks: this.allowedSinks,
123
- allowExternalization: true,
124
- }),
125
- }),
125
+ head: authority.headHash,
126
+ memoryRevision,
127
+ items: messagesToContextItems(messages, {
128
+ sessionId,
129
+ allowedSinks: this.allowedSinks,
130
+ allowExternalization: true,
131
+ requiredUserMessages: this.requiredUserMessages,
132
+ }),
133
+ };
134
+ },
126
135
  };
127
136
  });
128
137
  }
@@ -1,3 +1,9 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import {
3
+ resolveTaskBinding,
4
+ taskBindingMatchCount,
5
+ advanceTaskBinding,
6
+ } from "./task-binding.js";
1
7
  import { randomUUID } from "node:crypto";
2
8
  import {
3
9
  canonicalDigest,
@@ -84,6 +90,7 @@ class CallbackSessionContextPort extends InMemorySessionContextPort {
84
90
  head,
85
91
  physicalAuthority,
86
92
  allowExternalization = false,
93
+ requiredUserMessages = [],
87
94
  }) {
88
95
  const initialHead =
89
96
  head ||
@@ -99,6 +106,7 @@ class CallbackSessionContextPort extends InMemorySessionContextPort {
99
106
  items: messagesToContextItems(messages, {
100
107
  sessionId,
101
108
  allowExternalization,
109
+ requiredUserMessages,
102
110
  }),
103
111
  },
104
112
  ]);
@@ -262,10 +270,22 @@ export async function compactLiveMessagesCanonical(
262
270
  !isCompactionHostBase(message) &&
263
271
  !isRuntimeOnlySessionMessage(message),
264
272
  );
273
+ const requiredUserBindings = (options.requiredUserBindings || []).map(
274
+ (binding) => ({
275
+ ...binding,
276
+ message: structuredClone(binding.message),
277
+ matchCount: taskBindingMatchCount(binding),
278
+ }),
279
+ );
280
+ const summarizerOptions = { ...(options.compressOptions || {}) };
281
+ delete summarizerOptions.requiredUserBindings;
282
+ for (const binding of requiredUserBindings)
283
+ resolveTaskBinding(liveMessages, binding);
265
284
  const ephemeralMessages = [];
285
+ const durableSourceIndexes = new Map();
266
286
  {
267
287
  let sequence = 0;
268
- for (const message of liveMessages) {
288
+ for (const [liveIndex, message] of liveMessages.entries()) {
269
289
  if (
270
290
  isRuntimeOnlySessionMessage(message) ||
271
291
  isCompactionHostBase(message) ||
@@ -274,9 +294,29 @@ export async function compactLiveMessagesCanonical(
274
294
  !getDurableSystemMessageProvenance(message))
275
295
  )
276
296
  ephemeralMessages.push({ sequence, message });
277
- else sequence += 1;
297
+ else {
298
+ durableSourceIndexes.set(liveIndex, sequence);
299
+ sequence += 1;
300
+ }
278
301
  }
279
302
  }
303
+ // Carry host-resolved source indexes into the verified authority projection.
304
+ // Identical historical user text is not a substitute for this source identity.
305
+ const requiredUserMessages = requiredUserBindings.map((binding) => ({
306
+ index: durableSourceIndexes.get(
307
+ resolveTaskBinding(liveMessages, binding).index,
308
+ ),
309
+ message: binding.message,
310
+ }));
311
+ const initialItems = messagesToContextItems(messages, {
312
+ sessionId,
313
+ allowExternalization: persistentSession,
314
+ requiredUserMessages,
315
+ });
316
+ const boundItems = requiredUserMessages.map(({ index }, bindingIndex) => ({
317
+ item: initialItems.find((item) => item.sourceRef.eventSequence === index),
318
+ binding: requiredUserBindings[bindingIndex],
319
+ }));
280
320
  if (typeof options.commit === "function") {
281
321
  const bootstrapRuntime = createCliContextMemoryRuntime({
282
322
  sessionId,
@@ -288,6 +328,7 @@ export async function compactLiveMessagesCanonical(
288
328
  if (persistentSession) {
289
329
  verifiedAuthoritySnapshot = await new JsonlSessionContextPort({
290
330
  sessionId,
331
+ requiredUserMessages,
291
332
  }).readSnapshot(sessionId);
292
333
  }
293
334
  const sessionPort = new CallbackSessionContextPort({
@@ -298,6 +339,7 @@ export async function compactLiveMessagesCanonical(
298
339
  trigger,
299
340
  summaryStats: () => summaryStats,
300
341
  allowExternalization: persistentSession,
342
+ requiredUserMessages,
301
343
  ...(verifiedAuthoritySnapshot?.head
302
344
  ? { head: verifiedAuthoritySnapshot.head, physicalAuthority: true }
303
345
  : {}),
@@ -312,6 +354,10 @@ export async function compactLiveMessagesCanonical(
312
354
  });
313
355
  } else if (persistentSession) {
314
356
  runtime = createCliContextMemoryRuntime({
357
+ sessionPort: new JsonlSessionContextPort({
358
+ sessionId,
359
+ requiredUserMessages,
360
+ }),
315
361
  sessionId,
316
362
  scopeKey: `cli:session:${sessionId}`,
317
363
  env: options.env,
@@ -336,6 +382,7 @@ export async function compactLiveMessagesCanonical(
336
382
  items: messagesToContextItems(messages, {
337
383
  sessionId,
338
384
  allowExternalization: false,
385
+ requiredUserMessages,
339
386
  }),
340
387
  },
341
388
  ]);
@@ -362,6 +409,7 @@ export async function compactLiveMessagesCanonical(
362
409
  const inputItems = messagesToContextItems(messages, {
363
410
  sessionId,
364
411
  allowExternalization: true,
412
+ requiredUserMessages,
365
413
  });
366
414
  const inputDigest = canonicalDigest(
367
415
  inputItems.map((item) => item.digest),
@@ -384,11 +432,59 @@ export async function compactLiveMessagesCanonical(
384
432
  const model = options.model || "default";
385
433
  const memoryRevision = await runtime.memoryPort.getRevision();
386
434
  let settledOutputItems = null;
387
- if (ephemeralMessages.length) {
435
+ {
388
436
  const appendCompaction = runtime.sessionPort.appendCompaction.bind(
389
437
  runtime.sessionPort,
390
438
  );
391
439
  runtime.sessionPort.appendCompaction = async (event, expectedHead) => {
440
+ const outputMessages = contextItemsToMessages(event.outputItems, {
441
+ sessionId,
442
+ });
443
+ for (const { item, binding } of boundItems) {
444
+ resolveTaskBinding(messagesInput, binding);
445
+ const retained = event.outputItems.filter(
446
+ (output) => output.itemId === item.itemId,
447
+ );
448
+ const originalOutputs = contextItemsToMessages(
449
+ event.outputItems.filter((output) =>
450
+ initialItems.some(
451
+ (input) =>
452
+ input.itemId === output.itemId &&
453
+ input.digest === output.digest,
454
+ ),
455
+ ),
456
+ { sessionId },
457
+ );
458
+ // A summary can faithfully retain an older identical user turn from
459
+ // its verified dropped parents. Such history is not a new task copy.
460
+ const derivedParents = new Set(
461
+ event.outputItems
462
+ .filter(
463
+ (output) =>
464
+ !initialItems.some((input) => input.itemId === output.itemId),
465
+ )
466
+ .flatMap((output) => output.provenance.parentDigests || []),
467
+ );
468
+ const historicalOutputs = contextItemsToMessages(
469
+ initialItems.filter((input) => derivedParents.has(input.digest)),
470
+ { sessionId },
471
+ );
472
+ const matches = (list) =>
473
+ list.filter((message) => isDeepStrictEqual(message, binding.message))
474
+ .length;
475
+ if (
476
+ retained.length !== 1 ||
477
+ retained[0].digest !== item.digest ||
478
+ matches(outputMessages) >
479
+ matches(originalOutputs) + matches(historicalOutputs)
480
+ ) {
481
+ const error = new Error(
482
+ "The current user task was lost, changed or duplicated in compaction output",
483
+ );
484
+ error.code = "CC_AGENT_TASK_CONTEXT_LOST";
485
+ throw error;
486
+ }
487
+ }
392
488
  const result = await appendCompaction(event, expectedHead);
393
489
  if (result?.ok === true) settledOutputItems = event.outputItems;
394
490
  return result;
@@ -427,7 +523,7 @@ export async function compactLiveMessagesCanonical(
427
523
  });
428
524
  const result = await compressor.compress(droppedMessages, {
429
525
  preserveToolPairs: true,
430
- ...(options.compressOptions || {}),
526
+ ...summarizerOptions,
431
527
  });
432
528
  summaryStats = result.stats || {};
433
529
  summaryReceipt = usageReceipt(summaryStats, provider, model);
@@ -513,7 +609,9 @@ export async function compactLiveMessagesCanonical(
513
609
  receipt,
514
610
  };
515
611
  }
516
- const snapshot = await runtime.sessionPort.readSnapshot(sessionId);
612
+ const snapshot = settledOutputItems
613
+ ? null
614
+ : await runtime.sessionPort.readSnapshot(sessionId);
517
615
  // Reattach host policy and private retry nudges at their durable sequence
518
616
  // boundaries. They remain live, never enter the receipt, and do not move a
519
617
  // retry instruction behind tool exchanges that happened after that retry.
@@ -531,11 +629,21 @@ export async function compactLiveMessagesCanonical(
531
629
  ephemeralMessages[ephemeralIndex].sequence <= sequence
532
630
  )
533
631
  restoredMessages.push(ephemeralMessages[ephemeralIndex++].message);
534
- restoredMessages.push(...contextItemsToMessages([item], { sessionId }));
632
+ const bound = boundItems.find((entry) => entry.item.itemId === item.itemId);
633
+ restoredMessages.push(
634
+ ...(bound
635
+ ? [bound.binding.original]
636
+ : contextItemsToMessages([item], { sessionId })),
637
+ );
535
638
  }
536
639
  restoredMessages.push(
537
640
  ...ephemeralMessages.slice(ephemeralIndex).map(({ message }) => message),
538
641
  );
642
+ // Advance only the host binding's known multiplicity after a verified commit;
643
+ // older identical turns may have been legitimately summarized away.
644
+ requiredUserBindings.forEach((_, index) => {
645
+ advanceTaskBinding(options.requiredUserBindings[index], restoredMessages);
646
+ });
539
647
  return {
540
648
  messages: restoredMessages,
541
649
  stats: {
@@ -206,17 +206,22 @@ export function messagesToContextItems(messagesInput, options = {}) {
206
206
  ? [...options.allowedSinks]
207
207
  : DEFAULT_ALLOWED_SINKS;
208
208
  const items = [];
209
- const requiredUser = options.requiredUserMessage;
210
- if (
211
- requiredUser != null &&
212
- (!Number.isSafeInteger(requiredUser.index) ||
213
- requiredUser.index < 0 ||
214
- requiredUser.index >= messages.length ||
215
- requiredUser.message?.role !== "user" ||
216
- !isDeepStrictEqual(messages[requiredUser.index], requiredUser.message))
217
- ) {
218
- throw new TypeError("Invalid current-user task binding");
209
+ const requiredUsers =
210
+ options.requiredUserMessages ||
211
+ (options.requiredUserMessage ? [options.requiredUserMessage] : []);
212
+ for (const requiredUser of requiredUsers) {
213
+ if (
214
+ requiredUser != null &&
215
+ (!Number.isSafeInteger(requiredUser.index) ||
216
+ requiredUser.index < 0 ||
217
+ requiredUser.index >= messages.length ||
218
+ requiredUser.message?.role !== "user" ||
219
+ !isDeepStrictEqual(messages[requiredUser.index], requiredUser.message))
220
+ ) {
221
+ throw new TypeError("Invalid current-user task binding");
222
+ }
219
223
  }
224
+ const requiredIndexes = new Set(requiredUsers.map(({ index }) => index));
220
225
  const trustedSystemIndexes = new Set(
221
226
  Array.isArray(options.trustedSystemIndexes)
222
227
  ? options.trustedSystemIndexes.filter(Number.isSafeInteger)
@@ -305,14 +310,13 @@ export function messagesToContextItems(messagesInput, options = {}) {
305
310
  const item = items[index];
306
311
  if (
307
312
  index !== lastIndex &&
308
- item.sourceRef.eventSequence !== requiredUser?.index
313
+ !requiredIndexes.has(item.sourceRef.eventSequence)
309
314
  )
310
315
  continue;
311
316
  const payload = JSON.parse(loadBundleContent(item, sessionId));
312
317
  if (
313
318
  payload.messages.at(-1)?.role === "user" &&
314
- (index === lastIndex ||
315
- item.sourceRef.eventSequence === requiredUser?.index)
319
+ (index === lastIndex || requiredIndexes.has(item.sourceRef.eventSequence))
316
320
  ) {
317
321
  const protectedItem = { ...item };
318
322
  delete protectedItem.digest;
@@ -0,0 +1,41 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+
3
+ const committedMatchCounts = new WeakMap();
4
+ export function taskBindingMatchCount(binding) {
5
+ return committedMatchCounts.get(binding) ?? binding.matchCount ?? 1;
6
+ }
7
+ export function advanceTaskBinding(binding, messages) {
8
+ committedMatchCounts.set(
9
+ binding,
10
+ messages.filter((message) => isDeepStrictEqual(message, binding.message))
11
+ .length,
12
+ );
13
+ }
14
+
15
+ // A compaction may recreate objects, but may never reconstruct the user's task
16
+ // from a generated summary. Only an exact, unambiguous snapshot can relocate.
17
+ export function resolveTaskBinding(messages, binding) {
18
+ if (!binding) return null;
19
+ const identities = messages.flatMap((message, index) =>
20
+ message === binding.original ? [index] : [],
21
+ );
22
+ const matches = messages.flatMap((message, index) =>
23
+ message?.role === "user" && isDeepStrictEqual(message, binding.message)
24
+ ? [index]
25
+ : [],
26
+ );
27
+ if (
28
+ matches.length !== taskBindingMatchCount(binding) ||
29
+ identities.length > 1 ||
30
+ (identities.length === 1 && !matches.includes(identities[0])) ||
31
+ (!identities.length &&
32
+ (binding.allowContentRelocation !== true || matches.length !== 1))
33
+ ) {
34
+ const error = new Error(
35
+ "The current user task was lost, changed or duplicated in compaction context",
36
+ );
37
+ error.code = "CC_AGENT_TASK_CONTEXT_LOST";
38
+ throw error;
39
+ }
40
+ return { index: identities[0] ?? matches[0], message: binding.message };
41
+ }