@adhdev/daemon-core 0.9.44 → 0.9.46

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,37 @@
1
+ function normalizeMacAppPath(appPath: string): string | null {
2
+ const trimmed = String(appPath || '').trim()
3
+ if (!trimmed) return null
4
+ return trimmed.replace(/\/+$/, '')
5
+ }
6
+
7
+ function parsePsLine(line: string): { pid: number; args: string } | null {
8
+ const match = line.match(/^\s*(\d+)\s+(.+)$/)
9
+ if (!match) return null
10
+ const pid = Number.parseInt(match[1], 10)
11
+ if (!Number.isFinite(pid)) return null
12
+ return { pid, args: match[2] }
13
+ }
14
+
15
+ export function isMacAppProcessArgs(args: string, appPath: string): boolean {
16
+ const normalized = normalizeMacAppPath(appPath)
17
+ if (!normalized) return false
18
+ return String(args || '').startsWith(`${normalized}/`)
19
+ }
20
+
21
+ export function findMacAppProcessPids(psOutput: string, appPaths: readonly string[]): number[] {
22
+ const normalizedPaths = appPaths
23
+ .map(normalizeMacAppPath)
24
+ .filter((value): value is string => !!value)
25
+
26
+ if (normalizedPaths.length === 0) return []
27
+
28
+ const pids: number[] = []
29
+ for (const line of String(psOutput || '').split(/\r?\n/)) {
30
+ const parsed = parsePsLine(line)
31
+ if (!parsed) continue
32
+ if (normalizedPaths.some(appPath => isMacAppProcessArgs(parsed.args, appPath))) {
33
+ pids.push(parsed.pid)
34
+ }
35
+ }
36
+ return pids
37
+ }
package/src/launch.ts CHANGED
@@ -24,6 +24,7 @@ import { detectIDEs } from './detection/ide-detector.js';
24
24
  import { IDEInfo } from './detection/ide-detector.js';
25
25
  import { ProviderLoader } from './providers/provider-loader.js';
26
26
  import type { ProviderModule } from './providers/contracts.js';
27
+ import { findMacAppProcessPids } from './launch/macos-app-process.js';
27
28
 
28
29
  // ─── Provider-based dynamic IDE infrastructure ────────────────
29
30
  // Reads cdpPorts, processNames from provider.js — only create provider.js to add new IDE
@@ -71,6 +72,37 @@ function escapeForAppleScript(value: string): string {
71
72
  return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
72
73
  }
73
74
 
75
+ function getIdePathCandidates(ideId: string): string[] {
76
+ return getProviderLoader().getIdePathCandidates(ideId);
77
+ }
78
+
79
+ function getMacAppProcessPids(ideId: string): number[] {
80
+ const appPaths = getIdePathCandidates(ideId);
81
+ if (appPaths.length === 0) return [];
82
+ try {
83
+ const output = execSync('ps axww -o pid=,args=', {
84
+ encoding: 'utf-8',
85
+ timeout: 3000,
86
+ stdio: ['pipe', 'pipe', 'pipe'],
87
+ });
88
+ return findMacAppProcessPids(output, appPaths);
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ function killMacAppPathProcesses(ideId: string, signal: NodeJS.Signals): boolean {
95
+ const pids = getMacAppProcessPids(ideId);
96
+ let signalled = false;
97
+ for (const pid of pids) {
98
+ try {
99
+ process.kill(pid, signal);
100
+ signalled = true;
101
+ } catch { }
102
+ }
103
+ return signalled;
104
+ }
105
+
74
106
  // ─── Helpers ────────────────────────────────────
75
107
 
76
108
  /** Find available port (primary → secondary → sequential after) */
@@ -137,6 +169,7 @@ export async function killIdeProcess(ideId: string): Promise<boolean> {
137
169
  } catch {
138
170
  try { execSync(`pkill -x "${appName}" 2>/dev/null`, { timeout: 5000 }); } catch { }
139
171
  }
172
+ killMacAppPathProcesses(ideId, 'SIGTERM');
140
173
  } else if (plat === 'win32' && winProcesses) {
141
174
  // Windows: taskkill for each process name
142
175
  for (const proc of winProcesses) {
@@ -164,6 +197,7 @@ export async function killIdeProcess(ideId: string): Promise<boolean> {
164
197
  // Force terminate retry
165
198
  if (plat === 'darwin' && appName) {
166
199
  try { execSync(`pkill -9 -x "${appName}" 2>/dev/null`, { timeout: 5000 }); } catch { }
200
+ killMacAppPathProcesses(ideId, 'SIGKILL');
167
201
  } else if (plat === 'win32' && winProcesses) {
168
202
  for (const proc of winProcesses) {
169
203
  try { execSync(`taskkill /IM "${proc}" /F 2>nul`); } catch { }
@@ -185,14 +219,16 @@ export function isIdeRunning(ideId: string): boolean {
185
219
  try {
186
220
  if (plat === 'darwin') {
187
221
  const appName = getMacAppIdentifiers()[ideId];
188
- if (!appName) return false;
222
+ if (!appName) return getMacAppProcessPids(ideId).length > 0;
189
223
  try {
190
224
  const result = execSync(`pgrep -x "${appName}" 2>/dev/null`, {
191
225
  encoding: 'utf-8',
192
226
  timeout: 3000,
193
227
  });
194
- return result.trim().length > 0;
195
- } catch {
228
+ if (result.trim().length > 0) return true;
229
+ } catch { }
230
+
231
+ try {
196
232
  const result = execSync(
197
233
  `osascript -e 'tell application "System Events" to count (every process whose name is "${escapeForAppleScript(appName)}")'`,
198
234
  {
@@ -201,8 +237,10 @@ export function isIdeRunning(ideId: string): boolean {
201
237
  stdio: ['pipe', 'pipe', 'pipe'],
202
238
  },
203
239
  );
204
- return Number.parseInt(result.trim() || '0', 10) > 0;
205
- }
240
+ if (Number.parseInt(result.trim() || '0', 10) > 0) return true;
241
+ } catch { }
242
+
243
+ return getMacAppProcessPids(ideId).length > 0;
206
244
  } else if (plat === 'win32') {
207
245
  const winProcesses = getWinProcessNames()[ideId];
208
246
  if (!winProcesses) return false;
@@ -17,7 +17,7 @@ import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
17
17
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
18
18
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
19
19
  import { StatusMonitor } from './status-monitor.js';
20
- import { ChatHistoryWriter, readChatHistory, rebuildClaudeSavedHistoryFromNativeProject, rebuildHermesSavedHistoryFromCanonicalSession } from '../config/chat-history.js';
20
+ import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
21
21
  import { LOG } from '../logging/logger.js';
22
22
  import type { ChatMessage } from '../types.js';
23
23
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
@@ -179,11 +179,8 @@ export class CliProviderInstance implements ProviderInstance {
179
179
  private historyWriter: ChatHistoryWriter;
180
180
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
181
181
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
182
- private lastCanonicalHermesSyncMtimeMs = 0;
183
- private lastCanonicalHermesExistCheckAt = 0;
184
- private lastCanonicalHermesWatchPath: string | undefined = undefined;
185
- private lastCanonicalClaudeRebuildMtimeMs = 0;
186
- private lastCanonicalClaudeCheckAt = 0;
182
+ private lastNativeSourceCanonicalCheckAt = 0;
183
+ private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
187
184
  private cachedSqliteDb: {
188
185
  prepare(sql: string): { get(...values: Array<string | number>): unknown };
189
186
  close(): void;
@@ -1047,46 +1044,48 @@ export class CliProviderInstance implements ProviderInstance {
1047
1044
  const canonicalHistory = this.provider.canonicalHistory;
1048
1045
  if (!canonicalHistory) return false;
1049
1046
 
1047
+ if (isNativeSourceCanonicalHistory(canonicalHistory)) {
1048
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir].join('\0');
1049
+ const now = Date.now();
1050
+ if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1051
+ return true;
1052
+ }
1053
+ this.lastNativeSourceCanonicalCacheKey = cacheKey;
1054
+ this.lastNativeSourceCanonicalCheckAt = now;
1055
+
1056
+ const restoredHistory = readProviderChatHistory(this.type, {
1057
+ canonicalHistory,
1058
+ historySessionId: this.providerSessionId,
1059
+ workspace: this.workingDir,
1060
+ offset: 0,
1061
+ limit: Number.MAX_SAFE_INTEGER,
1062
+ historyBehavior: this.provider.historyBehavior,
1063
+ scripts: this.provider.scripts as any,
1064
+ });
1065
+ if (restoredHistory.source === 'provider-native') {
1066
+ this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
1067
+ role: message.role,
1068
+ content: message.content,
1069
+ kind: message.kind,
1070
+ senderName: message.senderName,
1071
+ receivedAt: message.receivedAt,
1072
+ }));
1073
+ }
1074
+ return true;
1075
+ }
1076
+
1050
1077
  try {
1051
- let rebuilt = false;
1052
- if (canonicalHistory.format === 'hermes-json') {
1053
- const watchPath = canonicalHistory.watchPath
1054
- .replace(/^~/, os.homedir())
1055
- .replace('{{sessionId}}', this.providerSessionId);
1056
- // Throttle existsSync: check file existence at most once per 2s
1057
- const now = Date.now();
1058
- if (watchPath !== this.lastCanonicalHermesWatchPath || now - this.lastCanonicalHermesExistCheckAt >= 2_000) {
1059
- this.lastCanonicalHermesWatchPath = watchPath;
1060
- this.lastCanonicalHermesExistCheckAt = now;
1061
- if (!fs.existsSync(watchPath)) return false;
1062
- } else if (this.lastCanonicalHermesSyncMtimeMs === 0) {
1063
- // First check: file existence not yet confirmed, must verify
1064
- if (!fs.existsSync(watchPath)) return false;
1065
- }
1066
- const stat = fs.statSync(watchPath);
1067
- if (stat.mtimeMs <= this.lastCanonicalHermesSyncMtimeMs) return true;
1068
- rebuilt = rebuildHermesSavedHistoryFromCanonicalSession(this.providerSessionId);
1069
- if (rebuilt) this.lastCanonicalHermesSyncMtimeMs = stat.mtimeMs;
1070
- } else if (canonicalHistory.format === 'claude-jsonl') {
1071
- // Throttle: only check for changes at most once per 2s
1072
- const now = Date.now();
1073
- if (now - this.lastCanonicalClaudeCheckAt < 2_000 && this.lastCanonicalClaudeRebuildMtimeMs !== 0) {
1074
- return true;
1075
- }
1076
- this.lastCanonicalClaudeCheckAt = now;
1077
- // Only rebuild if the transcript file has changed since last rebuild
1078
- const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
1079
- const workspaceSegment = typeof this.workingDir === 'string'
1080
- ? this.workingDir.replace(/[\\/]/g, '-').replace(/^-+/, '')
1081
- : '';
1082
- const transcriptFile = path.join(claudeProjectsDir, workspaceSegment, `${this.providerSessionId}.jsonl`);
1083
- let transcriptMtime = 0;
1084
- try { transcriptMtime = fs.statSync(transcriptFile).mtimeMs; } catch { /* not found yet */ }
1085
- if (transcriptMtime > 0 && transcriptMtime <= this.lastCanonicalClaudeRebuildMtimeMs) return true;
1086
- rebuilt = rebuildClaudeSavedHistoryFromNativeProject(this.providerSessionId, this.workingDir);
1087
- if (rebuilt) this.lastCanonicalClaudeRebuildMtimeMs = transcriptMtime || Date.now();
1078
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror'].join('\0');
1079
+ const now = Date.now();
1080
+ if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1081
+ return true;
1082
+ }
1083
+ this.lastNativeSourceCanonicalCacheKey = cacheKey;
1084
+ this.lastNativeSourceCanonicalCheckAt = now;
1085
+
1086
+ if (!materializeProviderNativeHistory(this.type, canonicalHistory, this.providerSessionId, this.workingDir, this.provider.scripts as any)) {
1087
+ return false;
1088
1088
  }
1089
- if (!rebuilt) return false;
1090
1089
  const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
1091
1090
  this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
1092
1091
  role: message.role,
@@ -1104,8 +1103,20 @@ export class CliProviderInstance implements ProviderInstance {
1104
1103
  private restorePersistedHistoryFromCurrentSession(): void {
1105
1104
  if (!this.providerSessionId) return;
1106
1105
  this.syncCanonicalSavedHistoryIfNeeded();
1107
- this.historyWriter.compactHistorySession(this.type, this.providerSessionId, this.provider.historyBehavior);
1108
- const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
1106
+ const restoredHistory = isNativeSourceCanonicalHistory(this.provider.canonicalHistory)
1107
+ ? readProviderChatHistory(this.type, {
1108
+ canonicalHistory: this.provider.canonicalHistory,
1109
+ historySessionId: this.providerSessionId,
1110
+ workspace: this.workingDir,
1111
+ offset: 0,
1112
+ limit: Number.MAX_SAFE_INTEGER,
1113
+ historyBehavior: this.provider.historyBehavior,
1114
+ scripts: this.provider.scripts as any,
1115
+ })
1116
+ : (() => {
1117
+ this.historyWriter.compactHistorySession(this.type, this.providerSessionId!, this.provider.historyBehavior);
1118
+ return readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
1119
+ })();
1109
1120
  this.historyWriter.seedSessionHistory(
1110
1121
  this.type,
1111
1122
  restoredHistory.messages,
@@ -36,7 +36,10 @@ export interface ReadChatResult {
36
36
  controlValues?: Record<string, string | number | boolean>;
37
37
  /** Flexible always-visible metadata for compact/live surfaces. */
38
38
  summaryMetadata?: ProviderSummaryMetadata;
39
- /** Provider-driven UI effects derived from chat state */
39
+ /** Provider-owned transcript authority/coverage hints for daemon/dashboard sync. */
40
+ transcriptAuthority?: 'provider' | 'daemon';
41
+ coverage?: 'full' | 'tail' | 'current-turn';
42
+ /** Provider-driven UI effects derived from chat state */
40
43
  effects?: ProviderEffect[];
41
44
  }
42
45
 
@@ -599,23 +602,43 @@ export interface ProviderHistoryBehavior {
599
602
  requireStrictSessionIdFormat?: boolean;
600
603
  }
601
604
 
605
+ /**
606
+ * Provider-owned native history script names.
607
+ *
608
+ * These functions live in the provider's versioned CLI script bundle, not in
609
+ * daemon-core. They let each provider own native transcript file discovery and
610
+ * parsing while daemon-core only validates/pages the normalized result.
611
+ */
612
+ export interface ProviderCanonicalHistoryScriptsConfig {
613
+ /** Reads one native session. Default: 'readNativeHistory'. */
614
+ readSession?: string;
615
+ /** Lists native sessions with summary metadata. Default: 'listNativeHistory'. */
616
+ listSessions?: string;
617
+ }
618
+
602
619
  /**
603
620
  * Canonical history sync config — for providers that maintain their own native history files.
604
- * When set, daemon syncs from the provider's native format into the ADHDev JSONL store.
605
- * Replaces hardcoded hermes-cli / claude-cli checks in cli-provider-instance.ts.
621
+ *
622
+ * Preferred mode is provider-owned scripts via `scripts`. `format` is now an
623
+ * opaque provider label retained for diagnostics/backward compatibility; daemon
624
+ * live paths must not branch on provider-specific format values.
606
625
  */
607
626
  export interface ProviderCanonicalHistoryConfig {
627
+ /** Opaque provider-owned history format label. */
628
+ format?: string;
629
+ /** Optional native history glob/template for diagnostics only. */
630
+ watchPath?: string;
631
+ /** Provider-owned script entry points for native transcript list/read. */
632
+ scripts?: ProviderCanonicalHistoryScriptsConfig;
608
633
  /**
609
- * Native history format.
610
- * - 'hermes-json': single JSON file per session (~/.hermes/sessions/session_{{sessionId}}.json)
611
- * - 'claude-jsonl': JSONL transcript under ~/.claude/projects/
612
- */
613
- format: 'hermes-json' | 'claude-jsonl';
614
- /**
615
- * Path to the native history file. Supports ~ and {{sessionId}} placeholder.
616
- * e.g. "~/.hermes/sessions/session_{{sessionId}}.json"
634
+ * How ADHDev should use native history.
635
+ * - 'native-source': provider-native files are canonical; ADHDev reads them directly and keeps only in-memory/thin projections.
636
+ * - 'materialized-mirror': transitional compatibility mode; native files are rewritten into ~/.adhdev/history before read/list.
637
+ * - 'disabled': ignore native history and use ADHDev mirror only.
638
+ *
639
+ * Omitted mode defaults to 'native-source'.
617
640
  */
618
- watchPath: string;
641
+ mode?: 'native-source' | 'materialized-mirror' | 'disabled';
619
642
  }
620
643
 
621
644
  /**
@@ -123,6 +123,7 @@ export function validateProviderDefinition(raw: unknown): ProviderValidationResu
123
123
  }
124
124
 
125
125
  validateCapabilities(provider as unknown as ProviderModule, controls, errors)
126
+ validateCanonicalHistory(provider.canonicalHistory, errors)
126
127
 
127
128
  for (const control of controls) {
128
129
  validateControl(control as ProviderControlDef, errors)
@@ -192,6 +193,45 @@ function validateCapabilities(provider: ProviderModule, controls: ProviderContro
192
193
  }
193
194
  }
194
195
 
196
+ function validateCanonicalHistory(raw: unknown, errors: string[]): void {
197
+ if (raw === undefined) return
198
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
199
+ errors.push('canonicalHistory must be an object')
200
+ return
201
+ }
202
+
203
+ const canonicalHistory = raw as Record<string, unknown>
204
+ const format = canonicalHistory.format
205
+ if (format !== undefined && (typeof format !== 'string' || !format.trim())) {
206
+ errors.push('canonicalHistory.format must be a non-empty string when provided')
207
+ }
208
+
209
+ const watchPath = canonicalHistory.watchPath
210
+ if (watchPath !== undefined && (typeof watchPath !== 'string' || !watchPath.trim())) {
211
+ errors.push('canonicalHistory.watchPath must be a non-empty string when provided')
212
+ }
213
+
214
+ const mode = canonicalHistory.mode
215
+ if (mode !== undefined && !['native-source', 'materialized-mirror', 'disabled'].includes(String(mode))) {
216
+ errors.push('canonicalHistory.mode must be one of: native-source, materialized-mirror, disabled')
217
+ }
218
+
219
+ const scripts = canonicalHistory.scripts
220
+ if (scripts === undefined) return
221
+ if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
222
+ errors.push('canonicalHistory.scripts must be an object')
223
+ return
224
+ }
225
+
226
+ const scriptConfig = scripts as Record<string, unknown>
227
+ for (const key of ['readSession', 'listSessions']) {
228
+ const value = scriptConfig[key]
229
+ if (typeof value !== 'string' || !value.trim()) {
230
+ errors.push(`canonicalHistory.scripts.${key} must be a non-empty string`)
231
+ }
232
+ }
233
+ }
234
+
195
235
  function validateControl(control: ProviderControlDef, errors: string[]): void {
196
236
  if (!control || typeof control !== 'object') {
197
237
  errors.push('controls: each control must be an object')
@@ -154,6 +154,8 @@ export function validateReadChatResultPayload(raw: unknown, source = 'read_chat'
154
154
  if (raw.summaryMetadata !== undefined) normalized.summaryMetadata = raw.summaryMetadata as any
155
155
  if (Array.isArray(raw.effects)) normalized.effects = raw.effects as any
156
156
  if (typeof raw.providerSessionId === 'string') normalized.providerSessionId = raw.providerSessionId
157
+ if (raw.transcriptAuthority === 'provider' || raw.transcriptAuthority === 'daemon') normalized.transcriptAuthority = raw.transcriptAuthority
158
+ if (raw.coverage === 'full' || raw.coverage === 'tail' || raw.coverage === 'current-turn') normalized.coverage = raw.coverage
157
159
 
158
160
  return normalized
159
161
  }