claude-simple 0.1.0

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 (50) hide show
  1. package/README.md +225 -0
  2. package/client.js +139 -0
  3. package/dist/addon-fit.min.js +2 -0
  4. package/dist/app.0.1.0.min.js +825 -0
  5. package/dist/highlight.min.css +10 -0
  6. package/dist/highlight.min.js +1244 -0
  7. package/dist/index.html +16 -0
  8. package/dist/marked.min.js +6 -0
  9. package/dist/style.0.1.0.min.css +1050 -0
  10. package/dist/xterm.min.css +209 -0
  11. package/dist/xterm.min.js +2 -0
  12. package/gateway.js +349 -0
  13. package/package.json +41 -0
  14. package/public/addon-fit.min.js +2 -0
  15. package/public/agent-panel.js +1079 -0
  16. package/public/api.js +78 -0
  17. package/public/app.js +95 -0
  18. package/public/base.css +4 -0
  19. package/public/cache.js +27 -0
  20. package/public/chat.js +1087 -0
  21. package/public/files.js +476 -0
  22. package/public/git.js +130 -0
  23. package/public/index.html +61 -0
  24. package/public/lib.js +2 -0
  25. package/public/router.js +71 -0
  26. package/public/settings.js +90 -0
  27. package/public/shell/boot.js +159 -0
  28. package/public/shell/dashboard.js +269 -0
  29. package/public/shell/hub.js +192 -0
  30. package/public/shell/layout.js +195 -0
  31. package/public/shell/notes.js +323 -0
  32. package/public/shell/session-context.js +19 -0
  33. package/public/shell/session-list.js +442 -0
  34. package/public/shell/session-nav.js +299 -0
  35. package/public/shell/templates.js +342 -0
  36. package/public/shell/util.js +63 -0
  37. package/public/shell.js +589 -0
  38. package/public/state.js +362 -0
  39. package/public/style.css +1048 -0
  40. package/public/terminal.js +177 -0
  41. package/public/xterm.min.css +209 -0
  42. package/public/xterm.min.js +2 -0
  43. package/server.js +12 -0
  44. package/src/agents/claude.js +58 -0
  45. package/src/agents/codex.js +284 -0
  46. package/src/agents/grok.js +318 -0
  47. package/src/agents/meta-agent.js +1952 -0
  48. package/src/agents/models.js +339 -0
  49. package/src/agents/sessions.js +1508 -0
  50. package/src/app.js +1282 -0
@@ -0,0 +1,1508 @@
1
+ import { promises as fs } from 'fs';
2
+ import fsSync from 'fs';
3
+ import readline from 'readline';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import crypto from 'crypto';
7
+
8
+ // ─── Claude ───────────────────────────────────────────────────────────────────
9
+
10
+ async function loadClaudeHistoryNames(configDirs) {
11
+ const map = new Map();
12
+ await Promise.all(configDirs.map(async dir => {
13
+ try {
14
+ const stream = fsSync.createReadStream(path.join(dir, 'history.jsonl'));
15
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
16
+ for await (const line of rl) {
17
+ try {
18
+ const d = JSON.parse(line);
19
+ if (d.sessionId && d.display) map.set(d.sessionId, d.display);
20
+ } catch {}
21
+ }
22
+ } catch {}
23
+ }));
24
+ return map;
25
+ }
26
+
27
+ async function readClaudeSessionMeta(filePath) {
28
+ let sessionId = null;
29
+ let cwd = null;
30
+ let totalTokens = 0;
31
+ let turnCount = 0;
32
+ try {
33
+ const stream = fsSync.createReadStream(filePath);
34
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
35
+ for await (const line of rl) {
36
+ try {
37
+ const d = JSON.parse(line);
38
+ if (!sessionId && d.sessionId) sessionId = d.sessionId;
39
+ if (!cwd && d.cwd) cwd = d.cwd;
40
+ // Count user turns (exclude tool-result only / meta noise when possible)
41
+ if (d.type === 'user') {
42
+ const content = d.message?.content;
43
+ const isToolResult = Array.isArray(content)
44
+ && content.length > 0
45
+ && content.every(p => p?.type === 'tool_result');
46
+ if (!isToolResult) turnCount += 1;
47
+ }
48
+ const u = d.message?.usage;
49
+ if (u) {
50
+ totalTokens += (u.input_tokens ?? 0) + (u.output_tokens ?? 0);
51
+ }
52
+ } catch {}
53
+ }
54
+ } catch {}
55
+ return { sessionId, cwd, totalTokens, turnCount };
56
+ }
57
+
58
+ async function readClaudeFirstMessage(filePath) {
59
+ const SKIP = ['<', 'Caveat:', 'This session', '[Request'];
60
+ try {
61
+ const stream = fsSync.createReadStream(filePath);
62
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
63
+ for await (const line of rl) {
64
+ try {
65
+ const d = JSON.parse(line);
66
+ if (d.type === 'user' && Array.isArray(d.message?.content)) {
67
+ for (const part of d.message.content) {
68
+ if (part.type === 'text' && part.text?.trim()) {
69
+ const text = part.text.trim();
70
+ if (!SKIP.some(p => text.startsWith(p))) {
71
+ rl.close(); stream.destroy();
72
+ return text.slice(0, 100);
73
+ }
74
+ }
75
+ }
76
+ }
77
+ } catch {}
78
+ }
79
+ } catch {}
80
+ return null;
81
+ }
82
+
83
+ async function scanClaudeConfigDir(configDir, nameMap, filterCwd) {
84
+ const projectsDir = path.join(configDir, 'projects');
85
+ const sessions = [];
86
+ let projectDirs;
87
+ try {
88
+ projectDirs = (await fs.readdir(projectsDir, { withFileTypes: true })).filter(e => e.isDirectory());
89
+ } catch { return []; }
90
+
91
+ await Promise.all(projectDirs.map(async dirEntry => {
92
+ const projectDir = path.join(projectsDir, dirEntry.name);
93
+ let entries;
94
+ try { entries = await fs.readdir(projectDir, { withFileTypes: true }); }
95
+ catch { return; }
96
+
97
+ const hasMemory = entries.some(e =>
98
+ (e.isFile() && e.name === 'MEMORY.md') || (e.isDirectory() && e.name === 'memory')
99
+ );
100
+
101
+ const jsonlFiles = entries.filter(e =>
102
+ e.isFile() && e.name.endsWith('.jsonl') && !e.name.startsWith('agent-')
103
+ );
104
+ await Promise.all(jsonlFiles.map(async fileEntry => {
105
+ const filePath = path.join(projectDir, fileEntry.name);
106
+ try {
107
+ const [stat, meta] = await Promise.all([fs.stat(filePath), readClaudeSessionMeta(filePath)]);
108
+ if (filterCwd && meta.cwd !== filterCwd) return;
109
+ const sessionId = meta.sessionId || fileEntry.name.replace('.jsonl', '');
110
+ const firstMsg = await readClaudeFirstMessage(filePath);
111
+ sessions.push({
112
+ sessionId,
113
+ agent: 'claude',
114
+ cwd: meta.cwd || null,
115
+ configDir,
116
+ display: nameMap.get(sessionId) || firstMsg || 'Session',
117
+ updatedAt: stat.mtimeMs,
118
+ hasMemory,
119
+ totalTokens: meta.totalTokens || 0,
120
+ turnCount: meta.turnCount || 0,
121
+ });
122
+ } catch {}
123
+ }));
124
+ }));
125
+ return sessions;
126
+ }
127
+
128
+ export async function scanClaudeSessions(configDirs, filterCwd = null) {
129
+ const nameMap = await loadClaudeHistoryNames(configDirs);
130
+ const results = await Promise.all(configDirs.map(d => scanClaudeConfigDir(d, nameMap, filterCwd)));
131
+ return results.flat();
132
+ }
133
+
134
+ // sessionId → absolute jsonl path (avoids re-scanning projects on every history load)
135
+ const claudeFileCache = new Map();
136
+
137
+ export async function findClaudeSessionFile(sessionId, configDirs) {
138
+ if (!configDirs?.length) return null;
139
+ const cached = claudeFileCache.get(sessionId);
140
+ if (cached) {
141
+ try { await fs.access(cached); return cached; }
142
+ catch { claudeFileCache.delete(sessionId); }
143
+ }
144
+ for (const configDir of configDirs) {
145
+ const projectsDir = path.join(configDir, 'projects');
146
+ let projectDirs;
147
+ try { projectDirs = await fs.readdir(projectsDir); }
148
+ catch { continue; }
149
+ for (const dir of projectDirs) {
150
+ const candidate = path.join(projectsDir, dir, `${sessionId}.jsonl`);
151
+ try {
152
+ await fs.access(candidate);
153
+ claudeFileCache.set(sessionId, candidate);
154
+ return candidate;
155
+ } catch {}
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+
161
+ const INTERNAL_PREFIXES = ['<command-', '<local-command', '<system-reminder', 'Caveat:', 'This session is being continued', '[Request interrupted'];
162
+
163
+ function isInternal(text) {
164
+ return INTERNAL_PREFIXES.some(p => text.startsWith(p));
165
+ }
166
+
167
+ export async function parseClaudeMessages(filePath) {
168
+ const messages = [];
169
+ let pendingUsage = null;
170
+
171
+ function flushUsage() {
172
+ if (!pendingUsage) return;
173
+ const u = pendingUsage;
174
+ pendingUsage = null;
175
+ if (u.input_tokens || u.output_tokens) messages.push({ type: 'token_usage', usage: u });
176
+ }
177
+
178
+ try {
179
+ const stream = fsSync.createReadStream(filePath);
180
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
181
+ for await (const line of rl) {
182
+ let d;
183
+ try { d = JSON.parse(line); } catch { continue; }
184
+
185
+ if (d.type === 'user' && d.message?.content) {
186
+ flushUsage();
187
+ const content = d.message.content;
188
+ const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
189
+ for (const part of parts) {
190
+ if (part.type === 'text' && part.text?.trim() && !isInternal(part.text.trim())) {
191
+ messages.push({ role: 'user', type: 'text', content: part.text.trim(), ts: d.timestamp });
192
+ }
193
+ }
194
+ continue;
195
+ }
196
+
197
+ if (d.type === 'assistant' && Array.isArray(d.message?.content)) {
198
+ const u = d.message.usage;
199
+ if (u) {
200
+ if (!pendingUsage) pendingUsage = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 };
201
+ pendingUsage.input_tokens += u.input_tokens ?? 0;
202
+ pendingUsage.output_tokens += u.output_tokens ?? 0;
203
+ pendingUsage.cache_creation_input_tokens += u.cache_creation_input_tokens ?? 0;
204
+ pendingUsage.cache_read_input_tokens += u.cache_read_input_tokens ?? 0;
205
+ }
206
+ for (const part of d.message.content) {
207
+ if (part.type === 'text' && part.text?.trim()) {
208
+ messages.push({ role: 'assistant', type: 'text', content: part.text.trim(), ts: d.timestamp });
209
+ } else if (part.type === 'tool_use') {
210
+ messages.push({ role: 'tool', type: 'tool_use', name: part.name, input: part.input ?? {}, ts: d.timestamp });
211
+ }
212
+ }
213
+ }
214
+ }
215
+ flushUsage();
216
+ } catch {}
217
+ return messages;
218
+ }
219
+
220
+ // ─── Codex ────────────────────────────────────────────────────────────────────
221
+
222
+ async function loadCodexIndex(codexHome) {
223
+ const map = new Map();
224
+ try {
225
+ const stream = fsSync.createReadStream(path.join(codexHome, 'session_index.jsonl'));
226
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
227
+ for await (const line of rl) {
228
+ try {
229
+ const d = JSON.parse(line);
230
+ if (d.id) map.set(d.id, d);
231
+ } catch {}
232
+ }
233
+ } catch {}
234
+ return map;
235
+ }
236
+
237
+ async function findCodexRollouts(codexHome) {
238
+ const sessionsRoot = path.join(codexHome, 'sessions');
239
+ const files = [];
240
+ async function walk(dir, depth = 0) {
241
+ if (depth > 5) return;
242
+ let entries;
243
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); }
244
+ catch { return; }
245
+ for (const e of entries) {
246
+ const p = path.join(dir, e.name);
247
+ if (e.isDirectory()) await walk(p, depth + 1);
248
+ else if (e.isFile() && e.name.endsWith('.jsonl') && e.name.startsWith('rollout-')) {
249
+ files.push(p);
250
+ }
251
+ }
252
+ }
253
+ await walk(sessionsRoot);
254
+ return files;
255
+ }
256
+
257
+ async function readCodexMeta(filePath) {
258
+ let id = null, cwd = null, ts = null, turnCount = 0;
259
+ try {
260
+ // Full scan: meta is early; turnCount needs whole file
261
+ const stream = fsSync.createReadStream(filePath);
262
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
263
+ for await (const line of rl) {
264
+ try {
265
+ const d = JSON.parse(line);
266
+ if (d.type === 'session_meta' && d.payload) {
267
+ id = d.payload.id || id;
268
+ cwd = d.payload.cwd || cwd;
269
+ ts = d.payload.timestamp || d.timestamp || ts;
270
+ }
271
+ // Codex user turns appear in a few event shapes across versions
272
+ if (d.type === 'event_msg' && d.payload?.type === 'user_message') turnCount += 1;
273
+ else if (d.type === 'response_item' && d.payload?.role === 'user') turnCount += 1;
274
+ else if (d.type === 'turn_context' && d.payload?.turn_index != null) {
275
+ // last turn_index + 1 is a better total; bump max
276
+ const idx = Number(d.payload.turn_index) + 1;
277
+ if (idx > turnCount) turnCount = idx;
278
+ }
279
+ } catch {}
280
+ }
281
+ } catch {}
282
+ // Fallback: parse id from filename rollout-...-<uuid>.jsonl
283
+ if (!id) {
284
+ const m = path.basename(filePath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
285
+ if (m) id = m[1];
286
+ }
287
+ return { id, cwd, ts, turnCount };
288
+ }
289
+
290
+ export async function scanCodexSessions(codexHome, filterCwd = null) {
291
+ const home = codexHome || path.join(os.homedir(), '.codex');
292
+ const index = await loadCodexIndex(home);
293
+ const rollouts = await findCodexRollouts(home);
294
+ const sessions = [];
295
+
296
+ await Promise.all(rollouts.map(async filePath => {
297
+ try {
298
+ const [stat, meta] = await Promise.all([fs.stat(filePath), readCodexMeta(filePath)]);
299
+ if (!meta.id) return;
300
+ if (filterCwd && meta.cwd && meta.cwd !== filterCwd) return;
301
+ const idx = index.get(meta.id);
302
+ sessions.push({
303
+ sessionId: meta.id,
304
+ agent: 'codex',
305
+ cwd: meta.cwd || null,
306
+ configDir: home,
307
+ display: idx?.thread_name || meta.id.slice(0, 8),
308
+ updatedAt: stat.mtimeMs,
309
+ hasMemory: false,
310
+ filePath,
311
+ turnCount: meta.turnCount || 0,
312
+ });
313
+ } catch {}
314
+ }));
315
+ return sessions;
316
+ }
317
+
318
+ export async function findCodexSessionFile(sessionId, codexHome) {
319
+ const home = codexHome || path.join(os.homedir(), '.codex');
320
+ const rollouts = await findCodexRollouts(home);
321
+ for (const f of rollouts) {
322
+ if (f.includes(sessionId)) return f;
323
+ }
324
+ // slower path: read meta
325
+ for (const f of rollouts) {
326
+ const meta = await readCodexMeta(f);
327
+ if (meta.id === sessionId) return f;
328
+ }
329
+ return null;
330
+ }
331
+
332
+ export async function parseCodexMessages(filePath) {
333
+ const messages = [];
334
+ // Codex writes the same assistant turn twice:
335
+ // response_item (message/assistant) AND event_msg (agent_message)
336
+ // Prefer response_item; only use event_msg when that text wasn't already seen.
337
+ const seenAssistant = new Set();
338
+
339
+ const assistantKey = (text, ts) => {
340
+ const t = String(text || '').trim();
341
+ if (!t) return '';
342
+ // Same second + same body is the dual-write case (identical timestamps in UI)
343
+ const tsPart = ts != null ? String(ts) : '';
344
+ return `${tsPart}\n${t}`;
345
+ };
346
+
347
+ const pushAssistant = (text, ts) => {
348
+ const t = String(text || '').trim();
349
+ if (!t) return;
350
+ const key = assistantKey(t, ts);
351
+ if (seenAssistant.has(key)) return;
352
+ // Also skip pure text duplicates even if ts differs slightly
353
+ if (seenAssistant.has(t)) return;
354
+ seenAssistant.add(key);
355
+ seenAssistant.add(t);
356
+ messages.push({ role: 'assistant', type: 'text', content: t, ts });
357
+ };
358
+
359
+ try {
360
+ const stream = fsSync.createReadStream(filePath);
361
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
362
+ for await (const line of rl) {
363
+ let d;
364
+ try { d = JSON.parse(line); } catch { continue; }
365
+
366
+ if (d.type === 'response_item' && d.payload) {
367
+ const p = d.payload;
368
+ if (p.type === 'message' && p.role === 'user') {
369
+ const texts = (p.content || [])
370
+ .filter(c => c.type === 'input_text' || c.type === 'text')
371
+ .map(c => c.text || '')
372
+ .filter(Boolean);
373
+ const text = texts.join('\n').trim();
374
+ // skip huge injected system/env blobs
375
+ if (text && !text.startsWith('# AGENTS.md') && !text.startsWith('<environment_context')
376
+ && !text.startsWith('<permissions') && text.length < 2000) {
377
+ messages.push({ role: 'user', type: 'text', content: text, ts: d.timestamp });
378
+ }
379
+ } else if (p.type === 'message' && p.role === 'assistant') {
380
+ const texts = (p.content || [])
381
+ .filter(c => c.type === 'output_text' || c.type === 'text')
382
+ .map(c => c.text || '')
383
+ .filter(Boolean);
384
+ pushAssistant(texts.join('\n'), d.timestamp);
385
+ } else if (p.type === 'function_call') {
386
+ let input = p.arguments;
387
+ try { input = typeof input === 'string' ? JSON.parse(input) : input; } catch {}
388
+ messages.push({ role: 'tool', type: 'tool_use', name: p.name || 'function', input: input ?? {}, ts: d.timestamp });
389
+ } else if (p.type === 'command_execution' || p.type === 'custom_tool_call') {
390
+ messages.push({
391
+ role: 'tool',
392
+ type: 'tool_use',
393
+ name: p.type === 'command_execution' ? 'Bash' : (p.name || 'tool'),
394
+ input: { command: p.command || p.input || '' },
395
+ ts: d.timestamp,
396
+ });
397
+ }
398
+ }
399
+
400
+ // Secondary channel — skip if response_item already recorded the same body
401
+ if (d.type === 'event_msg' && d.payload?.type === 'agent_message') {
402
+ const text = d.payload.message || d.payload.text;
403
+ pushAssistant(text, d.timestamp);
404
+ }
405
+ }
406
+ } catch {}
407
+ return messages;
408
+ }
409
+
410
+ // ─── Grok ─────────────────────────────────────────────────────────────────────
411
+
412
+ export async function scanGrokSessions(grokHome, filterCwd = null) {
413
+ const home = grokHome || path.join(os.homedir(), '.grok');
414
+ const sessionsRoot = path.join(home, 'sessions');
415
+ const sessions = [];
416
+
417
+ let projectDirs;
418
+ try {
419
+ projectDirs = (await fs.readdir(sessionsRoot, { withFileTypes: true })).filter(e => e.isDirectory());
420
+ } catch { return []; }
421
+
422
+ await Promise.all(projectDirs.map(async dirEntry => {
423
+ // dir name is encodeURIComponent(cwd)
424
+ let cwd;
425
+ try { cwd = decodeURIComponent(dirEntry.name); } catch { cwd = dirEntry.name; }
426
+ if (filterCwd && cwd !== filterCwd) return;
427
+
428
+ const projectDir = path.join(sessionsRoot, dirEntry.name);
429
+ let sessionDirs;
430
+ try {
431
+ sessionDirs = (await fs.readdir(projectDir, { withFileTypes: true })).filter(e => e.isDirectory());
432
+ } catch { return; }
433
+
434
+ await Promise.all(sessionDirs.map(async sEntry => {
435
+ const sessionId = sEntry.name;
436
+ if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return;
437
+ const sessionDir = path.join(projectDir, sessionId);
438
+ let display = sessionId.slice(0, 8);
439
+ let updatedAt = 0;
440
+ let hasMemory = false;
441
+ try {
442
+ const summary = JSON.parse(await fs.readFile(path.join(sessionDir, 'summary.json'), 'utf8'));
443
+ display = summary.session_summary || summary.info?.title || display;
444
+ if (summary.updated_at) updatedAt = Date.parse(summary.updated_at) || 0;
445
+ if (summary.info?.cwd) cwd = summary.info.cwd;
446
+ } catch {}
447
+ try {
448
+ const st = await fs.stat(path.join(sessionDir, 'chat_history.jsonl'));
449
+ if (!updatedAt) updatedAt = st.mtimeMs;
450
+ else updatedAt = Math.max(updatedAt, st.mtimeMs);
451
+ } catch {
452
+ try {
453
+ const st = await fs.stat(sessionDir);
454
+ if (!updatedAt) updatedAt = st.mtimeMs;
455
+ } catch { return; }
456
+ }
457
+ try {
458
+ await fs.access(path.join(sessionDir, 'memory'));
459
+ hasMemory = true;
460
+ } catch {}
461
+
462
+ let totalTokens = 0;
463
+ try {
464
+ const sig = JSON.parse(await fs.readFile(path.join(sessionDir, 'signals.json'), 'utf8'));
465
+ totalTokens = sig.contextTokensUsed ?? 0;
466
+ } catch {}
467
+
468
+ let turnCount = 0;
469
+ try {
470
+ const stream = fsSync.createReadStream(path.join(sessionDir, 'chat_history.jsonl'));
471
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
472
+ for await (const line of rl) {
473
+ try {
474
+ const d = JSON.parse(line);
475
+ if (d.role === 'user' || d.type === 'user') turnCount += 1;
476
+ } catch {}
477
+ }
478
+ } catch {}
479
+
480
+ sessions.push({
481
+ sessionId,
482
+ agent: 'grok',
483
+ cwd: cwd || null,
484
+ configDir: home,
485
+ display: String(display).slice(0, 120),
486
+ updatedAt,
487
+ hasMemory,
488
+ sessionDir,
489
+ totalTokens,
490
+ turnCount,
491
+ });
492
+ }));
493
+ }));
494
+
495
+ return sessions;
496
+ }
497
+
498
+ export async function findGrokSessionDir(sessionId, grokHome) {
499
+ const home = grokHome || path.join(os.homedir(), '.grok');
500
+ const sessionsRoot = path.join(home, 'sessions');
501
+ let projectDirs;
502
+ try {
503
+ projectDirs = await fs.readdir(sessionsRoot);
504
+ } catch { return null; }
505
+ for (const dir of projectDirs) {
506
+ const candidate = path.join(sessionsRoot, dir, sessionId);
507
+ try {
508
+ const st = await fs.stat(candidate);
509
+ if (st.isDirectory()) return candidate;
510
+ } catch {}
511
+ }
512
+ return null;
513
+ }
514
+
515
+ /**
516
+ * Grok stores content as either a plain string or an array of blocks:
517
+ * [{ "type": "text", "text": "<user_query>\n...\n</user_query>" }]
518
+ * Real user prompts are wrapped in <user_query>; env/context injections use
519
+ * <user_info>, <git_status>, <system-reminder>, etc. and should be hidden.
520
+ */
521
+ function extractGrokText(content) {
522
+ if (content == null) return '';
523
+ if (typeof content === 'string') return content;
524
+ if (Array.isArray(content)) {
525
+ return content
526
+ .map(part => {
527
+ if (typeof part === 'string') return part;
528
+ if (part && typeof part === 'object') {
529
+ if (typeof part.text === 'string') return part.text;
530
+ if (typeof part.content === 'string') return part.content;
531
+ }
532
+ return '';
533
+ })
534
+ .filter(Boolean)
535
+ .join('\n');
536
+ }
537
+ if (typeof content === 'object' && typeof content.text === 'string') return content.text;
538
+ return '';
539
+ }
540
+
541
+ function unwrapGrokUserQuery(text) {
542
+ if (!text) return text;
543
+ // Prefer the <user_query> body when present
544
+ const m = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i);
545
+ if (m) return m[1].trim();
546
+ return text.trim();
547
+ }
548
+
549
+ function isGrokInternalUserText(text) {
550
+ if (!text) return true;
551
+ const t = text.trim();
552
+ // Whole-message system injections (often the entire content)
553
+ if (/^<(user_info|git_status|system-reminder|system_reminder|agent_skills|available_skills|mcp_server|environment_context|permissions)\b/i.test(t)) {
554
+ return true;
555
+ }
556
+ // Messages that are ONLY those tags (no real user_query)
557
+ if (/^<[^>]+>[\s\S]*<\/[^>]+>$/.test(t) && !/<user_query>/i.test(t)) {
558
+ // Allow short non-system tags; block known wrappers
559
+ if (/<\/?(user_info|git_status|system-reminder|system_reminder|agent_skills|available_skills)\b/i.test(t)) {
560
+ return true;
561
+ }
562
+ }
563
+ return false;
564
+ }
565
+
566
+ function parseGrokToolInput(raw) {
567
+ if (raw == null) return {};
568
+ if (typeof raw === 'object') return raw;
569
+ if (typeof raw === 'string') {
570
+ try { return JSON.parse(raw); } catch { return { input: raw }; }
571
+ }
572
+ return {};
573
+ }
574
+
575
+ function toMs(raw) {
576
+ if (raw == null || raw === '') return null;
577
+ if (typeof raw === 'number') return raw < 1e12 ? raw * 1000 : raw;
578
+ const ms = Date.parse(raw);
579
+ return Number.isNaN(ms) ? null : ms;
580
+ }
581
+
582
+ /**
583
+ * Grok `updates.jsonl` lines look like:
584
+ * { "timestamp": 1784518536, "method": "session/update",
585
+ * "params": { "update": { "sessionUpdate": "user_message_chunk", ... } } }
586
+ * `timestamp` is Unix **seconds** (not ms).
587
+ */
588
+ async function loadGrokUpdates(sessionDir) {
589
+ const updatesPath = path.join(sessionDir, 'updates.jsonl');
590
+ const users = []; // { ts, text }
591
+ const assistants = []; // { ts }
592
+ const tools = []; // { ts, name }
593
+ const turnUsages = []; // { ts, usage }
594
+ let pendingAsst = null;
595
+
596
+ const flushAsst = () => {
597
+ if (pendingAsst) { assistants.push(pendingAsst); pendingAsst = null; }
598
+ };
599
+
600
+ try {
601
+ const stream = fsSync.createReadStream(updatesPath);
602
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
603
+ for await (const line of rl) {
604
+ let d;
605
+ try { d = JSON.parse(line); } catch { continue; }
606
+
607
+ // Prefer explicit `timestamp` field (Unix seconds)
608
+ const ts = toMs(d.timestamp ?? d.ts);
609
+ if (ts == null) continue;
610
+
611
+ const update = d.params?.update || d.update || {};
612
+ const kind = update.sessionUpdate || update.type || '';
613
+
614
+ if (kind === 'user_message_chunk') {
615
+ flushAsst();
616
+ const text = update.content?.text
617
+ || (typeof update.content === 'string' ? update.content : '')
618
+ || extractGrokText(update.content)
619
+ || '';
620
+ users.push({ ts, text: text.trim() });
621
+ } else if (kind === 'agent_message_chunk') {
622
+ if (!pendingAsst) pendingAsst = { ts };
623
+ } else if (kind === 'tool_call') {
624
+ flushAsst();
625
+ tools.push({
626
+ ts,
627
+ name: update.title || update.toolName || update.name || null,
628
+ });
629
+ } else if (kind === 'turn_completed') {
630
+ flushAsst();
631
+ if (update.usage) {
632
+ turnUsages.push({ ts, usage: normalizeGrokUsage(update.usage) });
633
+ }
634
+ }
635
+ }
636
+ flushAsst();
637
+ } catch {}
638
+
639
+ return { users, assistants, tools, turnUsages };
640
+ }
641
+
642
+ /** Grok usage uses camelCase; normalize to snake_case used by the UI. */
643
+ function normalizeGrokUsage(u = {}) {
644
+ const costTicks = u.costUsdTicks ?? u.cost_usd_ticks;
645
+ const costUSD = u.costUSD ?? u.cost_usd
646
+ ?? (typeof costTicks === 'number' ? costTicks / 1e10 : undefined);
647
+ return {
648
+ input_tokens: u.inputTokens ?? u.input_tokens ?? 0,
649
+ output_tokens: u.outputTokens ?? u.output_tokens ?? 0,
650
+ cache_read_input_tokens: u.cachedReadTokens ?? u.cache_read_input_tokens ?? 0,
651
+ cache_creation_input_tokens: u.cacheCreationInputTokens ?? u.cache_creation_input_tokens ?? 0,
652
+ total_tokens: u.totalTokens ?? u.total_tokens ?? 0,
653
+ reasoning_tokens: u.reasoningTokens ?? u.reasoning_tokens ?? 0,
654
+ costUSD,
655
+ num_turns: u.numTurns ?? u.num_turns,
656
+ };
657
+ }
658
+
659
+ function applyGrokTimestamps(messages, { users, assistants, tools }) {
660
+ let ui = 0, ai = 0, ti = 0;
661
+
662
+ for (const m of messages) {
663
+ if (m.role === 'user' && m.type === 'text') {
664
+ // Prefer content match so filtered synthetic users don't shift the index
665
+ let hit = -1;
666
+ const needle = (m.content || '').trim();
667
+ for (let j = ui; j < users.length; j++) {
668
+ const t = (users[j].text || '').trim();
669
+ if (!t) continue;
670
+ if (t === needle || needle.includes(t.slice(0, 48)) || t.includes(needle.slice(0, 48))) {
671
+ hit = j;
672
+ break;
673
+ }
674
+ }
675
+ if (hit < 0 && ui < users.length) hit = ui;
676
+ if (hit >= 0) {
677
+ m.ts = users[hit].ts;
678
+ ui = hit + 1;
679
+ }
680
+ } else if (m.role === 'assistant' && m.type === 'text') {
681
+ if (ai < assistants.length) m.ts = assistants[ai++].ts;
682
+ } else if (m.type === 'tool_use') {
683
+ if (ti < tools.length) m.ts = tools[ti++].ts;
684
+ }
685
+ }
686
+ }
687
+
688
+ /** Insert per-turn token_usage rows after each user turn. */
689
+ function insertTurnUsages(messages, turnUsages) {
690
+ if (!turnUsages?.length) return messages;
691
+ const userIdx = [];
692
+ messages.forEach((m, i) => {
693
+ if (m.role === 'user' && m.type === 'text') userIdx.push(i);
694
+ });
695
+ // splice from the end so earlier indices stay valid
696
+ for (let i = turnUsages.length - 1; i >= 0; i--) {
697
+ const start = userIdx[i];
698
+ if (start == null) continue;
699
+ const end = userIdx[i + 1] != null ? userIdx[i + 1] : messages.length;
700
+ messages.splice(end, 0, {
701
+ type: 'token_usage',
702
+ usage: turnUsages[i].usage,
703
+ ts: turnUsages[i].ts,
704
+ });
705
+ }
706
+ return messages;
707
+ }
708
+
709
+ async function loadGrokContext(sessionDir) {
710
+ const context = {
711
+ tokensUsed: null,
712
+ windowTokens: null,
713
+ usagePercent: null,
714
+ model: null,
715
+ turnCount: null,
716
+ toolCallCount: null,
717
+ sessionDurationSeconds: null,
718
+ lastTurn: null, // last turn_completed usage from updates.jsonl
719
+ };
720
+ try {
721
+ const sig = JSON.parse(await fs.readFile(path.join(sessionDir, 'signals.json'), 'utf8'));
722
+ context.tokensUsed = sig.contextTokensUsed ?? null;
723
+ context.windowTokens = sig.contextWindowTokens ?? null;
724
+ context.usagePercent = sig.contextWindowUsage ?? null;
725
+ context.model = sig.primaryModelId ?? (sig.modelsUsed && sig.modelsUsed[0]) ?? null;
726
+ context.turnCount = sig.turnCount ?? null;
727
+ context.toolCallCount = sig.toolCallCount ?? null;
728
+ context.sessionDurationSeconds = sig.sessionDurationSeconds ?? null;
729
+ } catch {}
730
+ try {
731
+ const summary = JSON.parse(await fs.readFile(path.join(sessionDir, 'summary.json'), 'utf8'));
732
+ if (!context.model) context.model = summary.current_model_id || null;
733
+ context.createdAt = summary.created_at || null;
734
+ context.updatedAt = summary.updated_at || summary.last_active_at || null;
735
+ context.title = summary.session_summary || summary.generated_title || null;
736
+ } catch {}
737
+ // Last completed turn usage (input/output/cache/cost)
738
+ try {
739
+ const updates = await loadGrokUpdates(sessionDir);
740
+ if (updates.turnUsages?.length) {
741
+ const last = updates.turnUsages[updates.turnUsages.length - 1];
742
+ context.lastTurn = { ...last.usage, ts: last.ts };
743
+ // Aggregate totals across turns when available
744
+ const sum = { input_tokens: 0, output_tokens: 0, total_tokens: 0, cache_read_input_tokens: 0, costUSD: 0 };
745
+ for (const t of updates.turnUsages) {
746
+ sum.input_tokens += t.usage.input_tokens || 0;
747
+ sum.output_tokens += t.usage.output_tokens || 0;
748
+ sum.total_tokens += t.usage.total_tokens || 0;
749
+ sum.cache_read_input_tokens += t.usage.cache_read_input_tokens || 0;
750
+ sum.costUSD += Number(t.usage.costUSD) || 0;
751
+ }
752
+ context.sessionTotals = sum;
753
+ context.completedTurns = updates.turnUsages.length;
754
+ }
755
+ } catch {}
756
+ return context;
757
+ }
758
+
759
+ /** Public helper: load usage/context for any agent session. */
760
+ export async function loadSessionUsage(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
761
+ const a = (agent || 'claude').toLowerCase();
762
+ if (a === 'grok') {
763
+ const dir = await findGrokSessionDir(sessionId, grokHome);
764
+ if (!dir) return null;
765
+ const context = await loadGrokContext(dir);
766
+ return { agent: 'grok', sessionId, context };
767
+ }
768
+ if (a === 'codex') {
769
+ const file = await findCodexSessionFile(sessionId, codexHome);
770
+ if (!file) return null;
771
+ // Codex has no signals.json equivalent here — return file meta only
772
+ try {
773
+ const st = await fs.stat(file);
774
+ return {
775
+ agent: 'codex',
776
+ sessionId,
777
+ context: {
778
+ model: null,
779
+ tokensUsed: null,
780
+ windowTokens: null,
781
+ usagePercent: null,
782
+ updatedAt: new Date(st.mtimeMs).toISOString(),
783
+ note: 'Codex per-session context window not exposed; see last-turn bar after a reply.',
784
+ },
785
+ };
786
+ } catch {
787
+ return { agent: 'codex', sessionId, context: null };
788
+ }
789
+ }
790
+ // Claude — best-effort from session jsonl last usage
791
+ const file = await findClaudeSessionFile(sessionId, claudeConfigDirs || []);
792
+ if (!file) return null;
793
+ let lastUsage = null;
794
+ try {
795
+ const stream = fsSync.createReadStream(file);
796
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
797
+ for await (const line of rl) {
798
+ let d;
799
+ try { d = JSON.parse(line); } catch { continue; }
800
+ const u = d.message?.usage || d.usage;
801
+ if (u && (u.input_tokens || u.output_tokens)) {
802
+ lastUsage = {
803
+ input_tokens: u.input_tokens ?? 0,
804
+ output_tokens: u.output_tokens ?? 0,
805
+ cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
806
+ cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
807
+ total_tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
808
+ };
809
+ }
810
+ }
811
+ } catch {}
812
+ return {
813
+ agent: 'claude',
814
+ sessionId,
815
+ context: {
816
+ model: null,
817
+ lastTurn: lastUsage,
818
+ note: lastUsage ? null : 'No usage records in this Claude session yet.',
819
+ },
820
+ };
821
+ }
822
+
823
+ export async function parseGrokMessages(sessionDir) {
824
+ const messages = [];
825
+ const histPath = path.join(sessionDir, 'chat_history.jsonl');
826
+ try {
827
+ const stream = fsSync.createReadStream(histPath);
828
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
829
+ for await (const line of rl) {
830
+ let d;
831
+ try { d = JSON.parse(line); } catch { continue; }
832
+
833
+ // Skip system prompts, reasoning blobs, tool results noise in chat view
834
+ if (d.type === 'system' || d.type === 'reasoning' || d.type === 'tool_result') continue;
835
+ if (d.synthetic_reason) continue;
836
+
837
+ // chat_history rows rarely have timestamps; will be filled from updates.jsonl
838
+ let ts = toMs(d.timestamp ?? d.ts ?? d.created_at);
839
+
840
+ if (d.type === 'user') {
841
+ const raw = extractGrokText(d.content);
842
+ if (!raw.trim()) continue;
843
+ if (isGrokInternalUserText(raw)) continue;
844
+ const text = unwrapGrokUserQuery(raw);
845
+ if (!text || isGrokInternalUserText(text)) continue;
846
+ messages.push({ role: 'user', type: 'text', content: text, ts });
847
+ continue;
848
+ }
849
+
850
+ if (d.type === 'assistant') {
851
+ const text = extractGrokText(d.content).trim();
852
+ if (text) {
853
+ messages.push({ role: 'assistant', type: 'text', content: text, ts });
854
+ }
855
+ if (Array.isArray(d.tool_calls)) {
856
+ for (const tc of d.tool_calls) {
857
+ const name = tc.name || tc.function?.name || 'tool';
858
+ const input = parseGrokToolInput(tc.arguments ?? tc.input ?? tc.function?.arguments);
859
+ messages.push({ role: 'tool', type: 'tool_use', name, input, ts });
860
+ }
861
+ }
862
+ continue;
863
+ }
864
+
865
+ if (d.type === 'tool_use' || d.type === 'tool_call') {
866
+ messages.push({
867
+ role: 'tool',
868
+ type: 'tool_use',
869
+ name: d.name || 'tool',
870
+ input: parseGrokToolInput(d.input ?? d.arguments),
871
+ ts,
872
+ });
873
+ }
874
+ }
875
+ } catch {}
876
+
877
+ // timestamps + per-turn usage from updates.jsonl (`timestamp` = Unix seconds)
878
+ try {
879
+ const updates = await loadGrokUpdates(sessionDir);
880
+ applyGrokTimestamps(messages, updates);
881
+ insertTurnUsages(messages, updates.turnUsages);
882
+ } catch {}
883
+
884
+ return messages;
885
+ }
886
+
887
+ export async function loadGrokSessionBundle(sessionDir) {
888
+ const messages = await parseGrokMessages(sessionDir);
889
+ const context = await loadGrokContext(sessionDir);
890
+ return { messages, context };
891
+ }
892
+
893
+ // ─── Unified ──────────────────────────────────────────────────────────────────
894
+
895
+ /** Normalize cwd so /private/tmp and /tmp (etc.) group together on macOS. */
896
+ export function normalizeCwd(cwd) {
897
+ if (!cwd) return null;
898
+ let p = cwd;
899
+ if (p.startsWith('/private/')) p = p.slice('/private'.length) || '/';
900
+ // strip trailing slash except root
901
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
902
+ return p;
903
+ }
904
+
905
+ export function projectNameFromCwd(cwd) {
906
+ if (!cwd) return '(no path)';
907
+ const parts = cwd.split('/').filter(Boolean);
908
+ return parts[parts.length - 1] || cwd;
909
+ }
910
+
911
+ function publicSession(s) {
912
+ const cwd = normalizeCwd(s.cwd) || s.cwd || null;
913
+ return {
914
+ sessionId: s.sessionId,
915
+ agent: s.agent || 'claude',
916
+ cwd,
917
+ configDir: s.configDir || null,
918
+ display: (s.display || s.sessionId?.slice(0, 8) || 'Session').toString().slice(0, 160),
919
+ updatedAt: s.updatedAt || 0,
920
+ hasMemory: Boolean(s.hasMemory),
921
+ projectName: projectNameFromCwd(cwd),
922
+ totalTokens: s.totalTokens || 0,
923
+ turnCount: Number(s.turnCount) || 0,
924
+ // keep for deep search only when needed — not sent if stripped below
925
+ _filePath: s.filePath || null,
926
+ _sessionDir: s.sessionDir || null,
927
+ };
928
+ }
929
+
930
+ export async function scanAllSessions({ claudeConfigDirs, codexHome, grokHome, filterCwd, agents }) {
931
+ const want = new Set(agents || ['claude', 'codex', 'grok']);
932
+ const tasks = [];
933
+ if (want.has('claude')) tasks.push(scanClaudeSessions(claudeConfigDirs, filterCwd));
934
+ if (want.has('codex')) tasks.push(scanCodexSessions(codexHome, filterCwd));
935
+ if (want.has('grok')) tasks.push(scanGrokSessions(grokHome, filterCwd));
936
+
937
+ const results = await Promise.all(tasks);
938
+ const seen = new Set();
939
+ const sessions = results.flat()
940
+ .map(publicSession)
941
+ .filter(s => {
942
+ const key = `${s.agent}:${s.sessionId}`;
943
+ if (seen.has(key)) return false;
944
+ seen.add(key);
945
+ return true;
946
+ });
947
+ return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
948
+ }
949
+
950
+ /** Strip internal-only fields before sending to clients. */
951
+ export function toClientSession(s) {
952
+ const { _filePath, _sessionDir, ...rest } = s;
953
+ return rest;
954
+ }
955
+
956
+ /**
957
+ * Search / filter sessions for project-management views.
958
+ * - metadata match: cwd, projectName, display, agent
959
+ * - optional deep content: peek recent session files for keyword (bounded)
960
+ */
961
+ export async function searchActivity(sessions, {
962
+ q = '',
963
+ days = 0,
964
+ agent = null,
965
+ limit = 80,
966
+ deep = true,
967
+ } = {}) {
968
+ const query = (q || '').trim().toLowerCase();
969
+ const now = Date.now();
970
+ const since = days > 0 ? now - days * 86400000 : 0;
971
+
972
+ let list = sessions.filter(s => {
973
+ if (agent && s.agent !== agent) return false;
974
+ if (since && (s.updatedAt || 0) < since) return false;
975
+ return true;
976
+ });
977
+
978
+ // Metadata filter first
979
+ if (query) {
980
+ const metaHit = [];
981
+ const needDeep = [];
982
+ for (const s of list) {
983
+ const hay = [
984
+ s.display, s.cwd, s.projectName, s.agent, s.sessionId,
985
+ ].filter(Boolean).join(' ').toLowerCase();
986
+ if (hay.includes(query)) metaHit.push({ ...s, match: 'meta', snippet: s.display });
987
+ else needDeep.push(s);
988
+ }
989
+
990
+ if (deep && needDeep.length) {
991
+ // Only deep-scan the most recent candidates to stay fast
992
+ const candidates = needDeep
993
+ .sort((a, b) => b.updatedAt - a.updatedAt)
994
+ .slice(0, 60);
995
+ const deepHits = await Promise.all(candidates.map(async s => {
996
+ const snippet = await peekSessionText(s, query);
997
+ if (!snippet) return null;
998
+ return { ...s, match: 'content', snippet: snippet.slice(0, 180) };
999
+ }));
1000
+ list = [...metaHit, ...deepHits.filter(Boolean)];
1001
+ } else {
1002
+ list = metaHit;
1003
+ }
1004
+ } else {
1005
+ list = list.map(s => ({ ...s, match: 'recent', snippet: s.display }));
1006
+ }
1007
+
1008
+ list.sort((a, b) => b.updatedAt - a.updatedAt);
1009
+ return list.slice(0, limit).map(s => {
1010
+ const client = toClientSession(s);
1011
+ return {
1012
+ ...client,
1013
+ match: s.match,
1014
+ snippet: s.snippet || s.display,
1015
+ };
1016
+ });
1017
+ }
1018
+
1019
+ /** Read a short text sample from a session file looking for `query`. */
1020
+ async function peekSessionText(s, query) {
1021
+ try {
1022
+ if (s.agent === 'grok') {
1023
+ const dir = s._sessionDir || await findGrokSessionDir(s.sessionId);
1024
+ if (!dir) return null;
1025
+ const hist = path.join(dir, 'chat_history.jsonl');
1026
+ return await scanFileForQuery(hist, query, {
1027
+ maxLines: 120,
1028
+ extract: (d) => {
1029
+ if (d.synthetic_reason || d.type === 'system' || d.type === 'reasoning') return null;
1030
+ if (d.type === 'user' && d.content) {
1031
+ const raw = extractGrokText(d.content);
1032
+ if (isGrokInternalUserText(raw)) return null;
1033
+ return unwrapGrokUserQuery(raw);
1034
+ }
1035
+ if (d.type === 'assistant' && d.content) {
1036
+ return extractGrokText(d.content) || null;
1037
+ }
1038
+ return null;
1039
+ },
1040
+ });
1041
+ }
1042
+ if (s.agent === 'codex') {
1043
+ const file = s._filePath || await findCodexSessionFile(s.sessionId);
1044
+ if (!file) return null;
1045
+ return await scanFileForQuery(file, query, {
1046
+ maxLines: 80,
1047
+ extract: (d) => {
1048
+ if (d.type === 'response_item' && d.payload?.type === 'message') {
1049
+ const p = d.payload;
1050
+ if (p.role !== 'user' && p.role !== 'assistant') return null;
1051
+ const texts = (p.content || [])
1052
+ .map(c => c.text || '')
1053
+ .filter(Boolean);
1054
+ const text = texts.join(' ').trim();
1055
+ if (!text || text.startsWith('# AGENTS.md') || text.startsWith('<')) return null;
1056
+ if (text.length > 2000) return null;
1057
+ return text;
1058
+ }
1059
+ return null;
1060
+ },
1061
+ });
1062
+ }
1063
+ // claude
1064
+ const file = await findClaudeSessionFile(s.sessionId, null);
1065
+ // findClaudeSessionFile needs config dirs — skip if we don't have path
1066
+ if (!file && !s.configDir) return null;
1067
+ const f = file || path.join(s.configDir, 'projects'); // fallback won't work well
1068
+ // Use load path via find with single configDir if present
1069
+ const claudeFile = s.configDir
1070
+ ? await findClaudeSessionFile(s.sessionId, [s.configDir])
1071
+ : file;
1072
+ if (!claudeFile) return null;
1073
+ return await scanFileForQuery(claudeFile, query, {
1074
+ maxLines: 100,
1075
+ extract: (d) => {
1076
+ if (d.type === 'user' && d.message?.content) {
1077
+ const parts = Array.isArray(d.message.content) ? d.message.content : [];
1078
+ for (const part of parts) {
1079
+ if (part.type === 'text' && part.text?.trim() && !isInternal(part.text.trim())) {
1080
+ return part.text.trim();
1081
+ }
1082
+ }
1083
+ }
1084
+ if (d.type === 'assistant' && Array.isArray(d.message?.content)) {
1085
+ for (const part of d.message.content) {
1086
+ if (part.type === 'text' && part.text?.trim()) return part.text.trim();
1087
+ }
1088
+ }
1089
+ return null;
1090
+ },
1091
+ });
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+
1097
+ async function scanFileForQuery(filePath, query, { maxLines = 80, extract }) {
1098
+ if (!filePath || !query) return null;
1099
+ try {
1100
+ const stream = fsSync.createReadStream(filePath, { encoding: 'utf8' });
1101
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1102
+ let n = 0;
1103
+ let lastUser = null;
1104
+ for await (const line of rl) {
1105
+ n++;
1106
+ if (n > maxLines * 3) break; // hard cap
1107
+ let d;
1108
+ try { d = JSON.parse(line); } catch { continue; }
1109
+ const text = extract(d);
1110
+ if (!text) continue;
1111
+ if (text.toLowerCase().includes(query)) {
1112
+ rl.close(); stream.destroy();
1113
+ // highlight-ish snippet around match
1114
+ const idx = text.toLowerCase().indexOf(query);
1115
+ const start = Math.max(0, idx - 40);
1116
+ const end = Math.min(text.length, idx + query.length + 80);
1117
+ return (start > 0 ? '…' : '') + text.slice(start, end).replace(/\s+/g, ' ') + (end < text.length ? '…' : '');
1118
+ }
1119
+ if (!lastUser && text.length < 300) lastUser = text;
1120
+ if (n >= maxLines && lastUser) break;
1121
+ }
1122
+ } catch {}
1123
+ return null;
1124
+ }
1125
+
1126
+ /** Group sessions into projects for the projects API. */
1127
+ export function groupByProject(sessions) {
1128
+ const map = new Map();
1129
+ for (const s of sessions) {
1130
+ const key = s.cwd || '(no path)';
1131
+ if (!map.has(key)) {
1132
+ map.set(key, {
1133
+ cwd: s.cwd || null,
1134
+ projectName: s.projectName || projectNameFromCwd(s.cwd),
1135
+ updatedAt: 0,
1136
+ sessionCount: 0,
1137
+ agents: {},
1138
+ latest: null,
1139
+ sessions: [],
1140
+ });
1141
+ }
1142
+ const g = map.get(key);
1143
+ g.sessionCount++;
1144
+ g.agents[s.agent] = (g.agents[s.agent] || 0) + 1;
1145
+ if ((s.updatedAt || 0) > g.updatedAt) {
1146
+ g.updatedAt = s.updatedAt;
1147
+ g.latest = {
1148
+ sessionId: s.sessionId,
1149
+ agent: s.agent,
1150
+ display: s.display,
1151
+ updatedAt: s.updatedAt,
1152
+ };
1153
+ }
1154
+ g.sessions.push(s);
1155
+ }
1156
+ return [...map.values()]
1157
+ .map(g => ({
1158
+ ...g,
1159
+ sessions: g.sessions.sort((a, b) => b.updatedAt - a.updatedAt),
1160
+ }))
1161
+ .sort((a, b) => b.updatedAt - a.updatedAt);
1162
+ }
1163
+
1164
+ /**
1165
+ * Load session history. Returns { messages, context } or null.
1166
+ * `context` carries window / token usage when the agent exposes it (Grok signals.json).
1167
+ */
1168
+ export async function loadSessionMessages(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
1169
+ const a = agent || 'claude';
1170
+ if (a === 'codex') {
1171
+ const file = await findCodexSessionFile(sessionId, codexHome);
1172
+ if (!file) return null;
1173
+ return { messages: await parseCodexMessages(file), context: null, agent: 'codex' };
1174
+ }
1175
+ if (a === 'grok') {
1176
+ const dir = await findGrokSessionDir(sessionId, grokHome);
1177
+ if (!dir) return null;
1178
+ const bundle = await loadGrokSessionBundle(dir);
1179
+ return { ...bundle, agent: 'grok' };
1180
+ }
1181
+ const file = await findClaudeSessionFile(sessionId, claudeConfigDirs);
1182
+ if (!file) return null;
1183
+ return { messages: await parseClaudeMessages(file), context: null, agent: 'claude' };
1184
+ }
1185
+
1186
+ export async function loadSessionMemory(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
1187
+ if (agent === 'grok') {
1188
+ const dir = await findGrokSessionDir(sessionId, grokHome);
1189
+ if (!dir) return null;
1190
+ // Grok may store memory differently; best-effort empty
1191
+ return { index: null, files: [] };
1192
+ }
1193
+ if (agent === 'codex') {
1194
+ return { index: null, files: [] };
1195
+ }
1196
+ const file = await findClaudeSessionFile(sessionId, claudeConfigDirs);
1197
+ if (!file) return null;
1198
+ return readProjectMemory(path.dirname(file));
1199
+ }
1200
+
1201
+ export async function readProjectMemory(projectDir) {
1202
+ const memoryDir = path.join(projectDir, 'memory');
1203
+ let index = null;
1204
+ try { index = await fs.readFile(path.join(projectDir, 'MEMORY.md'), 'utf8'); }
1205
+ catch {}
1206
+
1207
+ const files = [];
1208
+ let entries = [];
1209
+ try { entries = await fs.readdir(memoryDir, { withFileTypes: true }); }
1210
+ catch {}
1211
+ await Promise.all(
1212
+ entries
1213
+ .filter(e => e.isFile() && e.name.endsWith('.md'))
1214
+ .map(async e => {
1215
+ try {
1216
+ const content = await fs.readFile(path.join(memoryDir, e.name), 'utf8');
1217
+ const stat = await fs.stat(path.join(memoryDir, e.name));
1218
+ files.push({ name: e.name, content, updatedAt: stat.mtimeMs });
1219
+ } catch {}
1220
+ })
1221
+ );
1222
+ files.sort((a, b) => a.name.localeCompare(b.name));
1223
+ return { index, files };
1224
+ }
1225
+
1226
+ export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
1227
+ const a = (agent || 'claude').toLowerCase();
1228
+ let cwd = null;
1229
+ let display = null;
1230
+ if (a === 'grok') {
1231
+ const dir = await findGrokSessionDir(sessionId, grokHome);
1232
+ if (dir) {
1233
+ try {
1234
+ const summary = JSON.parse(await fs.readFile(path.join(dir, 'summary.json'), 'utf8'));
1235
+ cwd = summary.info?.cwd || null;
1236
+ display = summary.session_summary || summary.info?.title || null;
1237
+ } catch {}
1238
+ }
1239
+ } else if (a === 'codex') {
1240
+ const file = await findCodexSessionFile(sessionId, codexHome);
1241
+ if (file) {
1242
+ const meta = await readCodexMeta(file);
1243
+ cwd = meta.cwd;
1244
+ const index = await loadCodexIndex(codexHome || path.join(os.homedir(), '.codex'));
1245
+ const idx = index.get(sessionId);
1246
+ display = idx?.thread_name || null;
1247
+ }
1248
+ } else {
1249
+ // claude
1250
+ const file = await findClaudeSessionFile(sessionId, claudeConfigDirs);
1251
+ if (file) {
1252
+ const meta = await readClaudeSessionMeta(file);
1253
+ cwd = meta.cwd;
1254
+ const nameMap = await loadClaudeHistoryNames(claudeConfigDirs);
1255
+ display = nameMap.get(sessionId) || await readClaudeFirstMessage(file);
1256
+ }
1257
+ }
1258
+ return { cwd, display };
1259
+ }
1260
+
1261
+ export async function convertSession(sourceSessionId, sourceAgent, targetAgent, { claudeConfigDirs, codexHome, grokHome }) {
1262
+ // 1. Get meta and messages from source session
1263
+ const meta = await getSessionMeta(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome });
1264
+ const cwd = meta.cwd || null;
1265
+ const display = meta.display || 'Converted Session';
1266
+
1267
+ const bundle = await loadSessionMessages(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome });
1268
+ const messages = bundle.messages || [];
1269
+
1270
+ // 2. Generate target session ID
1271
+ const targetSessionId = crypto.randomUUID();
1272
+
1273
+ // 3. Serialize messages to the target agent
1274
+ const ta = targetAgent.toLowerCase();
1275
+
1276
+ if (ta === 'claude') {
1277
+ const configDir = claudeConfigDirs?.[0] || path.join(os.homedir(), '.claude');
1278
+ const projectsDir = path.join(configDir, 'projects');
1279
+
1280
+ // Attempt to find existing project folder with matching cwd
1281
+ let projectDirName = null;
1282
+ try {
1283
+ const projectDirs = (await fs.readdir(projectsDir, { withFileTypes: true })).filter(e => e.isDirectory());
1284
+ for (const dirEntry of projectDirs) {
1285
+ const candidateProjDir = path.join(projectsDir, dirEntry.name);
1286
+ const entries = await fs.readdir(candidateProjDir);
1287
+ const jsonlFiles = entries.filter(e => e.endsWith('.jsonl') && !e.startsWith('agent-'));
1288
+ for (const file of jsonlFiles) {
1289
+ const m = await readClaudeSessionMeta(path.join(candidateProjDir, file));
1290
+ if (m.cwd === cwd) {
1291
+ projectDirName = dirEntry.name;
1292
+ break;
1293
+ }
1294
+ }
1295
+ if (projectDirName) break;
1296
+ }
1297
+ } catch {}
1298
+
1299
+ if (!projectDirName) {
1300
+ if (cwd) {
1301
+ projectDirName = '-' + cwd.replace(/^\//, '').replace(/\//g, '-');
1302
+ } else {
1303
+ projectDirName = 'default-project';
1304
+ }
1305
+ }
1306
+
1307
+ const targetDir = path.join(projectsDir, projectDirName);
1308
+ await fs.mkdir(targetDir, { recursive: true });
1309
+
1310
+ const targetFile = path.join(targetDir, `${targetSessionId}.jsonl`);
1311
+ const lines = [];
1312
+ let parentUuid = null;
1313
+ let lastPromptText = '';
1314
+ let lastLeafUuid = null;
1315
+
1316
+ for (const msg of messages) {
1317
+ const ts = msg.ts ? new Date(msg.ts).toISOString() : new Date().toISOString();
1318
+ const currentUuid = crypto.randomUUID();
1319
+
1320
+ if (msg.role === 'user') {
1321
+ lastPromptText = msg.content || '';
1322
+ lines.push(JSON.stringify({
1323
+ parentUuid,
1324
+ isSidechain: false,
1325
+ promptId: crypto.randomUUID(),
1326
+ type: 'user',
1327
+ message: { role: 'user', content: msg.content },
1328
+ uuid: currentUuid,
1329
+ timestamp: ts,
1330
+ permissionMode: 'default',
1331
+ promptSource: 'sdk',
1332
+ userType: 'external',
1333
+ entrypoint: 'sdk-cli',
1334
+ cwd,
1335
+ sessionId: targetSessionId,
1336
+ version: '2.1.177',
1337
+ gitBranch: 'main'
1338
+ }));
1339
+ parentUuid = currentUuid;
1340
+ } else if (msg.role === 'assistant') {
1341
+ lines.push(JSON.stringify({
1342
+ parentUuid,
1343
+ isSidechain: false,
1344
+ message: {
1345
+ id: crypto.randomBytes(16).toString('hex'),
1346
+ type: 'message',
1347
+ role: 'assistant',
1348
+ content: [{ type: 'text', text: msg.content || '' }],
1349
+ model: 'claude-3-5-sonnet',
1350
+ stop_reason: 'end_turn',
1351
+ stop_sequence: null,
1352
+ usage: { input_tokens: 0, output_tokens: 0 }
1353
+ },
1354
+ type: 'assistant',
1355
+ uuid: currentUuid,
1356
+ timestamp: ts,
1357
+ userType: 'external',
1358
+ entrypoint: 'sdk-cli',
1359
+ cwd,
1360
+ sessionId: targetSessionId,
1361
+ version: '2.1.177',
1362
+ gitBranch: 'main'
1363
+ }));
1364
+ parentUuid = currentUuid;
1365
+ lastLeafUuid = currentUuid;
1366
+ } else if (msg.role === 'tool' && msg.type === 'tool_use') {
1367
+ lines.push(JSON.stringify({
1368
+ parentUuid,
1369
+ isSidechain: false,
1370
+ message: {
1371
+ id: crypto.randomBytes(16).toString('hex'),
1372
+ type: 'message',
1373
+ role: 'assistant',
1374
+ content: [{ type: 'tool_use', name: msg.name, input: msg.input || {} }],
1375
+ model: 'claude-3-5-sonnet',
1376
+ stop_reason: 'end_turn',
1377
+ stop_sequence: null,
1378
+ usage: { input_tokens: 0, output_tokens: 0 }
1379
+ },
1380
+ type: 'assistant',
1381
+ uuid: currentUuid,
1382
+ timestamp: ts,
1383
+ userType: 'external',
1384
+ entrypoint: 'sdk-cli',
1385
+ cwd,
1386
+ sessionId: targetSessionId,
1387
+ version: '2.1.177',
1388
+ gitBranch: 'main'
1389
+ }));
1390
+ parentUuid = currentUuid;
1391
+ lastLeafUuid = currentUuid;
1392
+ }
1393
+ }
1394
+
1395
+ if (lastPromptText && lastLeafUuid) {
1396
+ lines.push(JSON.stringify({
1397
+ type: 'last-prompt',
1398
+ lastPrompt: lastPromptText,
1399
+ leafUuid: lastLeafUuid,
1400
+ sessionId: targetSessionId
1401
+ }));
1402
+ }
1403
+
1404
+ await fs.writeFile(targetFile, lines.join('\n') + '\n', 'utf8');
1405
+
1406
+ // Also save history mapping
1407
+ try {
1408
+ const historyFile = path.join(configDir, 'history.jsonl');
1409
+ await fs.appendFile(historyFile, JSON.stringify({
1410
+ display,
1411
+ pastedContents: {},
1412
+ timestamp: Date.now(),
1413
+ project: cwd,
1414
+ sessionId: targetSessionId
1415
+ }) + '\n', 'utf8');
1416
+ } catch {}
1417
+
1418
+ } else if (ta === 'codex') {
1419
+ const home = codexHome || path.join(os.homedir(), '.codex');
1420
+ const targetDir = path.join(home, 'sessions');
1421
+ await fs.mkdir(targetDir, { recursive: true });
1422
+
1423
+ const targetFile = path.join(targetDir, `rollout-converted-${targetSessionId}.jsonl`);
1424
+ const lines = [];
1425
+ const baseTs = Date.now();
1426
+ lines.push(JSON.stringify({
1427
+ type: 'session_meta',
1428
+ timestamp: baseTs,
1429
+ payload: { id: targetSessionId, cwd }
1430
+ }));
1431
+
1432
+ for (const msg of messages) {
1433
+ const ts = msg.ts || Date.now();
1434
+ if (msg.role === 'user') {
1435
+ lines.push(JSON.stringify({
1436
+ type: 'response_item',
1437
+ timestamp: ts,
1438
+ payload: { type: 'message', role: 'user', content: [{ type: 'text', text: msg.content }] }
1439
+ }));
1440
+ } else if (msg.role === 'assistant') {
1441
+ lines.push(JSON.stringify({
1442
+ type: 'response_item',
1443
+ timestamp: ts,
1444
+ payload: { type: 'message', role: 'assistant', content: [{ type: 'text', text: msg.content }] }
1445
+ }));
1446
+ } else if (msg.role === 'tool' && msg.type === 'tool_use') {
1447
+ lines.push(JSON.stringify({
1448
+ type: 'response_item',
1449
+ timestamp: ts,
1450
+ payload: { type: 'function_call', name: msg.name, arguments: JSON.stringify(msg.input || {}) }
1451
+ }));
1452
+ }
1453
+ }
1454
+ await fs.writeFile(targetFile, lines.join('\n') + '\n', 'utf8');
1455
+
1456
+ // Write index
1457
+ try {
1458
+ const indexFile = path.join(home, 'session_index.jsonl');
1459
+ await fs.appendFile(indexFile, JSON.stringify({ id: targetSessionId, thread_name: display }) + '\n', 'utf8');
1460
+ } catch {}
1461
+
1462
+ } else if (ta === 'grok') {
1463
+ const home = grokHome || path.join(os.homedir(), '.grok');
1464
+ const folderName = cwd ? encodeURIComponent(cwd) : 'default';
1465
+ const targetDir = path.join(home, 'sessions', folderName, targetSessionId);
1466
+ await fs.mkdir(targetDir, { recursive: true });
1467
+
1468
+ const targetFile = path.join(targetDir, 'chat_history.jsonl');
1469
+ const lines = [];
1470
+ for (const msg of messages) {
1471
+ const ts = msg.ts || Date.now();
1472
+ if (msg.role === 'user') {
1473
+ lines.push(JSON.stringify({
1474
+ type: 'user',
1475
+ content: msg.content,
1476
+ timestamp: ts
1477
+ }));
1478
+ } else if (msg.role === 'assistant') {
1479
+ lines.push(JSON.stringify({
1480
+ type: 'assistant',
1481
+ content: msg.content,
1482
+ timestamp: ts
1483
+ }));
1484
+ } else if (msg.role === 'tool' && msg.type === 'tool_use') {
1485
+ lines.push(JSON.stringify({
1486
+ type: 'assistant',
1487
+ content: '',
1488
+ tool_calls: [{ name: msg.name, arguments: JSON.stringify(msg.input || {}) }],
1489
+ timestamp: ts
1490
+ }));
1491
+ }
1492
+ }
1493
+ await fs.writeFile(targetFile, lines.join('\n') + '\n', 'utf8');
1494
+
1495
+ const summaryFile = path.join(targetDir, 'summary.json');
1496
+ await fs.writeFile(summaryFile, JSON.stringify({
1497
+ session_summary: display,
1498
+ info: {
1499
+ cwd,
1500
+ title: display
1501
+ },
1502
+ updated_at: new Date().toISOString()
1503
+ }, null, 2), 'utf8');
1504
+ }
1505
+
1506
+ return { targetSessionId };
1507
+ }
1508
+