@adhdev/daemon-core 0.9.82-rc.466 → 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.
- package/dist/index.js +188 -147
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +195 -154
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-effect-format.d.ts +30 -0
- package/dist/providers/cli-provider-instance-types.d.ts +45 -0
- package/dist/providers/cli-provider-instance.d.ts +12 -6
- package/dist/providers/cli-provider-transcript-merge.d.ts +7 -0
- package/package.json +3 -3
- package/src/mesh/mesh-events-pending.ts +2 -0
- package/src/mesh/mesh-reconcile-loop.ts +32 -5
- package/src/mesh/mesh-runtime-store.ts +46 -1
- package/src/providers/cli-provider-effect-format.ts +53 -0
- package/src/providers/cli-provider-instance-types.ts +131 -0
- package/src/providers/cli-provider-instance.ts +87 -303
- package/src/providers/cli-provider-transcript-merge.ts +114 -0
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 ${
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
3267
|
-
//
|
|
3268
|
-
//
|
|
3269
|
-
//
|
|
3270
|
-
//
|
|
3271
|
-
//
|
|
3272
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|