@adhdev/daemon-core 0.9.82-rc.160 → 0.9.82-rc.161

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.
Files changed (63) hide show
  1. package/dist/cli-adapter-types.d.ts +14 -1
  2. package/dist/commands/mesh-coordinator.d.ts +72 -1
  3. package/dist/config/chat-history.d.ts +2 -0
  4. package/dist/config/mesh-config.d.ts +3 -0
  5. package/dist/index.d.ts +11 -0
  6. package/dist/index.js +4923 -1411
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +4975 -1476
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/coordinator-prompt.d.ts +30 -0
  11. package/dist/mesh/coordinator-registry.d.ts +35 -1
  12. package/dist/providers/cli-provider-instance.d.ts +1 -1
  13. package/dist/providers/contracts.d.ts +48 -0
  14. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +1 -1
  15. package/dist/providers/native-history/claude-cli-transcript.d.ts +1 -1
  16. package/dist/providers/native-history/codex-cli-transcript.d.ts +1 -1
  17. package/dist/providers/native-history/dispatcher.d.ts +24 -0
  18. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  19. package/dist/providers/native-history/index.d.ts +2 -0
  20. package/dist/providers/spec/adapter.d.ts +56 -0
  21. package/dist/providers/spec/cli-adapter.d.ts +76 -0
  22. package/dist/providers/spec/driver.d.ts +148 -0
  23. package/dist/providers/spec/evaluator.d.ts +47 -0
  24. package/dist/providers/spec/loader.d.ts +14 -0
  25. package/dist/providers/spec/native-history-executor.d.ts +39 -0
  26. package/dist/providers/spec/route.d.ts +4 -0
  27. package/dist/providers/spec/schema.gen.d.ts +507 -0
  28. package/dist/providers/spec/types.d.ts +211 -0
  29. package/dist/repo-mesh-types.d.ts +33 -1
  30. package/dist/sessions/registry.d.ts +3 -0
  31. package/package.json +2 -1
  32. package/src/cli-adapter-types.ts +15 -1
  33. package/src/commands/chat-commands.ts +150 -12
  34. package/src/commands/cli-manager.ts +11 -0
  35. package/src/commands/mesh-coordinator.ts +235 -1
  36. package/src/commands/router.ts +238 -50
  37. package/src/config/chat-history.ts +11 -3
  38. package/src/config/mesh-config.ts +16 -1
  39. package/src/index.ts +19 -0
  40. package/src/mesh/coordinator-prompt.ts +164 -8
  41. package/src/mesh/coordinator-registry.ts +50 -4
  42. package/src/providers/cli-provider-instance.ts +8 -3
  43. package/src/providers/contracts.ts +53 -0
  44. package/src/providers/native-history/antigravity-cli-transcript.ts +2 -2
  45. package/src/providers/native-history/claude-cli-transcript.ts +1 -1
  46. package/src/providers/native-history/codex-cli-transcript.ts +1 -1
  47. package/src/providers/native-history/dispatcher.ts +227 -0
  48. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  49. package/src/providers/native-history/index.ts +7 -0
  50. package/src/providers/provider-loader.ts +126 -3
  51. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +13 -0
  52. package/src/providers/spec/adapter.ts +153 -0
  53. package/src/providers/spec/cli-adapter.ts +318 -0
  54. package/src/providers/spec/driver.ts +498 -0
  55. package/src/providers/spec/evaluator.ts +268 -0
  56. package/src/providers/spec/loader.ts +130 -0
  57. package/src/providers/spec/native-history-executor.ts +612 -0
  58. package/src/providers/spec/route.ts +51 -0
  59. package/src/providers/spec/schema.gen.ts +507 -0
  60. package/src/providers/spec/schema.json +210 -0
  61. package/src/providers/spec/types.ts +230 -0
  62. package/src/repo-mesh-types.ts +33 -1
  63. package/src/sessions/registry.ts +3 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Spec-aware dispatcher for native conversation history.
3
+ *
4
+ * The four existing readers (claude/codex/antigravity/hermes) already know
5
+ * how to parse each agent's on-disk format. This dispatcher just resolves
6
+ * the right file given a workspace + sessionId hint, then hands it off.
7
+ *
8
+ * Wired into ProviderModule.scripts.readNativeHistory by provider-loader
9
+ * when spec.json declares native_history.reader. The script signature
10
+ * matches what chat-history.ts expects (see callProviderNativeHistoryRead).
11
+ */
12
+ 'use strict';
13
+
14
+ import * as fs from 'node:fs';
15
+ import * as os from 'node:os';
16
+ import * as path from 'node:path';
17
+ import { readSession as readClaudeCliSession } from './claude-cli-transcript.js';
18
+ import { readSession as readCodexCliSession } from './codex-cli-transcript.js';
19
+ import { readSession as readAntigravityCliSession } from './antigravity-cli-transcript.js';
20
+ import { readSession as readHermesCliSession } from './hermes-cli-transcript.js';
21
+
22
+ export type ReaderId = 'claude-cli' | 'codex-cli' | 'antigravity-cli' | 'hermes-cli';
23
+
24
+ export interface NativeHistoryInput {
25
+ agentType?: string;
26
+ sessionId?: string;
27
+ providerSessionId?: string;
28
+ historySessionId?: string;
29
+ workspace?: string;
30
+ format?: string;
31
+ watchPath?: string;
32
+ args?: Record<string, unknown>;
33
+ }
34
+
35
+ export interface NativeHistoryResult {
36
+ messages: Array<{ role: string; content: string; receivedAt?: number; kind?: string }>;
37
+ providerSessionId?: string;
38
+ sourcePath: string;
39
+ sourceMtimeMs: number;
40
+ nativeHistoryCoverage?: 'full' | 'partial' | 'best-effort';
41
+ }
42
+
43
+ export function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeHistoryInput) => NativeHistoryResult | null {
44
+ return (input: NativeHistoryInput) => {
45
+ const workspace = input.workspace || '';
46
+ const sessionId = input.sessionId || input.historySessionId || '';
47
+ // Caller may pass a providerSessionId (the *provider's own* id, e.g.
48
+ // the uuid claude writes into the jsonl basename). When present, the
49
+ // dispatcher only returns a transcript whose file id matches —
50
+ // otherwise the newest_by_mtime fallback can surface a different
51
+ // session's chat (round 9 part b: the user reported "previous chat
52
+ // shows up before I type anything" on every provider).
53
+ const requestedProviderSid = input.providerSessionId || '';
54
+
55
+ const sourcePath = resolveSourcePath(reader, workspace, sessionId);
56
+ if (!sourcePath) return null;
57
+
58
+ const session = readByReader(reader, sourcePath, sessionId, workspace);
59
+ if (!session) return null;
60
+
61
+ if (requestedProviderSid && session.providerSessionId && session.providerSessionId !== requestedProviderSid) {
62
+ return null;
63
+ }
64
+
65
+ return {
66
+ messages: session.messages.map((m: any) => ({
67
+ role: normalizeRole(m.role),
68
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
69
+ receivedAt: typeof m.receivedAt === 'number' ? m.receivedAt : Date.parse(m.timestamp || '') || Date.now(),
70
+ kind: typeof m.kind === 'string' ? m.kind : 'standard',
71
+ })),
72
+ providerSessionId: session.providerSessionId,
73
+ sourcePath: session.sourcePath,
74
+ sourceMtimeMs: session.sourceMtimeMs,
75
+ nativeHistoryCoverage: (session as any).nativeHistoryCoverage || 'full',
76
+ };
77
+ };
78
+ }
79
+
80
+ // ────────────────────────────────────────────────────────────────────────────
81
+ // Per-provider path resolution
82
+ // ────────────────────────────────────────────────────────────────────────────
83
+
84
+ function resolveSourcePath(reader: ReaderId, workspace: string, sessionId: string): string | null {
85
+ switch (reader) {
86
+ case 'claude-cli': return resolveClaudePath(workspace, sessionId);
87
+ case 'codex-cli': return resolveCodexPath(workspace);
88
+ case 'antigravity-cli': return resolveAntigravityPath(workspace);
89
+ case 'hermes-cli': return resolveHermesPath(workspace, sessionId);
90
+ }
91
+ }
92
+
93
+ function resolveClaudePath(workspace: string, sessionId: string): string | null {
94
+ // claude stores per-cwd: ~/.claude/projects/<cwd-as-dashes>/<uuid>.jsonl
95
+ const dir = path.join(os.homedir(), '.claude', 'projects', cwdAsDashes(workspace));
96
+ if (!fs.existsSync(dir)) return null;
97
+ if (sessionId) {
98
+ const candidate = path.join(dir, `${sessionId}.jsonl`);
99
+ if (fs.existsSync(candidate)) return candidate;
100
+ }
101
+ // No exact match: return null. The newest_by_mtime fallback used to
102
+ // grab whichever transcript was touched most recently, but that
103
+ // surfaced a different (often the user's *external* claude session
104
+ // in the same workspace) chat in the dashboard before the user
105
+ // typed anything. The right answer is to show nothing until either
106
+ // the daemon's sessionId actually maps to a file or the caller
107
+ // passes the correct providerSessionId (round 9 part b).
108
+ return null;
109
+ }
110
+
111
+ function resolveCodexPath(workspace: string): string | null {
112
+ void workspace;
113
+ // codex stores by UTC date: ~/.codex/sessions/<year>/<month>/<day>/<file>.jsonl
114
+ const now = new Date();
115
+ const dir = path.join(
116
+ os.homedir(), '.codex', 'sessions',
117
+ String(now.getUTCFullYear()),
118
+ String(now.getUTCMonth() + 1).padStart(2, '0'),
119
+ String(now.getUTCDate()).padStart(2, '0'),
120
+ );
121
+ if (fs.existsSync(dir)) {
122
+ const f = newestRecentFile(dir, /\.jsonl$/);
123
+ if (f) return f;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function resolveAntigravityPath(workspace: string): string | null {
129
+ void workspace;
130
+ // agy brain/<uuid>/.system_generated/logs/transcript.jsonl
131
+ const brainRoot = path.join(os.homedir(), '.gemini', 'antigravity-cli', 'brain');
132
+ if (!fs.existsSync(brainRoot)) return null;
133
+ const cutoff = Date.now() - RECENT_WINDOW_MS;
134
+ const entries = fs.readdirSync(brainRoot, { withFileTypes: true })
135
+ .filter(e => e.isDirectory())
136
+ .map(e => ({ p: path.join(brainRoot, e.name), mtime: safeMtime(path.join(brainRoot, e.name)) }))
137
+ .filter(e => e.mtime >= cutoff)
138
+ .sort((a, b) => b.mtime - a.mtime);
139
+ for (const e of entries) {
140
+ const t = path.join(e.p, '.system_generated', 'logs', 'transcript.jsonl');
141
+ if (fs.existsSync(t)) return t;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ function resolveHermesPath(workspace: string, sessionId: string): string | null {
147
+ void workspace; void sessionId;
148
+ // Hermes ≥ 0.14 persists all chat to ~/.hermes/state.db (SQLite). The
149
+ // db file is the "path" we hand to the reader — it pulls the newest
150
+ // source='cli' session inside readSession.
151
+ const dbPath = path.join(os.homedir(), '.hermes', 'state.db');
152
+ if (fs.existsSync(dbPath)) return dbPath;
153
+ // Legacy fallback: pre-db hermes wrote per-session JSON dumps.
154
+ const dir = path.join(os.homedir(), '.hermes', 'sessions');
155
+ if (!fs.existsSync(dir)) return null;
156
+ return newestRecentFile(dir, /^session_.*\.json$/);
157
+ }
158
+
159
+ // ────────────────────────────────────────────────────────────────────────────
160
+ // Reader dispatch
161
+ // ────────────────────────────────────────────────────────────────────────────
162
+
163
+ function readByReader(
164
+ reader: ReaderId,
165
+ sourcePath: string,
166
+ sessionId: string,
167
+ workspace: string,
168
+ ): any | null {
169
+ switch (reader) {
170
+ case 'claude-cli': return readClaudeCliSession(sourcePath);
171
+ case 'codex-cli': return readCodexCliSession(sourcePath);
172
+ case 'antigravity-cli': return readAntigravityCliSession(sourcePath, sessionId || undefined, workspace || undefined);
173
+ case 'hermes-cli': return readHermesCliSession(sourcePath);
174
+ }
175
+ }
176
+
177
+ // ────────────────────────────────────────────────────────────────────────────
178
+ // Helpers
179
+ // ────────────────────────────────────────────────────────────────────────────
180
+
181
+ function cwdAsDashes(cwd: string): string {
182
+ if (!cwd) return '';
183
+ return cwd.replace(/\//g, '-');
184
+ }
185
+
186
+ function newestFile(dir: string, pattern: RegExp): string | null {
187
+ try {
188
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
189
+ .filter(e => e.isFile() && pattern.test(e.name))
190
+ .map(e => ({ p: path.join(dir, e.name), mtime: safeMtime(path.join(dir, e.name)) }))
191
+ .sort((a, b) => b.mtime - a.mtime);
192
+ return entries[0]?.p ?? null;
193
+ } catch { return null; }
194
+ }
195
+
196
+ /**
197
+ * Like newestFile but only returns a candidate when its mtime is within
198
+ * the recent activity window. Prevents the dashboard from surfacing a
199
+ * prior session's transcript when the daemon's sessionId doesn't match
200
+ * any file on disk (e.g. claude allocates its own uuid; daemon and
201
+ * agent disagree about what the "current" session is).
202
+ */
203
+ const RECENT_WINDOW_MS = 5 * 60 * 1000;
204
+ function newestRecentFile(dir: string, pattern: RegExp): string | null {
205
+ try {
206
+ const cutoff = Date.now() - RECENT_WINDOW_MS;
207
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
208
+ .filter(e => e.isFile() && pattern.test(e.name))
209
+ .map(e => ({ p: path.join(dir, e.name), mtime: safeMtime(path.join(dir, e.name)) }))
210
+ .filter(e => e.mtime >= cutoff)
211
+ .sort((a, b) => b.mtime - a.mtime);
212
+ return entries[0]?.p ?? null;
213
+ } catch { return null; }
214
+ }
215
+
216
+ function safeMtime(p: string): number {
217
+ try { return Math.floor(fs.statSync(p).mtimeMs); } catch { return 0; }
218
+ }
219
+
220
+ function normalizeRole(r: any): 'user' | 'assistant' | 'system' {
221
+ const s = String(r ?? '').toLowerCase();
222
+ if (s === 'user' || s === 'human') return 'user';
223
+ if (s === 'assistant' || s === 'ai' || s === 'model') return 'assistant';
224
+ // daemon's chat schema rejects 'tool'/'function' — surface as assistant.
225
+ if (s === 'tool' || s === 'tool_result' || s === 'function') return 'assistant';
226
+ return 'system';
227
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Hermes Agent — native history adapter.
3
+ *
4
+ * Hermes persists state in ~/.hermes/state.db (SQLite, better-sqlite3):
5
+ * sessions(id, source, started_at, ended_at, message_count, ...)
6
+ * messages(id, session_id, role, content, timestamp, ...)
7
+ *
8
+ * Earlier releases dumped per-session JSON files under ~/.hermes/sessions/;
9
+ * recent hermes (≥ 0.14) writes only to state.db. We read straight from
10
+ * the db when available and fall back to scanning legacy JSON dumps for
11
+ * archived sessions.
12
+ *
13
+ * The dispatcher hands us a sourcePath (the file the resolveHermesPath
14
+ * function picks). For the SQLite case the "path" is the db file itself
15
+ * and we resolve the most recent cli-source session inside readSession.
16
+ */
17
+ 'use strict';
18
+
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import * as os from 'node:os';
22
+
23
+ export interface NativeHistoryMessage {
24
+ id: string;
25
+ role: 'user' | 'assistant' | 'system';
26
+ content: string;
27
+ receivedAt: number;
28
+ kind?: string;
29
+ }
30
+
31
+ export interface NativeHistorySession {
32
+ messages: NativeHistoryMessage[];
33
+ providerSessionId: string;
34
+ source: 'provider-native';
35
+ sourcePath: string;
36
+ sourceMtimeMs: number;
37
+ nativeHistoryCoverage: 'full';
38
+ workspace?: string;
39
+ }
40
+
41
+ export interface NativeHistorySessionMeta {
42
+ historySessionId: string;
43
+ sessionId: string;
44
+ sourcePath: string;
45
+ sourceMtimeMs: number;
46
+ messageCount: number;
47
+ firstMessageAt: number;
48
+ lastMessageAt: number;
49
+ sessionTitle?: string;
50
+ preview?: string;
51
+ workspace?: string;
52
+ }
53
+
54
+ const HERMES_STATE_DB = path.join(os.homedir(), '.hermes', 'state.db');
55
+ const HERMES_LEGACY_SESSIONS_DIR = path.join(os.homedir(), '.hermes', 'sessions');
56
+
57
+ function statMtimeMs(p: string): number {
58
+ try { return Math.floor(fs.statSync(p).mtimeMs); } catch { return 0; }
59
+ }
60
+
61
+ function openDb(): any | null {
62
+ if (!fs.existsSync(HERMES_STATE_DB)) return null;
63
+ try {
64
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
65
+ const Database = require('better-sqlite3');
66
+ return new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true });
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function loadMessagesForSession(db: any, sessionId: string): NativeHistoryMessage[] {
73
+ const rows: any[] = db.prepare(
74
+ `SELECT id, role, content, timestamp
75
+ FROM messages
76
+ WHERE session_id = ? AND content IS NOT NULL AND content != ''
77
+ ORDER BY timestamp ASC, id ASC`,
78
+ ).all(sessionId);
79
+ const out: NativeHistoryMessage[] = [];
80
+ for (const r of rows) {
81
+ const role = normalizeHermesRole(r.role);
82
+ // Hermes stores some tool/system rows under role='tool'; surface as
83
+ // 'assistant' so they don't get dropped on the daemon's chat schema
84
+ // validation (role must be user/assistant/system).
85
+ out.push({
86
+ id: String(r.id),
87
+ role,
88
+ content: String(r.content),
89
+ receivedAt: Math.floor(Number(r.timestamp) * 1000),
90
+ kind: 'standard',
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ export function readSession(sessionPath: string): NativeHistorySession | null {
97
+ if (!sessionPath) return null;
98
+
99
+ // Path mode A: SQLite — sourcePath is the state.db path. Pick the
100
+ // newest source='cli' session that has at least one persisted message.
101
+ if (sessionPath === HERMES_STATE_DB) {
102
+ const db = openDb();
103
+ if (!db) return null;
104
+ try {
105
+ const row: any = db.prepare(
106
+ `SELECT id, started_at FROM sessions
107
+ WHERE source = 'cli' AND message_count > 0
108
+ ORDER BY started_at DESC LIMIT 1`,
109
+ ).get();
110
+ if (!row) return null;
111
+ const messages = loadMessagesForSession(db, row.id);
112
+ if (messages.length === 0) return null;
113
+ return {
114
+ messages,
115
+ providerSessionId: String(row.id),
116
+ source: 'provider-native',
117
+ sourcePath: sessionPath,
118
+ sourceMtimeMs: statMtimeMs(sessionPath),
119
+ nativeHistoryCoverage: 'full',
120
+ };
121
+ } finally {
122
+ try { db.close(); } catch { /* ignore */ }
123
+ }
124
+ }
125
+
126
+ // Path mode B: legacy JSON dump — keep the old reader for archived
127
+ // sessions that pre-date the db format.
128
+ if (!path.isAbsolute(sessionPath) || !fs.existsSync(sessionPath)) return null;
129
+ let raw: any;
130
+ try { raw = JSON.parse(fs.readFileSync(sessionPath, 'utf8')); } catch { return null; }
131
+ if (!raw || typeof raw !== 'object') return null;
132
+ const rawMessages = Array.isArray(raw.messages) ? raw.messages : [];
133
+ if (rawMessages.length === 0) return null;
134
+ const sourceMtimeMs = statMtimeMs(sessionPath);
135
+ const sessionStart = Number(raw.session_start) || sourceMtimeMs - rawMessages.length * 1000;
136
+ const messages: NativeHistoryMessage[] = [];
137
+ for (let i = 0; i < rawMessages.length; i += 1) {
138
+ const m = rawMessages[i];
139
+ const content = typeof m?.content === 'string' ? m.content : '';
140
+ if (!content) continue;
141
+ messages.push({
142
+ id: typeof m?.id === 'string' ? m.id : `msg_${i}`,
143
+ role: normalizeHermesRole(m?.role),
144
+ content,
145
+ receivedAt: sessionStart + i * 1000,
146
+ kind: 'standard',
147
+ });
148
+ }
149
+ if (messages.length === 0) return null;
150
+ const sessionId = typeof raw.session_id === 'string' && raw.session_id
151
+ ? raw.session_id
152
+ : path.basename(sessionPath, '.json').replace(/^session_/, '');
153
+ return {
154
+ messages,
155
+ providerSessionId: sessionId,
156
+ source: 'provider-native',
157
+ sourcePath: sessionPath,
158
+ sourceMtimeMs,
159
+ nativeHistoryCoverage: 'full',
160
+ };
161
+ }
162
+
163
+ export async function listSessions(_watchPath: string): Promise<NativeHistorySessionMeta[]> {
164
+ const out: NativeHistorySessionMeta[] = [];
165
+
166
+ const db = openDb();
167
+ if (db) {
168
+ try {
169
+ const rows: any[] = db.prepare(
170
+ `SELECT id, started_at, ended_at, message_count, title
171
+ FROM sessions
172
+ WHERE source = 'cli' AND message_count > 0
173
+ ORDER BY started_at DESC LIMIT 100`,
174
+ ).all();
175
+ const mtime = statMtimeMs(HERMES_STATE_DB);
176
+ for (const r of rows) {
177
+ out.push({
178
+ historySessionId: String(r.id),
179
+ sessionId: String(r.id),
180
+ sourcePath: HERMES_STATE_DB,
181
+ sourceMtimeMs: mtime,
182
+ messageCount: Number(r.message_count) || 0,
183
+ firstMessageAt: Math.floor(Number(r.started_at) * 1000),
184
+ lastMessageAt: Math.floor(Number(r.ended_at || r.started_at) * 1000),
185
+ sessionTitle: typeof r.title === 'string' ? r.title : undefined,
186
+ });
187
+ }
188
+ } finally {
189
+ try { db.close(); } catch { /* ignore */ }
190
+ }
191
+ }
192
+
193
+ if (fs.existsSync(HERMES_LEGACY_SESSIONS_DIR)) {
194
+ for (const name of fs.readdirSync(HERMES_LEGACY_SESSIONS_DIR)) {
195
+ if (!/^session_.*\.json$/.test(name)) continue;
196
+ const p = path.join(HERMES_LEGACY_SESSIONS_DIR, name);
197
+ const mtime = statMtimeMs(p);
198
+ if (!mtime) continue;
199
+ let raw: any;
200
+ try { raw = JSON.parse(fs.readFileSync(p, 'utf8')); } catch { continue; }
201
+ const msgs = Array.isArray(raw?.messages) ? raw.messages : [];
202
+ if (msgs.length === 0) continue;
203
+ const sessionId = typeof raw.session_id === 'string' && raw.session_id
204
+ ? raw.session_id
205
+ : name.replace(/^session_/, '').replace(/\.json$/, '');
206
+ out.push({
207
+ historySessionId: sessionId,
208
+ sessionId,
209
+ sourcePath: p,
210
+ sourceMtimeMs: mtime,
211
+ messageCount: msgs.length,
212
+ firstMessageAt: Number(raw.session_start) || mtime,
213
+ lastMessageAt: Number(raw.last_updated) || mtime,
214
+ sessionTitle: typeof raw.title === 'string' ? raw.title : undefined,
215
+ preview: typeof msgs[0]?.content === 'string' ? String(msgs[0].content).slice(0, 80) : undefined,
216
+ });
217
+ }
218
+ }
219
+
220
+ out.sort((a, b) => b.sourceMtimeMs - a.sourceMtimeMs);
221
+ return out;
222
+ }
223
+
224
+ function normalizeHermesRole(r: any): 'user' | 'assistant' | 'system' {
225
+ const s = String(r ?? '').toLowerCase();
226
+ if (s === 'user' || s === 'human') return 'user';
227
+ if (s === 'assistant' || s === 'ai' || s === 'model') return 'assistant';
228
+ if (s === 'tool' || s === 'tool_result' || s === 'function') return 'assistant';
229
+ return 'system';
230
+ }
@@ -21,3 +21,10 @@ export {
21
21
  readSession as readAntigravityCliSession,
22
22
  listSessions as listAntigravityCliSessions,
23
23
  } from './antigravity-cli-transcript.js';
24
+
25
+ export {
26
+ readSession as readHermesCliSession,
27
+ listSessions as listHermesCliSessions,
28
+ } from './hermes-cli-transcript.js';
29
+
30
+ export { createNativeHistoryDispatcher, type ReaderId } from './dispatcher.js';
@@ -1084,6 +1084,125 @@ export class ProviderLoader {
1084
1084
  };
1085
1085
  }
1086
1086
 
1087
+ // (spec migration) Late-binding spec.json native-history hook. Runs
1088
+ // *after* every script-loading path (compatibility / defaultScriptDir /
1089
+ // overrides) so it deterministically wins over a legacy v1 scripts.js
1090
+ // export. Three modes, picked by spec.json's native_history block:
1091
+ // 1. source — declarative jsonl/sqlite executor (new-provider path,
1092
+ // no daemon change needed for new on-disk formats)
1093
+ // 2. override_path — provider-supplied reader file (escape hatch for
1094
+ // exotic formats); module default-exports a reader fn
1095
+ // 3. reader — built-in reader id (claude-cli / codex-cli /
1096
+ // antigravity-cli / hermes-cli), kept for backwards
1097
+ // compatibility with the four shipped providers
1098
+ if (providerDir) {
1099
+ try {
1100
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1101
+ const fs = require('node:fs');
1102
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1103
+ const path = require('node:path');
1104
+ // Pick the right spec file for the detected CLI version. Resolution
1105
+ // order:
1106
+ // 1. compatibility[i].spec where ideVersion matches currentVersion
1107
+ // (lets a provider ship specs/2.0.json, specs/2.1.json, etc.
1108
+ // alongside the matching scriptDir)
1109
+ // 2. specs/default.json — explicit fallback
1110
+ // 3. spec.json — legacy single-spec layout
1111
+ // Missing files fall through silently to the next candidate.
1112
+ const candidates: string[] = [];
1113
+ if (Array.isArray((base as any).compatibility)) {
1114
+ for (const entry of (base as any).compatibility) {
1115
+ if (typeof entry?.spec !== 'string') continue;
1116
+ // If currentVersion is unknown (cli-manager hasn't probed yet)
1117
+ // we still let compatibility entries that don't pin a version
1118
+ // through, plus any entry whose pin matches.
1119
+ const matches = !entry.ideVersion
1120
+ || (currentVersion && this.matchesVersion(currentVersion, entry.ideVersion))
1121
+ || !currentVersion;
1122
+ if (matches) candidates.push(path.join(providerDir, entry.spec));
1123
+ }
1124
+ }
1125
+ candidates.push(path.join(providerDir, 'specs', 'default.json'));
1126
+ candidates.push(path.join(providerDir, 'spec.json'));
1127
+ const specPath = candidates.find((p: string) => fs.existsSync(p));
1128
+ if (specPath) {
1129
+ // Hand the resolved spec path off to route.ts via a hidden field
1130
+ // so the routing layer doesn't have to repeat the candidate walk.
1131
+ (resolved as any)._resolvedSpecPath = specPath;
1132
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1133
+ const { loadSpec } = require('./spec/loader.js');
1134
+ const r = loadSpec(specPath);
1135
+ // Stub each control_bar entry as a provider.scripts.<id>. The
1136
+ // upstream invoke_provider_script gate checks that the script
1137
+ // name exists on provider.scripts before calling adapter.invokeScript;
1138
+ // for spec providers the *actual* dispatch happens inside
1139
+ // SpecCliAdapter.invokeScript which maps the name to control_bar.
1140
+ // The stub is just a presence marker so the gate doesn't reject.
1141
+ if (r.ok) {
1142
+ const controls = r.spec.control_bar ?? [];
1143
+ if (controls.length > 0) {
1144
+ resolved.scripts = { ...(resolved.scripts || {}) };
1145
+ for (const ctl of controls) {
1146
+ if (!(resolved.scripts as any)[ctl.id]) {
1147
+ (resolved.scripts as any)[ctl.id] = (..._args: unknown[]) => ({
1148
+ __spec_control: true,
1149
+ controlId: ctl.id,
1150
+ actionType: ctl.action.type,
1151
+ });
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+ const nh = r.ok ? r.spec.native_history : undefined;
1157
+ if (nh) {
1158
+ let reader: ((input: any) => any) | null = null;
1159
+ let format = 'spec';
1160
+
1161
+ if (nh.source) {
1162
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1163
+ const { executeNativeHistory } = require('./spec/native-history-executor.js');
1164
+ format = `spec-${nh.source.kind}`;
1165
+ reader = (input: any) => executeNativeHistory(nh, input);
1166
+ } else if (nh.override_path) {
1167
+ const overrideFile = path.resolve(providerDir, nh.override_path);
1168
+ if (fs.existsSync(overrideFile)) {
1169
+ try {
1170
+ registerProviderScriptRootSafely(path.dirname(path.dirname(providerDir)));
1171
+ delete require.cache[require.resolve(overrideFile)];
1172
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1173
+ const mod = require(overrideFile);
1174
+ const fn = typeof mod === 'function' ? mod : (mod && typeof mod.default === 'function' ? mod.default : null);
1175
+ if (fn) {
1176
+ format = 'spec-override';
1177
+ reader = (input: any) => fn(input);
1178
+ }
1179
+ } catch { /* fall through — leave native unavailable */ }
1180
+ }
1181
+ } else if (nh.reader) {
1182
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1183
+ const { createNativeHistoryDispatcher } = require('./native-history/dispatcher.js');
1184
+ const dispatch = createNativeHistoryDispatcher(nh.reader);
1185
+ format = nh.reader;
1186
+ reader = (input: any) => dispatch(input);
1187
+ }
1188
+
1189
+ if (reader) {
1190
+ resolved.scripts = { ...(resolved.scripts || {}) };
1191
+ (resolved.scripts as any).readNativeHistory = reader;
1192
+ (resolved as any).nativeHistory = {
1193
+ format,
1194
+ watchPath: undefined,
1195
+ scripts: { readSession: 'readNativeHistory' },
1196
+ mode: 'native-source',
1197
+ };
1198
+ }
1199
+ }
1200
+ }
1201
+ } catch {
1202
+ // Best-effort — spec wiring failure must not break legacy providers.
1203
+ }
1204
+ }
1205
+
1087
1206
  return resolved;
1088
1207
  }
1089
1208
 
@@ -1094,13 +1213,17 @@ export class ProviderLoader {
1094
1213
  private loadScriptsFromDir(type: string, scriptDir: string): Partial<ProviderScripts> | null {
1095
1214
  const providerDir = this.findProviderDirInternal(type);
1096
1215
  if (!providerDir) {
1097
- this.log(` [loadScriptsFromDir] ${type}: providerDir not found`);
1216
+ // No provider dir for this type a spec-only provider with no
1217
+ // legacy scripts/v1 layout is a normal configuration, not a
1218
+ // problem to surface at INFO. resolve() calls this on every
1219
+ // request; INFO spam every 200ms drowns out the real signal.
1220
+ this.debugLog(`[loadScriptsFromDir] ${type}: providerDir not found`);
1098
1221
  return null;
1099
1222
  }
1100
1223
 
1101
1224
  const dir = path.join(providerDir, scriptDir);
1102
1225
  if (!fs.existsSync(dir)) {
1103
- this.log(` [loadScriptsFromDir] ${type}: dir not found: ${dir}`);
1226
+ this.debugLog(`[loadScriptsFromDir] ${type}: dir not found: ${dir}`);
1104
1227
  return null;
1105
1228
  }
1106
1229
 
@@ -1121,7 +1244,7 @@ export class ProviderLoader {
1121
1244
  try {
1122
1245
  delete require.cache[require.resolve(scriptsJs)];
1123
1246
  const loaded = require(scriptsJs);
1124
- this.log(` [loadScriptsFromDir] ${type}: loaded scripts.js from ${dir} (${Object.keys(loaded).length} exports)`);
1247
+ this.debugLog(`[loadScriptsFromDir] ${type}: loaded scripts.js from ${dir} (${Object.keys(loaded).length} exports)`);
1125
1248
  this.scriptsCache.set(dir, loaded);
1126
1249
  return loaded;
1127
1250
  } catch (e) {
@@ -306,6 +306,19 @@
306
306
  "instructions": { "type": "string" },
307
307
  "template": { "type": "string" }
308
308
  }
309
+ },
310
+ "systemPromptInjection": {
311
+ "description": "How the coordinator system prompt reaches the launched CLI. Replaces hard-coded router branches per CLI type; users may override the prompt body or the injection mechanism here.",
312
+ "oneOf": [
313
+ { "type": "object", "additionalProperties": false, "required": ["mode", "flag"],
314
+ "properties": { "mode": { "const": "cli_arg" }, "flag": { "type": "string" } } },
315
+ { "type": "object", "additionalProperties": false, "required": ["mode", "flag", "template"],
316
+ "properties": { "mode": { "const": "config_override" }, "flag": { "type": "string" }, "template": { "type": "string" } } },
317
+ { "type": "object", "additionalProperties": false, "required": ["mode", "path"],
318
+ "properties": { "mode": { "const": "context_file" }, "path": { "type": "string" }, "wrapper": { "type": "string" } } },
319
+ { "type": "object", "additionalProperties": false, "required": ["mode", "name"],
320
+ "properties": { "mode": { "const": "env_var" }, "name": { "type": "string" } } }
321
+ ]
309
322
  }
310
323
  }
311
324
  },