dsh-ptc-plus 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.en.md +166 -76
  2. package/README.md +167 -77
  3. package/assets/dsh-ptc-plus-banner-en.webp +0 -0
  4. package/assets/dsh-ptc-plus-banner-zh.webp +0 -0
  5. package/assets/ptc-plus-icon.svg +5 -0
  6. package/client.js +593 -306
  7. package/compiler-core.cjs +162 -160
  8. package/compiler-core.cjs.map +3 -3
  9. package/docs/README.md +3 -1
  10. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +8 -7
  11. package/docs/adr/0008-define-agent-operating-contract.md +2 -2
  12. package/docs/adr/0010-session-log-derived-recovery-tips.md +7 -0
  13. package/docs/adr/0016-archive-resolved-review-findings.md +3 -3
  14. package/docs/adr/0019-plugin-settings-and-kill-switch.md +3 -3
  15. package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -0
  16. package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +14 -3
  17. package/docs/adr/0023-global-user-bindings.md +1 -1
  18. package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +23 -7
  19. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +4 -0
  20. package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +26 -6
  21. package/docs/adr/0031-close-semantic-obligations-by-owner.md +29 -0
  22. package/docs/adr/README.md +4 -4
  23. package/docs/architecture.md +6 -5
  24. package/docs/binding-review-placement-design.md +1 -1
  25. package/docs/client-ui.md +8 -4
  26. package/docs/durability-design.md +7 -7
  27. package/docs/installation.md +26 -8
  28. package/docs/runtime-reference.md +18 -11
  29. package/docs/semantic-validation.md +7 -3
  30. package/docs/user-bindings.en.md +7 -6
  31. package/docs/user-bindings.md +6 -5
  32. package/docs/value-wire.md +1 -1
  33. package/docs/verification.md +38 -1
  34. package/index.js +39 -34
  35. package/internal/async-local-storage-intrinsics.js +12 -7
  36. package/internal/callable-source-facts.js +37 -1
  37. package/internal/cell-analysis.js +64 -9
  38. package/internal/config-spec.js +1 -1
  39. package/internal/cordis-tools-owner.js +20 -10
  40. package/internal/edit-transport-owner.js +80 -20
  41. package/internal/execution-seam-compat.js +192 -30
  42. package/internal/kernel-worker.js +162 -72
  43. package/internal/message-sources.js +14 -0
  44. package/internal/module-compilation.js +2 -1
  45. package/internal/repl-memory-projection.js +10 -0
  46. package/internal/repl-scope-normalizer.js +72 -35
  47. package/internal/repl-value-observer.js +141 -45
  48. package/internal/runtime-config.js +62 -2
  49. package/internal/runtime-intrinsics.js +3 -0
  50. package/internal/runtime-messages.js +6 -4
  51. package/internal/session-cell-executor.js +24 -1
  52. package/internal/session-journal-recovery.js +267 -67
  53. package/internal/session-journal.js +466 -40
  54. package/internal/session-log-view.js +5 -4
  55. package/internal/session-runtime.js +45 -21
  56. package/internal/session-state.js +7 -29
  57. package/internal/settings-compat.js +66 -1
  58. package/internal/source-position-map.js +19 -0
  59. package/internal/user-binding-console-worker.js +23 -9
  60. package/internal/user-binding-draft-projection.js +3 -2
  61. package/internal/user-bindings-owner.js +2 -1
  62. package/internal/user-bindings.js +6 -1
  63. package/internal/validated-parse-repair.js +6 -2
  64. package/internal/value-wire.js +95 -30
  65. package/internal/worker-client.js +99 -8
  66. package/internal/worker-cwd-virtualization.js +10 -9
  67. package/internal/worker-output-capture.js +187 -0
  68. package/internal/worker-output-fence.js +26 -0
  69. package/internal/worker-realm-surfaces.js +197 -0
  70. package/internal/worker-repl-realm.js +10 -4
  71. package/package.json +36 -4
  72. package/src/client-activity.js +4 -1
  73. package/src/client-authoring-view.js +121 -62
  74. package/src/client-console.js +4 -2
  75. package/src/client-copy.js +4 -2
  76. package/src/client-feature-gates.js +7 -9
  77. package/src/client-host-compat.js +104 -27
  78. package/src/client-repl-view.js +3 -1
  79. package/src/client-styles.js +12 -1
  80. package/src/client-tool-view.js +3 -1
  81. package/src/client-transport.js +2 -2
  82. package/src/client-workbench.js +4 -3
  83. package/src/client.js +56 -17
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util'
1
2
  import { decodeValue, encodeValue, normalizeValueWire } from './value-wire.js'
2
3
  import { normalizeDiagnostic } from './diagnostic.js'
3
4
  import { assertOwnFields, isRecord } from './record-utils.js'
@@ -58,8 +59,10 @@ import {
58
59
  JOURNAL_VERSIONS,
59
60
  USER_BINDINGS_REUSE_POLICIES,
60
61
  RECOVERY_BOUNDARY_FIELDS,
62
+ REPL_TOOL_NAMES,
61
63
  REWRITE_FIELDS,
62
64
  REWRITE_KINDS,
65
+ usesCallSequenceConfirms,
63
66
  } from './session-journal-schema.js'
64
67
 
65
68
  export {
@@ -82,12 +85,16 @@ function validName(name) {
82
85
  return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)
83
86
  }
84
87
 
88
+ export function isCanonicalSequence(value) {
89
+ return Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0)
90
+ }
91
+
85
92
  function normalizeCalls(value) {
86
93
  if (!Array.isArray(value)) throw new Error('invalid dsh-ptc-plus journal calls')
87
94
  const calls = value.map((call, index) => {
88
95
  if (!isRecord(call) || typeof call.global !== 'string' || typeof call.member !== 'string'
89
96
  || !Object.hasOwn(call, 'args') || (call.ok !== true && call.ok !== false)
90
- || !Number.isSafeInteger(call.settle) || call.settle < 0) {
97
+ || !isCanonicalSequence(call.settle)) {
91
98
  throw new Error(`invalid dsh-ptc-plus journal call at index ${index}`)
92
99
  }
93
100
  assertOwnFields(call, call.ok ? CALL_SUCCESS_FIELDS : CALL_ERROR_FIELDS, `journal call at index ${index}`)
@@ -170,7 +177,7 @@ function normalizeLegacyConfirms(value, resolveLegacyConfirm) {
170
177
  throw new Error('legacy dsh-ptc-plus confirmed no-op calls require session call identity')
171
178
  }
172
179
  const confirms = value.map(callId => resolveLegacyConfirm(callId))
173
- if (confirms.some(callSeq => !Number.isSafeInteger(callSeq) || callSeq < 0)) {
180
+ if (confirms.some(callSeq => !isCanonicalSequence(callSeq))) {
174
181
  throw new Error('legacy dsh-ptc-plus confirmed no-op call is not uniquely persisted')
175
182
  }
176
183
  if (new Set(confirms).size !== confirms.length) {
@@ -181,7 +188,7 @@ function normalizeLegacyConfirms(value, resolveLegacyConfirm) {
181
188
 
182
189
  function normalizeConfirms(value) {
183
190
  if (value === undefined) return []
184
- if (!Array.isArray(value) || value.some(callSeq => !Number.isSafeInteger(callSeq) || callSeq < 0)) {
191
+ if (!Array.isArray(value) || value.some(callSeq => !isCanonicalSequence(callSeq))) {
185
192
  throw new Error('invalid dsh-ptc-plus confirmed no-op calls')
186
193
  }
187
194
  const confirms = [...new Set(value)]
@@ -386,7 +393,7 @@ export function normalizeJournal(value, options = {}) {
386
393
 
387
394
  /** Validate the required persisted relation that makes one edit result executable history. */
388
395
  export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
389
- if (!Number.isSafeInteger(expectedTargetCallSeq) || expectedTargetCallSeq < 0) {
396
+ if (!isCanonicalSequence(expectedTargetCallSeq)) {
390
397
  throw new Error('derived edit does not identify an eligible target call')
391
398
  }
392
399
  if (!isRecord(meta)) throw new Error('invalid dsh-ptc-plus derived edit metadata')
@@ -394,7 +401,8 @@ export function normalizeDerivedEditResult(meta, expectedTargetCallSeq) {
394
401
  const derived = meta[DERIVED_RUN_KEY]
395
402
  if (!isRecord(target)) throw new Error('invalid dsh-ptc-plus edit target metadata')
396
403
  assertOwnFields(target, EDIT_TARGET_FIELDS, 'edit target metadata')
397
- if (target.targetCallSeq !== expectedTargetCallSeq) {
404
+ if (!isCanonicalSequence(target.targetCallSeq)
405
+ || target.targetCallSeq !== expectedTargetCallSeq) {
398
406
  throw new Error('derived edit target does not match the eligible target call')
399
407
  }
400
408
  if (!isRecord(derived) || typeof derived.code !== 'string' || typeof derived.description !== 'string') {
@@ -488,13 +496,13 @@ export function recoveryBoundariesEqual(left, right) {
488
496
 
489
497
  function normalizeRecoveryBoundaryValue(value, eventSeq = undefined) {
490
498
  if (!isRecord(value)
491
- || !Number.isSafeInteger(value.failedCallSeq) || value.failedCallSeq < 0
499
+ || !isCanonicalSequence(value.failedCallSeq)
492
500
  || (value.frontierCallSeq !== null
493
- && (!Number.isSafeInteger(value.frontierCallSeq) || value.frontierCallSeq < 0))) {
501
+ && !isCanonicalSequence(value.frontierCallSeq))) {
494
502
  throw new Error('invalid dsh-ptc-plus recovery boundary')
495
503
  }
496
504
  assertOwnFields(value, RECOVERY_BOUNDARY_FIELDS, 'recovery boundary')
497
- if (eventSeq !== undefined && (!Number.isSafeInteger(eventSeq) || eventSeq < 0)) {
505
+ if (eventSeq !== undefined && !isCanonicalSequence(eventSeq)) {
498
506
  throw new Error('invalid dsh-ptc-plus recovery boundary event sequence')
499
507
  }
500
508
  return {
@@ -519,30 +527,399 @@ export function withRecoveryBoundaries(meta, boundaries) {
519
527
  return base
520
528
  }
521
529
 
530
+ const HOST_SEQUENCE_RELATION_POLICY_V0 = new Map([
531
+ ['agent-preset/selected', null],
532
+ ['agent/inbox/spliced', null],
533
+ ['approval/asked', null],
534
+ ['approval/decided', null],
535
+ ['approval/policy', null],
536
+ ['assistant/chunk', null],
537
+ ['assistant/message', null],
538
+ ['command/done', 'command-source'],
539
+ ['command/run', null],
540
+ // Released format 0 also wrote the legacy spellings of the compaction events
541
+ // and a steering message that its own v0-to-v1 stage normalizes to
542
+ // `compaction/*` and `user/message`. They belong to the same frozen
543
+ // vocabulary, so the retired-boundary converter accepts them with their
544
+ // normalized relation semantics. `request/header-delta` and `mode/set` stay
545
+ // unsupported because that stage refuses them as well.
546
+ ['compact/end', null],
547
+ ['compact/prune', 'compaction-shadow'],
548
+ ['compact/start', null],
549
+ ['compact/summary', 'compaction-shadow'],
550
+ ['compaction/end', null],
551
+ ['compaction/prune', 'compaction-shadow'],
552
+ ['compaction/start', null],
553
+ ['compaction/summary', 'compaction-shadow'],
554
+ ['feedback/record', null],
555
+ ['goal/change', null],
556
+ ['hook/invoked', null],
557
+ ['hook/result', null],
558
+ ['llm/retry', null],
559
+ ['llm/retry-started', null],
560
+ ['model/selection', null],
561
+ ['permission/preset', null],
562
+ ['plan/mode', null],
563
+ ['request/context', null],
564
+ ['request/header', null],
565
+ ['sandbox/mode', null],
566
+ ['schedule/change', null],
567
+ ['session-log-deepseek/delivery-accepted', 'delivery-through'],
568
+ ['session/end-seed', null],
569
+ ['session/title', 'title-messages'],
570
+ ['session/title-llm-request', 'title-request-messages'],
571
+ ['step/end', null],
572
+ ['step/start', null],
573
+ ['steering/message', null],
574
+ ['subagent/descriptor', null],
575
+ ['subagent/model-selection-policy', null],
576
+ ['team/member', null],
577
+ ['team/message/delivered', null],
578
+ ['team/message/queued', null],
579
+ ['team/task', null],
580
+ ['todo/write', null],
581
+ ['tool-workflow/agent-end', null],
582
+ ['tool-workflow/agent-start', null],
583
+ ['tool-workflow/run-end', null],
584
+ ['tool-workflow/run-start', null],
585
+ ['tool/call', null],
586
+ ['tool/code-dispatch', null],
587
+ ['tool/code-dispatch-start', null],
588
+ ['tool/result', null],
589
+ ['turn/end', null],
590
+ ['turn/start', null],
591
+ ['user/message', null],
592
+ ['web/deepseek-search-llm-request', null],
593
+ ])
594
+
595
+ const V0_SURFACE_EVENT_TYPES = new Set([
596
+ 'user/message', 'steering/message', 'assistant/message', 'tool/result',
597
+ ])
598
+ const HOST_DATA_SEQUENCE_RELATIONS_V0 = new Map([
599
+ ['command/done', new Set(['sourceEventSeq'])],
600
+ ['compact/prune', new Set(['shadowedSeqs'])],
601
+ ['compact/summary', new Set(['shadowedSeqs'])],
602
+ ['compaction/prune', new Set(['shadowedSeqs'])],
603
+ ['compaction/summary', new Set(['shadowedSeqs'])],
604
+ ['session-log-deepseek/delivery-accepted', new Set(['throughSeq'])],
605
+ ['session/title', new Set(['messageSeqs'])],
606
+ ['session/title-llm-request', new Set(['messageSeqs'])],
607
+ ])
608
+ const SEQUENCE_RELATION_FIELD = /Seqs?$/u
609
+
610
+ function validateKnownDataSequenceRelations(event) {
611
+ if (!isRecord(event.data)) return
612
+ const allowed = HOST_DATA_SEQUENCE_RELATIONS_V0.get(event.type) ?? new Set()
613
+ for (const field of Reflect.ownKeys(event.data)) {
614
+ if (typeof field === 'string' && SEQUENCE_RELATION_FIELD.test(field) && !allowed.has(field)) {
615
+ throw new Error(`unsupported ${event.type} data sequence relation ${JSON.stringify(field)}`)
616
+ }
617
+ }
618
+ }
619
+
620
+ function validateEarlierEventReference(events, event, seq, label) {
621
+ if (!isCanonicalSequence(seq) || seq >= event.seq || events[seq]?.seq !== seq) {
622
+ throw new Error(`invalid ${label} event reference ${JSON.stringify(seq)} at event ${event.seq}`)
623
+ }
624
+ return events[seq]
625
+ }
626
+
627
+ function validateEarlierEventReferences(events, event, seqs, label) {
628
+ if (!Array.isArray(seqs)) throw new Error(`invalid ${label} event references`)
629
+ const seen = new Set()
630
+ return seqs.map(seq => {
631
+ if (seen.has(seq)) throw new Error(`duplicate ${label} event reference ${JSON.stringify(seq)} at event ${event.seq}`)
632
+ seen.add(seq)
633
+ return validateEarlierEventReference(events, event, seq, label)
634
+ })
635
+ }
636
+
637
+ function sameSequences(left, right) {
638
+ return left.length === right.length && left.every((seq, index) => seq === right[index])
639
+ }
640
+
641
+ function validateSurfaceRelation(events, event, surface) {
642
+ const eligible = V0_SURFACE_EVENT_TYPES.has(event.type)
643
+ if (!eligible) {
644
+ if (event.surfaceOp !== undefined || event.sourceEventSeqs !== undefined) {
645
+ throw new Error(`session event ${JSON.stringify(event.type)} is not surface-eligible`)
646
+ }
647
+ return
648
+ }
649
+ if (event.sourceEventSeqs !== undefined) {
650
+ validateEarlierEventReferences(events, event, event.sourceEventSeqs, 'source')
651
+ }
652
+ if (event.sourceEventSeqs?.length === 0 && event.type !== 'assistant/message') {
653
+ throw new Error('source event references must not be empty except on assistant/message')
654
+ }
655
+ const op = event.surfaceOp
656
+ // Historical host formats predate the mandatory append marker.
657
+ if (op === undefined || op === 'append') {
658
+ surface.push(event.seq)
659
+ return
660
+ }
661
+ if (!isRecord(op) || op.op !== 'replace'
662
+ || Reflect.ownKeys(op).length !== 3
663
+ || !isCanonicalSequence(op.start)
664
+ || !isCanonicalSequence(op.end)) {
665
+ throw new Error('invalid surface operation during recovery-boundary migration')
666
+ }
667
+ validateEarlierEventReference(events, event, op.start, 'surface replacement start')
668
+ validateEarlierEventReference(events, event, op.end, 'surface replacement end')
669
+ const start = surface.indexOf(op.start)
670
+ const end = surface.indexOf(op.end)
671
+ if (start < 0 || end < start) {
672
+ throw new Error(`surface replacement at event ${event.seq} does not identify a current ordered surface range`)
673
+ }
674
+ const shadowed = surface.slice(start, end + 1)
675
+ const provenance = new Set(event.sourceEventSeqs ?? [])
676
+ if (shadowed.some(seq => !provenance.has(seq))) {
677
+ throw new Error(`surface replacement at event ${event.seq} does not cite every replaced surface node`)
678
+ }
679
+ if (event.type === 'tool/result'
680
+ && (shadowed.length !== 1 || events[shadowed[0]]?.type !== 'tool/result')) {
681
+ throw new Error('tool/result surface replacement must target one current tool/result')
682
+ }
683
+ if (event.type === 'tool/result') {
684
+ const withoutContent = candidate => {
685
+ const data = candidate?.data
686
+ const message = data?.message
687
+ const content = message?.content
688
+ if (!isRecord(data) || !isRecord(message) || !Array.isArray(content)
689
+ || content.length !== 1 || !isRecord(content[0])) {
690
+ throw new Error('tool/result surface replacement has invalid message content')
691
+ }
692
+ return {
693
+ ...data,
694
+ message: { ...message, content: [{ ...content[0], content: null }] },
695
+ }
696
+ }
697
+ if (!isDeepStrictEqual(withoutContent(events[shadowed[0]]), withoutContent(event))) {
698
+ throw new Error('tool/result surface replacement may change only content')
699
+ }
700
+ }
701
+ surface.splice(start, shadowed.length, event.seq)
702
+ }
703
+
704
+ function validateCommandSource(events, event) {
705
+ if (!isRecord(event.data)) {
706
+ throw new Error(`command/done event ${event.seq} has invalid data`)
707
+ }
708
+ if (event.data.sourceEventSeq === undefined) return
709
+ if (!isRecord(event.data) || event.data.kind !== 'success') {
710
+ throw new Error(`command/done event ${event.seq} has a source outside a successful result`)
711
+ }
712
+ const source = validateEarlierEventReference(events, event, event.data.sourceEventSeq, 'command source')
713
+ if (source.type === 'command/run' || source.type === 'command/done') {
714
+ throw new Error(`command/done event ${event.seq} cites a command lifecycle event as its source`)
715
+ }
716
+ }
717
+
718
+ function validateTitleRequestSources(events, event) {
719
+ if (!isRecord(event.data) || !Array.isArray(event.data.messageSeqs)) {
720
+ throw new Error('invalid session/title-llm-request message references')
721
+ }
722
+ const sources = validateEarlierEventReferences(
723
+ events, event, event.data.messageSeqs, 'session title request message',
724
+ )
725
+ if (sources.some(source => source.type !== 'user/message')) {
726
+ throw new Error(`session/title-llm-request event ${event.seq} must cite earlier user/message events`)
727
+ }
728
+ }
729
+
730
+ function validateDeliveryThrough(events, event) {
731
+ if (!isRecord(event.data)) {
732
+ throw new Error(`session-log-deepseek/delivery-accepted event ${event.seq} has invalid data`)
733
+ }
734
+ validateEarlierEventReference(events, event, event.data.throughSeq, 'session delivery through')
735
+ }
736
+
737
+ function validateTitleSources(events, event) {
738
+ if (!isRecord(event.data) || !Array.isArray(event.data.messageSeqs)) {
739
+ throw new Error('invalid session/title message references')
740
+ }
741
+ if ((event.data.messageSeqs.length === 0) !== (event.data.source?.kind === 'user')) {
742
+ const requirement = event.data.source?.kind === 'user'
743
+ ? 'cite no message seqs'
744
+ : 'cite at least one message seq'
745
+ throw new Error(`session/title event ${event.seq} must ${requirement}`)
746
+ }
747
+ const sources = validateEarlierEventReferences(events, event, event.data.messageSeqs, 'session title message')
748
+ if (sources.some(source => source.type !== 'user/message' || source.data?.source?.kind !== 'user')) {
749
+ throw new Error(`session/title event ${event.seq} must cite earlier human user/message events`)
750
+ }
751
+ }
752
+
753
+ function validateCompactionSources(events, event, surface) {
754
+ if (!isRecord(event.data) || !Array.isArray(event.data.shadowedSeqs)) {
755
+ throw new Error(`invalid ${event.type} shadow references`)
756
+ }
757
+ const shadowed = event.data.shadowedSeqs
758
+ validateEarlierEventReferences(events, event, shadowed, 'compaction shadow')
759
+ if (event.data.shadowedRange === undefined) {
760
+ if (shadowed.length === 0) return
761
+ const start = surface.indexOf(shadowed[0])
762
+ if (start < 0 || !sameSequences(surface.slice(start, start + shadowed.length), shadowed)) {
763
+ throw new Error(`${event.type} event ${event.seq} shadow list does not match the current surface`)
764
+ }
765
+ return
766
+ }
767
+ const range = event.data.shadowedRange
768
+ if (!isRecord(range)) throw new Error(`invalid ${event.type} shadow range`)
769
+ validateEarlierEventReference(events, event, range.start, 'compaction range start')
770
+ validateEarlierEventReference(events, event, range.end, 'compaction range end')
771
+ const start = surface.indexOf(range.start)
772
+ const end = surface.indexOf(range.end)
773
+ if (start < 0 || end < start
774
+ || !sameSequences(surface.slice(start, end + 1), shadowed)) {
775
+ throw new Error(`${event.type} event ${event.seq} shadow range does not match the current surface`)
776
+ }
777
+ }
778
+
779
+ function validateHostSequenceRelations(events) {
780
+ const surface = []
781
+ for (const event of events) {
782
+ if (event.type === RECOVERY_BOUNDARY_EVENT) continue
783
+ if (!HOST_SEQUENCE_RELATION_POLICY_V0.has(event.type)) {
784
+ throw new Error(`unsupported session event type ${JSON.stringify(event.type)} during recovery-boundary migration`)
785
+ }
786
+ validateKnownDataSequenceRelations(event)
787
+ validateSurfaceRelation(events, event, surface)
788
+ const relation = HOST_SEQUENCE_RELATION_POLICY_V0.get(event.type)
789
+ if (relation === 'command-source') validateCommandSource(events, event)
790
+ else if (relation === 'title-messages') validateTitleSources(events, event)
791
+ else if (relation === 'title-request-messages') validateTitleRequestSources(events, event)
792
+ else if (relation === 'delivery-through') validateDeliveryThrough(events, event)
793
+ else if (relation === 'compaction-shadow') validateCompactionSources(events, event, surface)
794
+ }
795
+ }
796
+
797
+ function rewriteHostSequenceRelations(event, mapEvent) {
798
+ const relation = HOST_SEQUENCE_RELATION_POLICY_V0.get(event.type)
799
+ if (Object.hasOwn(event, 'sourceEventSeqs')) {
800
+ event.sourceEventSeqs = event.sourceEventSeqs.map(seq => mapEvent(seq, 'source'))
801
+ }
802
+ if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') {
803
+ event.surfaceOp = {
804
+ ...event.surfaceOp,
805
+ start: mapEvent(event.surfaceOp.start, 'surface replacement start'),
806
+ end: mapEvent(event.surfaceOp.end, 'surface replacement end'),
807
+ }
808
+ }
809
+ if (relation === 'command-source') {
810
+ if (event.data.sourceEventSeq !== undefined) {
811
+ event.data = {
812
+ ...event.data,
813
+ sourceEventSeq: mapEvent(event.data.sourceEventSeq, 'command source'),
814
+ }
815
+ }
816
+ } else if (relation === 'title-messages') {
817
+ event.data = {
818
+ ...event.data,
819
+ messageSeqs: event.data.messageSeqs.map(seq => mapEvent(seq, 'session title message')),
820
+ }
821
+ } else if (relation === 'title-request-messages') {
822
+ const mapped = event.data.messageSeqs.map(seq => mapEvent(seq, 'session title request message'))
823
+ if (mapped.some((seq, index) => seq !== event.data.messageSeqs[index])) {
824
+ throw new Error('session/title-llm-request messageSeqs cannot be preserved across removed events')
825
+ }
826
+ } else if (relation === 'delivery-through') {
827
+ event.data = {
828
+ ...event.data,
829
+ throughSeq: mapEvent(event.data.throughSeq, 'session delivery through'),
830
+ }
831
+ } else if (relation === 'compaction-shadow') {
832
+ const data = {
833
+ ...event.data,
834
+ shadowedSeqs: event.data.shadowedSeqs.map(seq => mapEvent(seq, 'compaction shadow')),
835
+ }
836
+ if (event.data.shadowedRange !== undefined) {
837
+ data.shadowedRange = {
838
+ ...event.data.shadowedRange,
839
+ start: mapEvent(event.data.shadowedRange.start, 'compaction range start'),
840
+ end: mapEvent(event.data.shadowedRange.end, 'compaction range end'),
841
+ }
842
+ }
843
+ event.data = data
844
+ }
845
+ return event
846
+ }
847
+
522
848
  /**
523
849
  * Convert the retired custom boundary event in a raw log before DSH restores it.
524
850
  * The returned log is a detached, renumbered copy; the input is never mutated.
525
851
  */
526
852
  export function migrateRecoveryBoundaryEvents(events) {
527
853
  if (!Array.isArray(events)) throw new TypeError('recovery-boundary migration expects an event array')
854
+ const sequenceMap = new Map()
855
+ let nextSeq = 0
856
+ for (const [index, event] of events.entries()) {
857
+ if (!isCanonicalSequence(event?.seq)) {
858
+ throw new Error('invalid session event sequence during recovery-boundary migration')
859
+ }
860
+ if (sequenceMap.has(event.seq)) {
861
+ throw new Error(`duplicate session event sequence ${event.seq} during recovery-boundary migration`)
862
+ }
863
+ if (event.seq !== index) {
864
+ throw new Error(`session event sequence ${event.seq} at index ${index} is not contiguous from zero`)
865
+ }
866
+ const retainedSeq = event.type === RECOVERY_BOUNDARY_EVENT ? undefined : nextSeq++
867
+ sequenceMap.set(event.seq, { event, retainedSeq })
868
+ }
869
+ validateHostSequenceRelations(events)
870
+ const mapEvent = (seq, label) => {
871
+ if (!isCanonicalSequence(seq)) throw new Error(`invalid ${label} event reference`)
872
+ const target = sequenceMap.get(seq)
873
+ if (target === undefined || target.retainedSeq === undefined) {
874
+ throw new Error(`unmapped ${label} event reference ${seq}`)
875
+ }
876
+ return target.retainedSeq
877
+ }
878
+ const mapCall = (seq, resultSeq, label) => {
879
+ const target = sequenceMap.get(seq)
880
+ if (!isCanonicalSequence(seq) || target === undefined
881
+ || target.retainedSeq === undefined || target.event?.type !== 'tool/call'
882
+ || !REPL_TOOL_NAMES.has(target.event.data?.name) || seq >= resultSeq) {
883
+ throw new Error(`unproved ${label} call reference ${JSON.stringify(seq)} at event ${resultSeq}`)
884
+ }
885
+ return target.retainedSeq
886
+ }
887
+ const mapBoundary = (boundary, resultSeq) => ({
888
+ failedCallSeq: mapCall(boundary.failedCallSeq, resultSeq, 'recovery-boundary failure'),
889
+ frontierCallSeq: boundary.frontierCallSeq === null ? null : (() => {
890
+ if (boundary.frontierCallSeq >= boundary.failedCallSeq) {
891
+ throw new Error('recovery-boundary frontier must precede its failed call')
892
+ }
893
+ return mapCall(boundary.frontierCallSeq, resultSeq, 'recovery-boundary frontier')
894
+ })(),
895
+ })
528
896
  const migrated = []
529
897
  const pending = []
898
+ const boundaryOrigins = new Map()
530
899
  for (const event of events) {
531
900
  if (event?.type === RECOVERY_BOUNDARY_EVENT) {
532
- if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
533
- throw new Error('invalid dsh-ptc-plus recovery boundary event sequence')
534
- }
535
- pending.push(normalizeRecoveryBoundaryValue(event.data))
901
+ pending.push({ boundary: normalizeRecoveryBoundaryValue(event.data), eventSeq: event.seq })
536
902
  continue
537
903
  }
538
904
  const detached = cloneJson(event)
539
- if (pending.length > 0 && detached?.type === 'tool/result') {
540
- const data = isRecord(detached.data) ? { ...detached.data } : {}
541
- const meta = isRecord(data.meta) ? { ...data.meta } : {}
905
+ const sourceSeqs = detached?.sourceEventSeqs
906
+ const source = Array.isArray(sourceSeqs) && sourceSeqs.length === 1
907
+ ? sequenceMap.get(sourceSeqs[0]) : undefined
908
+ const carriesJournal = detached?.type === 'tool/result'
909
+ && isRecord(detached.data?.meta) && Object.hasOwn(detached.data.meta, JOURNAL_KEY)
910
+ && source?.retainedSeq !== undefined && source.event?.type === 'tool/call'
911
+ && REPL_TOOL_NAMES.has(source.event.data?.name) && source.event.seq < event.seq
912
+ if (pending.length > 0 && carriesJournal) {
913
+ const data = { ...detached.data }
914
+ const meta = { ...data.meta }
542
915
  const existing = meta[RECOVERY_BOUNDARY_KEY] === undefined
543
916
  ? []
544
917
  : normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
545
- meta[RECOVERY_BOUNDARY_KEY] = [...existing, ...pending]
918
+ meta[RECOVERY_BOUNDARY_KEY] = [...pending.map(item => item.boundary), ...existing]
919
+ boundaryOrigins.set(event.seq, {
920
+ pendingCount: pending.length,
921
+ pendingEventSeqs: pending.map(item => item.eventSeq),
922
+ })
546
923
  data.meta = meta
547
924
  detached.data = data
548
925
  pending.length = 0
@@ -550,19 +927,53 @@ export function migrateRecoveryBoundaryEvents(events) {
550
927
  migrated.push(detached)
551
928
  }
552
929
  if (pending.length > 0) {
553
- throw new Error('recovery boundary has no later tool/result settlement for migration')
930
+ throw new Error('recovery boundary has no later PTC journal result for migration')
554
931
  }
555
- const sequenceMap = new Map()
556
- for (const [index, event] of migrated.entries()) {
557
- if (Number.isSafeInteger(event?.seq) && event.seq >= 0) sequenceMap.set(event.seq, index)
932
+ return Object.freeze(migrated.map((event) => {
933
+ const oldSeq = event.seq
934
+ rewriteHostSequenceRelations(event, mapEvent)
935
+ if (event.type === 'tool/result' && isRecord(event.data?.meta)) {
936
+ const meta = event.data.meta
937
+ const journal = meta[JOURNAL_KEY]
938
+ if (usesCallSequenceConfirms(journal) && journal.confirms !== undefined) {
939
+ if (!Array.isArray(journal.confirms)) throw new Error('invalid dsh-ptc-plus confirmed no-op calls')
940
+ journal.confirms = journal.confirms.map(seq => mapCall(seq, oldSeq, 'journal confirmation'))
941
+ }
942
+ if (meta[EDIT_TARGET_KEY] !== undefined) {
943
+ if (!isRecord(meta[EDIT_TARGET_KEY])) throw new Error('invalid dsh-ptc-plus edit target')
944
+ meta[EDIT_TARGET_KEY].targetCallSeq = mapCall(
945
+ meta[EDIT_TARGET_KEY].targetCallSeq, oldSeq, 'edit target',
946
+ )
947
+ }
948
+ if (meta[RECOVERY_BOUNDARY_KEY] !== undefined) {
949
+ const origins = boundaryOrigins.get(oldSeq)
950
+ meta[RECOVERY_BOUNDARY_KEY] = normalizeRecoveryBoundaries(meta[RECOVERY_BOUNDARY_KEY])
951
+ .map((boundary, index) => mapBoundary(boundary,
952
+ origins !== undefined && index < origins.pendingCount
953
+ ? origins.pendingEventSeqs[index]
954
+ : oldSeq))
955
+ }
956
+ }
957
+ event.seq = sequenceMap.get(oldSeq).retainedSeq
958
+ return Object.freeze(event)
959
+ }))
960
+ }
961
+
962
+ function currentAssistantCallSeq(events, candidates, callId, toolName) {
963
+ let assistantIndex = events.length - 1
964
+ while (assistantIndex >= 0 && events[assistantIndex]?.type !== 'assistant/message') {
965
+ assistantIndex -= 1
558
966
  }
559
- return Object.freeze(migrated.map((event, index) => Object.freeze({
560
- ...event,
561
- seq: index,
562
- ...(Array.isArray(event.sourceEventSeqs)
563
- ? { sourceEventSeqs: event.sourceEventSeqs.map(seq => sequenceMap.get(seq) ?? seq) }
564
- : {}),
565
- })))
967
+ if (assistantIndex < 0) return undefined
968
+ const content = events[assistantIndex].data?.message?.content
969
+ if (!Array.isArray(content)) return undefined
970
+ const blocks = content.filter(part => part?.type === 'tool-call'
971
+ && part.id === callId && part.name === toolName)
972
+ if (blocks.length !== 1) return undefined
973
+ const eventIndexes = new Map(events.map((event, index) => [event, index]))
974
+ const current = candidates.filter(event => eventIndexes.get(event) > assistantIndex
975
+ && event.data.arguments === blocks[0].arguments)
976
+ return current.length === 1 ? current[0].seq : undefined
566
977
  }
567
978
 
568
979
  /** Resolve the persisted event identity for one named tool call being dispatched. */
@@ -571,27 +982,42 @@ export function liveToolCallSeq(session, callId, toolName) {
571
982
  if (!Array.isArray(events) || typeof callId !== 'string' || callId.length === 0
572
983
  || typeof toolName !== 'string' || toolName.length === 0) return undefined
573
984
 
574
- const pairedCallSeqs = new Set()
985
+ const pendingCalls = new Map()
575
986
  for (const event of events) {
576
- if (event?.type !== 'tool/result' || !Array.isArray(event.sourceEventSeqs)) continue
577
- for (const sourceSeq of event.sourceEventSeqs) {
578
- if (Number.isSafeInteger(sourceSeq) && sourceSeq >= 0) pairedCallSeqs.add(sourceSeq)
987
+ if (event?.type === 'tool/call' && event.data?.callId === callId) {
988
+ if (event.data.name === toolName && !isCanonicalSequence(event.seq)) {
989
+ throw new Error(`current ${toolName} call has an invalid session event sequence`)
990
+ }
991
+ if (isCanonicalSequence(event.seq)) pendingCalls.set(event.seq, event)
992
+ continue
993
+ }
994
+ if (event?.type !== 'tool/result') continue
995
+ const sourceRelation = event.sourceEventSeqs
996
+ const canonicalSourceRelation = Array.isArray(sourceRelation)
997
+ && sourceRelation.length === 1
998
+ && isCanonicalSequence(sourceRelation[0])
999
+ if (canonicalSourceRelation) {
1000
+ pendingCalls.delete(sourceRelation[0])
1001
+ continue
1002
+ }
1003
+ // A damaged result cannot recover state, but its ordered call identity can
1004
+ // still prove that the sole preceding same-id call is no longer live.
1005
+ if (event.data?.message?.source?.callId === callId
1006
+ && pendingCalls.size === 1) {
1007
+ pendingCalls.clear()
579
1008
  }
580
1009
  }
581
1010
 
582
- const candidates = []
583
- for (const event of events) {
584
- if (event?.type !== 'tool/call' || event.data?.name !== toolName
585
- || event.data.callId !== callId || pairedCallSeqs.has(event.seq)) continue
586
- if (!Number.isSafeInteger(event.seq) || event.seq < 0) {
587
- throw new Error(`current ${toolName} call has an invalid session event sequence`)
588
- }
589
- candidates.push(event.seq)
1011
+ const candidates = [...pendingCalls.values()]
1012
+ .filter(event => event.data?.name === toolName)
1013
+ if (candidates.length > 1) {
1014
+ const current = currentAssistantCallSeq(events, candidates, callId, toolName)
1015
+ if (current !== undefined) return current
590
1016
  }
591
1017
  if (candidates.length > 1) {
592
1018
  throw new Error(`session log contains multiple unpaired ${toolName} calls for callId ${JSON.stringify(callId)}`)
593
1019
  }
594
- return candidates[0]
1020
+ return candidates[0]?.seq
595
1021
  }
596
1022
 
597
1023
  /** Start a mutable journal for one live cell. */
@@ -1,16 +1,17 @@
1
1
  import {
2
2
  foldSessionTimeline,
3
3
  } from './session-journal-recovery.js'
4
+ import { isCanonicalSequence } from './session-journal.js'
4
5
  import { sessionEvents } from './session-events.js'
5
6
  import { readRuntimeMessage } from './runtime-messages.js'
6
7
 
7
- const SYSTEM_PROMPT_PLUGIN = '@deepseek-ai/dsh-system-prompt'
8
+ import { isHostRuntimeContextSource } from './message-sources.js'
8
9
  const SYSTEM_PROMPT_CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
9
10
 
10
11
  /** DSH's empty aggregate uses an exact owner marker without a snapshot form. */
11
12
  export function systemPromptSnapshotSections(message) {
12
13
  const source = message?.source
13
- if (source?.kind !== 'plugin' || source.plugin !== SYSTEM_PROMPT_PLUGIN) return undefined
14
+ if (!isHostRuntimeContextSource(source)) return undefined
14
15
  if (source.form === undefined && Array.isArray(message.content) && message.content.length === 1
15
16
  && message.content[0]?.type === 'text' && message.content[0].text === SYSTEM_PROMPT_CLEARED) {
16
17
  return Object.freeze([])
@@ -101,7 +102,7 @@ export function projectSessionLog(agent, requestedEdit) {
101
102
  try {
102
103
  const nodes = agent?.session?.surface?.nodes
103
104
  const knownSeqs = new Set(events.map(event => event?.seq))
104
- if (Array.isArray(nodes) && nodes.every(seq => Number.isSafeInteger(seq) && seq >= 0 && knownSeqs.has(seq))) {
105
+ if (Array.isArray(nodes) && nodes.every(seq => isCanonicalSequence(seq) && knownSeqs.has(seq))) {
105
106
  visibleRuntimeMessages = Object.freeze(nodes.flatMap(seq => {
106
107
  const record = runtimeMessagesBySeq.get(seq)
107
108
  return record === undefined ? [] : [record]
@@ -126,6 +127,6 @@ export function projectSessionLog(agent, requestedEdit) {
126
127
  /** Return the target snapshot captured at one persisted edit call event. */
127
128
  export function editTargetForCall(agent, callId, callSeq) {
128
129
  if (typeof callId !== 'string' || callId.length === 0
129
- || !Number.isSafeInteger(callSeq) || callSeq < 0) return undefined
130
+ || !isCanonicalSequence(callSeq)) return undefined
130
131
  return projectSessionLog(agent, { callId, callSeq }).requestedEditTarget
131
132
  }