claude-simple 0.2.8 → 0.2.10

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.
@@ -757,8 +757,18 @@ async function loadGrokContext(sessionDir) {
757
757
  }
758
758
 
759
759
  /** Public helper: load usage/context for any agent session. */
760
- export async function loadSessionUsage(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
760
+ export async function loadSessionUsage(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }) {
761
761
  const a = (agent || 'claude').toLowerCase();
762
+ if (a === 'cursor') {
763
+ const sessions = await scanCursorSessions(cursorHome);
764
+ if (!sessions.some(s => s.sessionId === sessionId)) return null;
765
+ return { agent: 'cursor', sessionId, context: await loadCursorContext(sessionId, cursorHome) };
766
+ }
767
+ if (a === 'gemini') {
768
+ const sessions = await scanGeminiSessions(geminiHome);
769
+ if (!sessions.some(s => s.sessionId === sessionId)) return null;
770
+ return { agent: 'gemini', sessionId, context: await loadGeminiContext(sessionId, geminiHome) };
771
+ }
762
772
  if (a === 'grok') {
763
773
  const dir = await findGrokSessionDir(sessionId, grokHome);
764
774
  if (!dir) return null;
@@ -890,8 +900,198 @@ export async function loadGrokSessionBundle(sessionDir) {
890
900
  return { messages, context };
891
901
  }
892
902
 
903
+ // ─── Gemini / agy ────────────────────────────────────────────────────────────
904
+
905
+ function geminiUiSessionsRoot(geminiHome) {
906
+ return path.join(geminiHome || path.join(os.homedir(), '.gemini'), 'claude-simple-ui', 'sessions');
907
+ }
908
+
909
+ function geminiJournalPath(sessionId, geminiHome) {
910
+ return path.join(geminiUiSessionsRoot(geminiHome), `${sessionId}.jsonl`);
911
+ }
912
+
913
+ function geminiPathFromUri(uri) {
914
+ if (!uri || typeof uri !== 'string') return null;
915
+ try { return decodeURIComponent(uri.replace(/^file:\/\//, '')); } catch { return null; }
916
+ }
917
+
918
+ async function readGeminiJournalMeta(filePath) {
919
+ let meta = null;
920
+ let firstUser = null;
921
+ let turnCount = 0;
922
+ let totalTokens = 0;
923
+ try {
924
+ const stream = fsSync.createReadStream(filePath);
925
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
926
+ for await (const line of rl) {
927
+ let d; try { d = JSON.parse(line); } catch { continue; }
928
+ if (d.type === 'session_meta') meta = { ...(meta || {}), ...d };
929
+ if (d.type === 'user' && d.content?.trim()) {
930
+ turnCount++;
931
+ if (!firstUser) firstUser = d.content.trim();
932
+ }
933
+ if (d.type === 'result' && d.usage) totalTokens += Number(d.usage.total_tokens) || 0;
934
+ }
935
+ } catch {}
936
+ return { meta, firstUser, turnCount, totalTokens };
937
+ }
938
+
939
+ /** agy keeps public conversation metadata plus a UI-owned readable journal. */
940
+ export async function scanGeminiSessions(geminiHome, filterCwd = null) {
941
+ const home = geminiHome || path.join(os.homedir(), '.gemini');
942
+ const records = new Map();
943
+ const metadataPath = path.join(home, 'antigravity-cli', 'cache', 'conversation_metadata.json');
944
+ try {
945
+ const raw = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
946
+ for (const [sessionId, item] of Object.entries(raw?.conversations || {})) {
947
+ const summary = item?.summary || {};
948
+ const cwd = geminiPathFromUri(summary.WorkspaceURIs?.[0]) || null;
949
+ records.set(sessionId, {
950
+ sessionId, agent: 'gemini', cwd, configDir: home,
951
+ display: summary.Title || summary.Preview || sessionId.slice(0, 8),
952
+ updatedAt: Date.parse(item?.last_modified_time || summary.UpdatedAt || '') || 0,
953
+ turnCount: Number(summary.NumSteps) || 0,
954
+ hasMemory: false,
955
+ });
956
+ }
957
+ } catch {}
958
+ // history.jsonl catches fresh print-mode conversations before metadata cache
959
+ // catches up, and provides a useful title for older conversations.
960
+ try {
961
+ const stream = fsSync.createReadStream(path.join(home, 'antigravity-cli', 'history.jsonl'));
962
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
963
+ for await (const line of rl) {
964
+ let d; try { d = JSON.parse(line); } catch { continue; }
965
+ const sessionId = d.conversationId;
966
+ if (!sessionId) continue;
967
+ const existing = records.get(sessionId) || {
968
+ sessionId, agent: 'gemini', cwd: d.workspace || null, configDir: home,
969
+ display: sessionId.slice(0, 8), updatedAt: 0, turnCount: 0, hasMemory: false,
970
+ };
971
+ existing.cwd ||= d.workspace || null;
972
+ existing.display = existing.display === sessionId.slice(0, 8) ? (d.display || existing.display) : existing.display;
973
+ existing.updatedAt = Math.max(existing.updatedAt || 0, Number(d.timestamp) || 0);
974
+ if (d.type !== 'slash_command') existing.turnCount = Math.max(existing.turnCount || 0, 1);
975
+ records.set(sessionId, existing);
976
+ }
977
+ } catch {}
978
+ try {
979
+ const entries = await fs.readdir(geminiUiSessionsRoot(home), { withFileTypes: true });
980
+ await Promise.all(entries.filter(e => e.isFile() && e.name.endsWith('.jsonl')).map(async e => {
981
+ const filePath = path.join(geminiUiSessionsRoot(home), e.name);
982
+ const sessionId = e.name.slice(0, -6);
983
+ const [stat, parsed] = await Promise.all([fs.stat(filePath), readGeminiJournalMeta(filePath)]);
984
+ const existing = records.get(sessionId) || { sessionId, agent: 'gemini', configDir: home, hasMemory: false };
985
+ existing.cwd = parsed.meta?.cwd || existing.cwd || null;
986
+ existing.display = parsed.firstUser || existing.display || sessionId.slice(0, 8);
987
+ existing.updatedAt = Math.max(existing.updatedAt || 0, stat.mtimeMs);
988
+ existing.turnCount = parsed.turnCount || existing.turnCount || 0;
989
+ existing.totalTokens = parsed.totalTokens || existing.totalTokens || 0;
990
+ existing.journalPath = filePath;
991
+ records.set(sessionId, existing);
992
+ }));
993
+ } catch {}
994
+ return [...records.values()].filter(s => !filterCwd || s.cwd === filterCwd);
995
+ }
996
+
997
+ export async function parseGeminiMessages(filePath) {
998
+ const messages = [];
999
+ let pendingAssistant = null;
1000
+ const flushAssistant = () => {
1001
+ if (pendingAssistant?.content) messages.push(pendingAssistant);
1002
+ pendingAssistant = null;
1003
+ };
1004
+ try {
1005
+ const stream = fsSync.createReadStream(filePath);
1006
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1007
+ for await (const line of rl) {
1008
+ let d; try { d = JSON.parse(line); } catch { continue; }
1009
+ if (d.type === 'user' && d.content?.trim()) {
1010
+ flushAssistant();
1011
+ messages.push({ role: 'user', type: 'text', content: d.content.trim(), ts: d.ts });
1012
+ } else if (d.type === 'assistant_delta' && d.data) {
1013
+ if (!pendingAssistant) pendingAssistant = { role: 'assistant', type: 'text', content: '', ts: d.ts };
1014
+ pendingAssistant.content += d.data;
1015
+ } else if (d.type === 'tool') {
1016
+ flushAssistant();
1017
+ messages.push({ role: 'tool', type: 'tool_use', name: d.name || 'tool', input: d.input || {}, ts: d.ts });
1018
+ } else if (d.type === 'result' && d.usage) {
1019
+ flushAssistant();
1020
+ messages.push({ type: 'token_usage', usage: d.usage, ts: d.ts });
1021
+ }
1022
+ }
1023
+ } catch {}
1024
+ flushAssistant();
1025
+ return messages;
1026
+ }
1027
+
1028
+ async function findGeminiJournal(sessionId, geminiHome) {
1029
+ const file = geminiJournalPath(sessionId, geminiHome);
1030
+ try { await fs.access(file); return file; } catch { return null; }
1031
+ }
1032
+
1033
+ async function loadGeminiContext(sessionId, geminiHome) {
1034
+ const file = await findGeminiJournal(sessionId, geminiHome);
1035
+ if (!file) return { note: 'This Gemini conversation predates UI journaling; its native transcript is not exposed by agy.' };
1036
+ const messages = await parseGeminiMessages(file);
1037
+ const usages = messages.filter(m => m.type === 'token_usage').map(m => m.usage || {});
1038
+ const total = { input_tokens: 0, output_tokens: 0, reasoning_tokens: 0, cache_read_input_tokens: 0, total_tokens: 0 };
1039
+ for (const u of usages) for (const key of Object.keys(total)) total[key] += Number(u[key]) || 0;
1040
+ return {
1041
+ turnCount: messages.filter(m => m.role === 'user').length,
1042
+ toolCallCount: messages.filter(m => m.type === 'tool_use').length,
1043
+ lastTurn: usages.at(-1) || null,
1044
+ sessionTotals: total,
1045
+ };
1046
+ }
1047
+
893
1048
  // ─── Unified ──────────────────────────────────────────────────────────────────
894
1049
 
1050
+ // ─── Cursor Agent ────────────────────────────────────────────────────────────
1051
+ // Cursor's native transcript format is private. Sessions created through this
1052
+ // UI have a durable, UI-owned journal at ~/.cursor/claude-simple-ui/sessions.
1053
+ function cursorUiSessionsRoot(cursorHome) {
1054
+ return path.join(cursorHome || path.join(os.homedir(), '.cursor'), 'claude-simple-ui', 'sessions');
1055
+ }
1056
+
1057
+ function cursorJournalPath(sessionId, cursorHome) {
1058
+ return path.join(cursorUiSessionsRoot(cursorHome), `${sessionId}.jsonl`);
1059
+ }
1060
+
1061
+ async function findCursorJournal(sessionId, cursorHome) {
1062
+ const file = cursorJournalPath(sessionId, cursorHome);
1063
+ try { await fs.access(file); return file; } catch { return null; }
1064
+ }
1065
+
1066
+ export async function scanCursorSessions(cursorHome, filterCwd = null) {
1067
+ const home = cursorHome || path.join(os.homedir(), '.cursor');
1068
+ const sessions = [];
1069
+ try {
1070
+ const entries = await fs.readdir(cursorUiSessionsRoot(home), { withFileTypes: true });
1071
+ await Promise.all(entries.filter(e => e.isFile() && e.name.endsWith('.jsonl')).map(async e => {
1072
+ const filePath = path.join(cursorUiSessionsRoot(home), e.name);
1073
+ const [stat, parsed] = await Promise.all([fs.stat(filePath), readGeminiJournalMeta(filePath)]);
1074
+ sessions.push({
1075
+ sessionId: e.name.slice(0, -6), agent: 'cursor', cwd: parsed.meta?.cwd || null,
1076
+ configDir: home, display: parsed.firstUser || e.name.slice(0, 8), updatedAt: stat.mtimeMs,
1077
+ turnCount: parsed.turnCount || 0, totalTokens: parsed.totalTokens || 0,
1078
+ hasMemory: false, journalPath: filePath,
1079
+ });
1080
+ }));
1081
+ } catch {}
1082
+ return sessions.filter(s => !filterCwd || s.cwd === filterCwd);
1083
+ }
1084
+
1085
+ async function loadCursorContext(sessionId, cursorHome) {
1086
+ const file = await findCursorJournal(sessionId, cursorHome);
1087
+ if (!file) return { note: 'This Cursor conversation predates UI journaling; its native transcript is not exposed by cursor-agent.' };
1088
+ const messages = await parseGeminiMessages(file);
1089
+ const usages = messages.filter(m => m.type === 'token_usage').map(m => m.usage || {});
1090
+ const total = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_write_input_tokens: 0 };
1091
+ for (const u of usages) for (const key of Object.keys(total)) total[key] += Number(u[key]) || 0;
1092
+ return { turnCount: messages.filter(m => m.role === 'user').length, toolCallCount: messages.filter(m => m.type === 'tool_use').length, lastTurn: usages.at(-1) || null, sessionTotals: total };
1093
+ }
1094
+
895
1095
  /** Normalize cwd so /private/tmp and /tmp (etc.) group together on macOS. */
896
1096
  export function normalizeCwd(cwd) {
897
1097
  if (!cwd) return null;
@@ -924,15 +1124,18 @@ function publicSession(s) {
924
1124
  // keep for deep search only when needed — not sent if stripped below
925
1125
  _filePath: s.filePath || null,
926
1126
  _sessionDir: s.sessionDir || null,
1127
+ _journalPath: s.journalPath || null,
927
1128
  };
928
1129
  }
929
1130
 
930
- export async function scanAllSessions({ claudeConfigDirs, codexHome, grokHome, filterCwd, agents }) {
931
- const want = new Set(agents || ['claude', 'codex', 'grok']);
1131
+ export async function scanAllSessions({ claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome, filterCwd, agents }) {
1132
+ const want = new Set(agents || ['claude', 'codex', 'grok', 'gemini', 'cursor']);
932
1133
  const tasks = [];
933
1134
  if (want.has('claude')) tasks.push(scanClaudeSessions(claudeConfigDirs, filterCwd));
934
1135
  if (want.has('codex')) tasks.push(scanCodexSessions(codexHome, filterCwd));
935
1136
  if (want.has('grok')) tasks.push(scanGrokSessions(grokHome, filterCwd));
1137
+ if (want.has('gemini')) tasks.push(scanGeminiSessions(geminiHome, filterCwd));
1138
+ if (want.has('cursor')) tasks.push(scanCursorSessions(cursorHome, filterCwd));
936
1139
 
937
1140
  const results = await Promise.all(tasks);
938
1141
  const seen = new Set();
@@ -949,7 +1152,7 @@ export async function scanAllSessions({ claudeConfigDirs, codexHome, grokHome, f
949
1152
 
950
1153
  /** Strip internal-only fields before sending to clients. */
951
1154
  export function toClientSession(s) {
952
- const { _filePath, _sessionDir, ...rest } = s;
1155
+ const { _filePath, _sessionDir, _journalPath, ...rest } = s;
953
1156
  return rest;
954
1157
  }
955
1158
 
@@ -1060,6 +1263,14 @@ async function peekSessionText(s, query) {
1060
1263
  },
1061
1264
  });
1062
1265
  }
1266
+ if (s.agent === 'gemini') {
1267
+ const file = s._journalPath || await findGeminiJournal(s.sessionId, s.configDir);
1268
+ if (!file) return null;
1269
+ return await scanFileForQuery(file, query, {
1270
+ maxLines: 160,
1271
+ extract: d => (d.type === 'user' ? d.content : d.type === 'assistant_delta' ? d.data : null),
1272
+ });
1273
+ }
1063
1274
  // claude
1064
1275
  const file = await findClaudeSessionFile(s.sessionId, null);
1065
1276
  // findClaudeSessionFile needs config dirs — skip if we don't have path
@@ -1165,8 +1376,25 @@ export function groupByProject(sessions) {
1165
1376
  * Load session history. Returns { messages, context } or null.
1166
1377
  * `context` carries window / token usage when the agent exposes it (Grok signals.json).
1167
1378
  */
1168
- export async function loadSessionMessages(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
1379
+ export async function loadSessionMessages(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }) {
1169
1380
  const a = agent || 'claude';
1381
+ if (a === 'cursor') {
1382
+ const file = await findCursorJournal(sessionId, cursorHome);
1383
+ if (!file) return null;
1384
+ return { messages: await parseGeminiMessages(file), context: await loadCursorContext(sessionId, cursorHome), agent: 'cursor' };
1385
+ }
1386
+ if (a === 'gemini') {
1387
+ const file = await findGeminiJournal(sessionId, geminiHome);
1388
+ if (!file) {
1389
+ const native = (await scanGeminiSessions(geminiHome)).some(s => s.sessionId === sessionId);
1390
+ if (!native) return null;
1391
+ // agy's native transcript is a private protobuf SQLite format. The
1392
+ // conversation can still be resumed normally; UI-created turns gain a
1393
+ // readable journal from that point onward.
1394
+ return { messages: [], context: await loadGeminiContext(sessionId, geminiHome), agent: 'gemini' };
1395
+ }
1396
+ return { messages: await parseGeminiMessages(file), context: await loadGeminiContext(sessionId, geminiHome), agent: 'gemini' };
1397
+ }
1170
1398
  if (a === 'codex') {
1171
1399
  const file = await findCodexSessionFile(sessionId, codexHome);
1172
1400
  if (!file) return null;
@@ -1183,7 +1411,8 @@ export async function loadSessionMessages(sessionId, agent, { claudeConfigDirs,
1183
1411
  return { messages: await parseClaudeMessages(file), context: null, agent: 'claude' };
1184
1412
  }
1185
1413
 
1186
- export async function loadSessionMemory(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
1414
+ export async function loadSessionMemory(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }) {
1415
+ if (agent === 'gemini' || agent === 'cursor') return { agent, files: [] };
1187
1416
  if (agent === 'grok') {
1188
1417
  const dir = await findGrokSessionDir(sessionId, grokHome);
1189
1418
  if (!dir) return null;
@@ -1248,6 +1477,7 @@ export async function readSessionMemoryFile(sessionId, agent, fileId, opts) {
1248
1477
  const id = String(fileId || '').replaceAll('\\', '/');
1249
1478
  if (!id || id.startsWith('/') || id.split('/').some(p => p === '..' || !p)) return null;
1250
1479
  let root;
1480
+ if (agent === 'gemini' || agent === 'cursor') return null;
1251
1481
  if (agent === 'codex') root = path.join(opts.codexHome || path.join(os.homedir(), '.codex'), 'memories');
1252
1482
  else if (agent === 'grok') {
1253
1483
  const dir = await findGrokSessionDir(sessionId, opts.grokHome);
@@ -1272,10 +1502,10 @@ export async function readSessionMemoryFile(sessionId, agent, fileId, opts) {
1272
1502
  }
1273
1503
 
1274
1504
  /** Return bounded raw JSONL for a particular agent session, for Meta Agent analysis. */
1275
- export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }, { offset = 0, maxLines = 500 } = {}) {
1505
+ export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset = 0, maxLines = 500 } = {}) {
1276
1506
  if (!agent) {
1277
- for (const candidate of ['claude', 'codex', 'grok']) {
1278
- const found = await loadSessionJsonl(sessionId, candidate, { claudeConfigDirs, codexHome, grokHome }, { offset, maxLines });
1507
+ for (const candidate of ['claude', 'codex', 'grok', 'gemini', 'cursor']) {
1508
+ const found = await loadSessionJsonl(sessionId, candidate, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { offset, maxLines });
1279
1509
  if (found) return found;
1280
1510
  }
1281
1511
  return null;
@@ -1283,6 +1513,8 @@ export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, cod
1283
1513
  const a = (agent || 'claude').toLowerCase();
1284
1514
  let file;
1285
1515
  if (a === 'codex') file = await findCodexSessionFile(sessionId, codexHome);
1516
+ else if (a === 'cursor') file = await findCursorJournal(sessionId, cursorHome);
1517
+ else if (a === 'gemini') file = await findGeminiJournal(sessionId, geminiHome);
1286
1518
  else if (a === 'grok') {
1287
1519
  const dir = await findGrokSessionDir(sessionId, grokHome);
1288
1520
  file = dir ? path.join(dir, 'chat_history.jsonl') : null;
@@ -1304,11 +1536,20 @@ export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, cod
1304
1536
  return { agent: a, fileName: path.basename(file), offset: start, lines, nextOffset: start + lines.length, truncated: hasMore, size: stat?.size || 0 };
1305
1537
  }
1306
1538
 
1307
- export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codexHome, grokHome }) {
1539
+ export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }) {
1308
1540
  const a = (agent || 'claude').toLowerCase();
1309
1541
  let cwd = null;
1310
1542
  let display = null;
1311
- if (a === 'grok') {
1543
+ if (a === 'cursor') {
1544
+ const found = (await scanCursorSessions(cursorHome)).find(s => s.sessionId === sessionId);
1545
+ cwd = found?.cwd || null;
1546
+ display = found?.display || null;
1547
+ } else if (a === 'gemini') {
1548
+ const sessions = await scanGeminiSessions(geminiHome);
1549
+ const found = sessions.find(s => s.sessionId === sessionId);
1550
+ cwd = found?.cwd || null;
1551
+ display = found?.display || null;
1552
+ } else if (a === 'grok') {
1312
1553
  const dir = await findGrokSessionDir(sessionId, grokHome);
1313
1554
  if (dir) {
1314
1555
  try {
@@ -1339,13 +1580,13 @@ export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codex
1339
1580
  return { cwd, display };
1340
1581
  }
1341
1582
 
1342
- export async function convertSession(sourceSessionId, sourceAgent, targetAgent, { claudeConfigDirs, codexHome, grokHome }) {
1583
+ export async function convertSession(sourceSessionId, sourceAgent, targetAgent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }) {
1343
1584
  // 1. Get meta and messages from source session
1344
- const meta = await getSessionMeta(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome });
1585
+ const meta = await getSessionMeta(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome });
1345
1586
  const cwd = meta.cwd || null;
1346
1587
  const display = meta.display || 'Converted Session';
1347
1588
 
1348
- const bundle = await loadSessionMessages(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome });
1589
+ const bundle = await loadSessionMessages(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome });
1349
1590
  const messages = bundle.messages || [];
1350
1591
 
1351
1592
  // 2. Generate target session ID
@@ -1353,6 +1594,9 @@ export async function convertSession(sourceSessionId, sourceAgent, targetAgent,
1353
1594
 
1354
1595
  // 3. Serialize messages to the target agent
1355
1596
  const ta = targetAgent.toLowerCase();
1597
+ if (ta === 'gemini' || ta === 'cursor') {
1598
+ throw new Error(`${ta === 'cursor' ? 'Cursor' : 'Gemini'} sessions use native conversation storage and cannot be cloned safely. Start a new session instead.`);
1599
+ }
1356
1600
 
1357
1601
  if (ta === 'claude') {
1358
1602
  const configDir = claudeConfigDirs?.[0] || path.join(os.homedir(), '.claude');
package/src/app.js CHANGED
@@ -14,6 +14,8 @@ import { fileURLToPath } from 'url';
14
14
  import { runClaude } from './agents/claude.js';
15
15
  import { runCodex } from './agents/codex.js';
16
16
  import { runGrok } from './agents/grok.js';
17
+ import { runGemini } from './agents/gemini.js';
18
+ import { runCursor } from './agents/cursor.js';
17
19
  import {
18
20
  scanAllSessions,
19
21
  loadSessionMessages,
@@ -32,6 +34,7 @@ import { createTextBatcher } from './agents/stream-batch.js';
32
34
  import { normalizeCommand, mapAgentEmit } from './runtime/protocol.js';
33
35
  import { createOutbox } from './runtime/outbox.js';
34
36
  import { resolveApproval, cancelApprovalsForSession } from './runtime/approvals.js';
37
+ import { createMcpHttpHandler, createMcpService } from './mcp.js';
35
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
39
  const rootDir = path.join(__dirname, '..');
37
40
 
@@ -44,12 +47,16 @@ const SESSION_META_FILE = process.env.SESSION_META_FILE || path.join(rootDir,
44
47
  const CLAUDE_CLI_PATH = process.env.CLAUDE_CLI_PATH || 'claude';
45
48
  const CODEX_CLI_PATH = process.env.CODEX_CLI_PATH || 'codex';
46
49
  const GROK_CLI_PATH = process.env.GROK_CLI_PATH || 'grok';
50
+ const GEMINI_CLI_PATH = process.env.GEMINI_CLI_PATH || process.env.AGY_PATH || 'agy';
51
+ const CURSOR_AGENT_PATH = process.env.CURSOR_AGENT_PATH || 'cursor-agent';
47
52
  const CODEX_HOME = (process.env.CODEX_HOME || path.join(os.homedir(), '.codex')).replace(/^~/, os.homedir());
48
53
  const GROK_HOME = (process.env.GROK_HOME || path.join(os.homedir(), '.grok')).replace(/^~/, os.homedir());
54
+ const GEMINI_HOME = (process.env.GEMINI_HOME || path.join(os.homedir(), '.gemini')).replace(/^~/, os.homedir());
55
+ const CURSOR_HOME = (process.env.CURSOR_HOME || path.join(os.homedir(), '.cursor')).replace(/^~/, os.homedir());
49
56
  const ENABLED_AGENTS = (() => {
50
- const raw = (process.env.AGENTS || 'claude,codex,grok')
57
+ const raw = (process.env.AGENTS || 'claude,codex,grok,gemini,cursor')
51
58
  .split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
52
- return [...new Set(raw.length ? raw : ['claude', 'codex', 'grok'])];
59
+ return [...new Set(raw.length ? raw : ['claude', 'codex', 'grok', 'gemini', 'cursor'])];
53
60
  })();
54
61
  const CLAUDE_CONFIG_DIRS = (() => {
55
62
  const dirs = (process.env.CLAUDE_CONFIG_DIRS || '')
@@ -186,6 +193,14 @@ function makeToken(user) {
186
193
  return jwt.sign({ userId: user.id, username: user.username }, jwtSecret(), { expiresIn: '7d' });
187
194
  }
188
195
 
196
+ function makeMcpToken(user, expiresInSeconds) {
197
+ return jwt.sign(
198
+ { userId: user.userId ?? user.id, username: user.username, scope: 'mcp', tokenId: crypto.randomUUID() },
199
+ jwtSecret(),
200
+ { expiresIn: expiresInSeconds },
201
+ );
202
+ }
203
+
189
204
  function verifyToken(token) {
190
205
  try { return jwt.verify(token, jwtSecret()); }
191
206
  catch { return null; }
@@ -306,11 +321,37 @@ async function listSessions(filterCwd = null, agentFilter = null) {
306
321
  claudeConfigDirs: CLAUDE_CONFIG_DIRS,
307
322
  codexHome: CODEX_HOME,
308
323
  grokHome: GROK_HOME,
324
+ geminiHome: GEMINI_HOME,
325
+ cursorHome: CURSOR_HOME,
309
326
  filterCwd,
310
327
  agents,
311
328
  });
312
329
  }
313
330
 
331
+ // The HTTP UI endpoint and MCP both need the same agent-aware lookup rule:
332
+ // prefer the stated agent, then fall back only when no preference was given.
333
+ async function readSessionBundle(sessionId, preferredAgent = null) {
334
+ const opts = {
335
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS,
336
+ codexHome: CODEX_HOME,
337
+ grokHome: GROK_HOME,
338
+ geminiHome: GEMINI_HOME,
339
+ cursorHome: CURSOR_HOME,
340
+ };
341
+ const order = preferredAgent
342
+ ? [preferredAgent]
343
+ : [...ENABLED_AGENTS];
344
+ for (const agent of order) {
345
+ const bundle = await loadSessionMessages(sessionId, agent, opts);
346
+ if (bundle) {
347
+ return Array.isArray(bundle)
348
+ ? { messages: bundle, context: null, agent }
349
+ : { ...bundle, agent: bundle.agent || agent, messages: bundle.messages || [] };
350
+ }
351
+ }
352
+ return null;
353
+ }
354
+
314
355
  /**
315
356
  * Wrap agent emit → map/filter → outbox (delta coalesce + batch frames).
316
357
  * Agents may still call send() with UI-shaped events; unknown noise is dropped.
@@ -337,6 +378,10 @@ async function dispatchAgent(agent, command, options, send) {
337
378
  await runCodex(command, options, send, { ...ctx, cliPath: CODEX_CLI_PATH });
338
379
  } else if (a === 'grok') {
339
380
  await runGrok(command, options, send, { ...ctx, cliPath: GROK_CLI_PATH });
381
+ } else if (a === 'gemini') {
382
+ await runGemini(command, options, send, { ...ctx, cliPath: GEMINI_CLI_PATH, geminiHome: GEMINI_HOME });
383
+ } else if (a === 'cursor') {
384
+ await runCursor(command, options, send, { ...ctx, cliPath: CURSOR_AGENT_PATH, cursorHome: CURSOR_HOME });
340
385
  } else {
341
386
  await runClaude(command, options, send, { ...ctx, cliPath: CLAUDE_CLI_PATH });
342
387
  }
@@ -496,6 +541,55 @@ export function createApp() {
496
541
 
497
542
  app.use(express.json({ limit: '10mb' }));
498
543
 
544
+ // MCP uses a separate short-lived bearer token. Normal UI JWTs deliberately
545
+ // cannot call /mcp, so a browser token leaked through a URL cannot become an
546
+ // agent-control credential.
547
+ const mcpService = createMcpService({
548
+ listSessions,
549
+ readSession: readSessionBundle,
550
+ dispatchAgent,
551
+ abortSession,
552
+ isSessionBusy,
553
+ enabledAgents: ENABLED_AGENTS,
554
+ toClientSession,
555
+ });
556
+ const authenticateMcp = (req) => {
557
+ const header = req.headers.authorization || '';
558
+ const token = /^Bearer\s+(.+)$/i.exec(header)?.[1];
559
+ if (!token) return false;
560
+ const claims = verifyToken(token);
561
+ if (claims?.scope === 'mcp') return true;
562
+
563
+ // Optional headless deployment token. It is accepted only with an explicit
564
+ // expiry, keeping the same expiration guarantee as issued JWTs.
565
+ const configured = process.env.MCP_TOKEN;
566
+ const expiresRaw = process.env.MCP_TOKEN_EXPIRES_AT;
567
+ if (!configured || !expiresRaw || token.length !== configured.length) return false;
568
+ let expiresAt = Number(expiresRaw);
569
+ if (!Number.isFinite(expiresAt)) expiresAt = Date.parse(expiresRaw);
570
+ if (expiresAt > 0 && expiresAt < 10_000_000_000) expiresAt *= 1000;
571
+ return Number.isFinite(expiresAt) && Date.now() < expiresAt
572
+ && crypto.timingSafeEqual(Buffer.from(token), Buffer.from(configured));
573
+ };
574
+ app.post('/mcp', createMcpHttpHandler({ service: mcpService, authenticate: authenticateMcp }));
575
+
576
+ // Issue a scoped credential from an already-authenticated UI/API client.
577
+ // The raw secret is returned once and never written to the credential store.
578
+ app.post('/api/mcp/tokens', authMiddleware, (req, res) => {
579
+ try {
580
+ const requested = req.body?.expiresInSeconds ?? 86_400;
581
+ if (!Number.isInteger(requested) || requested < 60 || requested > 2_592_000) {
582
+ return res.status(400).json({ error: 'expiresInSeconds must be an integer between 60 and 2592000' });
583
+ }
584
+ const token = makeMcpToken(req.user, requested);
585
+ const decoded = jwt.decode(token);
586
+ res.setHeader('Cache-Control', 'no-store');
587
+ res.status(201).json({ token, tokenType: 'Bearer', expiresAt: Number(decoded?.exp || 0) * 1000, expiresInSeconds: requested });
588
+ } catch (e) {
589
+ res.status(500).json({ error: e.message });
590
+ }
591
+ });
592
+
499
593
  const distDir = path.join(rootDir, 'dist');
500
594
  const publicDir = path.join(rootDir, 'public');
501
595
  // Only ship the built bundle in production. Local `node server.js` serves
@@ -716,6 +810,10 @@ export function createApp() {
716
810
  claudeConfigDirs: CLAUDE_CONFIG_DIRS,
717
811
  codexHome: CODEX_HOME,
718
812
  grokHome: GROK_HOME,
813
+ geminiHome: GEMINI_HOME,
814
+ geminiCliPath: GEMINI_CLI_PATH,
815
+ cursorHome: CURSOR_HOME,
816
+ cursorAgentPath: CURSOR_AGENT_PATH,
719
817
  agents: ENABLED_AGENTS,
720
818
  });
721
819
  res.json({
@@ -725,11 +823,15 @@ export function createApp() {
725
823
  claude: CLAUDE_CONFIG_DIRS,
726
824
  codex: CODEX_HOME,
727
825
  grok: GROK_HOME,
826
+ gemini: GEMINI_HOME,
827
+ cursor: CURSOR_HOME,
728
828
  },
729
829
  paths: {
730
830
  claude: CLAUDE_CLI_PATH,
731
831
  codex: CODEX_CLI_PATH,
732
832
  grok: GROK_CLI_PATH,
833
+ gemini: GEMINI_CLI_PATH,
834
+ cursor: CURSOR_AGENT_PATH,
733
835
  },
734
836
  });
735
837
  } catch (e) {
@@ -833,8 +935,10 @@ export function createApp() {
833
935
  claudeConfigDirs: CLAUDE_CONFIG_DIRS,
834
936
  codexHome: CODEX_HOME,
835
937
  grokHome: GROK_HOME,
938
+ geminiHome: GEMINI_HOME,
939
+ cursorHome: CURSOR_HOME,
836
940
  };
837
- const order = [...new Set([preferred, ...ENABLED_AGENTS, 'claude', 'codex', 'grok'].filter(Boolean))];
941
+ const order = [...new Set([preferred, ...ENABLED_AGENTS, 'claude', 'codex', 'grok', 'gemini', 'cursor'].filter(Boolean))];
838
942
  let data = null;
839
943
  for (const a of order) {
840
944
  data = await loadSessionUsage(sessionId, a, opts);
@@ -925,6 +1029,8 @@ export function createApp() {
925
1029
  claudeConfigDirs: CLAUDE_CONFIG_DIRS,
926
1030
  codexHome: CODEX_HOME,
927
1031
  grokHome: GROK_HOME,
1032
+ geminiHome: GEMINI_HOME,
1033
+ cursorHome: CURSOR_HOME,
928
1034
  };
929
1035
  // Prefer stated agent; only fall back if not found (avoids scanning all agents every time)
930
1036
  const order = preferred
@@ -975,11 +1081,13 @@ export function createApp() {
975
1081
  claudeConfigDirs: CLAUDE_CONFIG_DIRS,
976
1082
  codexHome: CODEX_HOME,
977
1083
  grokHome: GROK_HOME,
1084
+ geminiHome: GEMINI_HOME,
1085
+ cursorHome: CURSOR_HOME,
978
1086
  };
979
1087
 
980
1088
  let detectedSourceAgent = sourceAgent;
981
1089
  if (!detectedSourceAgent) {
982
- const order = [...new Set([...ENABLED_AGENTS, 'claude', 'codex', 'grok'])];
1090
+ const order = [...new Set([...ENABLED_AGENTS, 'claude', 'codex', 'grok', 'gemini', 'cursor'])];
983
1091
  for (const a of order) {
984
1092
  const bundle = await loadSessionMessages(sessionId, a, opts);
985
1093
  if (bundle) {
@@ -1009,12 +1117,14 @@ export function createApp() {
1009
1117
  claudeConfigDirs: CLAUDE_CONFIG_DIRS,
1010
1118
  codexHome: CODEX_HOME,
1011
1119
  grokHome: GROK_HOME,
1120
+ geminiHome: GEMINI_HOME,
1121
+ cursorHome: CURSOR_HOME,
1012
1122
  };
1013
1123
  // A selected session already knows its agent. Do not fall back to another
1014
1124
  // agent's account-level memory (especially Codex) when that session has none.
1015
1125
  const order = preferred
1016
1126
  ? [preferred]
1017
- : [...new Set([...ENABLED_AGENTS, 'claude', 'codex', 'grok'])];
1127
+ : [...new Set([...ENABLED_AGENTS, 'claude', 'codex', 'grok', 'gemini', 'cursor'])];
1018
1128
  let memory = null;
1019
1129
  for (const a of order) {
1020
1130
  memory = await loadSessionMemory(sessionId, a, opts);
@@ -1033,7 +1143,7 @@ export function createApp() {
1033
1143
  const file = typeof req.query.file === 'string' ? req.query.file : '';
1034
1144
  if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
1035
1145
  const row = await readSessionMemoryFile(sessionId, agent, file, {
1036
- claudeConfigDirs: CLAUDE_CONFIG_DIRS, codexHome: CODEX_HOME, grokHome: GROK_HOME,
1146
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS, codexHome: CODEX_HOME, grokHome: GROK_HOME, geminiHome: GEMINI_HOME, cursorHome: CURSOR_HOME,
1037
1147
  });
1038
1148
  if (!row) return res.status(404).json({ error: 'Memory file not found' });
1039
1149
  res.setHeader('Cache-Control', 'private, max-age=30');
@@ -1076,6 +1186,8 @@ export function createApp() {
1076
1186
  claudeConfigDirs: () => CLAUDE_CONFIG_DIRS,
1077
1187
  codexHome: () => CODEX_HOME,
1078
1188
  grokHome: () => GROK_HOME,
1189
+ geminiHome: () => GEMINI_HOME,
1190
+ cursorHome: () => CURSOR_HOME,
1079
1191
  });
1080
1192
 
1081
1193
  /** Write SSE event */
@@ -1485,8 +1597,18 @@ export function createApp() {
1485
1597
  }
1486
1598
  agentSend(data);
1487
1599
  };
1488
- await dispatchAgent(agent, cmd.command || '', options, runSend);
1489
- runOutbox.flush();
1600
+ try {
1601
+ await dispatchAgent(agent, cmd.command || '', options, runSend);
1602
+ } finally {
1603
+ // `complete` is a UI-facing agent event and is not a reliable
1604
+ // scheduling boundary (an adapter may emit it early or more than
1605
+ // once). The hub advances its Grok FIFO only after dispatchAgent has
1606
+ // actually returned from the ACP request.
1607
+ if (agent === 'grok') {
1608
+ runOutbox.send({ type: 'hub-turn-finished', agent, sessionId: runSessionId });
1609
+ }
1610
+ runOutbox.flush();
1611
+ }
1490
1612
  return;
1491
1613
  }
1492
1614
 
@@ -1656,7 +1778,7 @@ export async function init() {
1656
1778
  await bootstrapEnvUser();
1657
1779
  await syncConfigDirs();
1658
1780
  console.log(`[agents] enabled: ${ENABLED_AGENTS.join(', ')}`);
1659
- console.log(`[agents] claude=${CLAUDE_CLI_PATH} codex=${CODEX_CLI_PATH} grok=${GROK_CLI_PATH}`);
1781
+ console.log(`[agents] claude=${CLAUDE_CLI_PATH} codex=${CODEX_CLI_PATH} grok=${GROK_CLI_PATH} gemini=${GEMINI_CLI_PATH}`);
1660
1782
  }
1661
1783
 
1662
1784
  // (session helpers live in ./agents/sessions.js)