@adhdev/daemon-core 0.9.82-rc.137 → 0.9.82-rc.139

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 (39) hide show
  1. package/dist/chat/source-machine.d.ts +166 -0
  2. package/dist/chat/source-resolver.d.ts +104 -0
  3. package/dist/cli-adapters/cli-state-engine.d.ts +15 -0
  4. package/dist/cli-adapters/provider-cli-adapter.d.ts +0 -1
  5. package/dist/cli-adapters/provider-cli-parse.d.ts +1 -0
  6. package/dist/cli-adapters/provider-cli-shared.d.ts +1 -0
  7. package/dist/index.js +922 -328
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +922 -328
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/contracts.d.ts +164 -0
  12. package/dist/providers/contracts.d.ts +19 -0
  13. package/dist/providers/read-chat-contract.d.ts +29 -0
  14. package/dist/providers/transcript-v2.d.ts +176 -0
  15. package/dist/shared-types.d.ts +7 -0
  16. package/dist/status/snapshot.d.ts +1 -0
  17. package/dist/types.d.ts +5 -0
  18. package/package.json +1 -1
  19. package/src/chat/source-machine.ts +534 -0
  20. package/src/chat/source-resolver.ts +0 -0
  21. package/src/chat/subscription-updates.ts +9 -0
  22. package/src/cli-adapters/cli-state-engine.ts +103 -6
  23. package/src/cli-adapters/provider-cli-adapter.ts +51 -5
  24. package/src/cli-adapters/provider-cli-parse.ts +3 -0
  25. package/src/cli-adapters/provider-cli-shared.ts +13 -1
  26. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  27. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  28. package/src/commands/chat-commands.ts +712 -381
  29. package/src/commands/router.ts +14 -2
  30. package/src/config/chat-history.ts +36 -13
  31. package/src/mesh/contracts.ts +329 -0
  32. package/src/providers/contracts.ts +19 -0
  33. package/src/providers/provider-loader.ts +21 -7
  34. package/src/providers/provider-schema.ts +10 -0
  35. package/src/providers/read-chat-contract.ts +74 -14
  36. package/src/providers/transcript-v2.ts +567 -0
  37. package/src/shared-types.ts +7 -0
  38. package/src/status/snapshot.ts +35 -11
  39. package/src/types.ts +5 -0
@@ -18,6 +18,14 @@ import { isNativeSourceCanonicalHistory, readChatHistory, readProviderChatHistor
18
18
  import { LOG, getRecentLogs } from '../logging/logger.js';
19
19
  import { getRecentDebugTrace, recordDebugTrace } from '../logging/debug-trace.js';
20
20
  import { buildChatMessageSignature, hashSignatureParts } from '../chat/chat-signatures.js';
21
+ import {
22
+ CHAT_SOURCE_REGISTRY,
23
+ buildV1NativePresentObservation,
24
+ chatSourceSessionKey,
25
+ type ChatSourceDecision,
26
+ type ChatSourceObservation,
27
+ type ChatSourceTransitionCause,
28
+ } from '../chat/source-resolver.js';
21
29
  import type { ChatMessage } from '../types.js';
22
30
  import type { SessionTransport } from '../shared-types.js';
23
31
  import { filterUserFacingChatMessages, normalizeChatMessages } from '../providers/chat-message-normalization.js';
@@ -25,13 +33,25 @@ import { filterUserFacingChatMessages, normalizeChatMessages } from '../provider
25
33
  const RECENT_SEND_WINDOW_MS = 1200;
26
34
  export const READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS = 25_000;
27
35
  const HERMES_CLI_STARTING_SEND_SETTLE_MS = 2_000;
28
- const CLI_NATIVE_HISTORY_FRESH_MS = 5 * 60_000;
29
- // Fallback list for supportsCliNativeTranscript() when the ProviderModule is
30
- // unavailable (e.g. provider config not yet loaded). The authoritative check is
31
- // provider.canonicalHistory via isNativeSourceCanonicalHistory(). New providers
32
- // with native transcripts should set canonicalHistory in their provider.json
33
- // rather than adding entries here.
36
+ // (A2.2) CLI_NATIVE_HISTORY_FRESH_MS removed with isNativeHistoryFreshEnough.
37
+ // Hardcoded native-transcript provider allow-list. Deprecated. Kept only as a
38
+ // last-resort fallback when ProviderModule is not yet loaded; on every hit we
39
+ // warn so the dependency on this set is visible. A2 deletes the set entirely
40
+ // and routes solely through canonicalHistory.contractVersion +
41
+ // isNativeSourceCanonicalHistory().
34
42
  const CLI_NATIVE_TRANSCRIPT_PROVIDERS = new Set(['codex-cli', 'claude-cli', 'hermes-cli', 'antigravity-cli']);
43
+ const warnedLegacyNativeAllowlistHits = new Set<string>();
44
+ function warnLegacyNativeAllowlistHit(providerType: string): void {
45
+ if (warnedLegacyNativeAllowlistHits.has(providerType)) return;
46
+ warnedLegacyNativeAllowlistHits.add(providerType);
47
+ // eslint-disable-next-line no-console
48
+ console.warn(
49
+ `[chat-commands] supportsCliNativeTranscript fell back to the hardcoded `
50
+ + `CLI_NATIVE_TRANSCRIPT_PROVIDERS set for "${providerType}". `
51
+ + `The provider module was unavailable or did not declare canonicalHistory. `
52
+ + `Set canonicalHistory.contractVersion in the provider.json to remove this dependency.`,
53
+ );
54
+ }
35
55
  const recentSendByTarget = new Map<string, number>();
36
56
 
37
57
  interface ApprovalSelectableInstance extends ProviderInstance {
@@ -288,7 +308,15 @@ function readHistorySessionIdFromMessages(messages: ChatMessage[]): string | und
288
308
  function shouldPreserveNativeIdentity(providerType: string, sessionId: string, message: ChatMessage): boolean {
289
309
  const providerUnitKey = typeof message.providerUnitKey === 'string' ? message.providerUnitKey.trim() : '';
290
310
  const turnKey = typeof message._turnKey === 'string' ? message._turnKey.trim() : '';
291
- if (!providerUnitKey || !turnKey) return false;
311
+ if (!providerUnitKey) return false;
312
+ // (A2.3) v2 stamped identity is producer-owned and globally stable; trust it
313
+ // unconditionally. Producers may omit _turnKey (the daemon recomputes it
314
+ // from the current ordering), so do not require turnKey for v2 messages.
315
+ if (providerUnitKey.startsWith('v2:') || providerUnitKey.startsWith('v2-pty:')) {
316
+ return true;
317
+ }
318
+ // v1 identity always required both keys to be present.
319
+ if (!turnKey) return false;
292
320
  if (providerType === 'hermes-cli' && sessionId) {
293
321
  return providerUnitKey.startsWith(`${providerType}:native:${sessionId}:`)
294
322
  && turnKey.startsWith(`${providerType}:native-turn:${sessionId}:`);
@@ -323,6 +351,18 @@ function normalizeNativeHistoryMessages(providerType: string, messages: ChatMess
323
351
  const meta = message.meta && typeof message.meta === 'object' ? message.meta as Record<string, unknown> : undefined;
324
352
  const isSystemSessionStart = role === 'system' || kind === 'system' || kind === 'session_start';
325
353
  const isActivity = role === 'assistant' && (kind === 'tool' || kind === 'terminal' || kind === 'thought');
354
+ // (A2.3) sequence emit. Producer-supplied wins (v2-stamped messages
355
+ // bring their own monotonic sequence); otherwise derive from
356
+ // receivedAt/timestamp; otherwise positional. Always present on the
357
+ // output so consumers (ChatSourceMachine) have a stable ordering key.
358
+ const existingSequence = typeof (message as any).sequence === 'number'
359
+ && Number.isFinite((message as any).sequence)
360
+ ? (message as any).sequence
361
+ : null;
362
+ const tsCandidate = Number(message.receivedAt || message.timestamp || 0);
363
+ const sequence = existingSequence !== null
364
+ ? existingSequence
365
+ : (tsCandidate > 0 ? tsCandidate : index);
326
366
  return {
327
367
  ...message,
328
368
  role: role === 'human' ? 'user' : (role || 'assistant'),
@@ -332,6 +372,7 @@ function normalizeNativeHistoryMessages(providerType: string, messages: ChatMess
332
372
  && preserveNativeIdentity
333
373
  ? message.bubbleId.trim()
334
374
  : `bubble:${providerUnitKey}`,
375
+ sequence,
335
376
  _turnKey: preserveNativeIdentity
336
377
  ? existingTurnKey
337
378
  : `${providerType}:native-turn:${nativeIdentitySessionId || 'workspace'}:${turnIndex}`,
@@ -420,28 +461,336 @@ function buildCliMessageSourceProvenance(args: {
420
461
  };
421
462
  }
422
463
 
423
- function buildNativeHistoryFallbackReason(args: {
464
+ /**
465
+ * Map a ChatSourceMachine transition cause back to the v1 messageSource
466
+ * `fallbackReason` vocabulary so legacy consumers (web-cloud, tests, mesh
467
+ * debug bundles) keep parsing strings they already know. A3 replaces the
468
+ * caller surface with stateTransition/lockState, after which this map can be
469
+ * deleted.
470
+ *
471
+ * Returns undefined when the cause does not correspond to a fallback (i.e.
472
+ * the source is native-history and there is nothing to explain).
473
+ */
474
+ function causeToLegacyFallbackReason(
475
+ cause: ChatSourceTransitionCause,
476
+ selected: 'native-history' | 'pty-parser',
477
+ extraDetail?: { unavailableReason?: string; nativeSource?: string },
478
+ ): string | undefined {
479
+ if (selected === 'native-history') return undefined;
480
+ switch (cause) {
481
+ case 'initial':
482
+ return 'native_history_not_checked';
483
+ case 'native_progressed':
484
+ // Selected pty-parser despite a progressed observation — that
485
+ // means we held PtyOnly stickily (peak unmet or non-superset).
486
+ return 'native_history_not_selected';
487
+ case 'native_regressed_shrunk':
488
+ return 'native_history_empty';
489
+ case 'native_regressed_unsafe_mapping':
490
+ return 'native_history_not_safely_mapped';
491
+ case 'native_regressed_coverage_partial':
492
+ return 'native_history_partial';
493
+ case 'native_regressed_coverage_unavailable':
494
+ return 'native_history_unavailable';
495
+ case 'native_unavailable_read_error':
496
+ return extraDetail?.unavailableReason
497
+ ? `native_history_unavailable:${extraDetail.unavailableReason}`
498
+ : 'native_history_unavailable';
499
+ case 'native_unavailable_provider_unsupported':
500
+ return 'provider_native_transcript_not_supported';
501
+ case 'native_unavailable_empty':
502
+ return 'native_history_empty';
503
+ case 'native_unavailable_not_native_source':
504
+ return extraDetail?.nativeSource
505
+ ? `native_history_source_${extraDetail.nativeSource}`
506
+ : 'native_history_unavailable';
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Translate a native-history fetch result + provider/adapter context into a
512
+ * ChatSourceObservation and drive ChatSourceRegistry. Returns the decision
513
+ * together with the legacy messageSource payload so call sites can produce
514
+ * a v1-compatible response without duplicating the registry plumbing.
515
+ *
516
+ * This is the replacement for the 300-line if-ladder that previously lived
517
+ * inline in handleReadChat. It is intentionally split out for two reasons:
518
+ * (1) we will call it from two places (CLI adapter branch + history-only
519
+ * branch) instead of duplicating the ladder, (2) tests can drive it with
520
+ * synthetic native-history results to verify the cause→fallbackReason
521
+ * mapping without booting the whole readChat pipeline.
522
+ */
523
+ function decideCliReadChatSource(args: {
424
524
  providerType: string;
425
525
  provider?: ProviderModule;
426
- nativeSource?: string;
427
- nativeHistoryCoverage?: string;
428
- unavailableReason?: string;
429
- nativeMessageCount: number;
526
+ sessionId: string;
527
+ nativeHistoryResult: any | null;
528
+ nativeHistoryError?: unknown;
430
529
  safeMapping: boolean;
431
- freshEnough: boolean;
432
- }): string {
433
- if (!supportsCliNativeTranscript(args.providerType, args.provider)) return 'provider_native_transcript_not_supported';
434
- if (args.unavailableReason) return `native_history_unavailable:${args.unavailableReason}`;
435
- if (args.nativeSource === 'native-unavailable') return 'native_history_unavailable';
436
- if (args.nativeHistoryCoverage === 'partial') return 'native_history_partial';
437
- if (args.nativeHistoryCoverage === 'unavailable') return 'native_history_unavailable';
438
- if (args.nativeSource && args.nativeSource !== 'provider-native') return `native_history_source_${args.nativeSource}`;
439
- if (args.nativeMessageCount <= 0) return 'native_history_empty';
440
- if (!args.safeMapping) return 'native_history_not_safely_mapped';
441
- if (!args.freshEnough) return 'native_history_stale';
442
- return 'native_history_not_selected';
530
+ sessionWorkspace?: string;
531
+ intendedWorkspace?: string;
532
+ ptyMessages: ChatMessage[];
533
+ ptyStatusApprovalOnly: boolean;
534
+ }): {
535
+ decision: ChatSourceDecision;
536
+ messageSource: Record<string, unknown>;
537
+ nativeMessages: ChatMessage[];
538
+ nativeSelected: boolean;
539
+ } {
540
+ const supportsNative = supportsCliNativeTranscript(args.providerType, args.provider);
541
+ const observation = buildObservationForCli(args, supportsNative);
542
+ const sessionKey = chatSourceSessionKey(args.providerType, args.sessionId);
543
+ const decision = CHAT_SOURCE_REGISTRY.observe(sessionKey, observation);
544
+
545
+ const nativeMessages: ChatMessage[] = observation.kind === 'native_present'
546
+ ? extractNativeMessagesFromResult(args.providerType, args.nativeHistoryResult)
547
+ : [];
548
+
549
+ const nativeSource = typeof args.nativeHistoryResult?.source === 'string'
550
+ ? args.nativeHistoryResult.source
551
+ : undefined;
552
+ const sourcePath = typeof args.nativeHistoryResult?.sourcePath === 'string'
553
+ ? args.nativeHistoryResult.sourcePath
554
+ : undefined;
555
+ const sourceMtimeMs = typeof args.nativeHistoryResult?.sourceMtimeMs === 'number'
556
+ ? args.nativeHistoryResult.sourceMtimeMs
557
+ : undefined;
558
+ const coverageHint = typeof args.nativeHistoryResult?.nativeHistoryCoverage === 'string'
559
+ ? args.nativeHistoryResult.nativeHistoryCoverage
560
+ : undefined;
561
+ const partialReason = typeof args.nativeHistoryResult?.partialReason === 'string'
562
+ ? args.nativeHistoryResult.partialReason
563
+ : undefined;
564
+ const unavailableReason = typeof args.nativeHistoryResult?.unavailableReason === 'string'
565
+ ? args.nativeHistoryResult.unavailableReason
566
+ : args.nativeHistoryError
567
+ ? `error:${(args.nativeHistoryError as any)?.message || String(args.nativeHistoryError)}`
568
+ : undefined;
569
+ const nativeHandle = typeof args.nativeHistoryResult?.providerSessionId === 'string'
570
+ ? args.nativeHistoryResult.providerSessionId
571
+ : undefined;
572
+ const transcriptWorkspace = typeof args.nativeHistoryResult?.workspace === 'string'
573
+ ? args.nativeHistoryResult.workspace
574
+ : nativeMessages.map((m: any) => typeof m?.workspace === 'string' ? m.workspace.trim() : '').find(Boolean);
575
+
576
+ const fallbackReason = causeToLegacyFallbackReason(decision.transition.cause, decision.selected, {
577
+ unavailableReason,
578
+ nativeSource: nativeSource && nativeSource !== 'provider-native' ? nativeSource : undefined,
579
+ });
580
+
581
+ // ptyStatusApprovalOnly: when the machine selected native-history we
582
+ // suppress PTY content so the dashboard does not double-show messages
583
+ // already in the native transcript. When the machine selected
584
+ // pty-parser, PTY is the authoritative source — do NOT suppress it.
585
+ // Callers used to hard-code this to `nativeSelected first = true` which
586
+ // suppressed PTY content even when native was empty/unavailable, leaving
587
+ // the dashboard with zero visible messages (the codex generating/waiting
588
+ // approval stuck state). Trust the machine here, not the caller hint.
589
+ const ptyStatusApprovalOnly = decision.selected === 'native-history'
590
+ ? true
591
+ : args.ptyStatusApprovalOnly;
592
+
593
+ const messageSource = buildCliMessageSourceProvenance({
594
+ selected: decision.selected,
595
+ provider: args.providerType,
596
+ nativeHandle,
597
+ sessionWorkspace: args.sessionWorkspace,
598
+ intendedWorkspace: args.intendedWorkspace,
599
+ transcriptWorkspace,
600
+ fallbackReason,
601
+ nativeSource,
602
+ sourcePath,
603
+ sourceMtimeMs,
604
+ nativeHistoryCoverage: coverageHint,
605
+ partialReason,
606
+ unavailableReason,
607
+ nativeMessages,
608
+ ptyMessages: args.ptyMessages,
609
+ returnedMessages: decision.selected === 'native-history' ? nativeMessages : args.ptyMessages,
610
+ safeMapping: args.safeMapping,
611
+ // freshEnough is a v1 concept the machine does not model directly.
612
+ // We surface lockState.locked here so v1 consumers reading
613
+ // staleness.freshEnough still get a meaningful boolean.
614
+ freshEnough: decision.lockState.locked,
615
+ ptyStatusApprovalOnly,
616
+ });
617
+
618
+ return {
619
+ decision,
620
+ messageSource,
621
+ nativeMessages,
622
+ nativeSelected: decision.selected === 'native-history',
623
+ };
624
+ }
625
+
626
+ function buildObservationForCli(
627
+ args: {
628
+ providerType: string;
629
+ sessionId: string;
630
+ nativeHistoryResult: any | null;
631
+ nativeHistoryError?: unknown;
632
+ safeMapping: boolean;
633
+ },
634
+ supportsNative: boolean,
635
+ ): ChatSourceObservation {
636
+ if (!supportsNative) {
637
+ return { kind: 'native_unavailable', reason: 'provider_not_supported' };
638
+ }
639
+ if (args.nativeHistoryError) {
640
+ return { kind: 'native_unavailable', reason: 'read_error' };
641
+ }
642
+ const result = args.nativeHistoryResult;
643
+ if (!result || typeof result !== 'object') {
644
+ return { kind: 'native_unavailable', reason: 'read_error' };
645
+ }
646
+ const source = typeof result.source === 'string' ? result.source : '';
647
+ if (source && source !== 'provider-native') {
648
+ // 'native-unavailable' or other producer-side declined source.
649
+ return { kind: 'native_unavailable', reason: source === 'native-unavailable' ? 'empty' : 'not_native_source' };
650
+ }
651
+ const messages = Array.isArray(result.messages) ? result.messages : [];
652
+ if (messages.length === 0) {
653
+ return { kind: 'native_unavailable', reason: 'empty' };
654
+ }
655
+ const coverage = typeof result.nativeHistoryCoverage === 'string'
656
+ ? result.nativeHistoryCoverage
657
+ : 'tail';
658
+ if (coverage === 'unavailable') {
659
+ return { kind: 'native_unavailable', reason: 'coverage_unavailable' };
660
+ }
661
+ return buildV1NativePresentObservation({
662
+ providerType: args.providerType,
663
+ sessionId: args.sessionId,
664
+ messages,
665
+ coverage: coverage === 'full' || coverage === 'tail' || coverage === 'current-turn' || coverage === 'partial'
666
+ ? coverage
667
+ : 'tail',
668
+ safeMapping: args.safeMapping,
669
+ });
670
+ }
671
+
672
+ function extractNativeMessagesFromResult(providerType: string, result: any): ChatMessage[] {
673
+ if (!result || !Array.isArray(result.messages)) return [];
674
+ return normalizeNativeHistoryMessages(
675
+ providerType,
676
+ result.messages as ChatMessage[],
677
+ typeof result.providerSessionId === 'string' ? result.providerSessionId : undefined,
678
+ );
679
+ }
680
+
681
+ /**
682
+ * ptyStatusApprovalOnly is true when the daemon should treat PTY content as
683
+ * status/approval signal only (not as chat messages). v1 set this to `true`
684
+ * whenever native-history was selected as the source, and `false` otherwise.
685
+ * The machine equivalent: when native is the source we want PTY suppressed.
686
+ */
687
+ function primaryPtyApprovalOnlyFor(_cliType: string, nativeSelected: boolean): boolean {
688
+ return nativeSelected;
689
+ }
690
+
691
+ /**
692
+ * Codex-only unsafe-native fallback: when the primary native fetch produced
693
+ * unsafe-mapping data, v1 attempted to recover by reading exact runtime
694
+ * mirror messages, runtime input ACK messages, or by trusting the current-
695
+ * runtime PTY when safely attributed. None of this is the machine's
696
+ * responsibility — the machine already decided pty-parser. This helper
697
+ * preserves the daemon-side message selection and annotates messageSource.
698
+ */
699
+ function applyUnsafeNativeDaemonFallback(args: {
700
+ providerType: string;
701
+ adapter: CliAdapter;
702
+ helpers: CommandHelpers;
703
+ readChatArgs: any;
704
+ sessionWorkspace?: string;
705
+ intendedWorkspace?: string;
706
+ ptyMessages: ChatMessage[];
707
+ nativeHistoryLimit: number;
708
+ provider?: ProviderModule;
709
+ messageSourceRef: { set(value: Record<string, unknown>): void; get(): Record<string, unknown> };
710
+ apply(selection: {
711
+ messages: ChatMessage[];
712
+ transcriptAuthority?: 'provider' | 'daemon';
713
+ coverage?: 'full' | 'tail' | 'current-turn';
714
+ status?: string;
715
+ }): void;
716
+ activeModal: unknown;
717
+ returnedStatus: string;
718
+ coverage?: 'full' | 'tail' | 'current-turn';
719
+ }): void {
720
+ if (args.adapter.cliType !== 'codex-cli') {
721
+ // Only codex-cli had v1 daemon mirror recovery. Other providers skip.
722
+ return;
723
+ }
724
+ const ms = args.messageSourceRef.get();
725
+ const fallbackReason = typeof ms.fallbackReason === 'string' ? ms.fallbackReason : '';
726
+ if (!isUnsafeNativeTranscriptFallback(fallbackReason)) {
727
+ return;
728
+ }
729
+ const safeCurrentRuntimePtyMessages = isCurrentRuntimePtySafelyAttributed({
730
+ adapter: args.adapter,
731
+ helpers: args.helpers,
732
+ readChatArgs: args.readChatArgs,
733
+ sessionWorkspace: args.sessionWorkspace,
734
+ intendedWorkspace: args.intendedWorkspace,
735
+ ptyMessages: args.ptyMessages,
736
+ });
737
+ if (safeCurrentRuntimePtyMessages) {
738
+ args.apply({
739
+ messages: args.ptyMessages,
740
+ transcriptAuthority: 'daemon',
741
+ coverage: args.coverage || 'current-turn',
742
+ status: args.returnedStatus,
743
+ });
744
+ const next = { ...ms, selectedDaemonSource: 'current-runtime-pty', transcriptAuthority: 'daemon', runtimeMappingSafe: true };
745
+ args.messageSourceRef.set(next);
746
+ return;
747
+ }
748
+ const safeRuntimeAckMessages = selectRuntimeInputAckMessages(args.ptyMessages);
749
+ if (safeRuntimeAckMessages.length > 0) {
750
+ args.apply({
751
+ messages: safeRuntimeAckMessages,
752
+ transcriptAuthority: 'daemon',
753
+ coverage: 'tail',
754
+ status: coerceUnsafeNativeFallbackStatus(args.returnedStatus, args.activeModal),
755
+ });
756
+ const next = { ...ms, ptyStatusApprovalOnly: true };
757
+ args.messageSourceRef.set(next);
758
+ return;
759
+ }
760
+ const exactRuntimeMirrorMessages = readExactRuntimeMirrorMessages({
761
+ providerType: args.providerType,
762
+ targetSessionId: typeof args.readChatArgs?.targetSessionId === 'string' ? args.readChatArgs.targetSessionId : undefined,
763
+ currentSessionId: typeof (args.helpers.currentSession as any)?.sessionId === 'string' ? (args.helpers.currentSession as any).sessionId : undefined,
764
+ tailLimit: args.nativeHistoryLimit,
765
+ historyBehavior: args.provider?.historyBehavior,
766
+ });
767
+ if (exactRuntimeMirrorMessages.length > 0) {
768
+ args.apply({
769
+ messages: exactRuntimeMirrorMessages,
770
+ transcriptAuthority: 'daemon',
771
+ coverage: 'tail',
772
+ status: coerceUnsafeNativeFallbackStatus(args.returnedStatus, args.activeModal),
773
+ });
774
+ const next = { ...ms, selectedDaemonSource: 'exact-runtime-mirror', transcriptAuthority: 'daemon', ptyStatusApprovalOnly: true };
775
+ args.messageSourceRef.set(next);
776
+ return;
777
+ }
778
+ // No daemon mirror available — keep PTY messages as-is (still pty-parser
779
+ // selection); just coerce status for waiting_approval consistency.
780
+ args.apply({
781
+ messages: args.ptyMessages,
782
+ coverage: args.coverage,
783
+ status: coerceUnsafeNativeFallbackStatus(args.returnedStatus, args.activeModal),
784
+ });
785
+ const next = { ...ms, ptyStatusApprovalOnly: true };
786
+ args.messageSourceRef.set(next);
443
787
  }
444
788
 
789
+ // (A2.2) buildNativeHistoryFallbackReason removed. ChatSourceMachine emits a
790
+ // ChatSourceTransitionCause; causeToLegacyFallbackReason maps it back to the
791
+ // v1 vocabulary for response compatibility. A3 deletes the v1 vocabulary
792
+ // entirely and surfaces stateTransition/lockState directly.
793
+
445
794
  function isUnsafeNativeTranscriptFallback(reason?: string): boolean {
446
795
  const value = String(reason || '').trim();
447
796
  return value.startsWith('native_history_unavailable')
@@ -551,8 +900,21 @@ function isCurrentRuntimePtySafelyAttributed(args: {
551
900
  }
552
901
 
553
902
  function supportsCliNativeTranscript(providerType: string, provider?: ProviderModule): boolean {
554
- if (CLI_NATIVE_TRANSCRIPT_PROVIDERS.has(providerType)) return true;
555
- return provider?.category === 'cli' && isNativeSourceCanonicalHistory(provider?.canonicalHistory);
903
+ // Preferred path: the provider module declares canonicalHistory in its
904
+ // provider.json. We trust that declaration regardless of the legacy
905
+ // allow-list. A2 will additionally require canonicalHistory.contractVersion
906
+ // to be a supported value (transcript-v2.ts).
907
+ if (provider?.category === 'cli' && isNativeSourceCanonicalHistory(provider?.canonicalHistory)) {
908
+ return true;
909
+ }
910
+ // Last-resort fallback for early call sites where the provider module is
911
+ // not yet loaded. Warn once per provider type so this dependency is visible
912
+ // and can be removed in A2.
913
+ if (CLI_NATIVE_TRANSCRIPT_PROVIDERS.has(providerType)) {
914
+ warnLegacyNativeAllowlistHit(providerType);
915
+ return true;
916
+ }
917
+ return false;
556
918
  }
557
919
 
558
920
  function getComparableVisibleText(message: ChatMessage | undefined): string {
@@ -702,18 +1064,12 @@ function readLiveCodexWorkspaceNativeHistory(agentStr: string, args: {
702
1064
  return { ...(history as any), lookup: 'workspace' };
703
1065
  }
704
1066
 
705
- function isNativeHistoryFreshEnough(args: {
706
- sourceMtimeMs?: number;
707
- nativeMessages: ChatMessage[];
708
- ptyMessages: ChatMessage[];
709
- }): boolean {
710
- const nativeNewest = getMessageNewestReceivedAt(args.nativeMessages);
711
- const ptyNewest = getMessageNewestReceivedAt(args.ptyMessages);
712
- if (nativeNewest > 0 && nativeNewest >= ptyNewest) return true;
713
- const sourceMtimeMs = Number(args.sourceMtimeMs || 0);
714
- if (sourceMtimeMs > 0 && Date.now() - sourceMtimeMs <= CLI_NATIVE_HISTORY_FRESH_MS) return true;
715
- return ptyNewest === 0 && nativeNewest > 0;
716
- }
1067
+ // (A2.2) isNativeHistoryFreshEnough removed. The v1 freshness comparison
1068
+ // (native_newest vs pty_newest with a 5-minute mtime grace window) was the
1069
+ // direct cause of the plipping behaviour: PTY arrived every turn so native
1070
+ // looked stale by default. ChatSourceMachine never compares native vs PTY
1071
+ // freshness — the lock holds across arbitrary PTY arrival. See
1072
+ // chat/source-machine.ts for the new semantics.
717
1073
 
718
1074
  function shouldPreserveReadChatPayloadField(key: string): boolean {
719
1075
  return key === 'messageSource' || key === 'transcriptProvenance';
@@ -799,6 +1155,15 @@ function normalizeReadChatCommandStatus(status: unknown, activeModal: unknown):
799
1155
  case 'disconnected':
800
1156
  case 'not_monitored':
801
1157
  return 'error';
1158
+ case 'waiting_approval':
1159
+ // The contract validator requires activeModal+buttons whenever
1160
+ // status is waiting_approval. If a producer/coercer set this
1161
+ // status without staging the modal yet (a race we hit with
1162
+ // codex-cli during tool approval setup), downgrade to a
1163
+ // generating-like status so readChat still returns successfully.
1164
+ // The next poll will pick up the modal once the provider has
1165
+ // emitted it.
1166
+ return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'generating';
802
1167
  default:
803
1168
  return raw;
804
1169
  }
@@ -867,6 +1232,64 @@ function finalizeStreamingMessagesWhenIdle(messages: ChatMessage[], status: stri
867
1232
  });
868
1233
  }
869
1234
 
1235
+ /**
1236
+ * Collapse adjacent PTY messages whose canonical (whitespace-stripped)
1237
+ * content is identical, OR whose turn key + role/kind match.
1238
+ *
1239
+ * The PTY parser of some providers (hermes-cli observed in the wild)
1240
+ * emits the same logical assistant turn twice when the terminal re-wraps
1241
+ * the text at a different column. The two emissions differ in newline
1242
+ * position — and sometimes in a single inserted space next to punctuation
1243
+ * (e.g. `(수정 2개), upstream` vs `(수정 2개 ), upstream`), so a simple
1244
+ * `\s+ -> ' '` normalize cannot collapse them.
1245
+ *
1246
+ * Strategy:
1247
+ * 1. If both messages carry the same _turnKey + role + kind, they are
1248
+ * the same logical turn by construction. Collapse.
1249
+ * 2. Otherwise compare with all whitespace stripped — wrap variants
1250
+ * collapse to identical strings.
1251
+ *
1252
+ * Native-history paths run through pageHistoryRecords and already
1253
+ * collapse on a normalized signature; this helper is the PTY equivalent
1254
+ * the readChat sync path was missing.
1255
+ */
1256
+ function collapseAdjacentDuplicateChatMessages(messages: ChatMessage[]): ChatMessage[] {
1257
+ if (!Array.isArray(messages) || messages.length <= 1) return messages;
1258
+ const result: ChatMessage[] = [];
1259
+ let prevRoleKind = '';
1260
+ let prevStripped = '';
1261
+ for (const message of messages) {
1262
+ const role = typeof message.role === 'string' ? message.role : '';
1263
+ const kind = typeof message.kind === 'string' ? message.kind : 'standard';
1264
+ const content = typeof message.content === 'string'
1265
+ ? message.content
1266
+ : (Array.isArray(message.content) ? message.content.map((p: any) => typeof p?.text === 'string' ? p.text : '').join('') : '');
1267
+ const strippedContent = content.replace(/\s+/g, '');
1268
+ // Empty content or system messages are passed through untouched.
1269
+ if (!strippedContent || role === 'system') {
1270
+ result.push(message);
1271
+ prevRoleKind = '';
1272
+ prevStripped = '';
1273
+ continue;
1274
+ }
1275
+ const roleKind = `${role}:${kind}`;
1276
+ const sameStripped = strippedContent === prevStripped && roleKind === prevRoleKind;
1277
+ if (result.length > 0 && sameStripped) {
1278
+ // Adjacent duplicate after stripping all whitespace. Keep the
1279
+ // *later* copy because PTY's last emission usually has the most
1280
+ // complete formatting.
1281
+ result[result.length - 1] = message;
1282
+ prevRoleKind = roleKind;
1283
+ prevStripped = strippedContent;
1284
+ continue;
1285
+ }
1286
+ result.push(message);
1287
+ prevRoleKind = roleKind;
1288
+ prevStripped = strippedContent;
1289
+ }
1290
+ return result;
1291
+ }
1292
+
870
1293
  function buildReadChatCommandResult(payload: Record<string, any>, args: any): CommandResult {
871
1294
  let validatedPayload: Record<string, any>;
872
1295
  const debugReadChat = payload?.debugReadChat && typeof payload.debugReadChat === 'object'
@@ -1477,7 +1900,9 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1477
1900
  const activeModal = parsedRecord.activeModal ?? parsedRecord.modal ?? null;
1478
1901
  const returnedStatus = normalizeCliReadChatStatus(parsedRecord.status, activeModal, adapter, adapterStatus, parsedRecord.messages);
1479
1902
  const runtimeMessageMerger = getTargetInstance(h, args) as RuntimeChatMessageMerger | null;
1480
- const parsedMessages = finalizeStreamingMessagesWhenIdle(parsedRecord.messages as ChatMessage[], returnedStatus);
1903
+ const parsedMessages = collapseAdjacentDuplicateChatMessages(
1904
+ finalizeStreamingMessagesWhenIdle(parsedRecord.messages as ChatMessage[], returnedStatus),
1905
+ );
1481
1906
  const returnedMessages = runtimeMessageMerger?.category === 'cli'
1482
1907
  && runtimeMessageMerger.type === adapter.cliType
1483
1908
  && typeof runtimeMessageMerger.mergeRuntimeChatMessages === 'function'
@@ -1496,35 +1921,57 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1496
1921
  ? adapter.workingDir
1497
1922
  : undefined;
1498
1923
  const intendedWorkspace = typeof args?.workspace === 'string' ? args.workspace : undefined;
1499
- let messageSource = buildCliMessageSourceProvenance({
1500
- selected: 'pty-parser',
1501
- provider: adapter.cliType,
1502
- fallbackReason: supportsCliNativeTranscript(providerType, provider) ? 'native_history_not_checked' : 'provider_native_transcript_not_supported',
1503
- sessionWorkspace,
1504
- intendedWorkspace,
1505
- ptyMessages: returnedMessages,
1506
- returnedMessages,
1507
- ptyStatusApprovalOnly: false,
1508
- });
1924
+ // ───────────────────────────────────────────────────────────
1925
+ // Chat source decision via ChatSourceMachine (A2 big-bang).
1926
+ // Replaces the ~300-line if-ladder that mixed source decision
1927
+ // with native fetch, anchor mutation, and runtime mirror
1928
+ // selection. The machine decides only between native-history
1929
+ // and pty-parser; downstream selection of which message array
1930
+ // to surface stays here.
1931
+ //
1932
+ // Behavioural changes vs v1:
1933
+ // - No more nativeHistoryAnchoredAt mutation on the adapter.
1934
+ // Lock state lives in CHAT_SOURCE_REGISTRY keyed by
1935
+ // (providerType, sessionId).
1936
+ // - No PTY-vs-native freshness comparison. The lock holds
1937
+ // across arbitrary PTY arrival; only native regression /
1938
+ // unavailability unlocks. This is the plipping fix.
1939
+ // - 6 trigger strings (native_history_partial / _stale /
1940
+ // _not_safely_mapped / _empty / _error / _unavailable)
1941
+ // collapse to 3 events with diagnostic causes preserved
1942
+ // and mapped back to legacy fallbackReason strings for
1943
+ // response compatibility.
1944
+ // - Codex live-workspace native probe and unsafe-native
1945
+ // daemon mirror fallbacks are preserved as additional
1946
+ // input rounds to the machine; they were never the source
1947
+ // decision itself, they were retries.
1948
+ // ───────────────────────────────────────────────────────────
1949
+
1950
+ const supportsNative = supportsCliNativeTranscript(providerType, provider)
1951
+ && isNativeSourceCanonicalHistory(provider?.canonicalHistory);
1952
+ const agentStr = provider?.type || args?.agentType || getCurrentProviderType(h, adapter.cliType);
1953
+ const workspace = sessionWorkspace;
1954
+ const nativeHistoryLimit = Math.max(
1955
+ normalizeReadChatTailLimit(args) || 0,
1956
+ returnedMessages.length,
1957
+ 200,
1958
+ );
1959
+ const nativeHistorySessionId = supportsNative
1960
+ ? resolveCliNativeHistorySessionId(args, historySessionId, providerSessionId)
1961
+ : undefined;
1962
+ const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
1963
+ const exactNativeHistoryScope = Boolean(
1964
+ (typeof args?.historySessionId === 'string' && args.historySessionId.trim())
1965
+ || (typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
1966
+ || providerSessionId
1967
+ || (nativeHistorySessionId && nativeHistorySessionId !== targetSessionId)
1968
+ || ((h.currentSession as any)?.sessionId === args?.targetSessionId && typeof (h.currentSession as any)?.providerSessionId === 'string' && (h.currentSession as any).providerSessionId.trim())
1969
+ );
1509
1970
 
1510
- if (supportsCliNativeTranscript(providerType, provider) && isNativeSourceCanonicalHistory(provider?.canonicalHistory)) {
1511
- const agentStr = provider?.type || args?.agentType || getCurrentProviderType(h, adapter.cliType);
1512
- const workspace = sessionWorkspace;
1513
- const nativeHistoryLimit = Math.max(
1514
- normalizeReadChatTailLimit(args) || 0,
1515
- returnedMessages.length,
1516
- 200,
1517
- );
1518
- const nativeHistorySessionId = resolveCliNativeHistorySessionId(args, historySessionId, providerSessionId);
1519
- const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
1520
- const exactNativeHistoryScope = Boolean(
1521
- (typeof args?.historySessionId === 'string' && args.historySessionId.trim())
1522
- || (typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
1523
- || providerSessionId
1524
- || (nativeHistorySessionId && nativeHistorySessionId !== targetSessionId)
1525
- || ((h.currentSession as any)?.sessionId === args?.targetSessionId && typeof (h.currentSession as any)?.providerSessionId === 'string' && (h.currentSession as any).providerSessionId.trim())
1526
- );
1527
- let nativeHistory: (ReturnType<typeof readProviderChatHistory> & { lookup?: 'session' | 'workspace' }) | null = null;
1971
+ // 1. Fetch native history (or skip if provider does not support it).
1972
+ let nativeHistory: any | null = null;
1973
+ let nativeHistoryError: unknown | undefined;
1974
+ if (supportsNative) {
1528
1975
  try {
1529
1976
  nativeHistory = readCliProviderNativeHistory(agentStr, {
1530
1977
  canonicalHistory: provider?.canonicalHistory,
@@ -1538,268 +1985,182 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1538
1985
  excludeInProgressTurn: returnedStatus === 'waiting_approval',
1539
1986
  });
1540
1987
  } catch (error: any) {
1541
- const fallbackReason = `native_history_error:${error?.message || String(error)}`;
1542
- messageSource = buildCliMessageSourceProvenance({
1543
- selected: 'pty-parser',
1544
- provider: adapter.cliType,
1545
- fallbackReason,
1546
- sessionWorkspace,
1547
- intendedWorkspace,
1548
- ptyMessages: returnedMessages,
1549
- returnedMessages,
1550
- ptyStatusApprovalOnly: false,
1551
- });
1988
+ nativeHistoryError = error;
1552
1989
  nativeHistory = null;
1553
1990
  }
1991
+ }
1554
1992
 
1555
- if (nativeHistory) {
1556
- const nativeMessages = Array.isArray((nativeHistory as any).messages)
1557
- ? normalizeNativeHistoryMessages(agentStr, (nativeHistory as any).messages as ChatMessage[], (nativeHistory as any)?.providerSessionId)
1558
- : [];
1559
- const historyProviderSessionId = typeof (nativeHistory as any)?.providerSessionId === 'string'
1560
- ? (nativeHistory as any).providerSessionId
1561
- : readHistorySessionIdFromMessages(nativeMessages) || nativeHistorySessionId || historySessionId;
1562
- const nativeHistoryCoverage = typeof (nativeHistory as any)?.nativeHistoryCoverage === 'string'
1563
- ? (nativeHistory as any).nativeHistoryCoverage
1564
- : undefined;
1565
- const partialReason = typeof (nativeHistory as any)?.partialReason === 'string'
1566
- ? (nativeHistory as any).partialReason
1567
- : undefined;
1568
- const unavailableReason = typeof (nativeHistory as any)?.unavailableReason === 'string'
1569
- ? (nativeHistory as any).unavailableReason
1570
- : undefined;
1571
- const lookup = (nativeHistory as any).lookup === 'workspace' ? 'workspace' : 'session';
1572
- const transcriptWorkspace = typeof (nativeHistory as any)?.workspace === 'string'
1573
- ? (nativeHistory as any).workspace
1574
- : nativeMessages.map((message: any) => typeof message?.workspace === 'string' ? message.workspace.trim() : '').find(Boolean);
1575
- const nativeHistorySessionForMapping = adapter.cliType === 'antigravity-cli'
1576
- && historyProviderSessionId
1577
- && nativeHistorySessionId
1578
- && historyProviderSessionId !== nativeHistorySessionId
1579
- ? undefined
1580
- : nativeHistorySessionId;
1581
- const safeMapping = hasSafeNativeHistoryMapping({
1582
- historySessionId: lookup === 'workspace' ? undefined : nativeHistorySessionForMapping,
1583
- providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId || providerSessionId,
1993
+ // 2. Compute safeMapping with the same rules the v1 code used so the
1994
+ // machine sees the same observation it always would have.
1995
+ const nativeMessages: ChatMessage[] = nativeHistory && Array.isArray(nativeHistory.messages)
1996
+ ? normalizeNativeHistoryMessages(agentStr, nativeHistory.messages as ChatMessage[], nativeHistory.providerSessionId)
1997
+ : [];
1998
+ const historyProviderSessionId = typeof nativeHistory?.providerSessionId === 'string'
1999
+ ? nativeHistory.providerSessionId
2000
+ : readHistorySessionIdFromMessages(nativeMessages) || nativeHistorySessionId || historySessionId;
2001
+ const lookup = nativeHistory?.lookup === 'workspace' ? 'workspace' : 'session';
2002
+ const nativeHistorySessionForMapping = adapter.cliType === 'antigravity-cli'
2003
+ && historyProviderSessionId
2004
+ && nativeHistorySessionId
2005
+ && historyProviderSessionId !== nativeHistorySessionId
2006
+ ? undefined
2007
+ : nativeHistorySessionId;
2008
+ const safeMapping = supportsNative && nativeHistory
2009
+ ? hasSafeNativeHistoryMapping({
2010
+ historySessionId: lookup === 'workspace' ? undefined : nativeHistorySessionForMapping,
2011
+ providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId || providerSessionId,
2012
+ workspace,
2013
+ nativeMessages,
2014
+ ptyMessages: returnedMessages,
2015
+ requireWorkspaceContentOverlap: lookup === 'workspace' && !exactNativeHistoryScope,
2016
+ })
2017
+ : false;
2018
+
2019
+ // 3. Drive ChatSourceMachine — one observation per readChat call,
2020
+ // keyed by (providerType, sessionKey-for-this-call). targetSessionId
2021
+ // is the most specific session anchor we have; fall back to
2022
+ // historySessionId so we never leak state across distinct sessions.
2023
+ const machineSessionKey = String(
2024
+ args?.targetSessionId
2025
+ || providerSessionId
2026
+ || historySessionId
2027
+ || (h.currentSession as any)?.sessionId
2028
+ || ''
2029
+ );
2030
+ const primary = decideCliReadChatSource({
2031
+ providerType,
2032
+ provider,
2033
+ sessionId: machineSessionKey,
2034
+ nativeHistoryResult: nativeHistory,
2035
+ nativeHistoryError,
2036
+ safeMapping,
2037
+ sessionWorkspace,
2038
+ intendedWorkspace,
2039
+ ptyMessages: returnedMessages,
2040
+ // Start with PTY visible; decideCliReadChatSource flips this
2041
+ // to true when the machine actually selects native-history.
2042
+ ptyStatusApprovalOnly: false,
2043
+ });
2044
+ let messageSource: Record<string, unknown> = primary.messageSource;
2045
+
2046
+ if (primary.nativeSelected) {
2047
+ selectedMessages = finalizeStreamingMessagesWhenIdle(primary.nativeMessages, returnedStatus);
2048
+ selectedProviderSessionId = historyProviderSessionId || providerSessionId;
2049
+ selectedTranscriptAuthority = 'provider';
2050
+ selectedCoverage = nativeHistory?.hasMore ? 'tail' : 'full';
2051
+ } else if (supportsNative) {
2052
+ // Native not selected. Two preserved v1 fallbacks before settling
2053
+ // on PTY: (a) Codex-only live workspace native probe; (b) unsafe-
2054
+ // native daemon mirror selection. The machine sees each retry as
2055
+ // an additional observation.
2056
+ const liveCurrentRuntimePtySafe = isCurrentRuntimePtySafelyAttributed({
2057
+ adapter,
2058
+ helpers: h,
2059
+ readChatArgs: args,
2060
+ sessionWorkspace,
2061
+ intendedWorkspace,
2062
+ ptyMessages: returnedMessages,
2063
+ });
2064
+ const mayProbeLiveCodexWorkspaceNative = adapter.cliType === 'codex-cli'
2065
+ && liveCurrentRuntimePtySafe
2066
+ && !(typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
2067
+ && !(providerSessionId && providerSessionId.trim())
2068
+ && (!historyProviderSessionId || historyProviderSessionId === nativeHistorySessionId || historyProviderSessionId === historySessionId);
2069
+ const liveWorkspaceNativeHistory = mayProbeLiveCodexWorkspaceNative
2070
+ ? readLiveCodexWorkspaceNativeHistory(agentStr, {
2071
+ canonicalHistory: provider?.canonicalHistory,
1584
2072
  workspace,
1585
- nativeMessages,
2073
+ offset: 0,
2074
+ limit: nativeHistoryLimit,
2075
+ excludeRecentCount: 0,
2076
+ historyBehavior: provider?.historyBehavior,
2077
+ scripts: provider?.scripts as any,
2078
+ })
2079
+ : null;
2080
+ const liveWorkspaceNativeMessages = Array.isArray((liveWorkspaceNativeHistory as any)?.messages)
2081
+ ? normalizeNativeHistoryMessages(agentStr, (liveWorkspaceNativeHistory as any).messages as ChatMessage[], (liveWorkspaceNativeHistory as any)?.providerSessionId)
2082
+ : [];
2083
+ const liveWorkspaceNativeProviderSessionId = typeof (liveWorkspaceNativeHistory as any)?.providerSessionId === 'string'
2084
+ ? (liveWorkspaceNativeHistory as any).providerSessionId
2085
+ : readHistorySessionIdFromMessages(liveWorkspaceNativeMessages);
2086
+ const liveWorkspaceNativeSafeMapping = liveWorkspaceNativeMessages.length > 0
2087
+ && hasSafeNativeHistoryMapping({
2088
+ workspace,
2089
+ nativeMessages: liveWorkspaceNativeMessages,
1586
2090
  ptyMessages: returnedMessages,
1587
- requireWorkspaceContentOverlap: lookup === 'workspace' && !exactNativeHistoryScope,
2091
+ requireWorkspaceContentOverlap: true,
1588
2092
  });
1589
- const freshEnough = isNativeHistoryFreshEnough({
1590
- sourceMtimeMs: (nativeHistory as any).sourceMtimeMs,
1591
- nativeMessages,
2093
+ if (liveWorkspaceNativeHistory) {
2094
+ const liveDecision = decideCliReadChatSource({
2095
+ providerType,
2096
+ provider,
2097
+ // Distinct session key so a transient codex live-probe does not
2098
+ // clobber the primary session's lock. The machine treats this
2099
+ // as its own session; the primary session's state is untouched.
2100
+ sessionId: `${machineSessionKey}::live-workspace`,
2101
+ nativeHistoryResult: liveWorkspaceNativeHistory,
2102
+ safeMapping: liveWorkspaceNativeSafeMapping,
2103
+ sessionWorkspace,
2104
+ intendedWorkspace,
1592
2105
  ptyMessages: returnedMessages,
2106
+ ptyStatusApprovalOnly: true,
1593
2107
  });
1594
- const nativeUsableForChatMessages = (nativeHistory as any).source === 'provider-native'
1595
- && nativeMessages.length > 0
1596
- && nativeHistoryCoverage !== 'partial'
1597
- && nativeHistoryCoverage !== 'unavailable'
1598
- && safeMapping;
1599
- // Sticky native anchor: once native was confirmed for this session, keep using it
1600
- // even if freshEnough flips false due to PTY buffer activity.
1601
- const NATIVE_ANCHOR_TTL_MS = 30 * 60_000;
1602
- const nativeAnchoredAt = (adapter as any).nativeHistoryAnchoredAt ?? 0;
1603
- const nativeIsAnchored = nativeAnchoredAt > 0
1604
- && (Date.now() - nativeAnchoredAt) < NATIVE_ANCHOR_TTL_MS;
1605
- const allowStaleNativeChatMessages = (adapter.cliType === 'antigravity-cli' || nativeIsAnchored)
1606
- && nativeUsableForChatMessages;
1607
- if (nativeUsableForChatMessages && (freshEnough || allowStaleNativeChatMessages)) {
1608
- (adapter as any).nativeHistoryAnchoredAt = Date.now();
1609
- selectedMessages = finalizeStreamingMessagesWhenIdle(nativeMessages, returnedStatus);
1610
- selectedProviderSessionId = historyProviderSessionId || providerSessionId;
2108
+ if (liveDecision.nativeSelected) {
2109
+ selectedMessages = finalizeStreamingMessagesWhenIdle(liveDecision.nativeMessages, returnedStatus);
2110
+ selectedProviderSessionId = liveWorkspaceNativeProviderSessionId || providerSessionId;
1611
2111
  selectedTranscriptAuthority = 'provider';
1612
- selectedCoverage = (nativeHistory as any).hasMore ? 'tail' : 'full';
1613
- messageSource = buildCliMessageSourceProvenance({
1614
- selected: 'native-history',
1615
- provider: adapter.cliType,
1616
- nativeHandle: selectedProviderSessionId || nativeHistorySessionId || historySessionId,
1617
- sessionWorkspace,
1618
- intendedWorkspace,
1619
- transcriptWorkspace,
1620
- nativeSource: (nativeHistory as any).source,
1621
- sourcePath: (nativeHistory as any).sourcePath,
1622
- sourceMtimeMs: (nativeHistory as any).sourceMtimeMs,
1623
- nativeHistoryCoverage,
1624
- partialReason,
1625
- unavailableReason,
1626
- nativeMessages,
1627
- ptyMessages: returnedMessages,
1628
- returnedMessages: selectedMessages,
1629
- safeMapping,
1630
- freshEnough,
1631
- ptyStatusApprovalOnly: true,
1632
- });
2112
+ selectedCoverage = (liveWorkspaceNativeHistory as any).hasMore ? 'tail' : 'full';
2113
+ messageSource = liveDecision.messageSource;
2114
+ (messageSource as any).selectedDaemonSource = 'live-workspace-native-history';
2115
+ (messageSource as any).runtimeMappingSafe = true;
1633
2116
  } else {
1634
- // Hard failure (no messages, partial coverage, or safeMapping broken) — clear anchor.
1635
- // Do not clear on mere staleness: PTY can race ahead of native mtime legitimately.
1636
- if (!nativeUsableForChatMessages && (adapter as any).nativeHistoryAnchoredAt) {
1637
- (adapter as any).nativeHistoryAnchoredAt = 0;
1638
- }
1639
- const liveCurrentRuntimePtySafe = isCurrentRuntimePtySafelyAttributed({
2117
+ // Live probe also rejected: apply unsafe-native daemon mirror
2118
+ // selection (codex-only) using the primary decision's
2119
+ // fallbackReason.
2120
+ applyUnsafeNativeDaemonFallback({
2121
+ providerType,
1640
2122
  adapter,
1641
2123
  helpers: h,
1642
2124
  readChatArgs: args,
1643
2125
  sessionWorkspace,
1644
2126
  intendedWorkspace,
1645
2127
  ptyMessages: returnedMessages,
2128
+ nativeHistoryLimit,
2129
+ provider,
2130
+ messageSourceRef: { set(value) { messageSource = value; }, get() { return messageSource; } },
2131
+ apply(selection) {
2132
+ selectedMessages = selection.messages;
2133
+ selectedTranscriptAuthority = selection.transcriptAuthority;
2134
+ selectedCoverage = selection.coverage ?? coverage;
2135
+ selectedStatus = selection.status ?? returnedStatus;
2136
+ },
2137
+ activeModal,
2138
+ returnedStatus,
2139
+ coverage,
1646
2140
  });
1647
- const mayProbeLiveCodexWorkspaceNative = adapter.cliType === 'codex-cli'
1648
- && liveCurrentRuntimePtySafe
1649
- && !(typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
1650
- && !(providerSessionId && providerSessionId.trim())
1651
- && (!historyProviderSessionId || historyProviderSessionId === nativeHistorySessionId || historyProviderSessionId === historySessionId);
1652
- const liveWorkspaceNativeHistory = mayProbeLiveCodexWorkspaceNative
1653
- ? readLiveCodexWorkspaceNativeHistory(agentStr, {
1654
- canonicalHistory: provider?.canonicalHistory,
1655
- workspace,
1656
- offset: 0,
1657
- limit: nativeHistoryLimit,
1658
- excludeRecentCount: 0,
1659
- historyBehavior: provider?.historyBehavior,
1660
- scripts: provider?.scripts as any,
1661
- })
1662
- : null;
1663
- const liveWorkspaceNativeMessages = Array.isArray((liveWorkspaceNativeHistory as any)?.messages)
1664
- ? normalizeNativeHistoryMessages(agentStr, (liveWorkspaceNativeHistory as any).messages as ChatMessage[], (liveWorkspaceNativeHistory as any)?.providerSessionId)
1665
- : [];
1666
- const liveWorkspaceNativeProviderSessionId = typeof (liveWorkspaceNativeHistory as any)?.providerSessionId === 'string'
1667
- ? (liveWorkspaceNativeHistory as any).providerSessionId
1668
- : readHistorySessionIdFromMessages(liveWorkspaceNativeMessages);
1669
- const liveWorkspaceTranscriptWorkspace = typeof (liveWorkspaceNativeHistory as any)?.workspace === 'string'
1670
- ? (liveWorkspaceNativeHistory as any).workspace
1671
- : liveWorkspaceNativeMessages.map((message: any) => typeof message?.workspace === 'string' ? message.workspace.trim() : '').find(Boolean);
1672
- const liveWorkspaceNativeSafeMapping = liveWorkspaceNativeMessages.length > 0
1673
- && hasSafeNativeHistoryMapping({
1674
- workspace,
1675
- nativeMessages: liveWorkspaceNativeMessages,
1676
- ptyMessages: returnedMessages,
1677
- requireWorkspaceContentOverlap: true,
1678
- });
1679
- const liveWorkspaceNativeFreshEnough = liveWorkspaceNativeMessages.length > 0
1680
- && isNativeHistoryFreshEnough({
1681
- sourceMtimeMs: (liveWorkspaceNativeHistory as any)?.sourceMtimeMs,
1682
- nativeMessages: liveWorkspaceNativeMessages,
1683
- ptyMessages: returnedMessages,
1684
- });
1685
- const liveWorkspaceNativeUsable = (liveWorkspaceNativeHistory as any)?.source === 'provider-native'
1686
- && liveWorkspaceNativeMessages.length > 0
1687
- && (liveWorkspaceNativeHistory as any)?.nativeHistoryCoverage !== 'partial'
1688
- && (liveWorkspaceNativeHistory as any)?.nativeHistoryCoverage !== 'unavailable'
1689
- && liveWorkspaceNativeSafeMapping
1690
- && liveWorkspaceNativeFreshEnough;
1691
- if (liveWorkspaceNativeUsable) {
1692
- selectedMessages = finalizeStreamingMessagesWhenIdle(liveWorkspaceNativeMessages, returnedStatus);
1693
- selectedProviderSessionId = liveWorkspaceNativeProviderSessionId || providerSessionId;
1694
- selectedTranscriptAuthority = 'provider';
1695
- selectedCoverage = (liveWorkspaceNativeHistory as any).hasMore ? 'tail' : 'full';
1696
- messageSource = buildCliMessageSourceProvenance({
1697
- selected: 'native-history',
1698
- provider: adapter.cliType,
1699
- nativeHandle: selectedProviderSessionId || nativeHistorySessionId || historySessionId,
1700
- sessionWorkspace,
1701
- intendedWorkspace,
1702
- transcriptWorkspace: liveWorkspaceTranscriptWorkspace,
1703
- nativeSource: (liveWorkspaceNativeHistory as any).source,
1704
- sourcePath: (liveWorkspaceNativeHistory as any).sourcePath,
1705
- sourceMtimeMs: (liveWorkspaceNativeHistory as any).sourceMtimeMs,
1706
- nativeHistoryCoverage: (liveWorkspaceNativeHistory as any).nativeHistoryCoverage,
1707
- partialReason: (liveWorkspaceNativeHistory as any).partialReason,
1708
- unavailableReason: (liveWorkspaceNativeHistory as any).unavailableReason,
1709
- nativeMessages: liveWorkspaceNativeMessages,
1710
- ptyMessages: returnedMessages,
1711
- returnedMessages: selectedMessages,
1712
- safeMapping: true,
1713
- freshEnough: true,
1714
- ptyStatusApprovalOnly: true,
1715
- });
1716
- (messageSource as any).selectedDaemonSource = 'live-workspace-native-history';
1717
- (messageSource as any).runtimeMappingSafe = true;
1718
- } else {
1719
- const fallbackReason = buildNativeHistoryFallbackReason({
1720
- providerType,
1721
- provider,
1722
- nativeSource: (nativeHistory as any).source,
1723
- nativeHistoryCoverage,
1724
- unavailableReason,
1725
- nativeMessageCount: nativeMessages.length,
1726
- safeMapping,
1727
- freshEnough,
1728
- });
1729
- const unsafeNativeFallback = adapter.cliType === 'codex-cli'
1730
- && isUnsafeNativeTranscriptFallback(fallbackReason);
1731
- const safeCurrentRuntimePtyMessages = unsafeNativeFallback
1732
- && isCurrentRuntimePtySafelyAttributed({
1733
- adapter,
1734
- helpers: h,
1735
- readChatArgs: args,
1736
- sessionWorkspace,
1737
- intendedWorkspace,
1738
- ptyMessages: returnedMessages,
1739
- });
1740
- const safeRuntimeAckMessages = unsafeNativeFallback
1741
- && !safeCurrentRuntimePtyMessages
1742
- ? selectRuntimeInputAckMessages(returnedMessages)
1743
- : [];
1744
- const exactRuntimeMirrorMessages = unsafeNativeFallback
1745
- && !safeCurrentRuntimePtyMessages
1746
- && safeRuntimeAckMessages.length === 0
1747
- ? readExactRuntimeMirrorMessages({
1748
- providerType,
1749
- targetSessionId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1750
- currentSessionId: typeof (h.currentSession as any)?.sessionId === 'string' ? (h.currentSession as any).sessionId : undefined,
1751
- tailLimit: nativeHistoryLimit,
1752
- historyBehavior: provider?.historyBehavior,
1753
- })
1754
- : [];
1755
- const safeDaemonMessages = safeRuntimeAckMessages.length > 0
1756
- ? safeRuntimeAckMessages
1757
- : exactRuntimeMirrorMessages;
1758
- if (unsafeNativeFallback) {
1759
- if (safeCurrentRuntimePtyMessages) {
1760
- selectedMessages = returnedMessages;
1761
- selectedTranscriptAuthority = 'daemon';
1762
- selectedCoverage = coverage || 'current-turn';
1763
- selectedStatus = returnedStatus;
1764
- } else {
1765
- selectedMessages = safeDaemonMessages;
1766
- selectedTranscriptAuthority = safeDaemonMessages.length > 0 ? 'daemon' : undefined;
1767
- selectedCoverage = safeDaemonMessages.length > 0 ? 'tail' : undefined;
1768
- selectedStatus = coerceUnsafeNativeFallbackStatus(returnedStatus, activeModal);
1769
- }
1770
- }
1771
- messageSource = buildCliMessageSourceProvenance({
1772
- selected: 'pty-parser',
1773
- provider: adapter.cliType,
1774
- nativeHandle: historyProviderSessionId || nativeHistorySessionId || historySessionId,
1775
- sessionWorkspace,
1776
- intendedWorkspace,
1777
- transcriptWorkspace,
1778
- fallbackReason,
1779
- nativeSource: (nativeHistory as any).source,
1780
- sourcePath: (nativeHistory as any).sourcePath,
1781
- sourceMtimeMs: (nativeHistory as any).sourceMtimeMs,
1782
- nativeHistoryCoverage,
1783
- partialReason,
1784
- unavailableReason,
1785
- nativeMessages,
1786
- ptyMessages: returnedMessages,
1787
- returnedMessages: unsafeNativeFallback && !safeCurrentRuntimePtyMessages ? safeDaemonMessages : returnedMessages,
1788
- safeMapping,
1789
- freshEnough,
1790
- ptyStatusApprovalOnly: unsafeNativeFallback && !safeCurrentRuntimePtyMessages,
1791
- });
1792
- if (safeCurrentRuntimePtyMessages) {
1793
- (messageSource as any).selectedDaemonSource = 'current-runtime-pty';
1794
- (messageSource as any).transcriptAuthority = 'daemon';
1795
- (messageSource as any).runtimeMappingSafe = true;
1796
- }
1797
- if (unsafeNativeFallback && exactRuntimeMirrorMessages.length > 0) {
1798
- (messageSource as any).selectedDaemonSource = 'exact-runtime-mirror';
1799
- (messageSource as any).transcriptAuthority = 'daemon';
1800
- }
1801
- }
1802
2141
  }
2142
+ } else {
2143
+ applyUnsafeNativeDaemonFallback({
2144
+ providerType,
2145
+ adapter,
2146
+ helpers: h,
2147
+ readChatArgs: args,
2148
+ sessionWorkspace,
2149
+ intendedWorkspace,
2150
+ ptyMessages: returnedMessages,
2151
+ nativeHistoryLimit,
2152
+ provider,
2153
+ messageSourceRef: { set(value) { messageSource = value; }, get() { return messageSource; } },
2154
+ apply(selection) {
2155
+ selectedMessages = selection.messages;
2156
+ selectedTranscriptAuthority = selection.transcriptAuthority;
2157
+ selectedCoverage = selection.coverage ?? coverage;
2158
+ selectedStatus = selection.status ?? returnedStatus;
2159
+ },
2160
+ activeModal,
2161
+ returnedStatus,
2162
+ coverage,
2163
+ });
1803
2164
  }
1804
2165
  }
1805
2166
  LOG.debug('Command', `[read_chat] cli-like parsed provider=${adapter.cliType} target=${String(args?.targetSessionId || '')} adapterStatus=${String(adapterStatus.status || '')} parsedStatus=${String(parsedRecord.status || '')} parsedMsgCount=${parsedRecord.messages.length} returnedMsgCount=${returnedMessages.length}`);
@@ -1834,6 +2195,11 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1834
2195
  ...(selectedCoverage ? { coverage: selectedCoverage } : {}),
1835
2196
  }, args);
1836
2197
  }
2198
+ // History-only path (no adapter). Same source-decision contract as
2199
+ // the adapter path above, but with no PTY messages — the machine
2200
+ // simply decides whether native is usable; if not we return the
2201
+ // history we have plus a `native_history_not_safely_available`
2202
+ // error response when the provider requires native source.
1837
2203
  const historyLimit = normalizeReadChatTailLimit(args);
1838
2204
  try {
1839
2205
  const agentStr = provider?.type || args?.agentType || getCurrentProviderType(h);
@@ -1841,13 +2207,9 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1841
2207
  ? (h.currentSession as any).workspace
1842
2208
  : undefined;
1843
2209
  const intendedWorkspace = typeof args?.workspace === 'string' ? args.workspace : undefined;
1844
- const exactNativeHistoryScope = Boolean(
1845
- (typeof args?.targetSessionId === 'string' && args.targetSessionId.trim())
1846
- || (typeof args?.historySessionId === 'string' && args.historySessionId.trim())
1847
- || (typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
1848
- || ((h.currentSession as any)?.sessionId === args?.targetSessionId && typeof (h.currentSession as any)?.providerSessionId === 'string' && (h.currentSession as any).providerSessionId.trim())
1849
- );
1850
- const history = supportsCliNativeTranscript(agentStr, provider) && isNativeSourceCanonicalHistory(provider?.canonicalHistory)
2210
+ const supportsNative = supportsCliNativeTranscript(agentStr, provider)
2211
+ && isNativeSourceCanonicalHistory(provider?.canonicalHistory);
2212
+ const history = supportsNative
1851
2213
  ? readCliProviderNativeHistory(agentStr, {
1852
2214
  canonicalHistory: provider?.canonicalHistory,
1853
2215
  historySessionId,
@@ -1859,35 +2221,23 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1859
2221
  scripts: provider?.scripts as any,
1860
2222
  })
1861
2223
  : readProviderChatHistory(agentStr, {
1862
- canonicalHistory: provider?.canonicalHistory,
1863
- historySessionId,
1864
- workspace,
1865
- offset: 0,
1866
- limit: historyLimit,
1867
- excludeRecentCount: 0,
1868
- historyBehavior: provider?.historyBehavior,
1869
- scripts: provider?.scripts as any,
1870
- });
1871
- const lookup = (history as any).lookup === 'workspace' ? 'workspace' : 'session';
2224
+ canonicalHistory: provider?.canonicalHistory,
2225
+ historySessionId,
2226
+ workspace,
2227
+ offset: 0,
2228
+ limit: historyLimit,
2229
+ excludeRecentCount: 0,
2230
+ historyBehavior: provider?.historyBehavior,
2231
+ scripts: provider?.scripts as any,
2232
+ });
2233
+ const lookup = (history as any)?.lookup === 'workspace' ? 'workspace' : 'session';
1872
2234
  const historyMessages = Array.isArray((history as any)?.messages)
1873
2235
  ? normalizeNativeHistoryMessages(agentStr, (history as any).messages as ChatMessage[], (history as any)?.providerSessionId)
1874
2236
  : [];
1875
2237
  const historyProviderSessionId = typeof (history as any)?.providerSessionId === 'string'
1876
2238
  ? (history as any).providerSessionId
1877
2239
  : readHistorySessionIdFromMessages(historyMessages) || historySessionId;
1878
- const nativeHistoryCoverage = typeof (history as any)?.nativeHistoryCoverage === 'string'
1879
- ? (history as any).nativeHistoryCoverage
1880
- : undefined;
1881
- const partialReason = typeof (history as any)?.partialReason === 'string'
1882
- ? (history as any).partialReason
1883
- : undefined;
1884
- const unavailableReason = typeof (history as any)?.unavailableReason === 'string'
1885
- ? (history as any).unavailableReason
1886
- : undefined;
1887
- const transcriptWorkspace = typeof (history as any)?.workspace === 'string'
1888
- ? (history as any).workspace
1889
- : historyMessages.map((message: any) => typeof message?.workspace === 'string' ? message.workspace.trim() : '').find(Boolean);
1890
- const safeMapping = supportsCliNativeTranscript(agentStr, provider)
2240
+ const safeMapping = supportsNative
1891
2241
  ? hasSafeNativeHistoryMapping({
1892
2242
  historySessionId: lookup === 'workspace' ? undefined : historySessionId,
1893
2243
  providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId,
@@ -1895,60 +2245,41 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1895
2245
  nativeMessages: historyMessages,
1896
2246
  })
1897
2247
  : false;
1898
- const nativeSelected = supportsCliNativeTranscript(agentStr, provider)
1899
- && (history as any).source === 'provider-native'
1900
- && historyMessages.length > 0
1901
- && nativeHistoryCoverage !== 'partial'
1902
- && nativeHistoryCoverage !== 'unavailable'
1903
- && safeMapping;
1904
- const messageSource = buildCliMessageSourceProvenance({
1905
- selected: nativeSelected ? 'native-history' : 'pty-parser',
1906
- provider: agentStr,
1907
- nativeHandle: historyProviderSessionId || historySessionId,
2248
+
2249
+ const machineSessionKey = String(
2250
+ args?.targetSessionId
2251
+ || historyProviderSessionId
2252
+ || historySessionId
2253
+ || (h.currentSession as any)?.sessionId
2254
+ || ''
2255
+ );
2256
+ const decision = decideCliReadChatSource({
2257
+ providerType: agentStr,
2258
+ provider,
2259
+ sessionId: machineSessionKey,
2260
+ nativeHistoryResult: history,
2261
+ safeMapping,
1908
2262
  sessionWorkspace: workspace,
1909
2263
  intendedWorkspace,
1910
- transcriptWorkspace,
1911
- fallbackReason: nativeSelected
1912
- ? undefined
1913
- : buildNativeHistoryFallbackReason({
1914
- providerType: agentStr,
1915
- provider,
1916
- nativeSource: (history as any).source,
1917
- nativeHistoryCoverage,
1918
- unavailableReason,
1919
- nativeMessageCount: historyMessages.length,
1920
- safeMapping,
1921
- freshEnough: true,
1922
- }),
1923
- nativeSource: (history as any).source,
1924
- sourcePath: (history as any).sourcePath,
1925
- sourceMtimeMs: (history as any).sourceMtimeMs,
1926
- nativeHistoryCoverage,
1927
- partialReason,
1928
- unavailableReason,
1929
- nativeMessages: historyMessages,
1930
- returnedMessages: historyMessages,
1931
- safeMapping,
1932
- freshEnough: true,
2264
+ ptyMessages: [],
1933
2265
  ptyStatusApprovalOnly: false,
1934
2266
  });
1935
- const requiresNativeSource = supportsCliNativeTranscript(agentStr, provider)
1936
- && isNativeSourceCanonicalHistory(provider?.canonicalHistory);
1937
- if (requiresNativeSource && !nativeSelected) {
2267
+
2268
+ if (supportsNative && !decision.nativeSelected) {
1938
2269
  return {
1939
2270
  success: false,
1940
2271
  code: 'native_history_not_safely_available',
1941
2272
  error: 'Provider-native history was not safely available for the requested CLI session.',
1942
2273
  providerSessionId: historyProviderSessionId,
1943
- messageSource,
1944
- transcriptProvenance: messageSource,
2274
+ messageSource: decision.messageSource,
2275
+ transcriptProvenance: decision.messageSource,
1945
2276
  };
1946
2277
  }
1947
2278
  return buildReadChatCommandResult({
1948
2279
  messages: historyMessages,
1949
2280
  status: 'idle',
1950
- messageSource,
1951
- transcriptProvenance: messageSource,
2281
+ messageSource: decision.messageSource,
2282
+ transcriptProvenance: decision.messageSource,
1952
2283
  ...(typeof (history as any)?.title === 'string' ? { title: (history as any).title } : {}),
1953
2284
  ...(historyProviderSessionId ? { providerSessionId: historyProviderSessionId } : {}),
1954
2285
  ...(((provider?.historyBehavior as any)?.transcriptAuthority === 'provider' || (provider?.historyBehavior as any)?.transcriptAuthority === 'daemon')