@adhdev/daemon-core 0.9.82-rc.467 → 0.9.82-rc.468

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.
@@ -50,6 +50,23 @@ import {
50
50
  getForcedNewSessionScriptName,
51
51
  waitForCliAdapterReady,
52
52
  } from './cli-provider-status-helpers.js';
53
+ import {
54
+ STATUS_HYDRATION_TAIL_LIMIT,
55
+ COMPLETED_FINALIZATION_RETRY_MS,
56
+ COMPLETED_FINALIZATION_MAX_WAIT_MS,
57
+ NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
58
+ USER_INPUT_ACK_DEDUP_WINDOW_MS,
59
+ STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS,
60
+ TERMINAL_MESH_EVENTS,
61
+ } from './cli-provider-instance-types.js';
62
+ import type {
63
+ CompletedDebouncePending,
64
+ CompletedFinalizationBlock,
65
+ CompletionFinalAssistantEvidence,
66
+ ExternalTranscriptProbe,
67
+ } from './cli-provider-instance-types.js';
68
+ import { mergeConversationMessages, buildExternalTranscriptProbe } from './cli-provider-transcript-merge.js';
69
+ import { getEffectDedupKey, formatApprovalRequestMessage, formatMarkerTimestamp } from './cli-provider-effect-format.js';
53
70
 
54
71
  // Re-export moved public symbols so existing importers (index.ts, tests) keep
55
72
  // their `./cli-provider-instance.js` path. Pure move — no behavior change.
@@ -61,133 +78,15 @@ export {
61
78
  waitForCliAdapterReady,
62
79
  } from './cli-provider-status-helpers.js';
63
80
 
64
- // Status snapshots only ever surface the newest messages: the cloud 'live'
65
- // profile drops chat messages entirely (loaded lazily via read_chat on
66
- // subscribe) and the 'full' profile caps activeChat.messages to the last 60
67
- // (see status/normalize.ts). Unread/completion markers walk only the tail.
68
- // So getState()'s saved-history hydration which runs once per resume/manual
69
- // CLI session on every status report — must read only a bounded tail, not the
70
- // entire transcript. A full MAX_SAFE_INTEGER read here makes the initial
71
- // status report O(transcript) × N(sessions), which is the real cold first-
72
- // connection bottleneck on chat-heavy machines. The window comfortably exceeds
73
- // the 60-message snapshot cap so dedup/collapse at the boundary stays stable.
74
- const STATUS_HYDRATION_TAIL_LIMIT = 200;
75
-
76
- type CompletedDebouncePending = {
77
- chatTitle: string;
78
- duration: number;
79
- timestamp: number;
80
- firstObservedAt: number;
81
- previousStatus: string;
82
- loggedBlockReason?: string;
83
- loggedTranscriptProbe?: boolean;
84
- transcriptProbeHistory?: ExternalTranscriptProbe[];
85
- // ARCH-REFACTOR R1: the taskId of the turn that produced this (debounced) completion,
86
- // captured SYNCHRONOUSLY at the generating→idle transition. The actual completion
87
- // event is emitted later by the debounce flush, by which point a follow-up task may
88
- // already have started its own turn and overwritten engine.currentTurnTaskId — so the
89
- // id must be snapshotted here, not re-read at flush time.
90
- taskId?: string;
91
- // NOTIF Defect-B: the wall-clock start of the turn that produced this (debounced)
92
- // completion, snapshotted SYNCHRONOUSLY at the generating→idle transition (same
93
- // reason as taskId — a follow-up turn moves engine.currentTurnStartedAt). The
94
- // completion's finalSummary is turn-scoped to bubbles at/after this instant so a
95
- // debounce that flushes before the producing turn's final assistant bubble lands
96
- // in the native transcript never echoes the PRIOR task's last bubble.
97
- turnStartedAt?: number;
98
- // FALSE-IDLE continuity: the busyEpoch value at the instant this pending was armed.
99
- // The flush guard requires this.busyEpoch to still equal this — proving no busy
100
- // phase (generating/waiting_approval) opened since arming. A momentary busy→idle
101
- // blip in an inter-approval valley bumps busyEpoch, so a completion armed before
102
- // the blip is cancelled at flush instead of emitting a stale mid-turn summary.
103
- busyEpochAtArm?: number;
104
- // FALSE-IDLE continuity: the adapter's raw PTY lastOutputAt at arm time. New PTY
105
- // output after arming means the session was not continuously idle through the
106
- // settle window (the agent kept printing), so the completion is cancelled.
107
- lastOutputAtArm?: number;
108
- };
109
-
110
- type CompletedFinalizationBlock = {
111
- reason: string;
112
- terminal?: boolean;
113
- allowTimeout?: boolean;
114
- // (SETTLE-VALLEY) When set, suppress the CANON-C decoupled-immediate emit for this
115
- // missing_final_assistant block and HOLD (retry up to COMPLETED_FINALIZATION_MAX_WAIT_MS)
116
- // until the native transcript's final assistant turn arrives (block clears → genuine emit)
117
- // or the worker resumes (resume guard cancels). Set only for the inter-approval idle valley
118
- // of a native-history mesh worker, where an immediate weak emit would freeze a truncated
119
- // preamble summary (evidenceLevel=insufficient) into the append-only ledger before the
120
- // worker's next approval turn resumes. Independent of valley length.
121
- holdForTranscript?: boolean;
122
- };
123
-
124
- type CompletionFinalAssistantEvidence = {
125
- present: boolean;
126
- messages: unknown[];
127
- source: 'parsed' | 'external-native' | 'unavailable';
128
- };
129
-
130
- type ExternalTranscriptProbe = {
131
- readAt: number;
132
- msgCount: number;
133
- lastRole: string | null;
134
- lastKind: string | null;
135
- contentLen: number;
136
- sourcePath: string | null;
137
- sourceMtimeMs: number | null;
138
- mtimeAgeMs: number | null;
139
- };
140
-
141
- const COMPLETED_FINALIZATION_RETRY_MS = 1000;
142
- const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
143
- // (FALSEIDLE-BGCHILD-a) Minimum generating→idle settle window for native-history mesh worker
144
- // sessions. Native-history providers (e.g. claude-cli) normally flush the completion with
145
- // flushDelay=0 — the transcript is authoritative, so there is no reason to wait. But a worker
146
- // turn that spawns a BACKGROUND child (e.g. `npm test &`, a backgrounded Bash tool) can paint
147
- // a burst of child output, fall quiet, and have the screen parser read a PRIOR/intermediate
148
- // standard assistant as if the turn were done — firing a false idle while the agent is in fact
149
- // still generating (e.g. mid-commit). With flushDelay=0 there is no window for the resume guard
150
- // in flushCompletedDebounceIfFinalized (latestVisibleStatus !== 'idle' → cancel) to observe the
151
- // agent picking the turn back up. A short non-zero settle window restores that resume guard for
152
- // mesh workers without delaying genuinely-finished turns beyond this bound. Scoped to mesh
153
- // worker sessions so interactive native-history sessions keep the immediate flush.
154
- // 4000ms (was 1500): live measurement showed the completion event can fire 1.6–3s
155
- // BEFORE the worker's final-assistant turn lands in the transcript on a natural
156
- // generating→idle completion (no approval modal), freezing a prior intermediate
157
- // bubble as finalSummary (evidenceLevel=insufficient). The 68a3c324 waiting_approval
158
- // hold only covers the approval-resolved valley; widening this settle window to 4000ms
159
- // covers that race AND the ~3s waiting_approval valley within the settle bound.
160
- const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
161
- // TASKBUBBLE-DUP: window during which an identical user-input ack (same trimmed
162
- // content on the same instance) is treated as a redelivery of one dispatch and
163
- // suppressed from the chat transcript. Matches the coordinator-side
164
- // DUPLICATE_DISPATCH_WINDOW_MS (mesh-tools) so the daemon's bubble-level guard
165
- // covers the same retry horizon as the MCP-level dispatch dedup.
166
- const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60_000;
167
- // GENERATING-BOUNDARY (R4c): window — measured from the startup-grace COLLAPSE moment
168
- // (startupGraceCollapseAt), not boot — inside which a "first turn that ran+completed
169
- // without the FSM ever observing a 'generating' frame" is attributed to the startup-grace
170
- // collapse and synthesized (reason 'startup_grace_idle_turn_collapse').
171
- // The spec's startup-grace exit is elapsed_ms=8000, so the FSM spends ~8s in 'starting'
172
- // before collapsing to idle; a turn can be dispatched a few seconds AFTER that collapse
173
- // (the live R4b miss: collapse at boot+8s, dispatch at boot+12.4s — already past a 12s
174
- // boot-anchored window before the turn even started). Anchoring on the collapse moment
175
- // covers dispatch-delay; R4d additionally anchors on the turn-START moment
176
- // (engine.currentTurnStartedAt) so a non-trivial turn-DURATION cannot push the completion
177
- // past a now-anchored window (the live rc.405 Probe2 miss). The strong discriminator is
178
- // generatingStartedAt===0 (generating was never observed) AND a started-but-finished turn
179
- // — the window only keeps the synthesized reason honest and scopes the synthesis to the
180
- // boot collapse, so a much-later unobservably-fast turn is not mislabelled a startup collapse.
181
- const STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS = 12_000;
182
-
183
- /** Events that signal a dispatched mesh task has reached a terminal state.
184
- * Detach the mesh assignment after emitting one of these so the worker's
185
- * next unrelated turn doesn't impersonate another completion. */
186
- const TERMINAL_MESH_EVENTS = new Set([
187
- 'agent:generating_completed',
188
- 'agent:stopped',
189
- 'agent:ready',
190
- ]);
81
+ /**
82
+ * The auto-approve settle-gate identity signature: the modal's question line plus
83
+ * the NORMALIZED affirmative label, no volatile counters or raw button set. Shared
84
+ * by the fire path (maybeAutoApproveStatus) and the mask-stall nudge so both compare
85
+ * the SAME identity the settle clock tracks (AUTOAPPROVE-SETTLE-FLAP).
86
+ */
87
+ function approvalModalSignature(message: unknown, affirmativeAnchor: string): string {
88
+ return [typeof message === 'string' ? message.trim() : '', affirmativeAnchor].join('::');
89
+ }
191
90
 
192
91
  export class CliProviderInstance implements ProviderInstance {
193
92
  readonly type: string;
@@ -480,7 +379,7 @@ export class CliProviderInstance implements ProviderInstance {
480
379
  const resumedAt = Date.now();
481
380
  this.historyWriter.appendSystemMarker(
482
381
  this.type,
483
- `Resumed saved session at ${this.formatMarkerTimestamp(resumedAt)}`,
382
+ `Resumed saved session at ${formatMarkerTimestamp(resumedAt)}`,
484
383
  {
485
384
  instanceId: this.instanceId,
486
385
  historySessionId: this.providerSessionId,
@@ -649,7 +548,7 @@ export class CliProviderInstance implements ProviderInstance {
649
548
  ? parsedMessages.slice(-historyMessageCount)
650
549
  : [];
651
550
  }
652
- const mergedMessages = this.mergeConversationMessages(parsedMessages);
551
+ const mergedMessages = mergeConversationMessages(this.runtimeMessages, parsedMessages);
653
552
  const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
654
553
  ? this.syncCanonicalSavedHistoryIfNeeded()
655
554
  : false;
@@ -1257,23 +1156,6 @@ export class CliProviderInstance implements ProviderInstance {
1257
1156
  return true;
1258
1157
  }
1259
1158
 
1260
- private buildExternalTranscriptProbe(messages: unknown[], sourcePath?: string, sourceMtimeMs?: number): ExternalTranscriptProbe {
1261
- const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1262
- const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1263
- const readAt = Date.now();
1264
- const mtimeMs = Number(sourceMtimeMs) || 0;
1265
- return {
1266
- readAt,
1267
- msgCount: messages.length,
1268
- lastRole: typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null,
1269
- lastKind: typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null,
1270
- contentLen: lastVisible ? flattenContent(lastVisible.content).trim().length : 0,
1271
- sourcePath: typeof sourcePath === 'string' && sourcePath ? sourcePath : null,
1272
- sourceMtimeMs: mtimeMs || null,
1273
- mtimeAgeMs: mtimeMs ? Math.max(0, readAt - mtimeMs) : null,
1274
- };
1275
- }
1276
-
1277
1159
  private recordPendingTranscriptProbe(pending: CompletedDebouncePending): ExternalTranscriptProbe | null {
1278
1160
  const probe = this.lastExternalCompletionProbe;
1279
1161
  if (!probe) return null;
@@ -1327,7 +1209,7 @@ export class CliProviderInstance implements ProviderInstance {
1327
1209
  this.lastExternalCompletionProbe = null;
1328
1210
  return null;
1329
1211
  }
1330
- this.lastExternalCompletionProbe = this.buildExternalTranscriptProbe(
1212
+ this.lastExternalCompletionProbe = buildExternalTranscriptProbe(
1331
1213
  restoredHistory.messages,
1332
1214
  restoredHistory.sourcePath,
1333
1215
  restoredHistory.sourceMtimeMs,
@@ -1698,6 +1580,31 @@ export class CliProviderInstance implements ProviderInstance {
1698
1580
  : CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS;
1699
1581
  }
1700
1582
 
1583
+ /**
1584
+ * The settle-gate identity signature for a raw activeModal, or null when the
1585
+ * modal is NOT a concrete auto-approvable consent prompt (no captured buttons,
1586
+ * a picker/confirm kind, or no reliable affirmative+decline anchor). Mirrors the
1587
+ * gates the auto-approve fire path applies before computing modalSignature, so
1588
+ * the mask-stall nudge can ask the SAME question the settle gate is tracking —
1589
+ * "is THIS frame's modal the identity the settle clock is accruing against?" —
1590
+ * without duplicating the button-pick logic. The signature is message +
1591
+ * normalized affirmative label only (no volatile counters/button set), matching
1592
+ * the fire path exactly (AUTOAPPROVE-SETTLE-FLAP).
1593
+ */
1594
+ private approvableModalSignature(modal: any): string | null {
1595
+ const buttons = Array.isArray(modal?.buttons)
1596
+ ? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
1597
+ : [];
1598
+ if (!modal || buttons.length === 0) return null;
1599
+ const modalKind = typeof modal?.kind === 'string' ? modal.kind : 'approval';
1600
+ if (modalKind !== 'approval') return null;
1601
+ const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
1602
+ const hasReliableConsentAnchor = hasNegativeApprovalOption(buttons)
1603
+ || hasReliableApprovalAffirmative(buttons);
1604
+ if (buttonIndex < 0 || !hasReliableConsentAnchor) return null;
1605
+ return approvalModalSignature(modal?.message, normalizeApprovalLabel(buttonLabel));
1606
+ }
1607
+
1701
1608
  // FALSE-IDLE (self-coordinator settle): an autonomously-progressing mesh session
1702
1609
  // is either a delegated worker (isMeshWorkerSession) OR the coordinator's OWN
1703
1610
  // claude-cli session (meshCoordinatorFor). Both run auto-approved tool turns whose
@@ -2184,10 +2091,7 @@ export class CliProviderInstance implements ProviderInstance {
2184
2091
  // identifies the consent question without tracking the volatile button
2185
2092
  // positions.
2186
2093
  const affirmativeAnchor = normalizeApprovalLabel(buttonLabel);
2187
- const modalSignature = [
2188
- typeof modal?.message === 'string' ? modal.message.trim() : '',
2189
- affirmativeAnchor,
2190
- ].join('::');
2094
+ const modalSignature = approvalModalSignature(modal?.message, affirmativeAnchor);
2191
2095
  // Busy-window re-entry guard still needs the seq: two DISTINCT
2192
2096
  // back-to-back approvals can carry identical message/buttons (common
2193
2097
  // with claude-cli). Without the seq their busy signatures collide and
@@ -2553,7 +2457,7 @@ export class CliProviderInstance implements ProviderInstance {
2553
2457
  if (approvalFingerprint !== this.lastApprovalEventFingerprint) {
2554
2458
  this.lastApprovalEventFingerprint = approvalFingerprint;
2555
2459
  this.appendRuntimeSystemMessage(
2556
- this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
2460
+ formatApprovalRequestMessage(modal?.message, modal?.buttons),
2557
2461
  `approval_request:${now}`,
2558
2462
  now,
2559
2463
  );
@@ -3094,7 +2998,7 @@ export class CliProviderInstance implements ProviderInstance {
3094
2998
  if (effectWhen === 'turn_completed' && options.phase !== 'turn_completed') continue;
3095
2999
  if (effectWhen === 'immediate' && options.phase === 'turn_completed') continue;
3096
3000
 
3097
- const effectKey = this.getEffectDedupKey(effect);
3001
+ const effectKey = getEffectDedupKey(effect);
3098
3002
  if (this.appliedEffectKeys.has(effectKey)) continue;
3099
3003
  this.appliedEffectKeys.add(effectKey);
3100
3004
 
@@ -3144,40 +3048,6 @@ export class CliProviderInstance implements ProviderInstance {
3144
3048
  this.appliedEffectKeys = new Set(Array.from(this.appliedEffectKeys).slice(-100));
3145
3049
  }
3146
3050
  }
3147
-
3148
- private getEffectDedupKey(effect: { id?: string; type: string; message?: { content?: unknown }; toast?: { message?: string }; notification?: { title?: string; body?: string } }): string {
3149
- if (effect.id) return `provider_effect:${effect.id}`;
3150
- if (effect.type === 'message') {
3151
- const content = typeof effect.message?.content === 'string'
3152
- ? effect.message.content
3153
- : JSON.stringify(effect.message?.content || '');
3154
- return `provider_effect:message:${content}`;
3155
- }
3156
- if (effect.type === 'notification') {
3157
- return `provider_effect:notification:${effect.notification?.title || ''}:${effect.notification?.body || ''}`;
3158
- }
3159
- return `provider_effect:toast:${effect.toast?.message || ''}`;
3160
- }
3161
-
3162
- private getPersistedEffectContent(effect: { type: string; message?: { content?: unknown }; toast?: { message?: string }; notification?: { title?: string; body?: string; bubbleContent?: unknown } }): string | null {
3163
- if (effect.type === 'message') {
3164
- return typeof effect.message?.content === 'string'
3165
- ? effect.message.content
3166
- : JSON.stringify(effect.message?.content || '');
3167
- }
3168
- if (effect.type === 'toast') {
3169
- return effect.toast?.message || null;
3170
- }
3171
- if (effect.type === 'notification') {
3172
- if (typeof effect.notification?.bubbleContent === 'string') return effect.notification.bubbleContent;
3173
- if (typeof effect.notification?.title === 'string' && effect.notification.title.trim()) {
3174
- return `${effect.notification.title}\n${effect.notification.body || ''}`.trim();
3175
- }
3176
- return effect.notification?.body || null;
3177
- }
3178
- return null;
3179
- }
3180
-
3181
3051
  // ─── Adapter access (backward compat) ──────────────────
3182
3052
 
3183
3053
  getAdapter(): ProviderCliAdapter {
@@ -3263,18 +3133,34 @@ export class CliProviderInstance implements ProviderInstance {
3263
3133
  if (!this.isMeshWorkerSession()) return;
3264
3134
  if (adapterStatus?.status !== 'waiting_approval') return;
3265
3135
  if (!this.autoApproveMaskStalled(now)) return;
3266
- // AUTOAPPROVE-FLAP-RECUR (Fix C): the mask-stall bound tripped, but if a
3267
- // concrete approvable modal is on screen RIGHT NOW and the settle gate is
3268
- // already in progress, auto-approve is about to fire on its own (the same
3269
- // call runs the settle evaluation just below this nudge). Defer to the fire
3270
- // rather than paging the coordinator — the nudge would race a resolveModal
3271
- // that lands milliseconds later, producing the coordinator flap this fix
3272
- // targets. A genuinely stuck episode (no captured modal, or settle never
3273
- // engaged) still has pendingAutoApprovalSince === 0 here and pages normally.
3274
- const modalButtons = Array.isArray(adapterStatus.activeModal?.buttons)
3275
- ? adapterStatus.activeModal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
3276
- : [];
3277
- if (this.pendingAutoApprovalSince && modalButtons.length > 0) return;
3136
+ // AUTOAPPROVE-FLAP-RECUR (Fix C, redesigned): the mask-stall bound tripped.
3137
+ // If auto-approve is genuinely about to fire on its own, defer to that fire
3138
+ // the nudge would race a resolveModal landing milliseconds later, producing
3139
+ // the coordinator flap this fix targets.
3140
+ //
3141
+ // The ORIGINAL guard deferred on `pendingAutoApprovalSince && buttons > 0`
3142
+ // alone. That is FALSE-POSITIVE for a FLAPPING modal: a modal whose identity
3143
+ // changes every frame (message streaming) keeps pendingAutoApprovalSince
3144
+ // nonzero (it is re-stamped to `now` on every signature change) and keeps
3145
+ // buttons present, yet its settle clock is wiped back to ~0 each frame and
3146
+ // NEVER reaches SETTLE_MS — so auto-approve can never fire and this is exactly
3147
+ // the stuck episode the coordinator MUST be paged about. The old guard
3148
+ // silenced it permanently (the live rc.466 regression).
3149
+ //
3150
+ // Discriminator: a settle is genuinely PROGRESSING only if THIS frame's modal
3151
+ // carries the SAME identity the settle clock is already accruing against
3152
+ // (pendingAutoApprovalSignature). A stable modal keeps its signature across
3153
+ // frames → the clock climbs → resolveModal is imminent → defer. A flapping
3154
+ // modal presents a DIFFERENT signature this frame (or none — a non-concrete /
3155
+ // picker / never-captured modal) → the clock is about to reset (or never
3156
+ // engaged) → do NOT defer, page the coordinator. This is evaluated for the
3157
+ // CURRENT frame (not a stale prior-frame value), so a stable modal polled only
3158
+ // twice — start, then past the stall bound — is still correctly deferred.
3159
+ const currentSignature = this.approvableModalSignature(adapterStatus.activeModal);
3160
+ const settleProgressing = !!currentSignature
3161
+ && this.pendingAutoApprovalSince > 0
3162
+ && currentSignature === this.pendingAutoApprovalSignature;
3163
+ if (settleProgressing) return;
3278
3164
  // Exactly once per stalled episode (autoApproveMaskSince uniquely identifies it).
3279
3165
  if (this.stalledApprovalNudgeEpisode === this.autoApproveMaskSince) return;
3280
3166
  this.stalledApprovalNudgeEpisode = this.autoApproveMaskSince;
@@ -3282,7 +3168,7 @@ export class CliProviderInstance implements ProviderInstance {
3282
3168
  const dirName = workingDirBasename(this.workingDir);
3283
3169
  const chatTitle = `${this.provider.name} · ${dirName}`;
3284
3170
  this.appendRuntimeSystemMessage(
3285
- this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
3171
+ formatApprovalRequestMessage(modal?.message, modal?.buttons),
3286
3172
  `approval_request:${now}`,
3287
3173
  now,
3288
3174
  );
@@ -3313,12 +3199,6 @@ export class CliProviderInstance implements ProviderInstance {
3313
3199
  );
3314
3200
  }
3315
3201
 
3316
- private formatMarkerTimestamp(timestamp: number): string {
3317
- const date = new Date(timestamp);
3318
- const pad = (value: number) => String(value).padStart(2, '0');
3319
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
3320
- }
3321
-
3322
3202
  private maybeAppendRuntimeRecoveryMessage(runtime: PtyRuntimeMetadata | null): void {
3323
3203
  if (!runtime?.restoredFromStorage || !runtime.runtimeId) return;
3324
3204
 
@@ -3389,103 +3269,7 @@ export class CliProviderInstance implements ProviderInstance {
3389
3269
  }
3390
3270
 
3391
3271
  mergeRuntimeChatMessages(parsedMessages: ChatMessage[]): ChatMessage[] {
3392
- return this.mergeConversationMessages(parsedMessages);
3393
- }
3394
-
3395
- private mergeConversationMessages(parsedMessages: any[]): ChatMessage[] {
3396
- if (this.runtimeMessages.length === 0) return normalizeChatMessages(parsedMessages);
3397
-
3398
- type MergeEntry = { message: ChatMessage; index: number; source: 'parsed' | 'runtime'; runtimeKey?: string };
3399
- const parsedEntries: MergeEntry[] = parsedMessages.map((message, index) => ({
3400
- message,
3401
- index,
3402
- source: 'parsed',
3403
- }));
3404
- const getRole = (message: ChatMessage): string => typeof message.role === 'string'
3405
- ? message.role.trim().toLowerCase()
3406
- : '';
3407
- const runtimeEntries: MergeEntry[] = this.runtimeMessages.map((entry, index) => ({
3408
- message: entry.message,
3409
- index: parsedMessages.length + index,
3410
- source: 'runtime' as const,
3411
- runtimeKey: entry.key,
3412
- })).filter((entry) => {
3413
- const meta = entry.message.meta && typeof entry.message.meta === 'object' && !Array.isArray(entry.message.meta)
3414
- ? entry.message.meta as Record<string, unknown>
3415
- : {};
3416
- if (meta.runtimeInputAck !== true) return true;
3417
- const runtimeText = flattenContent(entry.message.content).replace(/\s+/g, ' ').trim();
3418
- if (!runtimeText) return false;
3419
- return !parsedEntries.some((parsedEntry) => {
3420
- const parsedRole = getRole(parsedEntry.message);
3421
- if (parsedRole !== 'user' && parsedRole !== 'human') return false;
3422
- const parsedText = flattenContent(parsedEntry.message.content).replace(/\s+/g, ' ').trim();
3423
- return parsedText === runtimeText;
3424
- });
3425
- });
3426
- const getTime = (message: ChatMessage): number => {
3427
- const value = typeof message.receivedAt === 'number'
3428
- ? message.receivedAt
3429
- : typeof message.timestamp === 'number'
3430
- ? message.timestamp
3431
- : 0;
3432
- return Number.isFinite(value) && value > 0 ? value : 0;
3433
- };
3434
-
3435
- const isRuntimeOverlay = (entry: MergeEntry): boolean => {
3436
- if (entry.source !== 'runtime') return false;
3437
- const key = typeof entry.runtimeKey === 'string' ? entry.runtimeKey.trim().toLowerCase() : '';
3438
- if (key.startsWith('auto_approval:')) return true;
3439
- return !isUserFacingChatMessage(entry.message);
3440
- };
3441
- const shouldKeepParsedBeforeUntimedRuntime = (message: ChatMessage): boolean => {
3442
- const role = getRole(message);
3443
- return role === 'user' || role === 'human';
3444
- };
3445
- const shouldKeepParsedAfterUntimedRuntime = (message: ChatMessage): boolean => {
3446
- const role = getRole(message);
3447
- if (role !== 'assistant') return false;
3448
- const kind = resolveChatMessageKind(message);
3449
- return kind === 'standard' || kind === 'terminal';
3450
- };
3451
-
3452
- return normalizeChatMessages([...parsedEntries, ...runtimeEntries]
3453
- .sort((a, b) => {
3454
- const aTime = getTime(a.message);
3455
- const bTime = getTime(b.message);
3456
- if (aTime && bTime && aTime !== bTime) return aTime - bTime;
3457
- if (a.source !== b.source && aTime !== bTime) {
3458
- const parsedEntry = a.source === 'parsed' ? a : b.source === 'parsed' ? b : null;
3459
- const runtimeEntry = a.source === 'runtime' ? a : b.source === 'runtime' ? b : null;
3460
- if (parsedEntry && runtimeEntry && isRuntimeOverlay(runtimeEntry) && getTime(parsedEntry.message) === 0 && getTime(runtimeEntry.message) > 0) {
3461
- if (shouldKeepParsedBeforeUntimedRuntime(parsedEntry.message)) {
3462
- return a.source === 'parsed' ? -1 : 1;
3463
- }
3464
- if (shouldKeepParsedAfterUntimedRuntime(parsedEntry.message)) {
3465
- return a.source === 'parsed' ? 1 : -1;
3466
- }
3467
- }
3468
- }
3469
- // Many provider-owned CLI transcripts (including Hermes CLI in debug bundles)
3470
- // do not carry timestamps on parsed messages. In that case there is no safe
3471
- // clock basis for interleaving timestamped runtime/system messages into the
3472
- // provider transcript. Keep user prompts before runtime overlays, but do not
3473
- // let timed runtime/system/tool/internal overlays become the final chat turns
3474
- // after an untimed parsed assistant transcript.
3475
- return a.index - b.index;
3476
- })
3477
- .map((entry) => entry.message));
3478
- }
3479
-
3480
- private formatApprovalRequestMessage(modalMessage?: string, buttons?: string[]): string {
3481
- const lines = ['Approval requested'];
3482
- const cleanMessage = String(modalMessage || '').trim();
3483
- if (cleanMessage) lines.push(cleanMessage);
3484
- const labels = (buttons || []).map((button) => String(button || '').trim()).filter(Boolean);
3485
- if (labels.length > 0) {
3486
- lines.push(labels.map((label) => `[${label}]`).join(' '));
3487
- }
3488
- return lines.join('\n');
3272
+ return mergeConversationMessages(this.runtimeMessages, parsedMessages);
3489
3273
  }
3490
3274
 
3491
3275
  private promoteProviderSessionId(sessionId: string, opts: { authoritative?: boolean } = {}): void {
@@ -0,0 +1,114 @@
1
+ // Transcript assembly helpers extracted from cli-provider-instance.ts.
2
+ // Pure move — no behavior change. `mergeConversationMessages` was a private
3
+ // method reading only `this.runtimeMessages`; it now takes that array as a
4
+ // parameter. `buildExternalTranscriptProbe` used no instance state.
5
+
6
+ import { flattenContent } from './contracts.js';
7
+ import { normalizeChatMessages, resolveChatMessageKind, isUserFacingChatMessage } from './chat-message-normalization.js';
8
+ import type { ChatMessage } from '../types.js';
9
+ import type { ExternalTranscriptProbe } from './cli-provider-instance-types.js';
10
+
11
+ export function mergeConversationMessages(
12
+ runtimeMessages: Array<{ key: string; message: ChatMessage }>,
13
+ parsedMessages: any[],
14
+ ): ChatMessage[] {
15
+ if (runtimeMessages.length === 0) return normalizeChatMessages(parsedMessages);
16
+
17
+ type MergeEntry = { message: ChatMessage; index: number; source: 'parsed' | 'runtime'; runtimeKey?: string };
18
+ const parsedEntries: MergeEntry[] = parsedMessages.map((message, index) => ({
19
+ message,
20
+ index,
21
+ source: 'parsed',
22
+ }));
23
+ const getRole = (message: ChatMessage): string => typeof message.role === 'string'
24
+ ? message.role.trim().toLowerCase()
25
+ : '';
26
+ const runtimeEntries: MergeEntry[] = runtimeMessages.map((entry, index) => ({
27
+ message: entry.message,
28
+ index: parsedMessages.length + index,
29
+ source: 'runtime' as const,
30
+ runtimeKey: entry.key,
31
+ })).filter((entry) => {
32
+ const meta = entry.message.meta && typeof entry.message.meta === 'object' && !Array.isArray(entry.message.meta)
33
+ ? entry.message.meta as Record<string, unknown>
34
+ : {};
35
+ if (meta.runtimeInputAck !== true) return true;
36
+ const runtimeText = flattenContent(entry.message.content).replace(/\s+/g, ' ').trim();
37
+ if (!runtimeText) return false;
38
+ return !parsedEntries.some((parsedEntry) => {
39
+ const parsedRole = getRole(parsedEntry.message);
40
+ if (parsedRole !== 'user' && parsedRole !== 'human') return false;
41
+ const parsedText = flattenContent(parsedEntry.message.content).replace(/\s+/g, ' ').trim();
42
+ return parsedText === runtimeText;
43
+ });
44
+ });
45
+ const getTime = (message: ChatMessage): number => {
46
+ const value = typeof message.receivedAt === 'number'
47
+ ? message.receivedAt
48
+ : typeof message.timestamp === 'number'
49
+ ? message.timestamp
50
+ : 0;
51
+ return Number.isFinite(value) && value > 0 ? value : 0;
52
+ };
53
+
54
+ const isRuntimeOverlay = (entry: MergeEntry): boolean => {
55
+ if (entry.source !== 'runtime') return false;
56
+ const key = typeof entry.runtimeKey === 'string' ? entry.runtimeKey.trim().toLowerCase() : '';
57
+ if (key.startsWith('auto_approval:')) return true;
58
+ return !isUserFacingChatMessage(entry.message);
59
+ };
60
+ const shouldKeepParsedBeforeUntimedRuntime = (message: ChatMessage): boolean => {
61
+ const role = getRole(message);
62
+ return role === 'user' || role === 'human';
63
+ };
64
+ const shouldKeepParsedAfterUntimedRuntime = (message: ChatMessage): boolean => {
65
+ const role = getRole(message);
66
+ if (role !== 'assistant') return false;
67
+ const kind = resolveChatMessageKind(message);
68
+ return kind === 'standard' || kind === 'terminal';
69
+ };
70
+
71
+ return normalizeChatMessages([...parsedEntries, ...runtimeEntries]
72
+ .sort((a, b) => {
73
+ const aTime = getTime(a.message);
74
+ const bTime = getTime(b.message);
75
+ if (aTime && bTime && aTime !== bTime) return aTime - bTime;
76
+ if (a.source !== b.source && aTime !== bTime) {
77
+ const parsedEntry = a.source === 'parsed' ? a : b.source === 'parsed' ? b : null;
78
+ const runtimeEntry = a.source === 'runtime' ? a : b.source === 'runtime' ? b : null;
79
+ if (parsedEntry && runtimeEntry && isRuntimeOverlay(runtimeEntry) && getTime(parsedEntry.message) === 0 && getTime(runtimeEntry.message) > 0) {
80
+ if (shouldKeepParsedBeforeUntimedRuntime(parsedEntry.message)) {
81
+ return a.source === 'parsed' ? -1 : 1;
82
+ }
83
+ if (shouldKeepParsedAfterUntimedRuntime(parsedEntry.message)) {
84
+ return a.source === 'parsed' ? 1 : -1;
85
+ }
86
+ }
87
+ }
88
+ // Many provider-owned CLI transcripts (including Hermes CLI in debug bundles)
89
+ // do not carry timestamps on parsed messages. In that case there is no safe
90
+ // clock basis for interleaving timestamped runtime/system messages into the
91
+ // provider transcript. Keep user prompts before runtime overlays, but do not
92
+ // let timed runtime/system/tool/internal overlays become the final chat turns
93
+ // after an untimed parsed assistant transcript.
94
+ return a.index - b.index;
95
+ })
96
+ .map((entry) => entry.message));
97
+ }
98
+
99
+ export function buildExternalTranscriptProbe(messages: unknown[], sourcePath?: string, sourceMtimeMs?: number): ExternalTranscriptProbe {
100
+ const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
101
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
102
+ const readAt = Date.now();
103
+ const mtimeMs = Number(sourceMtimeMs) || 0;
104
+ return {
105
+ readAt,
106
+ msgCount: messages.length,
107
+ lastRole: typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null,
108
+ lastKind: typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null,
109
+ contentLen: lastVisible ? flattenContent(lastVisible.content).trim().length : 0,
110
+ sourcePath: typeof sourcePath === 'string' && sourcePath ? sourcePath : null,
111
+ sourceMtimeMs: mtimeMs || null,
112
+ mtimeAgeMs: mtimeMs ? Math.max(0, readAt - mtimeMs) : null,
113
+ };
114
+ }