@adhdev/daemon-core 0.9.4 → 0.9.6

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.
@@ -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;
@@ -260,37 +270,12 @@ export class CliProviderInstance implements ProviderInstance {
260
270
 
261
271
  async onTick(): Promise<void> {
262
272
  if (this.providerSessionId) return;
263
- if (this.type === 'hermes-cli' && this.launchMode === 'new') return;
273
+ if (this.provider.resume?.skipProbeOnNewSession && this.launchMode === 'new') return;
264
274
 
265
- let probedSessionId: string | null = null;
266
-
267
- // Prefer declarative probe from provider.json schema
268
275
  const probeConfig = this.provider.sessionProbe;
269
- if (probeConfig) {
270
- probedSessionId = this.probeSessionIdFromConfig(probeConfig);
271
- } else {
272
- // Legacy hardcoded probes (backward compat until providers migrate)
273
- if (this.type === 'opencode-cli') {
274
- probedSessionId = this.probeSessionIdFromConfig({
275
- dbPath: '~/.local/share/opencode/opencode.db',
276
- query: 'select id from session where directory in ({dirs}) and time_created >= ? and time_archived is null order by time_updated desc limit 1',
277
- timestampFormat: 'unix_ms',
278
- });
279
- } else if (this.type === 'codex-cli') {
280
- probedSessionId = this.probeSessionIdFromConfig({
281
- dbPath: '~/.codex/state_5.sqlite',
282
- query: 'select id from threads where cwd in ({dirs}) and updated_at >= ? and archived = 0 order by updated_at desc limit 1',
283
- timestampFormat: 'unix_s',
284
- });
285
- } else if (this.type === 'goose-cli') {
286
- probedSessionId = this.probeSessionIdFromConfig({
287
- dbPath: '~/.local/share/goose/sessions/sessions.db',
288
- query: 'select id from sessions where working_dir in ({dirs}) and created_at >= ? order by updated_at desc limit 1',
289
- timestampFormat: 'iso',
290
- });
291
- }
292
- }
276
+ if (!probeConfig) return;
293
277
 
278
+ const probedSessionId = this.probeSessionIdFromConfig(probeConfig);
294
279
  if (probedSessionId) {
295
280
  this.promoteProviderSessionId(probedSessionId);
296
281
  }
@@ -306,7 +291,13 @@ export class CliProviderInstance implements ProviderInstance {
306
291
  timestampFormat?: 'unix_ms' | 'unix_s' | 'iso';
307
292
  }): string | null {
308
293
  const resolvedDbPath = probe.dbPath.replace(/^~/, os.homedir());
309
- 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
+ }
310
301
 
311
302
  const directories = this.getProbeDirectories();
312
303
  const minCreatedAt = Math.max(0, this.startedAt - 60_000);
@@ -355,7 +346,7 @@ export class CliProviderInstance implements ProviderInstance {
355
346
  ? 'error'
356
347
  : (autoApproveActive ? 'generating' : adapterStatus.status);
357
348
  const parsedProviderSessionId = normalizeProviderSessionId(
358
- this.type,
349
+ this.provider,
359
350
  typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
360
351
  );
361
352
  if (parsedProviderSessionId) {
@@ -511,6 +502,9 @@ export class CliProviderInstance implements ProviderInstance {
511
502
  this.adapter.shutdown();
512
503
  this.monitor.reset();
513
504
  this.appliedEffectKeys.clear();
505
+ try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
506
+ this.cachedSqliteDb = null;
507
+ this.cachedSqliteDbPath = null;
514
508
  }
515
509
 
516
510
  private completedDebounceTimer: NodeJS.Timeout | null = null;
@@ -684,7 +678,7 @@ export class CliProviderInstance implements ProviderInstance {
684
678
  if (!data || typeof data !== 'object') return;
685
679
 
686
680
  const patchedProviderSessionId = normalizeProviderSessionId(
687
- this.type,
681
+ this.provider,
688
682
  typeof data.providerSessionId === 'string' ? data.providerSessionId : '',
689
683
  );
690
684
  if (patchedProviderSessionId) {
@@ -960,53 +954,68 @@ export class CliProviderInstance implements ProviderInstance {
960
954
 
961
955
  private syncCanonicalSavedHistoryIfNeeded(): boolean {
962
956
  if (!this.providerSessionId) return false;
963
- if (this.type === 'hermes-cli') {
964
- try {
965
- const canonicalPath = path.join(os.homedir(), '.hermes', 'sessions', `session_${this.providerSessionId}.json`);
966
- if (!fs.existsSync(canonicalPath)) return false;
967
- const stat = fs.statSync(canonicalPath);
957
+ const canonicalHistory = this.provider.canonicalHistory;
958
+ if (!canonicalHistory) return false;
959
+
960
+ try {
961
+ let rebuilt = false;
962
+ if (canonicalHistory.format === 'hermes-json') {
963
+ const watchPath = canonicalHistory.watchPath
964
+ .replace(/^~/, os.homedir())
965
+ .replace('{{sessionId}}', this.providerSessionId);
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
+ }
976
+ const stat = fs.statSync(watchPath);
968
977
  if (stat.mtimeMs <= this.lastCanonicalHermesSyncMtimeMs) return true;
969
- const rebuilt = rebuildHermesSavedHistoryFromCanonicalSession(this.providerSessionId);
970
- if (!rebuilt) return false;
971
- this.lastCanonicalHermesSyncMtimeMs = stat.mtimeMs;
972
- const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId);
973
- this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
974
- role: message.role,
975
- content: message.content,
976
- kind: message.kind,
977
- senderName: message.senderName,
978
- receivedAt: message.receivedAt,
979
- }));
980
- return true;
981
- } catch {
982
- return false;
983
- }
984
- }
985
- if (this.type === 'claude-cli') {
986
- try {
987
- const rebuilt = rebuildClaudeSavedHistoryFromNativeProject(this.providerSessionId, this.workingDir);
988
- if (!rebuilt) return false;
989
- const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId);
990
- this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
991
- role: message.role,
992
- content: message.content,
993
- kind: message.kind,
994
- senderName: message.senderName,
995
- receivedAt: message.receivedAt,
996
- }));
997
- return true;
998
- } catch {
999
- return false;
978
+ rebuilt = rebuildHermesSavedHistoryFromCanonicalSession(this.providerSessionId);
979
+ if (rebuilt) this.lastCanonicalHermesSyncMtimeMs = stat.mtimeMs;
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;
996
+ rebuilt = rebuildClaudeSavedHistoryFromNativeProject(this.providerSessionId, this.workingDir);
997
+ if (rebuilt) this.lastCanonicalClaudeRebuildMtimeMs = transcriptMtime || Date.now();
1000
998
  }
999
+ if (!rebuilt) return false;
1000
+ const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
1001
+ this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
1002
+ role: message.role,
1003
+ content: message.content,
1004
+ kind: message.kind,
1005
+ senderName: message.senderName,
1006
+ receivedAt: message.receivedAt,
1007
+ }));
1008
+ return true;
1009
+ } catch {
1010
+ return false;
1001
1011
  }
1002
- return false;
1003
1012
  }
1004
1013
 
1005
1014
  private restorePersistedHistoryFromCurrentSession(): void {
1006
1015
  if (!this.providerSessionId) return;
1007
1016
  this.syncCanonicalSavedHistoryIfNeeded();
1008
- this.historyWriter.compactHistorySession(this.type, this.providerSessionId);
1009
- const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId);
1017
+ this.historyWriter.compactHistorySession(this.type, this.providerSessionId, this.provider.historyBehavior);
1018
+ const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
1010
1019
  this.historyWriter.seedSessionHistory(
1011
1020
  this.type,
1012
1021
  restoredHistory.messages,
@@ -1058,24 +1067,24 @@ export class CliProviderInstance implements ProviderInstance {
1058
1067
  }
1059
1068
 
1060
1069
  private querySqliteText(dbPath: string, query: string, params: Array<string | number>): string | null {
1061
- let db: {
1062
- prepare(sql: string): { get(...values: Array<string | number>): unknown };
1063
- close(): void;
1064
- } | null = null;
1065
1070
  try {
1066
- const DatabaseSync = getDatabaseSync();
1067
- db = new DatabaseSync(dbPath, { readOnly: true });
1068
- 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;
1069
1080
  const sessionId = typeof row?.id === 'string' ? row.id.trim() : '';
1070
1081
  return sessionId || null;
1071
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;
1072
1087
  return null;
1073
- } finally {
1074
- try {
1075
- db?.close();
1076
- } catch {
1077
- // noop
1078
- }
1079
1088
  }
1080
1089
  }
1081
1090
  }
@@ -418,6 +418,13 @@ export interface ProviderModule {
418
418
  extensionIdPattern_flags?: string;
419
419
  compatibility?: ProviderCompatibilityEntry[];
420
420
  defaultScriptDir?: string;
421
+ /**
422
+ * Scripts that can run at the IDE main-page level (not just inside the extension webview session frame).
423
+ * Default: ['listModes', 'setMode', 'listModels', 'setModel'].
424
+ * Add extra scripts here if the provider supports them at the IDE level (e.g. 'setModelGui').
425
+ * Replaces hardcoded claude-code-vscode special-case in stream-commands.ts.
426
+ */
427
+ ideLevelScripts?: string[];
421
428
 
422
429
  // ─── CLI category only ───
423
430
  binary?: string;
@@ -426,6 +433,8 @@ export interface ProviderModule {
426
433
  args?: string[];
427
434
  shell?: boolean;
428
435
  env?: Record<string, string>;
436
+ /** Auto-implement spawn config — controls how this provider is invoked for autonomous script generation */
437
+ autoImpl?: ProviderAutoImplSpawnConfig;
429
438
  };
430
439
  /** Delay before submitting typed CLI input (provider-specific TUI tuning) */
431
440
  sendDelayMs?: number;
@@ -451,6 +460,24 @@ export interface ProviderModule {
451
460
  allowInputDuringGeneration?: boolean;
452
461
  /** Approval button priority hints used when auto-approve must pick a positive action */
453
462
  approvalPositiveHints?: string[];
463
+ /**
464
+ * Regex pattern (as string) that a valid provider session ID must match.
465
+ * If set and the ID doesn't match, it is rejected (treated as invalid).
466
+ * Replaces hardcoded HERMES_SESSION_ID_RE / CLAUDE_SESSION_ID_RE checks.
467
+ */
468
+ sessionIdPattern?: string;
469
+ /** History behavior config — controls message filtering and collapse during replay */
470
+ historyBehavior?: ProviderHistoryBehavior;
471
+ /**
472
+ * Canonical history sync config — for providers that maintain native history files.
473
+ * When set, daemon syncs from native format into ADHDev JSONL store on each tick.
474
+ */
475
+ canonicalHistory?: ProviderCanonicalHistoryConfig;
476
+ /**
477
+ * Auto-fix verification profile — provider-specific test expectations for `provider fix`.
478
+ * If not set, provider fix runs without pre/post verification.
479
+ */
480
+ autoFixProfile?: ProviderAutoFixProfile;
454
481
 
455
482
  // ─── CDP scripts (ide/extension category) ───
456
483
  scripts?: ProviderScripts;
@@ -538,6 +565,94 @@ export interface ProviderResumeCapability {
538
565
  resumeSessionArgs?: string[];
539
566
  newSessionArgs?: string[];
540
567
  sessionIdFormat?: 'uuid' | 'string';
568
+ /** Skip session ID probing when launchMode is 'new' — for providers that manage their own session IDs on new sessions */
569
+ skipProbeOnNewSession?: boolean;
570
+ /**
571
+ * Subcommands that carry a session ID as their next positional argument.
572
+ * e.g. ['resume', 'fork'] for codex-cli (codex resume <id> / codex fork <id>).
573
+ * Replaces the hardcoded readCodexResumeSessionId check in cli-manager.ts.
574
+ */
575
+ sessionIdFromSubcommand?: string[];
576
+ /**
577
+ * When --session-id is present without an explicit resume flag, treat as 'new' rather than 'resume'.
578
+ * e.g. goose-cli passes --session-id on new sessions but requires --resume/-r to actually resume.
579
+ * Replaces the hardcoded goose-cli check in cli-manager.ts.
580
+ */
581
+ sessionIdIsNewByDefault?: boolean;
582
+ }
583
+
584
+ /**
585
+ * History behavior config — controls how history messages are processed for this provider.
586
+ * Replaces hardcoded agentType checks in chat-history.ts.
587
+ */
588
+ export interface ProviderHistoryBehavior {
589
+ /** Collapse consecutive assistant turns during history replay (e.g. codex-cli shows replayed intermediate turns) */
590
+ collapseConsecutiveAssistantTurns?: boolean;
591
+ /** Regex patterns (as strings) to filter out from assistant messages — e.g. CLI starter prompt suggestions */
592
+ filterAssistantPatterns?: string[];
593
+ /** If true, session ID must match sessionIdPattern exactly — reject and return '' if it doesn't match */
594
+ requireStrictSessionIdFormat?: boolean;
595
+ }
596
+
597
+ /**
598
+ * Canonical history sync config — for providers that maintain their own native history files.
599
+ * When set, daemon syncs from the provider's native format into the ADHDev JSONL store.
600
+ * Replaces hardcoded hermes-cli / claude-cli checks in cli-provider-instance.ts.
601
+ */
602
+ export interface ProviderCanonicalHistoryConfig {
603
+ /**
604
+ * Native history format.
605
+ * - 'hermes-json': single JSON file per session (~/.hermes/sessions/session_{{sessionId}}.json)
606
+ * - 'claude-jsonl': JSONL transcript under ~/.claude/projects/
607
+ */
608
+ format: 'hermes-json' | 'claude-jsonl';
609
+ /**
610
+ * Path to the native history file. Supports ~ and {{sessionId}} placeholder.
611
+ * e.g. "~/.hermes/sessions/session_{{sessionId}}.json"
612
+ */
613
+ watchPath: string;
614
+ }
615
+
616
+ /**
617
+ * Auto-implement spawn config — controls how the provider is spawned for autonomous AI-driven
618
+ * provider script implementation (dev-auto-implement.ts).
619
+ * Replaces hardcoded per-command branching.
620
+ */
621
+ export interface ProviderAutoImplSpawnConfig {
622
+ /**
623
+ * How the meta-prompt is passed to the agent.
624
+ * - 'flag': passed via a CLI flag (e.g. `claude -p "..."`)
625
+ * - 'stdin': piped via stdin (generic fallback)
626
+ * - 'subcommand': prepended as a subcommand (e.g. `codex exec "..."`)
627
+ */
628
+ promptMode: 'flag' | 'stdin' | 'subcommand';
629
+ /** CLI flag used to pass the prompt (promptMode: 'flag') — e.g. '-p' */
630
+ promptFlag?: string;
631
+ /** Subcommand prepended before the prompt (promptMode: 'subcommand') — e.g. 'exec' */
632
+ subcommand?: string;
633
+ /** Extra args appended in auto-impl mode — e.g. ['--dangerously-skip-permissions'] */
634
+ extraArgs?: string[];
635
+ /** Custom meta-prompt template; use {{promptFile}} placeholder. If omitted, generic prompt is used. */
636
+ metaPrompt?: string;
637
+ /**
638
+ * If true, schedule an auto-stop timer when the agent output goes quiet during verification.
639
+ * Replaces the hardcoded `command !== 'codex'` check in dev-auto-implement.ts.
640
+ */
641
+ autoStopOnQuiet?: boolean;
642
+ }
643
+
644
+ /**
645
+ * Auto-fix verification profile — provider-specific test expectations for `provider fix`.
646
+ * Replaces the hardcoded CLI_AUTO_FIX_VERIFICATION_PROFILES record in provider-commands.ts.
647
+ */
648
+ export interface ProviderAutoFixProfile {
649
+ fixtureName: string;
650
+ description: string;
651
+ inspectFields?: string[];
652
+ focusAreas?: string[];
653
+ lastAssistantMustContainAny?: string[];
654
+ lastAssistantMustNotContainAny?: string[];
655
+ timeoutMs?: number;
541
656
  }
542
657
 
543
658
  /**
@@ -32,6 +32,12 @@ const KNOWN_PROVIDER_FIELDS = new Set<string>([
32
32
  'resume',
33
33
  'sessionProbe',
34
34
  'approvalPositiveHints',
35
+ 'sessionIdPattern',
36
+ 'historyBehavior',
37
+ 'canonicalHistory',
38
+ 'autoFixProfile',
39
+ 'ideLevelScripts',
40
+ 'allowInputDuringGeneration',
35
41
  'scripts',
36
42
  'vscodeCommands',
37
43
  'inputMethod',
@@ -1,26 +1,28 @@
1
- const HERMES_SESSION_ID_RE = /^\d{8}_\d{6}_[a-z0-9]+$/i
2
- const CLAUDE_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
1
+ import type { ProviderModule } from './contracts.js'
3
2
 
4
- export function normalizeProviderSessionId(providerType: string | undefined, providerSessionId: string | null | undefined): string {
5
- const normalizedProviderType = typeof providerType === 'string' ? providerType.trim() : ''
3
+ /**
4
+ * Normalize and validate a provider session ID using the declarative `sessionIdPattern`
5
+ * from the provider's ProviderModule definition.
6
+ */
7
+ export function normalizeProviderSessionId(
8
+ provider: ProviderModule | undefined,
9
+ providerSessionId: string | null | undefined,
10
+ ): string {
6
11
  const normalizedId = typeof providerSessionId === 'string' ? providerSessionId.trim() : ''
7
12
  if (!normalizedId) return ''
8
13
 
9
14
  const lowered = normalizedId.toLowerCase()
10
15
  if (lowered === 'undefined' || lowered === 'null') return ''
11
16
 
12
- if (normalizedProviderType === 'hermes-cli' && !HERMES_SESSION_ID_RE.test(normalizedId)) {
13
- return ''
14
- }
15
- if (normalizedProviderType === 'claude-cli' && !CLAUDE_SESSION_ID_RE.test(normalizedId)) {
16
- return ''
17
+ const sessionIdPattern = provider?.sessionIdPattern
18
+ if (sessionIdPattern) {
19
+ try {
20
+ const re = new RegExp(sessionIdPattern, 'i')
21
+ if (!re.test(normalizedId)) return ''
22
+ } catch {
23
+ // Invalid regex in provider.json — skip validation
24
+ }
17
25
  }
18
26
 
19
27
  return normalizedId
20
28
  }
21
-
22
- export function isLegacyVolatileSessionReadKey(key: string | null | undefined): boolean {
23
- const normalizedKey = typeof key === 'string' ? key.trim() : ''
24
- if (!normalizedKey) return false
25
- return normalizedKey.startsWith('provider:codex:vscode-webview://')
26
- }