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.
- package/README.md +13 -5
- package/dist/app.0.2.9.min.js +1084 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.9.min.css +1598 -0
- package/package.json +11 -3
- package/public/chat.js +14 -11
- package/public/files.js +5 -5
- package/public/index.html +4 -5
- package/public/lib.js +1 -1
- package/public/router.js +36 -8
- package/public/settings.js +54 -2
- package/public/shell/boot.js +7 -7
- package/public/shell/layout.js +2 -3
- package/public/shell/session-list.js +22 -16
- package/public/shell/templates.js +26 -24
- package/public/shell/util.js +3 -1
- package/public/shell.js +54 -13
- package/public/state.js +50 -4
- package/public/sw.js +6 -2
- package/public/ui.min.css +2 -0
- package/public/vendor/highlight-dark.min.css +10 -0
- package/public/vendor/highlight.min.js +1262 -0
- package/public/vendor/jszip.min.js +13 -0
- package/public/vendor/mammoth.browser.min.js +21 -0
- package/public/vendor/marked.min.js +6 -0
- package/public/vendor/mini-react.min.js +4 -0
- package/public/vendor/pdf.min.js +22 -0
- package/public/vendor/pdf.worker.min.js +22 -0
- package/public/vendor/xlsx.full.min.js +22 -0
- package/src/agents/cursor.js +189 -0
- package/src/agents/gemini.js +182 -0
- package/src/agents/grok.js +300 -37
- package/src/agents/meta-agent.js +9 -5
- package/src/agents/models.js +114 -3
- package/src/agents/sessions.js +258 -14
- package/src/app.js +131 -9
- package/src/mcp.js +275 -0
- package/dist/app.0.2.8.min.js +0 -1080
- package/dist/style.0.2.8.min.css +0 -1598
package/src/agents/sessions.js
CHANGED
|
@@ -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 === '
|
|
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
|
-
|
|
1489
|
-
|
|
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)
|