@adhdev/daemon-core 0.9.5 → 0.9.7

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.
@@ -94,6 +94,51 @@ export function hydrateCliParsedMessages(
94
94
  });
95
95
  }
96
96
 
97
+ function chooseMoreComparableCliMessage(left: CliChatMessage, right: CliChatMessage): CliChatMessage {
98
+ const leftComparable = normalizeComparableMessageContent(left.content || '');
99
+ const rightComparable = normalizeComparableMessageContent(right.content || '');
100
+
101
+ if (leftComparable && leftComparable === rightComparable) {
102
+ const leftNewlines = String(left.content || '').split(/\r\n|\n|\r/g).length - 1;
103
+ const rightNewlines = String(right.content || '').split(/\r\n|\n|\r/g).length - 1;
104
+ return rightNewlines < leftNewlines ? right : left;
105
+ }
106
+
107
+ return rightComparable.length > leftComparable.length ? right : left;
108
+ }
109
+
110
+ function dedupeConsecutiveComparableCliMessages(messages: CliChatMessage[]): CliChatMessage[] {
111
+ const deduped: CliChatMessage[] = [];
112
+
113
+ for (const message of messages) {
114
+ const current = {
115
+ ...message,
116
+ content: typeof message.content === 'string' ? message.content : String(message.content || ''),
117
+ } as CliChatMessage;
118
+ const previous = deduped[deduped.length - 1];
119
+ if (!previous) {
120
+ deduped.push(current);
121
+ continue;
122
+ }
123
+
124
+ const previousComparable = normalizeComparableMessageContent(previous.content || '');
125
+ const currentComparable = normalizeComparableMessageContent(current.content || '');
126
+ const sameRole = previous.role === current.role;
127
+ const sameKind = (previous.kind || 'standard') === (current.kind || 'standard');
128
+ const sameSender = (previous.senderName || '') === (current.senderName || '');
129
+ const comparableMatch = previousComparable && previousComparable === currentComparable;
130
+
131
+ if (sameRole && sameKind && sameSender && comparableMatch) {
132
+ deduped[deduped.length - 1] = chooseMoreComparableCliMessage(previous, current);
133
+ continue;
134
+ }
135
+
136
+ deduped.push(current);
137
+ }
138
+
139
+ return deduped;
140
+ }
141
+
97
142
  export function normalizeCliParsedMessages(
98
143
  parsedMessages: any[],
99
144
  options: {
@@ -103,7 +148,7 @@ export function normalizeCliParsedMessages(
103
148
  now?: number;
104
149
  },
105
150
  ): CliChatMessage[] {
106
- return hydrateCliParsedMessages(parsedMessages, options).map((message) => ({
151
+ return dedupeConsecutiveComparableCliMessages(hydrateCliParsedMessages(parsedMessages, options).map((message) => ({
107
152
  role: message.role,
108
153
  content: message.content,
109
154
  timestamp: message.timestamp,
@@ -113,7 +158,7 @@ export function normalizeCliParsedMessages(
113
158
  index: message.index,
114
159
  meta: message.meta,
115
160
  senderName: message.senderName,
116
- }));
161
+ })));
117
162
  }
118
163
 
119
164
  export function buildCliParseInput(options: {
@@ -373,8 +373,58 @@ export function normalizeScreenSnapshot(text: string): string {
373
373
  .trim();
374
374
  }
375
375
 
376
+ const COMMON_COMPARABLE_WRAP_WORDS = new Set([
377
+ 'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'in', 'into', 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'their', 'then', 'this', 'to', 'was', 'with',
378
+ ]);
379
+
380
+ function shouldReflowComparableMessageLines(lines: string[]): boolean {
381
+ return Array.isArray(lines)
382
+ && lines.length > 1
383
+ && lines.slice(0, -1).every((line) => String(line || '').trim().length >= 48)
384
+ && !lines.some((line) => /^```/.test(line))
385
+ && !lines.some((line) => /^\|/.test(line))
386
+ && !lines.some((line) => /^\s*(?:[-*+] |\d+\.\s)/.test(line));
387
+ }
388
+
389
+ function joinComparableMessageLines(lines: string[]): string {
390
+ return lines.reduce((acc, line) => {
391
+ const next = String(line || '').trim();
392
+ if (!next) return acc;
393
+ if (!acc) return next;
394
+
395
+ if (/[,\d]$/.test(acc) && /^\d/.test(next)) {
396
+ return `${acc}${next}`;
397
+ }
398
+
399
+ if (/[A-Za-z]$/.test(acc) && /^\d/.test(next)) {
400
+ return `${acc}${next}`;
401
+ }
402
+
403
+ const fragmentMatch = acc.match(/([A-Za-z]{1,4})$/);
404
+ const fragment = fragmentMatch ? fragmentMatch[1].toLowerCase() : '';
405
+ if (/^[a-z]/.test(next) && fragment && !COMMON_COMPARABLE_WRAP_WORDS.has(fragment)) {
406
+ return `${acc}${next}`;
407
+ }
408
+
409
+ return `${acc} ${next}`;
410
+ }, '')
411
+ .replace(/\s+([,.;:!?])/g, '$1')
412
+ .replace(/(\d)\s+,/g, '$1,')
413
+ .replace(/\s+/g, ' ')
414
+ .trim();
415
+ }
416
+
376
417
  export function normalizeComparableMessageContent(text: string): string {
377
- return String(text || '')
418
+ const lines = String(text || '')
419
+ .split(/\r\n|\n|\r/g)
420
+ .map((line) => line.trim())
421
+ .filter(Boolean);
422
+
423
+ if (lines.length === 0) return '';
424
+ if (shouldReflowComparableMessageLines(lines)) {
425
+ return joinComparableMessageLines(lines);
426
+ }
427
+ return lines.join(' ')
378
428
  .replace(/\s+/g, ' ')
379
429
  .trim();
380
430
  }
@@ -414,10 +464,6 @@ export function getLastUserPromptText(messages: Array<{ role?: string; content?:
414
464
  return '';
415
465
  }
416
466
 
417
- export function looksLikeConfirmOnlyLabel(label: string): boolean {
418
- return /^(?:continue|confirm|ok|yes|trust|proceed|enter)$/i.test(String(label || '').trim());
419
- }
420
-
421
467
  function parsePatternEntry(x: unknown): RegExp | null {
422
468
  if (x instanceof RegExp) return x;
423
469
  if (x && typeof x === 'object' && typeof (x as { source?: string }).source === 'string') {
@@ -1255,26 +1255,24 @@ export async function handleResolveAction(h: CommandHelpers, args: any): Promise
1255
1255
  ? 'waiting_approval'
1256
1256
  : status?.status;
1257
1257
  LOG.info('Command', `[resolveAction] CLI PTY gate target=${String(args?.targetSessionId || '')} rawStatus=${String(status?.status || '')} effectiveStatus=${String(effectiveStatus || '')} statusModal=${statusModal ? 'yes' : 'no'} surfacedModal=${surfacedModal ? 'yes' : 'no'} instance=${targetInstance ? 'yes' : 'no'}`);
1258
- if (effectiveStatus !== 'waiting_approval' && !effectiveModal) {
1258
+ if (!effectiveModal) {
1259
1259
  return { success: false, error: 'Not in approval state' };
1260
1260
  }
1261
- const buttons: string[] = effectiveModal?.buttons || ['Allow once', 'Always allow', 'Deny'];
1262
- // Resolve button index: explicit buttonIndex arg → button text match → action fallback
1261
+ const buttons: string[] = Array.isArray(effectiveModal.buttons) ? effectiveModal.buttons : [];
1262
+ // Resolve button index: explicit buttonIndex arg → exact text match → explicit action mapping
1263
1263
  let buttonIndex = typeof args?.buttonIndex === 'number' ? args.buttonIndex : -1;
1264
- if (buttonIndex < 0) {
1264
+ if (buttonIndex < 0 && button) {
1265
1265
  const btnLower = button.toLowerCase();
1266
1266
  buttonIndex = buttons.findIndex(b => b.toLowerCase().includes(btnLower));
1267
1267
  }
1268
+ if (buttonIndex < 0 && (action === 'reject' || action === 'deny')) {
1269
+ buttonIndex = buttons.findIndex(b => /deny|reject|no/i.test(b));
1270
+ }
1271
+ if (buttonIndex < 0 && (action === 'always' || /always/i.test(button))) {
1272
+ buttonIndex = buttons.findIndex(b => /always/i.test(b));
1273
+ }
1268
1274
  if (buttonIndex < 0) {
1269
- if (action === 'reject' || action === 'deny') {
1270
- buttonIndex = buttons.findIndex(b => /deny|reject|no/i.test(b));
1271
- if (buttonIndex < 0) buttonIndex = buttons.length - 1;
1272
- } else if (action === 'always' || /always/i.test(button)) {
1273
- buttonIndex = buttons.findIndex(b => /always/i.test(b));
1274
- if (buttonIndex < 0) buttonIndex = 1;
1275
- } else {
1276
- buttonIndex = 0; // approve → first option (default selected)
1277
- }
1275
+ return { success: false, error: 'Approval action did not match any visible button' };
1278
1276
  }
1279
1277
  if (typeof adapter.resolveModal === 'function') {
1280
1278
  adapter.resolveModal(buttonIndex);
@@ -161,6 +161,16 @@ export class CliProviderInstance implements ProviderInstance {
161
161
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
162
162
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
163
163
  private lastCanonicalHermesSyncMtimeMs = 0;
164
+ private lastCanonicalHermesExistCheckAt = 0;
165
+ private lastCanonicalHermesWatchPath: string | undefined = undefined;
166
+ private lastCanonicalClaudeRebuildMtimeMs = 0;
167
+ private lastCanonicalClaudeCheckAt = 0;
168
+ private cachedSqliteDb: {
169
+ prepare(sql: string): { get(...values: Array<string | number>): unknown };
170
+ close(): void;
171
+ } | null = null;
172
+ private cachedSqliteDbPath: string | null = null;
173
+ private cachedSqliteDbMissingUntil = 0;
164
174
  readonly instanceId: string;
165
175
  private suppressIdleHistoryReplay = false;
166
176
  private errorMessage: string | undefined = undefined;
@@ -281,7 +291,13 @@ export class CliProviderInstance implements ProviderInstance {
281
291
  timestampFormat?: 'unix_ms' | 'unix_s' | 'iso';
282
292
  }): string | null {
283
293
  const resolvedDbPath = probe.dbPath.replace(/^~/, os.homedir());
284
- if (!fs.existsSync(resolvedDbPath)) return null;
294
+ // Skip existsSync if we already confirmed DB is missing (cache for 10s)
295
+ const now = Date.now();
296
+ if (this.cachedSqliteDbMissingUntil > now) return null;
297
+ if (!fs.existsSync(resolvedDbPath)) {
298
+ this.cachedSqliteDbMissingUntil = now + 10_000;
299
+ return null;
300
+ }
285
301
 
286
302
  const directories = this.getProbeDirectories();
287
303
  const minCreatedAt = Math.max(0, this.startedAt - 60_000);
@@ -486,6 +502,9 @@ export class CliProviderInstance implements ProviderInstance {
486
502
  this.adapter.shutdown();
487
503
  this.monitor.reset();
488
504
  this.appliedEffectKeys.clear();
505
+ try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
506
+ this.cachedSqliteDb = null;
507
+ this.cachedSqliteDbPath = null;
489
508
  }
490
509
 
491
510
  private completedDebounceTimer: NodeJS.Timeout | null = null;
@@ -944,13 +963,38 @@ export class CliProviderInstance implements ProviderInstance {
944
963
  const watchPath = canonicalHistory.watchPath
945
964
  .replace(/^~/, os.homedir())
946
965
  .replace('{{sessionId}}', this.providerSessionId);
947
- if (!fs.existsSync(watchPath)) return false;
966
+ // Throttle existsSync: check file existence at most once per 2s
967
+ const now = Date.now();
968
+ if (watchPath !== this.lastCanonicalHermesWatchPath || now - this.lastCanonicalHermesExistCheckAt >= 2_000) {
969
+ this.lastCanonicalHermesWatchPath = watchPath;
970
+ this.lastCanonicalHermesExistCheckAt = now;
971
+ if (!fs.existsSync(watchPath)) return false;
972
+ } else if (this.lastCanonicalHermesSyncMtimeMs === 0) {
973
+ // First check: file existence not yet confirmed, must verify
974
+ if (!fs.existsSync(watchPath)) return false;
975
+ }
948
976
  const stat = fs.statSync(watchPath);
949
977
  if (stat.mtimeMs <= this.lastCanonicalHermesSyncMtimeMs) return true;
950
978
  rebuilt = rebuildHermesSavedHistoryFromCanonicalSession(this.providerSessionId);
951
979
  if (rebuilt) this.lastCanonicalHermesSyncMtimeMs = stat.mtimeMs;
952
980
  } else if (canonicalHistory.format === 'claude-jsonl') {
981
+ // Throttle: only check for changes at most once per 2s
982
+ const now = Date.now();
983
+ if (now - this.lastCanonicalClaudeCheckAt < 2_000 && this.lastCanonicalClaudeRebuildMtimeMs !== 0) {
984
+ return true;
985
+ }
986
+ this.lastCanonicalClaudeCheckAt = now;
987
+ // Only rebuild if the transcript file has changed since last rebuild
988
+ const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
989
+ const workspaceSegment = typeof this.workingDir === 'string'
990
+ ? this.workingDir.replace(/[\\/]/g, '-').replace(/^-+/, '')
991
+ : '';
992
+ const transcriptFile = path.join(claudeProjectsDir, workspaceSegment, `${this.providerSessionId}.jsonl`);
993
+ let transcriptMtime = 0;
994
+ try { transcriptMtime = fs.statSync(transcriptFile).mtimeMs; } catch { /* not found yet */ }
995
+ if (transcriptMtime > 0 && transcriptMtime <= this.lastCanonicalClaudeRebuildMtimeMs) return true;
953
996
  rebuilt = rebuildClaudeSavedHistoryFromNativeProject(this.providerSessionId, this.workingDir);
997
+ if (rebuilt) this.lastCanonicalClaudeRebuildMtimeMs = transcriptMtime || Date.now();
954
998
  }
955
999
  if (!rebuilt) return false;
956
1000
  const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
@@ -1023,24 +1067,24 @@ export class CliProviderInstance implements ProviderInstance {
1023
1067
  }
1024
1068
 
1025
1069
  private querySqliteText(dbPath: string, query: string, params: Array<string | number>): string | null {
1026
- let db: {
1027
- prepare(sql: string): { get(...values: Array<string | number>): unknown };
1028
- close(): void;
1029
- } | null = null;
1030
1070
  try {
1031
- const DatabaseSync = getDatabaseSync();
1032
- db = new DatabaseSync(dbPath, { readOnly: true });
1033
- const row = db.prepare(query).get(...params) as { id?: unknown } | undefined;
1071
+ if (this.cachedSqliteDb === null || this.cachedSqliteDbPath !== dbPath) {
1072
+ try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
1073
+ this.cachedSqliteDb = null;
1074
+ this.cachedSqliteDbPath = null;
1075
+ const DatabaseSync = getDatabaseSync();
1076
+ this.cachedSqliteDb = new DatabaseSync(dbPath, { readOnly: true });
1077
+ this.cachedSqliteDbPath = dbPath;
1078
+ }
1079
+ const row = this.cachedSqliteDb!.prepare(query).get(...params) as { id?: unknown } | undefined;
1034
1080
  const sessionId = typeof row?.id === 'string' ? row.id.trim() : '';
1035
1081
  return sessionId || null;
1036
1082
  } catch {
1083
+ // Close cached connection on error so we retry fresh next tick
1084
+ try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
1085
+ this.cachedSqliteDb = null;
1086
+ this.cachedSqliteDbPath = null;
1037
1087
  return null;
1038
- } finally {
1039
- try {
1040
- db?.close();
1041
- } catch {
1042
- // noop
1043
- }
1044
1088
  }
1045
1089
  }
1046
1090
  }
@@ -37,6 +37,7 @@ const KNOWN_PROVIDER_FIELDS = new Set<string>([
37
37
  'canonicalHistory',
38
38
  'autoFixProfile',
39
39
  'ideLevelScripts',
40
+ 'allowInputDuringGeneration',
40
41
  'scripts',
41
42
  'vscodeCommands',
42
43
  'inputMethod',