@adhdev/daemon-core 0.9.82-rc.14 → 0.9.82-rc.140

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 (115) 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/chat/subscription-updates.d.ts +1 -0
  4. package/dist/cli-adapter-types.d.ts +5 -1
  5. package/dist/cli-adapters/cli-script-runner.d.ts +45 -0
  6. package/dist/cli-adapters/cli-state-engine.d.ts +178 -0
  7. package/dist/cli-adapters/provider-cli-adapter.d.ts +87 -63
  8. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  9. package/dist/cli-adapters/provider-cli-shared.d.ts +21 -0
  10. package/dist/commands/router.d.ts +22 -0
  11. package/dist/config/chat-history.d.ts +5 -0
  12. package/dist/config/config.d.ts +5 -0
  13. package/dist/config/mesh-config.d.ts +68 -1
  14. package/dist/git/git-commands.d.ts +5 -1
  15. package/dist/index.d.ts +18 -6
  16. package/dist/index.js +10431 -2827
  17. package/dist/index.js.map +1 -1
  18. package/dist/index.mjs +10376 -2811
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/installer.d.ts +1 -4
  21. package/dist/launch.d.ts +1 -1
  22. package/dist/logging/async-batch-writer.d.ts +10 -0
  23. package/dist/mesh/beads-db.d.ts +72 -0
  24. package/dist/mesh/contracts.d.ts +164 -0
  25. package/dist/mesh/coordinator-registry.d.ts +25 -0
  26. package/dist/mesh/mesh-active-work.d.ts +90 -0
  27. package/dist/mesh/mesh-events.d.ts +77 -5
  28. package/dist/mesh/mesh-fast-forward.d.ts +39 -0
  29. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  30. package/dist/mesh/mesh-ledger.d.ts +58 -1
  31. package/dist/mesh/mesh-refine-status.d.ts +26 -0
  32. package/dist/mesh/mesh-work-queue.d.ts +44 -5
  33. package/dist/mesh/preview-freshness.d.ts +18 -0
  34. package/dist/mesh/refine-config.d.ts +193 -0
  35. package/dist/mesh/worktree-bootstrap-config.d.ts +113 -0
  36. package/dist/providers/approval-utils.d.ts +9 -0
  37. package/dist/providers/chat-message-normalization.d.ts +1 -0
  38. package/dist/providers/cli-provider-instance.d.ts +6 -1
  39. package/dist/providers/contracts.d.ts +19 -0
  40. package/dist/providers/read-chat-contract.d.ts +29 -0
  41. package/dist/providers/transcript-v2.d.ts +176 -0
  42. package/dist/repo-mesh-types.d.ts +67 -0
  43. package/dist/shared-types.d.ts +12 -0
  44. package/dist/status/reporter.d.ts +2 -0
  45. package/dist/status/snapshot.d.ts +1 -0
  46. package/dist/types.d.ts +5 -0
  47. package/package.json +3 -1
  48. package/src/boot/daemon-lifecycle.ts +3 -0
  49. package/src/chat/source-machine.ts +534 -0
  50. package/src/chat/source-resolver.ts +0 -0
  51. package/src/chat/subscription-updates.ts +14 -1
  52. package/src/cli-adapter-types.d.ts +1 -0
  53. package/src/cli-adapter-types.ts +3 -1
  54. package/src/cli-adapters/cli-script-runner.ts +145 -0
  55. package/src/cli-adapters/cli-state-engine.ts +1083 -0
  56. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  57. package/src/cli-adapters/provider-cli-adapter.ts +630 -1137
  58. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  59. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  60. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  61. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  62. package/src/cli-adapters/provider-cli-shared.ts +51 -11
  63. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  64. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  65. package/src/commands/chat-commands.ts +1428 -50
  66. package/src/commands/cli-manager.ts +145 -3
  67. package/src/commands/handler.ts +8 -1
  68. package/src/commands/mesh-coordinator.ts +13 -143
  69. package/src/commands/router.ts +3271 -427
  70. package/src/config/chat-history.ts +79 -24
  71. package/src/config/config.ts +12 -0
  72. package/src/config/mesh-config.ts +249 -2
  73. package/src/config/recent-activity.ts +8 -2
  74. package/src/daemon/dev-cli-debug.ts +10 -1
  75. package/src/detection/ide-detector.ts +26 -16
  76. package/src/git/git-commands.ts +17 -5
  77. package/src/git/git-worktree.ts +8 -1
  78. package/src/index.ts +45 -5
  79. package/src/installer.d.ts +1 -1
  80. package/src/installer.ts +8 -6
  81. package/src/launch.d.ts +1 -1
  82. package/src/launch.ts +37 -28
  83. package/src/logging/async-batch-writer.ts +55 -0
  84. package/src/logging/logger.ts +2 -1
  85. package/src/mesh/beads-db.ts +479 -0
  86. package/src/mesh/contracts.ts +329 -0
  87. package/src/mesh/coordinator-prompt.ts +40 -22
  88. package/src/mesh/coordinator-registry.ts +75 -0
  89. package/src/mesh/mesh-active-work.ts +437 -0
  90. package/src/mesh/mesh-events.ts +799 -56
  91. package/src/mesh/mesh-fast-forward.ts +430 -0
  92. package/src/mesh/mesh-host-ownership.ts +73 -0
  93. package/src/mesh/mesh-ledger.ts +457 -104
  94. package/src/mesh/mesh-refine-status.ts +144 -0
  95. package/src/mesh/mesh-work-queue.ts +216 -158
  96. package/src/mesh/preview-freshness.ts +118 -0
  97. package/src/mesh/refine-config.ts +366 -0
  98. package/src/mesh/worktree-bootstrap-config.ts +247 -0
  99. package/src/providers/approval-utils.ts +39 -5
  100. package/src/providers/chat-message-normalization.ts +7 -12
  101. package/src/providers/cli-provider-instance.ts +362 -41
  102. package/src/providers/contracts.ts +19 -0
  103. package/src/providers/ide-provider-instance.ts +17 -3
  104. package/src/providers/provider-loader.ts +31 -11
  105. package/src/providers/provider-schema.ts +12 -0
  106. package/src/providers/read-chat-contract.ts +76 -16
  107. package/src/providers/transcript-v2.ts +567 -0
  108. package/src/providers/version-archive.ts +38 -20
  109. package/src/repo-mesh-types.ts +77 -0
  110. package/src/shared-types.ts +9 -0
  111. package/src/status/builders.ts +23 -6
  112. package/src/status/reporter.ts +15 -0
  113. package/src/status/snapshot.ts +35 -11
  114. package/src/system/host-memory.ts +29 -12
  115. package/src/types.ts +5 -0
@@ -12,11 +12,20 @@ import type { CliAdapter } from '../cli-adapter-types.js';
12
12
  import { flattenContent, normalizeInputEnvelope, type InputEnvelope, type ProviderModule, type ProviderScripts } from '../providers/contracts.js';
13
13
  import { assertProviderSupportsDeclaredInput, assertTextOnlyInput } from '../providers/provider-input-support.js';
14
14
  import { validateReadChatResultPayload } from '../providers/read-chat-contract.js';
15
+ import { pickApprovalButton } from '../providers/approval-utils.js';
15
16
  import type { ProviderInstance } from '../providers/provider-instance.js';
16
- import { readProviderChatHistory } from '../config/chat-history.js';
17
+ import { isNativeSourceCanonicalHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
17
18
  import { LOG, getRecentLogs } from '../logging/logger.js';
18
19
  import { getRecentDebugTrace, recordDebugTrace } from '../logging/debug-trace.js';
19
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';
20
29
  import type { ChatMessage } from '../types.js';
21
30
  import type { SessionTransport } from '../shared-types.js';
22
31
  import { filterUserFacingChatMessages, normalizeChatMessages } from '../providers/chat-message-normalization.js';
@@ -24,6 +33,25 @@ import { filterUserFacingChatMessages, normalizeChatMessages } from '../provider
24
33
  const RECENT_SEND_WINDOW_MS = 1200;
25
34
  export const READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS = 25_000;
26
35
  const HERMES_CLI_STARTING_SEND_SETTLE_MS = 2_000;
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().
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
+ }
27
55
  const recentSendByTarget = new Map<string, number>();
28
56
 
29
57
  interface ApprovalSelectableInstance extends ProviderInstance {
@@ -32,6 +60,7 @@ interface ApprovalSelectableInstance extends ProviderInstance {
32
60
 
33
61
  interface RuntimeChatMessageMerger extends ProviderInstance {
34
62
  mergeRuntimeChatMessages?(messages: ChatMessage[]): ChatMessage[];
63
+ recordAcknowledgedUserInput?(input: InputEnvelope | string): void;
35
64
  }
36
65
 
37
66
  type LegacyStringScript = (params?: Record<string, unknown> | string) => string;
@@ -48,6 +77,16 @@ function getTargetedCliAdapter(h: CommandHelpers, args: any, providerType?: stri
48
77
  return h.getCliAdapter(args?.targetSessionId || providerType || h.currentSession?.providerType || h.currentManagerKey);
49
78
  }
50
79
 
80
+ function getExplicitHistorySessionId(args: any): string | undefined {
81
+ const explicit = typeof args?.historySessionId === 'string' ? args.historySessionId.trim() : '';
82
+ if (explicit) return explicit;
83
+
84
+ const explicitProviderSessionId = typeof args?.providerSessionId === 'string' ? args.providerSessionId.trim() : '';
85
+ if (explicitProviderSessionId) return explicitProviderSessionId;
86
+
87
+ return undefined;
88
+ }
89
+
51
90
  function getTargetInstance(h: CommandHelpers, args: any): ApprovalSelectableInstance | null {
52
91
  const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
53
92
  const sessionId = targetSessionId || h.currentSession?.sessionId || '';
@@ -139,19 +178,47 @@ async function waitOnceForFreshHermesCliStart(adapter: CliAdapter, log: (msg: st
139
178
  }
140
179
 
141
180
  function getHistorySessionId(h: CommandHelpers, args: any): string | undefined {
142
- const explicit = typeof args?.historySessionId === 'string' ? args.historySessionId.trim() : '';
181
+ const explicit = getExplicitHistorySessionId(args);
143
182
  if (explicit) return explicit;
144
183
 
145
- const explicitProviderSessionId = typeof args?.providerSessionId === 'string' ? args.providerSessionId.trim() : '';
146
- if (explicitProviderSessionId) return explicitProviderSessionId;
147
-
148
184
  const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
149
185
  if (!targetSessionId) return undefined;
150
186
 
151
- const instance = h.ctx.instanceManager?.getInstance(targetSessionId);
187
+ const session = h.ctx.sessionRegistry?.get(targetSessionId) as any;
188
+ const registeredProviderSessionId = typeof session?.providerSessionId === 'string' ? session.providerSessionId.trim() : '';
189
+ if (registeredProviderSessionId) return registeredProviderSessionId;
190
+
191
+ const instance = getTargetInstance(h, args);
152
192
  const state = instance?.getState?.();
153
193
  const providerSessionId = typeof state?.providerSessionId === 'string' ? state.providerSessionId.trim() : '';
154
- return providerSessionId || targetSessionId;
194
+ if (providerSessionId) return providerSessionId;
195
+
196
+ const currentSession = h.currentSession as any;
197
+ if (currentSession?.sessionId === targetSessionId) {
198
+ const currentProviderSessionId = typeof currentSession.providerSessionId === 'string'
199
+ ? currentSession.providerSessionId.trim()
200
+ : '';
201
+ if (currentProviderSessionId) return currentProviderSessionId;
202
+ }
203
+
204
+ return targetSessionId;
205
+ }
206
+
207
+ function resolveCliNativeHistorySessionId(args: any, currentHistorySessionId: string | undefined, parsedProviderSessionId: string | undefined): string | undefined {
208
+ const explicit = getExplicitHistorySessionId(args);
209
+ if (explicit) return explicit;
210
+
211
+ const parsed = typeof parsedProviderSessionId === 'string' ? parsedProviderSessionId.trim() : '';
212
+ const current = typeof currentHistorySessionId === 'string' ? currentHistorySessionId.trim() : '';
213
+ const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
214
+
215
+ // getHistorySessionId falls back to the runtime session id when no native
216
+ // handle has been registered yet. For live CLI adapters the parser may
217
+ // already know the provider-native handle; prefer it over the runtime id so
218
+ // exact native reads do not miss the worker transcript and fall back to PTY
219
+ // or same-workspace history.
220
+ if (parsed && (!current || current === targetSessionId)) return parsed;
221
+ return current || parsed || undefined;
155
222
  }
156
223
 
157
224
  function getInteractionId(args: any): string | undefined {
@@ -221,6 +288,808 @@ function normalizeReadChatMessages(payload: Record<string, any>): ChatMessage[]
221
288
  return normalizeChatMessages(messages);
222
289
  }
223
290
 
291
+ function getMessageNewestReceivedAt(messages: Array<{ receivedAt?: unknown; timestamp?: unknown }>): number {
292
+ let newest = 0;
293
+ for (const message of messages) {
294
+ const receivedAt = Number(message?.receivedAt ?? message?.timestamp ?? 0);
295
+ if (Number.isFinite(receivedAt) && receivedAt > newest) newest = receivedAt;
296
+ }
297
+ return newest;
298
+ }
299
+
300
+ function readHistorySessionIdFromMessages(messages: ChatMessage[]): string | undefined {
301
+ for (const message of messages as Array<ChatMessage & { historySessionId?: unknown }>) {
302
+ const historySessionId = typeof message?.historySessionId === 'string' ? message.historySessionId.trim() : '';
303
+ if (historySessionId) return historySessionId;
304
+ }
305
+ return undefined;
306
+ }
307
+
308
+ function shouldPreserveNativeIdentity(providerType: string, sessionId: string, message: ChatMessage): boolean {
309
+ const providerUnitKey = typeof message.providerUnitKey === 'string' ? message.providerUnitKey.trim() : '';
310
+ const turnKey = typeof message._turnKey === 'string' ? message._turnKey.trim() : '';
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;
320
+ if (providerType === 'hermes-cli' && sessionId) {
321
+ return providerUnitKey.startsWith(`${providerType}:native:${sessionId}:`)
322
+ && turnKey.startsWith(`${providerType}:native-turn:${sessionId}:`);
323
+ }
324
+ return true;
325
+ }
326
+
327
+ function normalizeNativeHistoryMessages(providerType: string, messages: ChatMessage[], nativeSessionId?: string): ChatMessage[] {
328
+ let turnIndex = 0;
329
+ return normalizeChatMessages(messages).map((message, index) => {
330
+ const role = typeof message.role === 'string' ? message.role.trim().toLowerCase() : '';
331
+ const kind = typeof message.kind === 'string' && message.kind.trim() ? message.kind.trim() : (role === 'system' ? 'system' : 'standard');
332
+ if ((role === 'user' || role === 'human') && index > 0) turnIndex += 1;
333
+ const historySessionId = typeof (message as any).historySessionId === 'string'
334
+ ? (message as any).historySessionId.trim()
335
+ : '';
336
+ const contentHash = hashSignatureParts([
337
+ providerType,
338
+ historySessionId,
339
+ String(message.receivedAt || message.timestamp || index),
340
+ role,
341
+ kind,
342
+ flattenContent(message.content),
343
+ ]).slice(0, 12);
344
+ const nativeIdentitySessionId = historySessionId || (typeof nativeSessionId === 'string' ? nativeSessionId.trim() : '');
345
+ const preserveNativeIdentity = shouldPreserveNativeIdentity(providerType, nativeIdentitySessionId, message);
346
+ const existingProviderUnitKey = typeof message.providerUnitKey === 'string' ? message.providerUnitKey.trim() : '';
347
+ const existingTurnKey = typeof message._turnKey === 'string' ? message._turnKey.trim() : '';
348
+ const providerUnitKey = preserveNativeIdentity
349
+ ? existingProviderUnitKey
350
+ : `${providerType}:native:${nativeIdentitySessionId || 'workspace'}:${index}:${role || 'message'}:${kind}:${contentHash}`;
351
+ const meta = message.meta && typeof message.meta === 'object' ? message.meta as Record<string, unknown> : undefined;
352
+ const isSystemSessionStart = role === 'system' || kind === 'system' || kind === 'session_start';
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);
366
+ return {
367
+ ...message,
368
+ role: role === 'human' ? 'user' : (role || 'assistant'),
369
+ kind: isSystemSessionStart ? 'system' : kind,
370
+ providerUnitKey,
371
+ bubbleId: typeof message.bubbleId === 'string' && message.bubbleId.trim()
372
+ && preserveNativeIdentity
373
+ ? message.bubbleId.trim()
374
+ : `bubble:${providerUnitKey}`,
375
+ sequence,
376
+ _turnKey: preserveNativeIdentity
377
+ ? existingTurnKey
378
+ : `${providerType}:native-turn:${nativeIdentitySessionId || 'workspace'}:${turnIndex}`,
379
+ bubbleState: message.bubbleState || 'final',
380
+ ...(isSystemSessionStart ? {
381
+ visibility: message.visibility || 'hidden',
382
+ transcriptVisibility: message.transcriptVisibility || 'hidden',
383
+ audience: message.audience || 'internal',
384
+ source: message.source || 'runtime_status',
385
+ } : isActivity ? {
386
+ source: message.source || (kind === 'terminal' ? 'terminal_command' : 'tool_call'),
387
+ meta: { ...meta, label: message.senderName || meta?.label || (kind === 'terminal' ? 'Terminal' : 'Tool') },
388
+ } : {
389
+ source: message.source || (role === 'assistant' ? 'assistant_text' : undefined),
390
+ }),
391
+ } as ChatMessage;
392
+ });
393
+ }
394
+
395
+ function buildCliMessageSourceProvenance(args: {
396
+ selected: 'native-history' | 'pty-parser';
397
+ provider: string;
398
+ nativeHandle?: string;
399
+ sessionWorkspace?: string;
400
+ intendedWorkspace?: string;
401
+ transcriptWorkspace?: string;
402
+ fallbackReason?: string;
403
+ nativeSource?: string;
404
+ sourcePath?: string;
405
+ sourceMtimeMs?: number;
406
+ nativeHistoryCoverage?: string;
407
+ partialReason?: string;
408
+ unavailableReason?: string;
409
+ nativeMessages?: ChatMessage[];
410
+ ptyMessages?: ChatMessage[];
411
+ returnedMessages?: ChatMessage[];
412
+ safeMapping?: boolean;
413
+ freshEnough?: boolean;
414
+ ptyStatusApprovalOnly?: boolean;
415
+ }): Record<string, unknown> {
416
+ const sourceMtimeMs = Number(args.sourceMtimeMs || 0);
417
+ const sourceMtimeAgeMs = sourceMtimeMs > 0 ? Math.max(0, Date.now() - sourceMtimeMs) : undefined;
418
+ const nativeMessages = args.nativeMessages || [];
419
+ const ptyMessages = args.ptyMessages || [];
420
+ const returnedMessages = args.returnedMessages || [];
421
+ const identityStatus = args.selected === 'native-history'
422
+ ? 'safe'
423
+ : args.fallbackReason === 'native_history_not_safely_mapped'
424
+ ? 'ambiguous_session_identity'
425
+ : args.fallbackReason?.startsWith('native_history_unavailable')
426
+ ? 'transcript_unmapped'
427
+ : undefined;
428
+ return {
429
+ selected: args.selected,
430
+ provider: args.provider,
431
+ providerType: args.provider,
432
+ ...(identityStatus ? { identityStatus } : {}),
433
+ ...(args.nativeHandle ? { nativeHandle: args.nativeHandle } : {}),
434
+ ...(args.nativeHandle ? { nativeSessionId: args.nativeHandle } : {}),
435
+ ...(args.sessionWorkspace ? { sessionWorkspace: args.sessionWorkspace } : {}),
436
+ ...(args.intendedWorkspace ? { intendedWorkspace: args.intendedWorkspace } : {}),
437
+ ...(args.transcriptWorkspace ? { transcriptWorkspace: args.transcriptWorkspace } : {}),
438
+ ...(args.fallbackReason ? { fallbackReason: args.fallbackReason } : {}),
439
+ ...(args.nativeSource ? { nativeSource: args.nativeSource } : {}),
440
+ ...(args.sourcePath ? { sourcePath: args.sourcePath } : {}),
441
+ ...(args.nativeHistoryCoverage ? { nativeHistoryCoverage: args.nativeHistoryCoverage } : {}),
442
+ ...(args.partialReason ? { partialReason: args.partialReason } : {}),
443
+ ...(args.unavailableReason ? { unavailableReason: args.unavailableReason } : {}),
444
+ ptyStatusApprovalOnly: args.ptyStatusApprovalOnly === true,
445
+ staleness: {
446
+ sourceMtimeMs: sourceMtimeMs || undefined,
447
+ sourceMtimeAgeMs,
448
+ nativeNewestMessageAt: getMessageNewestReceivedAt(nativeMessages),
449
+ ptyNewestMessageAt: getMessageNewestReceivedAt(ptyMessages),
450
+ freshEnough: args.freshEnough === true,
451
+ },
452
+ coverage: {
453
+ nativeMessageCount: nativeMessages.length,
454
+ ptyMessageCount: ptyMessages.length,
455
+ returnedMessageCount: returnedMessages.length,
456
+ safeMapping: args.safeMapping === true,
457
+ // true when PTY message bodies are suppressed and must not be treated as
458
+ // chat content. PTY may still contribute status/approval/screen evidence.
459
+ ptyMessagesSuppressed: args.selected === 'native-history' || args.ptyStatusApprovalOnly === true,
460
+ },
461
+ };
462
+ }
463
+
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: {
524
+ providerType: string;
525
+ provider?: ProviderModule;
526
+ sessionId: string;
527
+ nativeHistoryResult: any | null;
528
+ nativeHistoryError?: unknown;
529
+ safeMapping: boolean;
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);
787
+ }
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
+
794
+ function isUnsafeNativeTranscriptFallback(reason?: string): boolean {
795
+ const value = String(reason || '').trim();
796
+ return value.startsWith('native_history_unavailable')
797
+ || value === 'native_history_not_safely_mapped'
798
+ || value === 'native_history_stale'
799
+ || value === 'native_history_partial';
800
+ }
801
+
802
+ function coerceUnsafeNativeFallbackStatus(status: string, activeModal: unknown): string {
803
+ if (status === 'waiting_approval' && activeModal) return status;
804
+ return 'idle';
805
+ }
806
+
807
+ function isRuntimeInputAckMessage(message: ChatMessage | undefined): boolean {
808
+ if (!message || typeof message !== 'object') return false;
809
+ const role = String((message as any).role || '').trim().toLowerCase();
810
+ if (role !== 'user' && role !== 'human') return false;
811
+ const meta = (message as any).meta;
812
+ return !!meta && typeof meta === 'object' && !Array.isArray(meta) && meta.runtimeInputAck === true;
813
+ }
814
+
815
+ function selectRuntimeInputAckMessages(messages: ChatMessage[]): ChatMessage[] {
816
+ return messages.filter((message) => isRuntimeInputAckMessage(message));
817
+ }
818
+
819
+ function readExactRuntimeMirrorMessages(args: {
820
+ providerType: string;
821
+ targetSessionId?: string;
822
+ currentSessionId?: string;
823
+ tailLimit: number;
824
+ historyBehavior?: ProviderModule['historyBehavior'];
825
+ }): ChatMessage[] {
826
+ const targetSessionId = String(args.targetSessionId || '').trim();
827
+ const currentSessionId = String(args.currentSessionId || '').trim();
828
+ if (!targetSessionId || targetSessionId !== currentSessionId) return [];
829
+
830
+ const history = readChatHistory(
831
+ args.providerType,
832
+ 0,
833
+ Math.max(args.tailLimit || 0, 200),
834
+ targetSessionId,
835
+ 0,
836
+ args.historyBehavior,
837
+ );
838
+ return normalizeChatMessages((history.messages || []) as ChatMessage[])
839
+ .filter((message) => {
840
+ const historySessionId = String((message as any).historySessionId || '').trim();
841
+ const instanceId = String((message as any).instanceId || '').trim();
842
+ return historySessionId === targetSessionId || instanceId === targetSessionId;
843
+ });
844
+ }
845
+
846
+ function normalizeComparableWorkspace(value: unknown): string {
847
+ const text = typeof value === 'string' ? value.trim() : '';
848
+ if (!text) return '';
849
+ return path.resolve(text);
850
+ }
851
+
852
+ function isCurrentRuntimePtySafelyAttributed(args: {
853
+ adapter: CliAdapter;
854
+ helpers: CommandHelpers;
855
+ readChatArgs: any;
856
+ sessionWorkspace?: string;
857
+ intendedWorkspace?: string;
858
+ ptyMessages: ChatMessage[];
859
+ }): boolean {
860
+ if (args.adapter.cliType !== 'codex-cli') return false;
861
+ if (!Array.isArray(args.ptyMessages) || args.ptyMessages.length === 0) return false;
862
+ const targetSessionId = typeof args.readChatArgs?.targetSessionId === 'string'
863
+ ? args.readChatArgs.targetSessionId.trim()
864
+ : '';
865
+ const currentSession = args.helpers.currentSession as any;
866
+ const currentSessionId = typeof currentSession?.sessionId === 'string'
867
+ ? currentSession.sessionId.trim()
868
+ : '';
869
+ if (!targetSessionId || !currentSessionId || targetSessionId !== currentSessionId) return false;
870
+
871
+ const runtimeMeta = typeof (args.adapter as any).getRuntimeMetadata === 'function'
872
+ ? (args.adapter as any).getRuntimeMetadata()
873
+ : null;
874
+ const runtimeId = typeof runtimeMeta?.runtimeId === 'string' ? runtimeMeta.runtimeId.trim() : '';
875
+ if (!runtimeId || runtimeId !== targetSessionId) return false;
876
+ const surfaceKind = typeof runtimeMeta?.surfaceKind === 'string' ? runtimeMeta.surfaceKind : '';
877
+ if (surfaceKind === 'inactive_record' || surfaceKind === 'recovery_snapshot') return false;
878
+
879
+ const sessionWorkspace = normalizeComparableWorkspace(args.sessionWorkspace);
880
+ const adapterWorkspace = normalizeComparableWorkspace(args.adapter.workingDir);
881
+ if (!sessionWorkspace || !adapterWorkspace || sessionWorkspace !== adapterWorkspace) return false;
882
+ const intendedWorkspace = normalizeComparableWorkspace(args.intendedWorkspace);
883
+ if (intendedWorkspace && intendedWorkspace !== sessionWorkspace) return false;
884
+
885
+ const registryEntry = args.helpers.ctx?.sessionRegistry?.get?.(targetSessionId) as any;
886
+ const registryInstanceKey = typeof registryEntry?.adapterKey === 'string' && registryEntry.adapterKey.trim()
887
+ ? registryEntry.adapterKey.trim()
888
+ : typeof registryEntry?.instanceKey === 'string' && registryEntry.instanceKey.trim()
889
+ ? registryEntry.instanceKey.trim()
890
+ : '';
891
+ if (registryInstanceKey) {
892
+ const targetInstance = args.helpers.ctx?.instanceManager?.getInstance?.(registryInstanceKey);
893
+ if (targetInstance) {
894
+ const instanceType = typeof (targetInstance as any).type === 'string' ? (targetInstance as any).type : '';
895
+ if (instanceType && instanceType !== args.adapter.cliType) return false;
896
+ }
897
+ }
898
+
899
+ return true;
900
+ }
901
+
902
+ function supportsCliNativeTranscript(providerType: string, provider?: ProviderModule): boolean {
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;
918
+ }
919
+
920
+ function getComparableVisibleText(message: ChatMessage | undefined): string {
921
+ if (!message) return '';
922
+ const role = String((message as any).role || '').trim().toLowerCase();
923
+ if (role !== 'user' && role !== 'assistant') return '';
924
+ const kind = String((message as any).kind || 'standard').trim().toLowerCase();
925
+ if (kind && kind !== 'standard') return '';
926
+ const content = flattenContent((message as any).content).replace(/\s+/g, ' ').trim();
927
+ return content;
928
+ }
929
+
930
+ function hasOverlappingVisibleConversationText(nativeMessages: ChatMessage[], ptyMessages: ChatMessage[]): boolean {
931
+ const nativeTexts = nativeMessages.map(getComparableVisibleText).filter(Boolean);
932
+ const ptyTexts = ptyMessages.map(getComparableVisibleText).filter(Boolean);
933
+ if (nativeTexts.length === 0 || ptyTexts.length === 0) return false;
934
+ for (const nativeText of nativeTexts) {
935
+ for (const ptyText of ptyTexts) {
936
+ if (nativeText === ptyText) return true;
937
+ const shorter = nativeText.length <= ptyText.length ? nativeText : ptyText;
938
+ const longer = nativeText.length <= ptyText.length ? ptyText : nativeText;
939
+ if (shorter.length >= 32 && longer.includes(shorter)) return true;
940
+ }
941
+ }
942
+ return false;
943
+ }
944
+
945
+ function hasSafeNativeHistoryMapping(args: {
946
+ historySessionId?: string;
947
+ providerSessionId?: string;
948
+ workspace?: string;
949
+ nativeMessages: ChatMessage[];
950
+ ptyMessages?: ChatMessage[];
951
+ requireWorkspaceContentOverlap?: boolean;
952
+ }): boolean {
953
+ const isCoordinatorTranscript = args.nativeMessages.some((m: any) => {
954
+ const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '');
955
+ return text.includes('mesh_send_task') || text.includes('mesh_status') || text.includes('mesh_read_chat') || text.includes('mesh_launch_session');
956
+ });
957
+
958
+ const explicitSessionId = String(args.historySessionId || args.providerSessionId || '').trim();
959
+ if (explicitSessionId) {
960
+ const messageSessionIds = args.nativeMessages
961
+ .map((message: any) => typeof message?.historySessionId === 'string' ? message.historySessionId.trim() : '')
962
+ .filter(Boolean);
963
+ if (messageSessionIds.length > 0) {
964
+ return messageSessionIds.some((id) => id === explicitSessionId);
965
+ }
966
+
967
+ // Messages carry no historySessionId — cannot confirm they belong to the requested session.
968
+ // Only allow a coordinator transcript that is also confirmed by the PTY side; otherwise
969
+ // fail closed so a same-workspace session's history is never silently accepted.
970
+ if (isCoordinatorTranscript && args.ptyMessages && args.ptyMessages.length > 0) {
971
+ const ptyHasCoordinator = args.ptyMessages.some((m: any) => {
972
+ const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '');
973
+ return text.includes('mesh_send_task') || text.includes('mesh_status') || text.includes('mesh_read_chat');
974
+ });
975
+ return ptyHasCoordinator;
976
+ }
977
+
978
+ // No historySessionId in messages and no coordinator cross-check: fail closed.
979
+ // Workspace-only matching must not override an explicit session identity.
980
+ return false;
981
+ }
982
+ const workspace = String(args.workspace || '').trim();
983
+ if (!workspace) return false;
984
+ const workspaceMatches = args.nativeMessages.some((message: any) => String(message?.workspace || '').trim() === workspace);
985
+ if (!workspaceMatches) return false;
986
+
987
+ if (isCoordinatorTranscript && args.ptyMessages && args.ptyMessages.length > 0) {
988
+ const ptyHasCoordinator = args.ptyMessages.some((m: any) => {
989
+ const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '');
990
+ return text.includes('mesh_send_task') || text.includes('mesh_status') || text.includes('mesh_read_chat');
991
+ });
992
+ if (!ptyHasCoordinator) {
993
+ return false;
994
+ }
995
+ }
996
+
997
+ if (!args.requireWorkspaceContentOverlap) return true;
998
+ return hasOverlappingVisibleConversationText(args.nativeMessages, args.ptyMessages || []);
999
+ }
1000
+
1001
+ // Provenance boundary: workspace-only native history lookup is never safe
1002
+ // because multiple concurrent sessions sharing the same cwd would alias each
1003
+ // other. historySessionId (the provider-native session key) is required to
1004
+ // establish ownership. hasSafeNativeHistoryMapping() enforces the same
1005
+ // invariant after the read; both guards must hold for native history to be used.
1006
+ function readCliProviderNativeHistory(agentStr: string, args: {
1007
+ canonicalHistory?: ProviderModule['canonicalHistory'];
1008
+ historySessionId?: string;
1009
+ workspace?: string;
1010
+ offset: number;
1011
+ limit: number;
1012
+ excludeRecentCount: number;
1013
+ historyBehavior?: ProviderModule['historyBehavior'];
1014
+ scripts?: ProviderScripts;
1015
+ excludeInProgressTurn?: boolean;
1016
+ }): ReturnType<typeof readProviderChatHistory> & { lookup: 'session' | 'workspace' } {
1017
+ if (!args.historySessionId) {
1018
+ return {
1019
+ messages: [],
1020
+ hasMore: false,
1021
+ source: 'native-unavailable',
1022
+ unavailableReason: 'native_history_workspace_only_lookup_unsafe',
1023
+ lookup: 'session',
1024
+ } as ReturnType<typeof readProviderChatHistory> & { lookup: 'session' | 'workspace' };
1025
+ }
1026
+ const sessionHistory = readProviderChatHistory(agentStr, {
1027
+ canonicalHistory: args.canonicalHistory,
1028
+ historySessionId: args.historySessionId,
1029
+ workspace: args.workspace,
1030
+ offset: args.offset,
1031
+ limit: args.limit,
1032
+ excludeRecentCount: args.excludeRecentCount,
1033
+ historyBehavior: args.historyBehavior,
1034
+ scripts: args.scripts as any,
1035
+ excludeInProgressTurn: args.excludeInProgressTurn,
1036
+ });
1037
+ // Native transcripts are keyed by provider/runtime session identity. Falling
1038
+ // back to workspace makes concurrent local Codex/Hermes sessions alias each
1039
+ // other when they share the same cwd.
1040
+ return { ...(sessionHistory as any), lookup: 'session' };
1041
+ }
1042
+
1043
+ function readLiveCodexWorkspaceNativeHistory(agentStr: string, args: {
1044
+ canonicalHistory?: ProviderModule['canonicalHistory'];
1045
+ workspace?: string;
1046
+ offset: number;
1047
+ limit: number;
1048
+ excludeRecentCount: number;
1049
+ historyBehavior?: ProviderModule['historyBehavior'];
1050
+ scripts?: ProviderScripts;
1051
+ }): (ReturnType<typeof readProviderChatHistory> & { lookup: 'workspace' }) | null {
1052
+ if (agentStr !== 'codex-cli') return null;
1053
+ const workspace = typeof args.workspace === 'string' ? args.workspace.trim() : '';
1054
+ if (!workspace) return null;
1055
+ const history = readProviderChatHistory(agentStr, {
1056
+ canonicalHistory: args.canonicalHistory,
1057
+ workspace,
1058
+ offset: args.offset,
1059
+ limit: args.limit,
1060
+ excludeRecentCount: args.excludeRecentCount,
1061
+ historyBehavior: args.historyBehavior,
1062
+ scripts: args.scripts as any,
1063
+ });
1064
+ return { ...(history as any), lookup: 'workspace' };
1065
+ }
1066
+
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.
1073
+
1074
+ function shouldPreserveReadChatPayloadField(key: string): boolean {
1075
+ return key === 'messageSource' || key === 'transcriptProvenance';
1076
+ }
1077
+
1078
+ function updateMessageSourceReturnedCount(value: unknown, returnedMessageCount: number): unknown {
1079
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
1080
+ const record = value as Record<string, unknown>;
1081
+ const coverage = record.coverage && typeof record.coverage === 'object' && !Array.isArray(record.coverage)
1082
+ ? record.coverage as Record<string, unknown>
1083
+ : undefined;
1084
+ if (!coverage) return value;
1085
+ return {
1086
+ ...record,
1087
+ coverage: {
1088
+ ...coverage,
1089
+ returnedMessageCount,
1090
+ },
1091
+ };
1092
+ }
224
1093
 
225
1094
  function deriveHistoryDedupKey(message: ChatMessage & { _unitKey?: string; _turnKey?: string }): string | undefined {
226
1095
  const unitKey = typeof message._unitKey === 'string' ? message._unitKey.trim() : '';
@@ -281,11 +1150,20 @@ function normalizeReadChatCommandStatus(status: unknown, activeModal: unknown):
281
1150
  }
282
1151
  switch (raw) {
283
1152
  case 'starting':
284
- return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'generating';
1153
+ return hasNonEmptyModalButtons(activeModal) ? 'waiting_approval' : 'starting';
285
1154
  case 'stopped':
286
1155
  case 'disconnected':
287
1156
  case 'not_monitored':
288
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';
289
1167
  default:
290
1168
  return raw;
291
1169
  }
@@ -295,6 +1173,16 @@ function isGeneratingLikeStatus(status: unknown): boolean {
295
1173
  return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
296
1174
  }
297
1175
 
1176
+ function hasVisibleAssistantMessage(messages: unknown[] | undefined): boolean {
1177
+ if (!Array.isArray(messages)) return false;
1178
+ return messages.some((message: any) => {
1179
+ if (!message || message.role !== 'assistant') return false;
1180
+ const kind = typeof message.kind === 'string' ? message.kind : 'standard';
1181
+ if (kind !== 'standard') return false;
1182
+ return String(message.content || '').trim().length > 0;
1183
+ });
1184
+ }
1185
+
298
1186
  function shouldTrustCliAdapterTerminalStatus(parsedStatus: unknown, activeModal: unknown, adapter: CliAdapter, adapterStatus: any): boolean {
299
1187
  if (!isGeneratingLikeStatus(parsedStatus)) return false;
300
1188
  if (hasNonEmptyModalButtons(activeModal)) return false;
@@ -304,7 +1192,26 @@ function shouldTrustCliAdapterTerminalStatus(parsedStatus: unknown, activeModal:
304
1192
  return true;
305
1193
  }
306
1194
 
307
- function normalizeCliReadChatStatus(parsedStatus: unknown, activeModal: unknown, adapter: CliAdapter, adapterStatus: any): string {
1195
+ function normalizeCliReadChatStatus(parsedStatus: unknown, activeModal: unknown, adapter: CliAdapter, adapterStatus: any, parsedMessages?: unknown[]): string {
1196
+ const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
1197
+ if (adapterRawStatus === 'starting'
1198
+ && isGeneratingLikeStatus(parsedStatus)
1199
+ && !hasNonEmptyModalButtons(activeModal)
1200
+ && Array.isArray(parsedMessages)
1201
+ && parsedMessages.length === 0
1202
+ && Array.isArray(adapterStatus?.messages)
1203
+ && adapterStatus.messages.length === 0
1204
+ && !(typeof adapter.isProcessing === 'function' && adapter.isProcessing())) {
1205
+ return 'starting';
1206
+ }
1207
+ if (
1208
+ isGeneratingLikeStatus(adapterRawStatus)
1209
+ && parsedStatus === 'idle'
1210
+ && !hasNonEmptyModalButtons(activeModal)
1211
+ && !hasVisibleAssistantMessage(parsedMessages)
1212
+ ) {
1213
+ return adapterRawStatus;
1214
+ }
308
1215
  if (shouldTrustCliAdapterTerminalStatus(parsedStatus, activeModal, adapter, adapterStatus)) return 'idle';
309
1216
  return typeof parsedStatus === 'string' && parsedStatus.trim() ? parsedStatus : 'idle';
310
1217
  }
@@ -325,6 +1232,64 @@ function finalizeStreamingMessagesWhenIdle(messages: ChatMessage[], status: stri
325
1232
  });
326
1233
  }
327
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
+
328
1293
  function buildReadChatCommandResult(payload: Record<string, any>, args: any): CommandResult {
329
1294
  let validatedPayload: Record<string, any>;
330
1295
  const debugReadChat = payload?.debugReadChat && typeof payload.debugReadChat === 'object'
@@ -342,6 +1307,13 @@ function buildReadChatCommandResult(payload: Record<string, any>, args: any): Co
342
1307
  const visibleMessages = filterUserFacingChatMessages(messages);
343
1308
  const sync = buildFullTail(visibleMessages, normalizeReadChatTailLimit(args));
344
1309
  const hiddenMsgCount = Math.max(0, messages.length - visibleMessages.length);
1310
+ const preservedPayloadFields = Object.fromEntries(Object.entries(payload).filter(([key]) => shouldPreserveReadChatPayloadField(key)));
1311
+ if (preservedPayloadFields.messageSource) {
1312
+ preservedPayloadFields.messageSource = updateMessageSourceReturnedCount(preservedPayloadFields.messageSource, sync.messages.length);
1313
+ }
1314
+ if (preservedPayloadFields.transcriptProvenance) {
1315
+ preservedPayloadFields.transcriptProvenance = updateMessageSourceReturnedCount(preservedPayloadFields.transcriptProvenance, sync.messages.length);
1316
+ }
345
1317
  const returnedDebugReadChat = debugReadChat
346
1318
  ? {
347
1319
  ...debugReadChat,
@@ -356,6 +1328,7 @@ function buildReadChatCommandResult(payload: Record<string, any>, args: any): Co
356
1328
  return {
357
1329
  success: true,
358
1330
  ...validatedPayload,
1331
+ ...preservedPayloadFields,
359
1332
  messages: sync.messages,
360
1333
  totalMessages: sync.totalMessages,
361
1334
  ...(returnedDebugReadChat ? { debugReadChat: returnedDebugReadChat } : {}),
@@ -584,6 +1557,8 @@ function buildChatDebugBundleSummary(bundle: Record<string, unknown>): Record<st
584
1557
  adapterStatus: debugReadChat.adapterStatus,
585
1558
  parsedStatus: debugReadChat.parsedStatus,
586
1559
  returnedStatus: debugReadChat.returnedStatus,
1560
+ selectedMessageSource: debugReadChat.selectedMessageSource,
1561
+ messageSource: debugReadChat.messageSource,
587
1562
  parsedMsgCount: debugReadChat.parsedMsgCount,
588
1563
  returnedMsgCount: debugReadChat.returnedMsgCount,
589
1564
  shouldPreferAdapterMessages: debugReadChat.shouldPreferAdapterMessages,
@@ -646,11 +1621,20 @@ export async function handleGetChatDebugBundle(h: CommandHelpers, args: any): Pr
646
1621
  providerSessionId: readResult.providerSessionId,
647
1622
  transcriptAuthority: readResult.transcriptAuthority,
648
1623
  coverage: readResult.coverage,
1624
+ messageSource: readResult.messageSource,
1625
+ transcriptProvenance: readResult.transcriptProvenance,
649
1626
  activeModal: readResult.activeModal,
650
1627
  messagesTail: Array.isArray(readResult.messages) ? readResult.messages.slice(-20) : [],
651
1628
  debugReadChat: readResult.debugReadChat,
652
1629
  }
653
- : { success: false, error: readResult.error };
1630
+ : {
1631
+ success: false,
1632
+ error: readResult.error,
1633
+ code: readResult.code,
1634
+ messageSource: readResult.messageSource,
1635
+ transcriptProvenance: readResult.transcriptProvenance,
1636
+ debugReadChat: readResult.debugReadChat,
1637
+ };
654
1638
  } catch (error: any) {
655
1639
  readChat = { success: false, error: error?.message || String(error) };
656
1640
  }
@@ -817,16 +1801,56 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
817
1801
  : typeof (h.currentSession as any)?.workspace === 'string'
818
1802
  ? (h.currentSession as any).workspace
819
1803
  : undefined;
820
- const result = readProviderChatHistory(agentStr, {
821
- canonicalHistory: provider?.canonicalHistory,
822
- historySessionId,
823
- workspace,
824
- offset: offset || 0,
825
- limit: limit || 30,
826
- excludeRecentCount,
827
- historyBehavior: provider?.historyBehavior,
828
- scripts: provider?.scripts as any,
829
- });
1804
+ const exactNativeHistoryScope = Boolean(
1805
+ (typeof args?.targetSessionId === 'string' && args.targetSessionId.trim())
1806
+ || (typeof args?.historySessionId === 'string' && args.historySessionId.trim())
1807
+ || (typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
1808
+ );
1809
+ const result = supportsCliNativeTranscript(agentStr, provider) && isNativeSourceCanonicalHistory(provider?.canonicalHistory)
1810
+ ? readCliProviderNativeHistory(agentStr, {
1811
+ canonicalHistory: provider?.canonicalHistory,
1812
+ historySessionId,
1813
+ workspace,
1814
+ offset: offset || 0,
1815
+ limit: limit || 30,
1816
+ excludeRecentCount,
1817
+ historyBehavior: provider?.historyBehavior,
1818
+ scripts: provider?.scripts as any,
1819
+ })
1820
+ : readProviderChatHistory(agentStr, {
1821
+ canonicalHistory: provider?.canonicalHistory,
1822
+ historySessionId,
1823
+ workspace,
1824
+ offset: offset || 0,
1825
+ limit: limit || 30,
1826
+ excludeRecentCount,
1827
+ historyBehavior: provider?.historyBehavior,
1828
+ scripts: provider?.scripts as any,
1829
+ });
1830
+ if (supportsCliNativeTranscript(agentStr, provider) && isNativeSourceCanonicalHistory(provider?.canonicalHistory)) {
1831
+ const lookup = (result as any).lookup === 'workspace' ? 'workspace' : 'session';
1832
+ const messages = Array.isArray((result as any).messages)
1833
+ ? normalizeNativeHistoryMessages(agentStr, (result as any).messages as ChatMessage[], (result as any)?.providerSessionId)
1834
+ : [];
1835
+ const historyProviderSessionId = typeof (result as any)?.providerSessionId === 'string'
1836
+ ? (result as any).providerSessionId
1837
+ : readHistorySessionIdFromMessages(messages) || historySessionId;
1838
+ const safeMapping = hasSafeNativeHistoryMapping({
1839
+ historySessionId: lookup === 'workspace' ? undefined : historySessionId,
1840
+ providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId,
1841
+ workspace,
1842
+ nativeMessages: messages,
1843
+ });
1844
+ if ((result as any).source === 'provider-native' && messages.length > 0 && !safeMapping) {
1845
+ return {
1846
+ success: true,
1847
+ messages: [],
1848
+ hasMore: false,
1849
+ source: 'native-unavailable',
1850
+ agent: agentStr,
1851
+ };
1852
+ }
1853
+ }
830
1854
  return { success: true, ...result, agent: agentStr };
831
1855
  } catch (e: any) {
832
1856
  return { success: false, error: e.message };
@@ -874,59 +1898,388 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
874
1898
  ? parsedRecord.coverage
875
1899
  : undefined;
876
1900
  const activeModal = parsedRecord.activeModal ?? parsedRecord.modal ?? null;
877
- const returnedStatus = normalizeCliReadChatStatus(parsedRecord.status, activeModal, adapter, adapterStatus);
1901
+ const returnedStatus = normalizeCliReadChatStatus(parsedRecord.status, activeModal, adapter, adapterStatus, parsedRecord.messages);
878
1902
  const runtimeMessageMerger = getTargetInstance(h, args) as RuntimeChatMessageMerger | null;
879
- const parsedMessages = finalizeStreamingMessagesWhenIdle(parsedRecord.messages as ChatMessage[], returnedStatus);
1903
+ const parsedMessages = collapseAdjacentDuplicateChatMessages(
1904
+ finalizeStreamingMessagesWhenIdle(parsedRecord.messages as ChatMessage[], returnedStatus),
1905
+ );
880
1906
  const returnedMessages = runtimeMessageMerger?.category === 'cli'
881
1907
  && runtimeMessageMerger.type === adapter.cliType
882
1908
  && typeof runtimeMessageMerger.mergeRuntimeChatMessages === 'function'
883
1909
  ? runtimeMessageMerger.mergeRuntimeChatMessages(parsedMessages)
884
1910
  : parsedMessages;
1911
+ const providerType = provider?.type || adapter.cliType;
1912
+ let selectedMessages = returnedMessages;
1913
+ let selectedTitle = title;
1914
+ let selectedProviderSessionId = providerSessionId;
1915
+ let selectedTranscriptAuthority = transcriptAuthority;
1916
+ let selectedCoverage = coverage;
1917
+ let selectedStatus = returnedStatus;
1918
+ const sessionWorkspace = typeof (h.currentSession as any)?.workspace === 'string'
1919
+ ? (h.currentSession as any).workspace
1920
+ : typeof adapter.workingDir === 'string'
1921
+ ? adapter.workingDir
1922
+ : undefined;
1923
+ const intendedWorkspace = typeof args?.workspace === 'string' ? args.workspace : undefined;
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
+ );
1970
+
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) {
1975
+ try {
1976
+ nativeHistory = readCliProviderNativeHistory(agentStr, {
1977
+ canonicalHistory: provider?.canonicalHistory,
1978
+ historySessionId: nativeHistorySessionId,
1979
+ workspace,
1980
+ offset: 0,
1981
+ limit: nativeHistoryLimit,
1982
+ excludeRecentCount: 0,
1983
+ historyBehavior: provider?.historyBehavior,
1984
+ scripts: provider?.scripts as any,
1985
+ excludeInProgressTurn: returnedStatus === 'waiting_approval',
1986
+ });
1987
+ } catch (error: any) {
1988
+ nativeHistoryError = error;
1989
+ nativeHistory = null;
1990
+ }
1991
+ }
1992
+
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,
2072
+ workspace,
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,
2090
+ ptyMessages: returnedMessages,
2091
+ requireWorkspaceContentOverlap: true,
2092
+ });
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,
2105
+ ptyMessages: returnedMessages,
2106
+ ptyStatusApprovalOnly: true,
2107
+ });
2108
+ if (liveDecision.nativeSelected) {
2109
+ selectedMessages = finalizeStreamingMessagesWhenIdle(liveDecision.nativeMessages, returnedStatus);
2110
+ selectedProviderSessionId = liveWorkspaceNativeProviderSessionId || providerSessionId;
2111
+ selectedTranscriptAuthority = 'provider';
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;
2116
+ } else {
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,
2122
+ adapter,
2123
+ helpers: h,
2124
+ readChatArgs: args,
2125
+ sessionWorkspace,
2126
+ intendedWorkspace,
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,
2140
+ });
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
+ });
2164
+ }
2165
+ }
885
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}`);
886
2167
  return buildReadChatCommandResult({
887
- messages: returnedMessages,
888
- status: returnedStatus,
2168
+ messages: selectedMessages,
2169
+ status: selectedStatus,
889
2170
  activeModal,
2171
+ messageSource,
2172
+ transcriptProvenance: messageSource,
890
2173
  debugReadChat: {
891
2174
  provider: adapter.cliType,
892
2175
  targetSessionId: String(args?.targetSessionId || ''),
893
2176
  adapterStatus: String(adapterStatus.status || ''),
894
2177
  parsedStatus: String(parsedRecord.status || ''),
895
- returnedStatus: String(returnedStatus || ''),
896
- shouldPreferAdapterMessages: false,
2178
+ returnedStatus: String(selectedStatus || ''),
2179
+ selectedMessageSource: (messageSource as any).selected,
2180
+ messageSource,
2181
+ shouldPreferAdapterMessages: supportsCliNativeTranscript(providerType, provider)
2182
+ && isNativeSourceCanonicalHistory(provider?.canonicalHistory)
2183
+ && (messageSource as any).selected !== 'native-history'
2184
+ && typeof (messageSource as any).fallbackReason === 'string'
2185
+ && (messageSource as any).fallbackReason.startsWith('native_history_')
2186
+ && (messageSource as any).fallbackReason !== 'native_history_not_checked'
2187
+ && !isUnsafeNativeTranscriptFallback((messageSource as any).fallbackReason)
2188
+ && !(selectedTranscriptAuthority === 'provider' && selectedCoverage === 'full'),
897
2189
  parsedMsgCount: parsedRecord.messages.length,
898
- returnedMsgCount: returnedMessages.length,
2190
+ returnedMsgCount: selectedMessages.length,
899
2191
  },
900
- ...(title ? { title } : {}),
901
- ...(providerSessionId ? { providerSessionId } : {}),
902
- ...(transcriptAuthority ? { transcriptAuthority } : {}),
903
- ...(coverage ? { coverage } : {}),
2192
+ ...(selectedTitle ? { title: selectedTitle } : {}),
2193
+ ...(selectedProviderSessionId ? { providerSessionId: selectedProviderSessionId } : {}),
2194
+ ...(selectedTranscriptAuthority ? { transcriptAuthority: selectedTranscriptAuthority } : {}),
2195
+ ...(selectedCoverage ? { coverage: selectedCoverage } : {}),
904
2196
  }, args);
905
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.
906
2203
  const historyLimit = normalizeReadChatTailLimit(args);
907
2204
  try {
908
2205
  const agentStr = provider?.type || args?.agentType || getCurrentProviderType(h);
909
- const workspace = typeof args?.workspace === 'string'
910
- ? args.workspace
911
- : typeof (h.currentSession as any)?.workspace === 'string'
912
- ? (h.currentSession as any).workspace
913
- : undefined;
914
- const history = readProviderChatHistory(agentStr, {
915
- canonicalHistory: provider?.canonicalHistory,
916
- historySessionId,
917
- workspace,
918
- offset: 0,
919
- limit: historyLimit,
920
- excludeRecentCount: 0,
921
- historyBehavior: provider?.historyBehavior,
922
- scripts: provider?.scripts as any,
923
- });
2206
+ const workspace = typeof (h.currentSession as any)?.workspace === 'string'
2207
+ ? (h.currentSession as any).workspace
2208
+ : undefined;
2209
+ const intendedWorkspace = typeof args?.workspace === 'string' ? args.workspace : undefined;
2210
+ const supportsNative = supportsCliNativeTranscript(agentStr, provider)
2211
+ && isNativeSourceCanonicalHistory(provider?.canonicalHistory);
2212
+ const history = supportsNative
2213
+ ? readCliProviderNativeHistory(agentStr, {
2214
+ canonicalHistory: provider?.canonicalHistory,
2215
+ historySessionId,
2216
+ workspace,
2217
+ offset: 0,
2218
+ limit: historyLimit,
2219
+ excludeRecentCount: 0,
2220
+ historyBehavior: provider?.historyBehavior,
2221
+ scripts: provider?.scripts as any,
2222
+ })
2223
+ : readProviderChatHistory(agentStr, {
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';
2234
+ const historyMessages = Array.isArray((history as any)?.messages)
2235
+ ? normalizeNativeHistoryMessages(agentStr, (history as any).messages as ChatMessage[], (history as any)?.providerSessionId)
2236
+ : [];
924
2237
  const historyProviderSessionId = typeof (history as any)?.providerSessionId === 'string'
925
2238
  ? (history as any).providerSessionId
926
- : historySessionId;
2239
+ : readHistorySessionIdFromMessages(historyMessages) || historySessionId;
2240
+ const safeMapping = supportsNative
2241
+ ? hasSafeNativeHistoryMapping({
2242
+ historySessionId: lookup === 'workspace' ? undefined : historySessionId,
2243
+ providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId,
2244
+ workspace,
2245
+ nativeMessages: historyMessages,
2246
+ })
2247
+ : false;
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,
2262
+ sessionWorkspace: workspace,
2263
+ intendedWorkspace,
2264
+ ptyMessages: [],
2265
+ ptyStatusApprovalOnly: false,
2266
+ });
2267
+
2268
+ if (supportsNative && !decision.nativeSelected) {
2269
+ return {
2270
+ success: false,
2271
+ code: 'native_history_not_safely_available',
2272
+ error: 'Provider-native history was not safely available for the requested CLI session.',
2273
+ providerSessionId: historyProviderSessionId,
2274
+ messageSource: decision.messageSource,
2275
+ transcriptProvenance: decision.messageSource,
2276
+ };
2277
+ }
927
2278
  return buildReadChatCommandResult({
928
- messages: Array.isArray((history as any)?.messages) ? (history as any).messages : [],
2279
+ messages: historyMessages,
929
2280
  status: 'idle',
2281
+ messageSource: decision.messageSource,
2282
+ transcriptProvenance: decision.messageSource,
930
2283
  ...(typeof (history as any)?.title === 'string' ? { title: (history as any).title } : {}),
931
2284
  ...(historyProviderSessionId ? { providerSessionId: historyProviderSessionId } : {}),
932
2285
  ...(((provider?.historyBehavior as any)?.transcriptAuthority === 'provider' || (provider?.historyBehavior as any)?.transcriptAuthority === 'daemon')
@@ -1184,8 +2537,24 @@ export async function handleSendChat(h: CommandHelpers, args: any): Promise<Comm
1184
2537
  assertTextOnlyInput(provider, input);
1185
2538
  if (!text) return { success: false, error: 'text required for PTY send' };
1186
2539
  await waitOnceForFreshHermesCliStart(adapter, _log);
1187
- await adapter.sendMessage(text);
1188
- return _logSendSuccess(`${transport}-adapter`, adapter.cliType);
2540
+ const forceSend = args?.force === true || args?.forceSend === true;
2541
+ if (forceSend && typeof adapter.forceSendMessage === 'function') {
2542
+ await adapter.forceSendMessage(text);
2543
+ } else if (forceSend) {
2544
+ await adapter.sendMessage(text, { force: true });
2545
+ } else {
2546
+ await adapter.sendMessage(text);
2547
+ }
2548
+ const target = getTargetInstance(h, args) as RuntimeChatMessageMerger | null;
2549
+ if (target?.category === 'cli'
2550
+ && target.type === adapter.cliType
2551
+ && typeof target.recordAcknowledgedUserInput === 'function') {
2552
+ target.recordAcknowledgedUserInput(input);
2553
+ }
2554
+ return {
2555
+ ..._logSendSuccess(`${transport}-adapter`, adapter.cliType),
2556
+ ...(forceSend ? { forceSent: true } : {}),
2557
+ };
1189
2558
  } catch (e: any) {
1190
2559
  return { success: false, error: `${transport} send failed: ${e.message}` };
1191
2560
  }
@@ -1742,9 +3111,18 @@ export async function handleResolveAction(h: CommandHelpers, args: any): Promise
1742
3111
  if (buttonIndex < 0 && (action === 'always' || /always/i.test(button))) {
1743
3112
  buttonIndex = buttons.findIndex(b => /always/i.test(b));
1744
3113
  }
3114
+ if (buttonIndex < 0 && (action === 'approve' || action === 'accept')) {
3115
+ buttonIndex = pickApprovalButton(buttons, provider).index;
3116
+ }
1745
3117
  if (buttonIndex < 0) {
1746
3118
  return { success: false, error: 'Approval action did not match any visible button' };
1747
3119
  }
3120
+ // Idempotency: if the adapter already resolved this approval within cooldown, report
3121
+ // stale_prompt rather than writing a second key to the PTY.
3122
+ if (typeof adapter.isApprovalRecentlyResolved === 'function' && adapter.isApprovalRecentlyResolved()) {
3123
+ LOG.info('Command', `[resolveAction] CLI PTY → stale_prompt (already resolved within cooldown)`);
3124
+ return { success: true, stalePrompt: true, buttonIndex, button: buttons[buttonIndex] ?? button };
3125
+ }
1748
3126
  if (typeof adapter.resolveModal === 'function') {
1749
3127
  adapter.resolveModal(buttonIndex);
1750
3128
  } else {