@adhdev/daemon-core 0.9.82-rc.464 → 0.9.82-rc.466
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 +1022 -823
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1028 -829
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-pending.d.ts +18 -0
- package/dist/mesh/mesh-reconcile-acked-hold.d.ts +17 -0
- package/dist/mesh/mesh-reconcile-identity.d.ts +6 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +2 -14
- package/dist/mesh/mesh-reconcile-v2-backstop.d.ts +16 -0
- package/dist/mesh/mesh-runtime-store.d.ts +26 -0
- package/dist/providers/cli-provider-history-dedup.d.ts +17 -0
- package/dist/providers/cli-provider-input-prompt.d.ts +12 -0
- package/dist/providers/cli-provider-instance.d.ts +43 -35
- package/dist/providers/cli-provider-status-helpers.d.ts +46 -0
- package/package.json +3 -3
- package/src/mesh/mesh-event-forwarding.ts +16 -1
- package/src/mesh/mesh-events-pending.ts +94 -5
- package/src/mesh/mesh-ledger.ts +10 -0
- package/src/mesh/mesh-reconcile-acked-hold.ts +230 -0
- package/src/mesh/mesh-reconcile-identity.ts +103 -0
- package/src/mesh/mesh-reconcile-loop.ts +28 -393
- package/src/mesh/mesh-reconcile-v2-backstop.ts +62 -0
- package/src/mesh/mesh-runtime-store.ts +53 -0
- package/src/providers/cli-provider-history-dedup.ts +75 -0
- package/src/providers/cli-provider-input-prompt.ts +133 -0
- package/src/providers/cli-provider-instance.ts +139 -298
- package/src/providers/cli-provider-status-helpers.ts +123 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI provider structured-input helpers — image materialization + prompt build.
|
|
3
|
+
*
|
|
4
|
+
* Pure move out of cli-provider-instance.ts (no behavior change): the input
|
|
5
|
+
* envelope → CLI prompt string construction and its image-materialization
|
|
6
|
+
* support. cli-provider-instance re-exports buildCliStructuredInputPrompt so
|
|
7
|
+
* existing importers/tests keep their path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import * as crypto from 'crypto';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import { type InputEnvelope, type InputPart } from './contracts.js';
|
|
15
|
+
|
|
16
|
+
const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
|
|
17
|
+
'image/png': '.png',
|
|
18
|
+
'image/jpeg': '.jpg',
|
|
19
|
+
'image/jpg': '.jpg',
|
|
20
|
+
'image/gif': '.gif',
|
|
21
|
+
'image/webp': '.webp',
|
|
22
|
+
'image/bmp': '.bmp',
|
|
23
|
+
'image/tiff': '.tiff',
|
|
24
|
+
'image/svg+xml': '.svg',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function filePathFromUri(uri: string): string | null {
|
|
28
|
+
if (!uri) return null;
|
|
29
|
+
if (uri.startsWith('file://')) {
|
|
30
|
+
try {
|
|
31
|
+
return decodeURIComponent(new URL(uri).pathname);
|
|
32
|
+
} catch {
|
|
33
|
+
return uri.slice('file://'.length);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (path.isAbsolute(uri)) return uri;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extensionForImageMime(mimeType: string): string {
|
|
41
|
+
return IMAGE_MIME_EXTENSIONS[mimeType.toLowerCase()] || '.img';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function safeInputImageBasename(index: number, mimeType: string): string {
|
|
45
|
+
const extension = extensionForImageMime(mimeType);
|
|
46
|
+
const suffix = crypto.randomBytes(6).toString('hex');
|
|
47
|
+
return `adhdev-input-image-${Date.now()}-${index}-${suffix}${extension}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function materializeImageDataPart(part: Extract<InputPart, { type: 'image' }>, index: number, dir: string): string | null {
|
|
51
|
+
if (!part.data) return null;
|
|
52
|
+
const rawData = part.data.includes(',') ? part.data.split(',').pop() || '' : part.data;
|
|
53
|
+
if (!rawData) return null;
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
const filePath = path.join(dir, safeInputImageBasename(index, part.mimeType));
|
|
56
|
+
fs.writeFileSync(filePath, Buffer.from(rawData, 'base64'));
|
|
57
|
+
cleanupStaleMaterializedImages(dir);
|
|
58
|
+
return filePath;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const MATERIALIZED_IMAGE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
|
62
|
+
const MATERIALIZED_IMAGE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
63
|
+
let lastMaterializedImageCleanupAt = 0;
|
|
64
|
+
|
|
65
|
+
function cleanupStaleMaterializedImages(dir: string): void {
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (now - lastMaterializedImageCleanupAt < MATERIALIZED_IMAGE_CLEANUP_INTERVAL_MS) return;
|
|
68
|
+
lastMaterializedImageCleanupAt = now;
|
|
69
|
+
try {
|
|
70
|
+
const entries = fs.readdirSync(dir);
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (!entry.startsWith('adhdev-input-image-')) continue;
|
|
73
|
+
const fullPath = path.join(dir, entry);
|
|
74
|
+
try {
|
|
75
|
+
const stat = fs.statSync(fullPath);
|
|
76
|
+
if (now - stat.mtimeMs > MATERIALIZED_IMAGE_MAX_AGE_MS) {
|
|
77
|
+
fs.unlinkSync(fullPath);
|
|
78
|
+
}
|
|
79
|
+
} catch { /* file may have been removed concurrently */ }
|
|
80
|
+
}
|
|
81
|
+
} catch { /* dir may not exist or be inaccessible */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildCliStructuredInputPrompt(
|
|
85
|
+
input: InputEnvelope,
|
|
86
|
+
options: { materializeDir?: string } = {},
|
|
87
|
+
): string {
|
|
88
|
+
const promptParts: string[] = [];
|
|
89
|
+
const imageRefs: string[] = [];
|
|
90
|
+
const resourceRefs: string[] = [];
|
|
91
|
+
const materializeDir = options.materializeDir || path.join(os.tmpdir(), 'adhdev-input-media');
|
|
92
|
+
|
|
93
|
+
input.parts.forEach((part, index) => {
|
|
94
|
+
if (part.type === 'text' && part.text.trim()) {
|
|
95
|
+
promptParts.push(part.text.trim());
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (part.type === 'image') {
|
|
100
|
+
const localPath = typeof part.uri === 'string' ? filePathFromUri(part.uri) : null;
|
|
101
|
+
const materializedPath = !localPath && part.data ? materializeImageDataPart(part, index, materializeDir) : null;
|
|
102
|
+
const ref = localPath || materializedPath || part.uri || '';
|
|
103
|
+
if (ref) imageRefs.push(ref);
|
|
104
|
+
if (part.alt?.trim()) promptParts.push(part.alt.trim());
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (part.type === 'resource_link') {
|
|
109
|
+
resourceRefs.push([part.title, part.name, part.description, part.uri].filter(Boolean).join('\n'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (part.type === 'resource') {
|
|
114
|
+
resourceRefs.push([part.name, part.text, part.uri].filter(Boolean).join('\n'));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Only use textFallback when no explicit text parts were collected — it is
|
|
119
|
+
// the flattened version of the same parts, so appending it alongside them
|
|
120
|
+
// would duplicate the content for multipart inputs.
|
|
121
|
+
const hasExplicitTextParts = input.parts.some((part) => part.type === 'text' && part.text.trim());
|
|
122
|
+
if (!hasExplicitTextParts && input.textFallback.trim()) {
|
|
123
|
+
promptParts.push(input.textFallback.trim());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ordered = [
|
|
127
|
+
...imageRefs,
|
|
128
|
+
...promptParts,
|
|
129
|
+
...resourceRefs,
|
|
130
|
+
].filter((value, index, values) => value.trim().length > 0 && values.indexOf(value) === index);
|
|
131
|
+
|
|
132
|
+
return ordered.join('\n');
|
|
133
|
+
}
|
|
@@ -9,8 +9,7 @@ import * as os from 'os';
|
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import * as crypto from 'crypto';
|
|
11
11
|
import * as fs from 'fs';
|
|
12
|
-
import {
|
|
13
|
-
import { normalizeInputEnvelope, type ProviderModule, flattenContent, type InputEnvelope, type InputPart } from './contracts.js';
|
|
12
|
+
import { normalizeInputEnvelope, type ProviderModule, flattenContent, type InputEnvelope } from './contracts.js';
|
|
14
13
|
import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport } from './provider-input-support.js';
|
|
15
14
|
import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, ProviderErrorReason, HotChatSessionState, SessionModalState } from './provider-instance.js';
|
|
16
15
|
import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, type InteractivePrompt } from './types/interactive-prompt.js';
|
|
@@ -39,14 +38,28 @@ import {
|
|
|
39
38
|
import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter, readChatMessageTimestampMs } from './chat-message-normalization.js';
|
|
40
39
|
import { workingDirBasename } from './working-dir.js';
|
|
41
40
|
import { ManualAttendanceTracker } from './manual-attendance.js';
|
|
42
|
-
|
|
43
|
-
type PersistableCliHistoryMessage
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
import { buildCliStructuredInputPrompt } from './cli-provider-input-prompt.js';
|
|
42
|
+
import { type PersistableCliHistoryMessage, buildIncrementalHistoryAppendMessages } from './cli-provider-history-dedup.js';
|
|
43
|
+
import {
|
|
44
|
+
isIdleStatus,
|
|
45
|
+
getMessageTime,
|
|
46
|
+
hasNonEmptyCliModalButtons,
|
|
47
|
+
isCliGeneratingLikeStatus,
|
|
48
|
+
computeTurnAnchoredDurationMs,
|
|
49
|
+
getDatabaseSync,
|
|
50
|
+
getForcedNewSessionScriptName,
|
|
51
|
+
waitForCliAdapterReady,
|
|
52
|
+
} from './cli-provider-status-helpers.js';
|
|
53
|
+
|
|
54
|
+
// Re-export moved public symbols so existing importers (index.ts, tests) keep
|
|
55
|
+
// their `./cli-provider-instance.js` path. Pure move — no behavior change.
|
|
56
|
+
export { buildCliStructuredInputPrompt } from './cli-provider-input-prompt.js';
|
|
57
|
+
export { buildIncrementalHistoryAppendMessages } from './cli-provider-history-dedup.js';
|
|
58
|
+
export {
|
|
59
|
+
computeTurnAnchoredDurationMs,
|
|
60
|
+
getForcedNewSessionScriptName,
|
|
61
|
+
waitForCliAdapterReady,
|
|
62
|
+
} from './cli-provider-status-helpers.js';
|
|
50
63
|
|
|
51
64
|
// Status snapshots only ever surface the newest messages: the cloud 'live'
|
|
52
65
|
// profile drops chat messages entirely (loaded lazily via read_chat on
|
|
@@ -94,19 +107,6 @@ type CompletedDebouncePending = {
|
|
|
94
107
|
lastOutputAtArm?: number;
|
|
95
108
|
};
|
|
96
109
|
|
|
97
|
-
function isIdleStatus(value: unknown): boolean {
|
|
98
|
-
const status = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
99
|
-
return !status || status === 'idle' || status === 'ready';
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
function getMessageTime(message: unknown): number {
|
|
104
|
-
if (!message || typeof message !== 'object') return 0;
|
|
105
|
-
const record = message as { receivedAt?: unknown; timestamp?: unknown };
|
|
106
|
-
const value = Number(record.receivedAt ?? record.timestamp ?? 0);
|
|
107
|
-
return Number.isFinite(value) ? value : 0;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
110
|
type CompletedFinalizationBlock = {
|
|
111
111
|
reason: string;
|
|
112
112
|
terminal?: boolean;
|
|
@@ -189,278 +189,6 @@ const TERMINAL_MESH_EVENTS = new Set([
|
|
|
189
189
|
'agent:ready',
|
|
190
190
|
]);
|
|
191
191
|
|
|
192
|
-
const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
|
|
193
|
-
'image/png': '.png',
|
|
194
|
-
'image/jpeg': '.jpg',
|
|
195
|
-
'image/jpg': '.jpg',
|
|
196
|
-
'image/gif': '.gif',
|
|
197
|
-
'image/webp': '.webp',
|
|
198
|
-
'image/bmp': '.bmp',
|
|
199
|
-
'image/tiff': '.tiff',
|
|
200
|
-
'image/svg+xml': '.svg',
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
function filePathFromUri(uri: string): string | null {
|
|
204
|
-
if (!uri) return null;
|
|
205
|
-
if (uri.startsWith('file://')) {
|
|
206
|
-
try {
|
|
207
|
-
return decodeURIComponent(new URL(uri).pathname);
|
|
208
|
-
} catch {
|
|
209
|
-
return uri.slice('file://'.length);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (path.isAbsolute(uri)) return uri;
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function extensionForImageMime(mimeType: string): string {
|
|
217
|
-
return IMAGE_MIME_EXTENSIONS[mimeType.toLowerCase()] || '.img';
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function safeInputImageBasename(index: number, mimeType: string): string {
|
|
221
|
-
const extension = extensionForImageMime(mimeType);
|
|
222
|
-
const suffix = crypto.randomBytes(6).toString('hex');
|
|
223
|
-
return `adhdev-input-image-${Date.now()}-${index}-${suffix}${extension}`;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function materializeImageDataPart(part: Extract<InputPart, { type: 'image' }>, index: number, dir: string): string | null {
|
|
227
|
-
if (!part.data) return null;
|
|
228
|
-
const rawData = part.data.includes(',') ? part.data.split(',').pop() || '' : part.data;
|
|
229
|
-
if (!rawData) return null;
|
|
230
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
231
|
-
const filePath = path.join(dir, safeInputImageBasename(index, part.mimeType));
|
|
232
|
-
fs.writeFileSync(filePath, Buffer.from(rawData, 'base64'));
|
|
233
|
-
cleanupStaleMaterializedImages(dir);
|
|
234
|
-
return filePath;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const MATERIALIZED_IMAGE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
|
238
|
-
const MATERIALIZED_IMAGE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
239
|
-
let lastMaterializedImageCleanupAt = 0;
|
|
240
|
-
|
|
241
|
-
function cleanupStaleMaterializedImages(dir: string): void {
|
|
242
|
-
const now = Date.now();
|
|
243
|
-
if (now - lastMaterializedImageCleanupAt < MATERIALIZED_IMAGE_CLEANUP_INTERVAL_MS) return;
|
|
244
|
-
lastMaterializedImageCleanupAt = now;
|
|
245
|
-
try {
|
|
246
|
-
const entries = fs.readdirSync(dir);
|
|
247
|
-
for (const entry of entries) {
|
|
248
|
-
if (!entry.startsWith('adhdev-input-image-')) continue;
|
|
249
|
-
const fullPath = path.join(dir, entry);
|
|
250
|
-
try {
|
|
251
|
-
const stat = fs.statSync(fullPath);
|
|
252
|
-
if (now - stat.mtimeMs > MATERIALIZED_IMAGE_MAX_AGE_MS) {
|
|
253
|
-
fs.unlinkSync(fullPath);
|
|
254
|
-
}
|
|
255
|
-
} catch { /* file may have been removed concurrently */ }
|
|
256
|
-
}
|
|
257
|
-
} catch { /* dir may not exist or be inaccessible */ }
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
|
|
261
|
-
const buttons = (activeModal as any)?.buttons;
|
|
262
|
-
return Array.isArray(buttons) && buttons.some((button) => String(button || '').trim().length > 0);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function isCliGeneratingLikeStatus(status: unknown): boolean {
|
|
266
|
-
return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* NOTIF Defect-2a: the REPORTED short-generating duration, anchored on the IMMUTABLE turn
|
|
271
|
-
* start. generatingStartedAt is reset to 0 on every mid-turn waiting_approval/idle blip and
|
|
272
|
-
* re-armed on the next →generating, so a long turn that blips would otherwise measure only the
|
|
273
|
-
* final 1.5-2.5s sliver. engine.currentTurnStartedAt (set once at onTurnStarted, surviving
|
|
274
|
-
* mid-turn blips until the next turn starts) is preferred; generatingStartedAt is the fallback
|
|
275
|
-
* for turns that never recorded an engine turn start. Returns 0 when neither anchor is set.
|
|
276
|
-
* Pure / unit-testable.
|
|
277
|
-
*/
|
|
278
|
-
export function computeTurnAnchoredDurationMs(
|
|
279
|
-
engineTurnStartedAt: number | undefined,
|
|
280
|
-
generatingStartedAt: number,
|
|
281
|
-
now: number,
|
|
282
|
-
): { durationMs: number; anchor: 'turn-start' | 'generatingStartedAt' | 'none' } {
|
|
283
|
-
const engineStart = typeof engineTurnStartedAt === 'number' && Number.isFinite(engineTurnStartedAt)
|
|
284
|
-
? engineTurnStartedAt
|
|
285
|
-
: 0;
|
|
286
|
-
if (engineStart > 0) return { durationMs: now - engineStart, anchor: 'turn-start' };
|
|
287
|
-
if (generatingStartedAt > 0) return { durationMs: now - generatingStartedAt, anchor: 'generatingStartedAt' };
|
|
288
|
-
return { durationMs: 0, anchor: 'none' };
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export function buildCliStructuredInputPrompt(
|
|
292
|
-
input: InputEnvelope,
|
|
293
|
-
options: { materializeDir?: string } = {},
|
|
294
|
-
): string {
|
|
295
|
-
const promptParts: string[] = [];
|
|
296
|
-
const imageRefs: string[] = [];
|
|
297
|
-
const resourceRefs: string[] = [];
|
|
298
|
-
const materializeDir = options.materializeDir || path.join(os.tmpdir(), 'adhdev-input-media');
|
|
299
|
-
|
|
300
|
-
input.parts.forEach((part, index) => {
|
|
301
|
-
if (part.type === 'text' && part.text.trim()) {
|
|
302
|
-
promptParts.push(part.text.trim());
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
if (part.type === 'image') {
|
|
307
|
-
const localPath = typeof part.uri === 'string' ? filePathFromUri(part.uri) : null;
|
|
308
|
-
const materializedPath = !localPath && part.data ? materializeImageDataPart(part, index, materializeDir) : null;
|
|
309
|
-
const ref = localPath || materializedPath || part.uri || '';
|
|
310
|
-
if (ref) imageRefs.push(ref);
|
|
311
|
-
if (part.alt?.trim()) promptParts.push(part.alt.trim());
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
if (part.type === 'resource_link') {
|
|
316
|
-
resourceRefs.push([part.title, part.name, part.description, part.uri].filter(Boolean).join('\n'));
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (part.type === 'resource') {
|
|
321
|
-
resourceRefs.push([part.name, part.text, part.uri].filter(Boolean).join('\n'));
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
// Only use textFallback when no explicit text parts were collected — it is
|
|
326
|
-
// the flattened version of the same parts, so appending it alongside them
|
|
327
|
-
// would duplicate the content for multipart inputs.
|
|
328
|
-
const hasExplicitTextParts = input.parts.some((part) => part.type === 'text' && part.text.trim());
|
|
329
|
-
if (!hasExplicitTextParts && input.textFallback.trim()) {
|
|
330
|
-
promptParts.push(input.textFallback.trim());
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const ordered = [
|
|
334
|
-
...imageRefs,
|
|
335
|
-
...promptParts,
|
|
336
|
-
...resourceRefs,
|
|
337
|
-
].filter((value, index, values) => value.trim().length > 0 && values.indexOf(value) === index);
|
|
338
|
-
|
|
339
|
-
return ordered.join('\n');
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function normalizePersistableCliHistoryContent(content: unknown): string {
|
|
343
|
-
return flattenContent(content as any).replace(/\s+/g, ' ').trim();
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function buildPersistableCliHistorySignature(message: PersistableCliHistoryMessage): string {
|
|
347
|
-
return [
|
|
348
|
-
String(message.role || ''),
|
|
349
|
-
String(message.kind || ''),
|
|
350
|
-
String(message.senderName || ''),
|
|
351
|
-
normalizePersistableCliHistoryContent(message.content),
|
|
352
|
-
].join('|');
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function hasSamePersistableCliHistoryIdentity(a: PersistableCliHistoryMessage, b: PersistableCliHistoryMessage): boolean {
|
|
356
|
-
return String(a?.role || '') === String(b?.role || '')
|
|
357
|
-
&& String(a?.kind || '') === String(b?.kind || '')
|
|
358
|
-
&& String(a?.senderName || '') === String(b?.senderName || '')
|
|
359
|
-
&& String(a?.content || '') === String(b?.content || '');
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
export function buildIncrementalHistoryAppendMessages(
|
|
363
|
-
previousMessages: PersistableCliHistoryMessage[],
|
|
364
|
-
currentMessages: PersistableCliHistoryMessage[],
|
|
365
|
-
): PersistableCliHistoryMessage[] {
|
|
366
|
-
if (!Array.isArray(currentMessages) || currentMessages.length === 0) return [];
|
|
367
|
-
if (!Array.isArray(previousMessages) || previousMessages.length === 0) return currentMessages;
|
|
368
|
-
|
|
369
|
-
const comparableLength = Math.min(previousMessages.length, currentMessages.length);
|
|
370
|
-
let sharedPrefixLength = 0;
|
|
371
|
-
while (
|
|
372
|
-
sharedPrefixLength < comparableLength
|
|
373
|
-
&& hasSamePersistableCliHistoryIdentity(previousMessages[sharedPrefixLength], currentMessages[sharedPrefixLength])
|
|
374
|
-
) {
|
|
375
|
-
sharedPrefixLength += 1;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (sharedPrefixLength === currentMessages.length) return [];
|
|
379
|
-
if (sharedPrefixLength === previousMessages.length) return currentMessages.slice(sharedPrefixLength);
|
|
380
|
-
|
|
381
|
-
// Rare fallback: preserve the older whitespace-normalized behavior only when
|
|
382
|
-
// the cheap identity check detects a changed prefix. Recomputing normalized
|
|
383
|
-
// signatures for the full transcript on every idle status poll was a CPU
|
|
384
|
-
// hot path for long CLI sessions.
|
|
385
|
-
while (
|
|
386
|
-
sharedPrefixLength < comparableLength
|
|
387
|
-
&& buildPersistableCliHistorySignature(previousMessages[sharedPrefixLength])
|
|
388
|
-
=== buildPersistableCliHistorySignature(currentMessages[sharedPrefixLength])
|
|
389
|
-
) {
|
|
390
|
-
sharedPrefixLength += 1;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (sharedPrefixLength === currentMessages.length) return [];
|
|
394
|
-
if (sharedPrefixLength === previousMessages.length) return currentMessages.slice(sharedPrefixLength);
|
|
395
|
-
return currentMessages;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
let CachedDatabaseSync: (new (path: string, options?: { readOnly?: boolean }) => {
|
|
399
|
-
prepare(sql: string): { get(...params: Array<string | number>): unknown };
|
|
400
|
-
close(): void;
|
|
401
|
-
}) | null = null;
|
|
402
|
-
|
|
403
|
-
function getDatabaseSync() {
|
|
404
|
-
if (CachedDatabaseSync) return CachedDatabaseSync;
|
|
405
|
-
const requireFn = typeof require === 'function'
|
|
406
|
-
? require
|
|
407
|
-
: createRequire(path.join(process.cwd(), '__adhdev_sqlite_loader__.js'));
|
|
408
|
-
const sqliteModule = requireFn(`node:${'sqlite'}`) as {
|
|
409
|
-
DatabaseSync: typeof CachedDatabaseSync;
|
|
410
|
-
};
|
|
411
|
-
CachedDatabaseSync = sqliteModule.DatabaseSync;
|
|
412
|
-
if (!CachedDatabaseSync) {
|
|
413
|
-
throw new Error('node:sqlite DatabaseSync unavailable');
|
|
414
|
-
}
|
|
415
|
-
return CachedDatabaseSync;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
export function getForcedNewSessionScriptName(
|
|
419
|
-
provider: ProviderModule | undefined,
|
|
420
|
-
launchMode: 'new' | 'resume' | 'manual',
|
|
421
|
-
): string | null {
|
|
422
|
-
if (!provider || launchMode !== 'new') return null;
|
|
423
|
-
const resume = provider.resume;
|
|
424
|
-
if (!resume?.supported) return null;
|
|
425
|
-
if (Array.isArray(resume.newSessionArgs) && resume.newSessionArgs.length > 0) return null;
|
|
426
|
-
|
|
427
|
-
const controls = Array.isArray((provider as any).controls) ? (provider as any).controls : [];
|
|
428
|
-
for (const control of controls) {
|
|
429
|
-
if (control?.type !== 'action') continue;
|
|
430
|
-
if (typeof control?.confirmTitle === 'string' && control.confirmTitle.trim()) continue;
|
|
431
|
-
if (typeof control?.confirmMessage === 'string' && control.confirmMessage.trim()) continue;
|
|
432
|
-
if (typeof control?.confirmLabel === 'string' && control.confirmLabel.trim()) continue;
|
|
433
|
-
const invokeScript = typeof control?.invokeScript === 'string' ? control.invokeScript.trim() : '';
|
|
434
|
-
if (!invokeScript) continue;
|
|
435
|
-
const controlId = typeof control?.id === 'string' ? control.id.trim() : '';
|
|
436
|
-
if (controlId === 'new_session' || /^new.?session$/i.test(invokeScript)) {
|
|
437
|
-
return invokeScript;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return null;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
export async function waitForCliAdapterReady(
|
|
445
|
-
adapter: { isReady?: () => boolean; getStatus?: () => { status?: string } },
|
|
446
|
-
options?: { timeoutMs?: number; pollMs?: number },
|
|
447
|
-
): Promise<void> {
|
|
448
|
-
const timeoutMs = Math.max(100, options?.timeoutMs ?? 15_000);
|
|
449
|
-
const pollMs = Math.max(10, options?.pollMs ?? 50);
|
|
450
|
-
const deadline = Date.now() + timeoutMs;
|
|
451
|
-
|
|
452
|
-
while (Date.now() < deadline) {
|
|
453
|
-
if (adapter?.isReady?.()) return;
|
|
454
|
-
const status = adapter?.getStatus?.()?.status;
|
|
455
|
-
if (status === 'stopped') {
|
|
456
|
-
throw new Error('CLI runtime stopped before it became ready');
|
|
457
|
-
}
|
|
458
|
-
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
throw new Error(`CLI runtime did not become ready within ${timeoutMs}ms`);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
192
|
export class CliProviderInstance implements ProviderInstance {
|
|
465
193
|
readonly type: string;
|
|
466
194
|
readonly category = 'cli' as const;
|
|
@@ -493,6 +221,34 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
493
221
|
*/
|
|
494
222
|
private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS = 1500;
|
|
495
223
|
|
|
224
|
+
/**
|
|
225
|
+
* AUTOAPPROVE-FLAP-RECUR (Fix B): extended busy-side continuity window for a
|
|
226
|
+
* DELEGATED-WORKER auto-approve episode that is genuinely still cycling.
|
|
227
|
+
*
|
|
228
|
+
* The default AUTO_APPROVE_GATE_HYSTERESIS_MS (1500) absorbs a *momentary*
|
|
229
|
+
* `generating` blip. But a delegated worker running a Bash approval observed
|
|
230
|
+
* the FSM cycle the FULL state waiting_approval → busy → waiting_approval on a
|
|
231
|
+
* 2–5s period (the button set scrolls in/out AND the modal question repaints,
|
|
232
|
+
* so the adapter genuinely reports status=generating for whole seconds between
|
|
233
|
+
* approval frames). Each busy phase outran the 1500ms hysteresis, so the
|
|
234
|
+
* settle clock was WIPED (the genuine-resolution branch), the 600ms settle
|
|
235
|
+
* window never accumulated across the flap, resolveModal never fired
|
|
236
|
+
* (resolveModal count 0), and the mask-stall clock instead tripped at 4500ms →
|
|
237
|
+
* coordinator nudge → the flap the coordinator observed.
|
|
238
|
+
*
|
|
239
|
+
* A genuine resolution and a flap both start with a busy phase; they diverge
|
|
240
|
+
* only in whether waiting_approval RETURNS. So we cannot simply lengthen the
|
|
241
|
+
* blanket hysteresis (that would make every real resolution hold the gate
|
|
242
|
+
* open for seconds). Instead this longer window applies ONLY while an active
|
|
243
|
+
* mask episode is alive (autoApproveMaskSince > 0) AND the session is a
|
|
244
|
+
* delegated worker — i.e. exactly the never-resolving-flap case. A foreground
|
|
245
|
+
* / attended session keeps the tight 1500ms window unchanged. The mask-stall
|
|
246
|
+
* bound below still caps the episode, so a worker whose approval truly never
|
|
247
|
+
* returns is surfaced to the coordinator within AUTO_APPROVE_MASK_STALL_MS
|
|
248
|
+
* rather than held forever.
|
|
249
|
+
*/
|
|
250
|
+
private static readonly AUTO_APPROVE_FLAP_CONTINUITY_MS = 4000;
|
|
251
|
+
|
|
496
252
|
/**
|
|
497
253
|
* STATUS-MISMATCH: upper bound on how long the auto-approve→`generating` SURFACE
|
|
498
254
|
* mask may hide a worker's `waiting_approval` (status + activeModal) before we give
|
|
@@ -568,9 +324,22 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
568
324
|
private pendingAutoApprovalSince = 0;
|
|
569
325
|
private autoApproveSettleTimer: NodeJS.Timeout | null = null;
|
|
570
326
|
// Wall-clock when auto-approve first observed status!=waiting_approval while
|
|
571
|
-
// a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS
|
|
327
|
+
// a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS (or,
|
|
328
|
+
// for a delegated-worker flap episode, AUTO_APPROVE_FLAP_CONTINUITY_MS) so a
|
|
572
329
|
// brief generating flip does not immediately wipe the settle clock.
|
|
573
330
|
private autoApproveInactiveSince = 0;
|
|
331
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix A): wall-clock when the CURRENT waiting_approval
|
|
332
|
+
// episode last presented a concrete, captured modal (buttons.length > 0). The
|
|
333
|
+
// Claude TUI momentarily reports status=waiting_approval with activeModal=null
|
|
334
|
+
// / an empty button block while the button block scrolls out of the captured
|
|
335
|
+
// frame; the raw guard below (buttons.length===0) used to bail on that frame,
|
|
336
|
+
// never advancing the settle gate and leaving no re-check armed — so a modal
|
|
337
|
+
// that flapped modal=none ↔ N-buttons around the settle boundary never
|
|
338
|
+
// accumulated its 600ms. This tracks the last GOOD-modal frame so a short
|
|
339
|
+
// scroll-out blip is absorbed (settle keeps running against the last captured
|
|
340
|
+
// signature) while a genuinely closed modal — buttons empty continuously past
|
|
341
|
+
// the continuity window — is still recognised and resets the gate.
|
|
342
|
+
private autoApproveLastModalSeenAt = 0;
|
|
574
343
|
// STATUS-MISMATCH: wall-clock when the CURRENT auto-approve episode (waiting_approval
|
|
575
344
|
// + shouldAutoApprove) first began wanting to mask. Unlike pendingAutoApprovalSince it
|
|
576
345
|
// is NOT reset when the modal signature changes (a still-streaming/flapping prompt) and
|
|
@@ -1912,6 +1681,23 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1912
1681
|
|| this.settings.meshNodeId || this.settings.launchedByCoordinator);
|
|
1913
1682
|
}
|
|
1914
1683
|
|
|
1684
|
+
/**
|
|
1685
|
+
* AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
|
|
1686
|
+
* persist before the in-progress settle gate is torn down. For a delegated
|
|
1687
|
+
* worker whose auto-approve episode is genuinely still cycling (mask clock
|
|
1688
|
+
* alive), the FSM's full waiting_approval → busy → waiting_approval flap runs
|
|
1689
|
+
* on a multi-second period, so the settle continuity window is extended to
|
|
1690
|
+
* AUTO_APPROVE_FLAP_CONTINUITY_MS to bridge it (still bounded, and still capped
|
|
1691
|
+
* by AUTO_APPROVE_MASK_STALL_MS). Every other case — foreground/attended
|
|
1692
|
+
* session, or no active mask episode — keeps the tight default hysteresis so a
|
|
1693
|
+
* genuine resolution frees the gate promptly.
|
|
1694
|
+
*/
|
|
1695
|
+
private autoApproveContinuityWindowMs(): number {
|
|
1696
|
+
return this.autoApproveMaskSince > 0 && this.isMeshWorkerSession()
|
|
1697
|
+
? CliProviderInstance.AUTO_APPROVE_FLAP_CONTINUITY_MS
|
|
1698
|
+
: CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1915
1701
|
// FALSE-IDLE (self-coordinator settle): an autonomously-progressing mesh session
|
|
1916
1702
|
// is either a delegated worker (isMeshWorkerSession) OR the coordinator's OWN
|
|
1917
1703
|
// claude-cli session (meshCoordinatorFor). Both run auto-approved tool turns whose
|
|
@@ -2218,6 +2004,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2218
2004
|
// returns false), so end the mask episode.
|
|
2219
2005
|
this.autoApproveMaskSince = 0;
|
|
2220
2006
|
this.stalledApprovalNudgeEpisode = 0;
|
|
2007
|
+
this.autoApproveLastModalSeenAt = 0;
|
|
2221
2008
|
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2222
2009
|
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2223
2010
|
this.autoApproveSettleTimer = null;
|
|
@@ -2245,12 +2032,20 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2245
2032
|
if (this.pendingAutoApprovalSince) {
|
|
2246
2033
|
if (!this.autoApproveInactiveSince) this.autoApproveInactiveSince = now;
|
|
2247
2034
|
const goneForMs = now - this.autoApproveInactiveSince;
|
|
2248
|
-
|
|
2035
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix B): a delegated-worker flap cycles the
|
|
2036
|
+
// FULL waiting_approval → busy → waiting_approval state on a
|
|
2037
|
+
// multi-second period, outrunning the tight default hysteresis and
|
|
2038
|
+
// wiping the settle clock before 600ms ever accumulates. For an
|
|
2039
|
+
// active worker mask episode the continuity window is widened (still
|
|
2040
|
+
// capped by AUTO_APPROVE_MASK_STALL_MS) so the settle clock survives
|
|
2041
|
+
// the busy phase and the returning approval keeps accruing settle time.
|
|
2042
|
+
const continuityMs = this.autoApproveContinuityWindowMs();
|
|
2043
|
+
if (goneForMs < continuityMs) {
|
|
2249
2044
|
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2250
2045
|
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2251
2046
|
this.autoApproveSettleTimer = null;
|
|
2252
2047
|
this.recheckAutoApproveSettled();
|
|
2253
|
-
},
|
|
2048
|
+
}, continuityMs - goneForMs + 20);
|
|
2254
2049
|
return autoApproveActive;
|
|
2255
2050
|
}
|
|
2256
2051
|
}
|
|
@@ -2263,6 +2058,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2263
2058
|
// end the mask episode too (a later approval starts a fresh stall clock).
|
|
2264
2059
|
this.autoApproveMaskSince = 0;
|
|
2265
2060
|
this.stalledApprovalNudgeEpisode = 0;
|
|
2061
|
+
this.autoApproveLastModalSeenAt = 0;
|
|
2266
2062
|
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
2267
2063
|
return autoApproveActive;
|
|
2268
2064
|
}
|
|
@@ -2290,8 +2086,40 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2290
2086
|
? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
|
|
2291
2087
|
: [];
|
|
2292
2088
|
if (!modal || buttons.length === 0) {
|
|
2089
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix A): the button block momentarily scrolled
|
|
2090
|
+
// out of the captured frame (status is still waiting_approval — we are
|
|
2091
|
+
// on the active path). Do NOT tear the settle gate down on this frame:
|
|
2092
|
+
// if a concrete modal was captured within the continuity window and a
|
|
2093
|
+
// settle gate is in progress, this is a short scroll-out blip — keep the
|
|
2094
|
+
// gate warm against the last-captured signature and arm a re-check so a
|
|
2095
|
+
// silent PTY still re-drives the decision when the buttons repaint. Only
|
|
2096
|
+
// once the modal has stayed empty PAST the continuity window is it a
|
|
2097
|
+
// genuine close, and the gate is cleared here so a later approval
|
|
2098
|
+
// re-settles from scratch (never fires on a stale timestamp).
|
|
2099
|
+
const blipForMs = this.autoApproveLastModalSeenAt ? now - this.autoApproveLastModalSeenAt : Infinity;
|
|
2100
|
+
if (this.pendingAutoApprovalSince && blipForMs < this.autoApproveContinuityWindowMs()) {
|
|
2101
|
+
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2102
|
+
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2103
|
+
this.autoApproveSettleTimer = null;
|
|
2104
|
+
this.recheckAutoApproveSettled();
|
|
2105
|
+
}, this.autoApproveContinuityWindowMs() - blipForMs + 20);
|
|
2106
|
+
return autoApproveActive;
|
|
2107
|
+
}
|
|
2108
|
+
if (blipForMs >= this.autoApproveContinuityWindowMs()) {
|
|
2109
|
+
// Buttons empty continuously past the window → the modal genuinely
|
|
2110
|
+
// closed (or never captured). Reset the per-signature settle gate so
|
|
2111
|
+
// a later approval re-settles cleanly. The mask-stall clock keeps
|
|
2112
|
+
// running underneath so a never-captured worker modal still surfaces
|
|
2113
|
+
// to the coordinator within AUTO_APPROVE_MASK_STALL_MS.
|
|
2114
|
+
this.pendingAutoApprovalSignature = '';
|
|
2115
|
+
this.pendingAutoApprovalSince = 0;
|
|
2116
|
+
}
|
|
2293
2117
|
return autoApproveActive;
|
|
2294
2118
|
}
|
|
2119
|
+
// Concrete modal captured this frame — mark the last-good-modal timestamp so
|
|
2120
|
+
// a subsequent scroll-out blip (buttons.length===0) can be told apart from a
|
|
2121
|
+
// genuine close by how long it persists (Fix A above).
|
|
2122
|
+
this.autoApproveLastModalSeenAt = now;
|
|
2295
2123
|
// Picker/confirm exclusion (provider-common). A /model or /mode picker is
|
|
2296
2124
|
// surfaced with status=waiting_approval so the dashboard shows it, but it
|
|
2297
2125
|
// has no "correct" answer to auto-pick — blindly selecting the first
|
|
@@ -2409,6 +2237,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
2409
2237
|
// Fired (resolveModal in flight) — the episode resolved; end the mask-stall clock.
|
|
2410
2238
|
this.autoApproveMaskSince = 0;
|
|
2411
2239
|
this.stalledApprovalNudgeEpisode = 0;
|
|
2240
|
+
this.autoApproveLastModalSeenAt = 0;
|
|
2412
2241
|
if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
|
|
2413
2242
|
this.autoApproveBusyTimer = setTimeout(() => {
|
|
2414
2243
|
this.autoApproveBusy = false;
|
|
@@ -3434,6 +3263,18 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
3434
3263
|
if (!this.isMeshWorkerSession()) return;
|
|
3435
3264
|
if (adapterStatus?.status !== 'waiting_approval') return;
|
|
3436
3265
|
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;
|
|
3437
3278
|
// Exactly once per stalled episode (autoApproveMaskSince uniquely identifies it).
|
|
3438
3279
|
if (this.stalledApprovalNudgeEpisode === this.autoApproveMaskSince) return;
|
|
3439
3280
|
this.stalledApprovalNudgeEpisode = this.autoApproveMaskSince;
|