@adhdev/daemon-core 0.9.82-rc.463 → 0.9.82-rc.465

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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * CLI provider persisted-history dedup — incremental append computation.
3
+ *
4
+ * Pure move out of cli-provider-instance.ts (no behavior change): the
5
+ * shared-prefix diff that turns a full parsed transcript into the newly-added
6
+ * tail to append to the persisted chat history. cli-provider-instance
7
+ * re-exports buildIncrementalHistoryAppendMessages so existing importers/tests
8
+ * keep their path.
9
+ */
10
+
11
+ import { flattenContent } from './contracts.js';
12
+
13
+ export type PersistableCliHistoryMessage = {
14
+ role: string;
15
+ content: string;
16
+ kind?: string;
17
+ senderName?: string;
18
+ receivedAt?: number;
19
+ };
20
+
21
+ function normalizePersistableCliHistoryContent(content: unknown): string {
22
+ return flattenContent(content as any).replace(/\s+/g, ' ').trim();
23
+ }
24
+
25
+ function buildPersistableCliHistorySignature(message: PersistableCliHistoryMessage): string {
26
+ return [
27
+ String(message.role || ''),
28
+ String(message.kind || ''),
29
+ String(message.senderName || ''),
30
+ normalizePersistableCliHistoryContent(message.content),
31
+ ].join('|');
32
+ }
33
+
34
+ function hasSamePersistableCliHistoryIdentity(a: PersistableCliHistoryMessage, b: PersistableCliHistoryMessage): boolean {
35
+ return String(a?.role || '') === String(b?.role || '')
36
+ && String(a?.kind || '') === String(b?.kind || '')
37
+ && String(a?.senderName || '') === String(b?.senderName || '')
38
+ && String(a?.content || '') === String(b?.content || '');
39
+ }
40
+
41
+ export function buildIncrementalHistoryAppendMessages(
42
+ previousMessages: PersistableCliHistoryMessage[],
43
+ currentMessages: PersistableCliHistoryMessage[],
44
+ ): PersistableCliHistoryMessage[] {
45
+ if (!Array.isArray(currentMessages) || currentMessages.length === 0) return [];
46
+ if (!Array.isArray(previousMessages) || previousMessages.length === 0) return currentMessages;
47
+
48
+ const comparableLength = Math.min(previousMessages.length, currentMessages.length);
49
+ let sharedPrefixLength = 0;
50
+ while (
51
+ sharedPrefixLength < comparableLength
52
+ && hasSamePersistableCliHistoryIdentity(previousMessages[sharedPrefixLength], currentMessages[sharedPrefixLength])
53
+ ) {
54
+ sharedPrefixLength += 1;
55
+ }
56
+
57
+ if (sharedPrefixLength === currentMessages.length) return [];
58
+ if (sharedPrefixLength === previousMessages.length) return currentMessages.slice(sharedPrefixLength);
59
+
60
+ // Rare fallback: preserve the older whitespace-normalized behavior only when
61
+ // the cheap identity check detects a changed prefix. Recomputing normalized
62
+ // signatures for the full transcript on every idle status poll was a CPU
63
+ // hot path for long CLI sessions.
64
+ while (
65
+ sharedPrefixLength < comparableLength
66
+ && buildPersistableCliHistorySignature(previousMessages[sharedPrefixLength])
67
+ === buildPersistableCliHistorySignature(currentMessages[sharedPrefixLength])
68
+ ) {
69
+ sharedPrefixLength += 1;
70
+ }
71
+
72
+ if (sharedPrefixLength === currentMessages.length) return [];
73
+ if (sharedPrefixLength === previousMessages.length) return currentMessages.slice(sharedPrefixLength);
74
+ return currentMessages;
75
+ }
@@ -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 { createRequire } from 'node:module';
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
- role: string;
45
- content: string;
46
- kind?: string;
47
- senderName?: string;
48
- receivedAt?: number;
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;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * CLI provider status/launch pure helpers.
3
+ *
4
+ * Pure move out of cli-provider-instance.ts (no behavior change): the
5
+ * side-effect-free status predicates, the turn-anchored duration computation,
6
+ * the forced-new-session script resolver, the adapter-ready poll, and the lazy
7
+ * node:sqlite DatabaseSync loader. cli-provider-instance re-exports the
8
+ * public symbols (computeTurnAnchoredDurationMs, getForcedNewSessionScriptName,
9
+ * waitForCliAdapterReady) so existing importers/tests keep their path.
10
+ */
11
+
12
+ import * as path from 'path';
13
+ import { createRequire } from 'node:module';
14
+ import type { ProviderModule } from './contracts.js';
15
+
16
+ export function isIdleStatus(value: unknown): boolean {
17
+ const status = typeof value === 'string' ? value.trim().toLowerCase() : '';
18
+ return !status || status === 'idle' || status === 'ready';
19
+ }
20
+
21
+ export function getMessageTime(message: unknown): number {
22
+ if (!message || typeof message !== 'object') return 0;
23
+ const record = message as { receivedAt?: unknown; timestamp?: unknown };
24
+ const value = Number(record.receivedAt ?? record.timestamp ?? 0);
25
+ return Number.isFinite(value) ? value : 0;
26
+ }
27
+
28
+ export function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
29
+ const buttons = (activeModal as any)?.buttons;
30
+ return Array.isArray(buttons) && buttons.some((button) => String(button || '').trim().length > 0);
31
+ }
32
+
33
+ export function isCliGeneratingLikeStatus(status: unknown): boolean {
34
+ return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
35
+ }
36
+
37
+ /**
38
+ * NOTIF Defect-2a: the REPORTED short-generating duration, anchored on the IMMUTABLE turn
39
+ * start. generatingStartedAt is reset to 0 on every mid-turn waiting_approval/idle blip and
40
+ * re-armed on the next →generating, so a long turn that blips would otherwise measure only the
41
+ * final 1.5-2.5s sliver. engine.currentTurnStartedAt (set once at onTurnStarted, surviving
42
+ * mid-turn blips until the next turn starts) is preferred; generatingStartedAt is the fallback
43
+ * for turns that never recorded an engine turn start. Returns 0 when neither anchor is set.
44
+ * Pure / unit-testable.
45
+ */
46
+ export function computeTurnAnchoredDurationMs(
47
+ engineTurnStartedAt: number | undefined,
48
+ generatingStartedAt: number,
49
+ now: number,
50
+ ): { durationMs: number; anchor: 'turn-start' | 'generatingStartedAt' | 'none' } {
51
+ const engineStart = typeof engineTurnStartedAt === 'number' && Number.isFinite(engineTurnStartedAt)
52
+ ? engineTurnStartedAt
53
+ : 0;
54
+ if (engineStart > 0) return { durationMs: now - engineStart, anchor: 'turn-start' };
55
+ if (generatingStartedAt > 0) return { durationMs: now - generatingStartedAt, anchor: 'generatingStartedAt' };
56
+ return { durationMs: 0, anchor: 'none' };
57
+ }
58
+
59
+ let CachedDatabaseSync: (new (path: string, options?: { readOnly?: boolean }) => {
60
+ prepare(sql: string): { get(...params: Array<string | number>): unknown };
61
+ close(): void;
62
+ }) | null = null;
63
+
64
+ export function getDatabaseSync() {
65
+ if (CachedDatabaseSync) return CachedDatabaseSync;
66
+ const requireFn = typeof require === 'function'
67
+ ? require
68
+ : createRequire(path.join(process.cwd(), '__adhdev_sqlite_loader__.js'));
69
+ const sqliteModule = requireFn(`node:${'sqlite'}`) as {
70
+ DatabaseSync: typeof CachedDatabaseSync;
71
+ };
72
+ CachedDatabaseSync = sqliteModule.DatabaseSync;
73
+ if (!CachedDatabaseSync) {
74
+ throw new Error('node:sqlite DatabaseSync unavailable');
75
+ }
76
+ return CachedDatabaseSync;
77
+ }
78
+
79
+ export function getForcedNewSessionScriptName(
80
+ provider: ProviderModule | undefined,
81
+ launchMode: 'new' | 'resume' | 'manual',
82
+ ): string | null {
83
+ if (!provider || launchMode !== 'new') return null;
84
+ const resume = provider.resume;
85
+ if (!resume?.supported) return null;
86
+ if (Array.isArray(resume.newSessionArgs) && resume.newSessionArgs.length > 0) return null;
87
+
88
+ const controls = Array.isArray((provider as any).controls) ? (provider as any).controls : [];
89
+ for (const control of controls) {
90
+ if (control?.type !== 'action') continue;
91
+ if (typeof control?.confirmTitle === 'string' && control.confirmTitle.trim()) continue;
92
+ if (typeof control?.confirmMessage === 'string' && control.confirmMessage.trim()) continue;
93
+ if (typeof control?.confirmLabel === 'string' && control.confirmLabel.trim()) continue;
94
+ const invokeScript = typeof control?.invokeScript === 'string' ? control.invokeScript.trim() : '';
95
+ if (!invokeScript) continue;
96
+ const controlId = typeof control?.id === 'string' ? control.id.trim() : '';
97
+ if (controlId === 'new_session' || /^new.?session$/i.test(invokeScript)) {
98
+ return invokeScript;
99
+ }
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ export async function waitForCliAdapterReady(
106
+ adapter: { isReady?: () => boolean; getStatus?: () => { status?: string } },
107
+ options?: { timeoutMs?: number; pollMs?: number },
108
+ ): Promise<void> {
109
+ const timeoutMs = Math.max(100, options?.timeoutMs ?? 15_000);
110
+ const pollMs = Math.max(10, options?.pollMs ?? 50);
111
+ const deadline = Date.now() + timeoutMs;
112
+
113
+ while (Date.now() < deadline) {
114
+ if (adapter?.isReady?.()) return;
115
+ const status = adapter?.getStatus?.()?.status;
116
+ if (status === 'stopped') {
117
+ throw new Error('CLI runtime stopped before it became ready');
118
+ }
119
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
120
+ }
121
+
122
+ throw new Error(`CLI runtime did not become ready within ${timeoutMs}ms`);
123
+ }