@cookielab.io/klovi 1.1.0 → 2.0.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.
package/dist/server.js
CHANGED
|
@@ -3,15 +3,78 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
4
|
|
|
5
5
|
// index.ts
|
|
6
|
-
import { existsSync as
|
|
7
|
-
import { dirname, join as
|
|
6
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
7
|
+
import { dirname, join as join8 } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
|
|
10
10
|
// src/server/cli.ts
|
|
11
11
|
import { readSync } from "node:fs";
|
|
12
12
|
|
|
13
|
-
// src/server/
|
|
14
|
-
|
|
13
|
+
// src/server/api/projects.ts
|
|
14
|
+
async function handleProjects(registry) {
|
|
15
|
+
const projects = await registry.discoverAllProjects();
|
|
16
|
+
return Response.json({ projects });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/server/iso-time.ts
|
|
20
|
+
function sortByIsoDesc(items, select) {
|
|
21
|
+
items.sort((a, b) => select(b).localeCompare(select(a)));
|
|
22
|
+
}
|
|
23
|
+
function maxIso(values) {
|
|
24
|
+
let latest = "";
|
|
25
|
+
for (const value of values) {
|
|
26
|
+
if (value > latest)
|
|
27
|
+
latest = value;
|
|
28
|
+
}
|
|
29
|
+
return latest;
|
|
30
|
+
}
|
|
31
|
+
function epochMsToIso(epochMs) {
|
|
32
|
+
return new Date(epochMs).toISOString();
|
|
33
|
+
}
|
|
34
|
+
function epochSecondsToIso(epochSeconds) {
|
|
35
|
+
return new Date(epochSeconds * 1000).toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/server/api/search.ts
|
|
39
|
+
async function handleSearchSessions(registry) {
|
|
40
|
+
const projects = await registry.discoverAllProjects();
|
|
41
|
+
const allSessions = [];
|
|
42
|
+
for (const project of projects) {
|
|
43
|
+
const sessions = await registry.listAllSessions(project);
|
|
44
|
+
const projectName = projectNameFromPath(project.name);
|
|
45
|
+
for (const session of sessions) {
|
|
46
|
+
allSessions.push({
|
|
47
|
+
...session,
|
|
48
|
+
encodedPath: project.encodedPath,
|
|
49
|
+
projectName
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
sortByIsoDesc(allSessions, (session) => session.timestamp);
|
|
54
|
+
return Response.json({ sessions: allSessions });
|
|
55
|
+
}
|
|
56
|
+
function projectNameFromPath(fullPath) {
|
|
57
|
+
const parts = fullPath.split("/").filter(Boolean);
|
|
58
|
+
return parts.slice(-2).join("/");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/shared/session-id.ts
|
|
62
|
+
var SESSION_ID_SEPARATOR = "::";
|
|
63
|
+
function encodeSessionId(pluginId, rawSessionId) {
|
|
64
|
+
return `${pluginId}${SESSION_ID_SEPARATOR}${rawSessionId}`;
|
|
65
|
+
}
|
|
66
|
+
function parseSessionId(sessionId) {
|
|
67
|
+
const separatorIdx = sessionId.indexOf(SESSION_ID_SEPARATOR);
|
|
68
|
+
if (separatorIdx === -1) {
|
|
69
|
+
return { pluginId: null, rawSessionId: sessionId };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
pluginId: sessionId.slice(0, separatorIdx),
|
|
73
|
+
rawSessionId: sessionId.slice(separatorIdx + SESSION_ID_SEPARATOR.length)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/server/plugins/claude-code/parser.ts
|
|
15
78
|
import { join as join2 } from "node:path";
|
|
16
79
|
|
|
17
80
|
// src/server/config.ts
|
|
@@ -27,8 +90,19 @@ function setClaudeCodeDir(dir) {
|
|
|
27
90
|
function getProjectsDir() {
|
|
28
91
|
return join(claudeCodeDir, "projects");
|
|
29
92
|
}
|
|
30
|
-
|
|
31
|
-
|
|
93
|
+
var codexCliDir = join(homedir(), ".codex");
|
|
94
|
+
function getCodexCliDir() {
|
|
95
|
+
return codexCliDir;
|
|
96
|
+
}
|
|
97
|
+
function setCodexCliDir(dir) {
|
|
98
|
+
codexCliDir = dir;
|
|
99
|
+
}
|
|
100
|
+
var openCodeDir = join(homedir(), ".local", "share", "opencode");
|
|
101
|
+
function getOpenCodeDir() {
|
|
102
|
+
return openCodeDir;
|
|
103
|
+
}
|
|
104
|
+
function setOpenCodeDir(dir) {
|
|
105
|
+
openCodeDir = dir;
|
|
32
106
|
}
|
|
33
107
|
|
|
34
108
|
// src/server/parser/command-message.ts
|
|
@@ -55,200 +129,35 @@ function parseCommandMessage(text) {
|
|
|
55
129
|
return { name, args };
|
|
56
130
|
}
|
|
57
131
|
|
|
58
|
-
// src/server/
|
|
59
|
-
|
|
60
|
-
const entries = await readdir(getProjectsDir(), { withFileTypes: true });
|
|
61
|
-
const projects = [];
|
|
62
|
-
for (const entry of entries) {
|
|
63
|
-
if (!entry.isDirectory())
|
|
64
|
-
continue;
|
|
65
|
-
const projectDir = join2(getProjectsDir(), entry.name);
|
|
66
|
-
const sessionFiles = (await readdir(projectDir)).filter((f) => f.endsWith(".jsonl"));
|
|
67
|
-
if (sessionFiles.length === 0)
|
|
68
|
-
continue;
|
|
69
|
-
let lastActivity = "";
|
|
70
|
-
let fullPath = "";
|
|
71
|
-
for (const sf of sessionFiles) {
|
|
72
|
-
const fileStat = await stat(join2(projectDir, sf));
|
|
73
|
-
const mtime = fileStat.mtime.toISOString();
|
|
74
|
-
if (mtime > lastActivity)
|
|
75
|
-
lastActivity = mtime;
|
|
76
|
-
if (!fullPath) {
|
|
77
|
-
fullPath = await extractCwd(join2(projectDir, sf));
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const name = fullPath || decodeEncodedPath(entry.name);
|
|
81
|
-
projects.push({
|
|
82
|
-
encodedPath: entry.name,
|
|
83
|
-
name,
|
|
84
|
-
fullPath: fullPath || decodeEncodedPath(entry.name),
|
|
85
|
-
sessionCount: sessionFiles.length,
|
|
86
|
-
lastActivity
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
projects.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
90
|
-
return projects;
|
|
91
|
-
}
|
|
92
|
-
var PLAN_PREFIX = "Implement the following plan";
|
|
93
|
-
async function listSessions(encodedPath) {
|
|
94
|
-
const projectDir = join2(getProjectsDir(), encodedPath);
|
|
95
|
-
const files = (await readdir(projectDir)).filter((f) => f.endsWith(".jsonl"));
|
|
96
|
-
const sessions = [];
|
|
97
|
-
for (const file of files) {
|
|
98
|
-
const filePath = join2(projectDir, file);
|
|
99
|
-
const sessionId = file.replace(".jsonl", "");
|
|
100
|
-
const meta = await extractSessionMeta(filePath);
|
|
101
|
-
if (meta)
|
|
102
|
-
sessions.push({ sessionId, ...meta });
|
|
103
|
-
}
|
|
104
|
-
classifySessionTypes(sessions);
|
|
105
|
-
sessions.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
106
|
-
return sessions;
|
|
107
|
-
}
|
|
108
|
-
function classifySessionTypes(sessions) {
|
|
109
|
-
const implSlugs = new Set;
|
|
110
|
-
for (const session of sessions) {
|
|
111
|
-
if (session.firstMessage.startsWith(PLAN_PREFIX)) {
|
|
112
|
-
session.sessionType = "implementation";
|
|
113
|
-
if (session.slug)
|
|
114
|
-
implSlugs.add(session.slug);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
for (const session of sessions) {
|
|
118
|
-
if (!session.sessionType && session.slug && implSlugs.has(session.slug)) {
|
|
119
|
-
session.sessionType = "plan";
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
async function extractCwd(filePath) {
|
|
124
|
-
const text = await readFile(filePath, "utf-8");
|
|
125
|
-
const lines = text.split(`
|
|
126
|
-
`);
|
|
127
|
-
for (const line of lines.slice(0, 20)) {
|
|
128
|
-
if (!line.trim())
|
|
129
|
-
continue;
|
|
130
|
-
try {
|
|
131
|
-
const obj = JSON.parse(line);
|
|
132
|
-
if (obj.cwd)
|
|
133
|
-
return obj.cwd;
|
|
134
|
-
} catch {}
|
|
135
|
-
}
|
|
136
|
-
return "";
|
|
137
|
-
}
|
|
138
|
-
function extractTextFromContent(content) {
|
|
139
|
-
if (typeof content === "string")
|
|
140
|
-
return content;
|
|
141
|
-
if (Array.isArray(content)) {
|
|
142
|
-
for (const block of content) {
|
|
143
|
-
if (block.type === "text" && "text" in block)
|
|
144
|
-
return block.text;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return "";
|
|
148
|
-
}
|
|
149
|
-
function isInternalMessage(text) {
|
|
150
|
-
return text.startsWith("<local-command") || text.startsWith("<command-name") || /^\[.+\]$/.test(text.trim());
|
|
151
|
-
}
|
|
152
|
-
function isMetaComplete(meta) {
|
|
153
|
-
return !!(meta.timestamp && meta.slug && meta.firstMessage && meta.model && meta.gitBranch);
|
|
154
|
-
}
|
|
155
|
-
function processMetaLine(obj, meta) {
|
|
156
|
-
if (obj.timestamp && !meta.timestamp)
|
|
157
|
-
meta.timestamp = obj.timestamp;
|
|
158
|
-
if (obj.slug && !meta.slug)
|
|
159
|
-
meta.slug = obj.slug;
|
|
160
|
-
if (obj.gitBranch && !meta.gitBranch)
|
|
161
|
-
meta.gitBranch = obj.gitBranch;
|
|
162
|
-
if (obj.message?.model && !meta.model)
|
|
163
|
-
meta.model = obj.message.model;
|
|
164
|
-
if (!meta.firstMessage && obj.type === "user" && !obj.isMeta && obj.message) {
|
|
165
|
-
const raw = extractTextFromContent(obj.message.content);
|
|
166
|
-
if (raw && !isInternalMessage(raw)) {
|
|
167
|
-
meta.firstMessage = cleanCommandMessage(raw).slice(0, 200);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
async function extractSessionMeta(filePath) {
|
|
172
|
-
const text = await readFile(filePath, "utf-8");
|
|
132
|
+
// src/server/plugins/shared/jsonl-utils.ts
|
|
133
|
+
function iterateJsonl(text, visitor, options = {}) {
|
|
173
134
|
const lines = text.split(`
|
|
174
135
|
`);
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
136
|
+
const start = Math.max(0, options.startAt ?? 0);
|
|
137
|
+
const end = options.maxLines === undefined ? lines.length : Math.min(lines.length, start + options.maxLines);
|
|
138
|
+
for (let i = start;i < end; i++) {
|
|
139
|
+
const line = lines[i];
|
|
140
|
+
if (!line || !line.trim())
|
|
178
141
|
continue;
|
|
179
142
|
try {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (!meta.timestamp || !meta.firstMessage)
|
|
187
|
-
return null;
|
|
188
|
-
return {
|
|
189
|
-
timestamp: meta.timestamp,
|
|
190
|
-
slug: meta.slug || "unknown",
|
|
191
|
-
firstMessage: meta.firstMessage,
|
|
192
|
-
model: meta.model || "unknown",
|
|
193
|
-
gitBranch: meta.gitBranch || ""
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
function projectNameFromPath(fullPath) {
|
|
197
|
-
const parts = fullPath.split("/").filter(Boolean);
|
|
198
|
-
return parts.slice(-2).join("/");
|
|
199
|
-
}
|
|
200
|
-
function aggregateSessions(projects, sessionsByProject) {
|
|
201
|
-
const results = [];
|
|
202
|
-
for (const project of projects) {
|
|
203
|
-
const sessions = sessionsByProject.get(project.encodedPath) ?? [];
|
|
204
|
-
const projectName = projectNameFromPath(project.name);
|
|
205
|
-
for (const session of sessions) {
|
|
206
|
-
results.push({
|
|
207
|
-
...session,
|
|
208
|
-
encodedPath: project.encodedPath,
|
|
209
|
-
projectName
|
|
143
|
+
const parsed = JSON.parse(line);
|
|
144
|
+
const shouldContinue = visitor({
|
|
145
|
+
parsed,
|
|
146
|
+
line,
|
|
147
|
+
lineIndex: i,
|
|
148
|
+
lineNumber: i + 1
|
|
210
149
|
});
|
|
150
|
+
if (shouldContinue === false)
|
|
151
|
+
break;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
options.onMalformed?.(line, i + 1, error);
|
|
211
154
|
}
|
|
212
155
|
}
|
|
213
|
-
results.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
214
|
-
return results;
|
|
215
|
-
}
|
|
216
|
-
async function listAllSessions() {
|
|
217
|
-
const projects = await discoverProjects();
|
|
218
|
-
const sessionsByProject = new Map;
|
|
219
|
-
for (const project of projects) {
|
|
220
|
-
const sessions = await listSessions(project.encodedPath);
|
|
221
|
-
sessionsByProject.set(project.encodedPath, sessions);
|
|
222
|
-
}
|
|
223
|
-
return aggregateSessions(projects, sessionsByProject);
|
|
224
|
-
}
|
|
225
|
-
function decodeEncodedPath(encoded) {
|
|
226
|
-
if (encoded.startsWith("-")) {
|
|
227
|
-
const withSlashes = encoded.slice(1).replace(/-/g, "/");
|
|
228
|
-
if (process.platform === "win32" && /^[A-Za-z]\//.test(withSlashes)) {
|
|
229
|
-
return `${withSlashes[0]}:${withSlashes.slice(1)}`;
|
|
230
|
-
}
|
|
231
|
-
return `/${withSlashes}`;
|
|
232
|
-
}
|
|
233
|
-
return encoded.replace(/-/g, "/");
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// src/server/api/projects.ts
|
|
237
|
-
async function handleProjects() {
|
|
238
|
-
const projects = await discoverProjects();
|
|
239
|
-
return Response.json({ projects });
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// src/server/api/search.ts
|
|
243
|
-
async function handleSearchSessions() {
|
|
244
|
-
const sessions = await listAllSessions();
|
|
245
|
-
return Response.json({ sessions });
|
|
246
156
|
}
|
|
247
157
|
|
|
248
|
-
// src/server/parser
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const { rawLines, parseErrors } = await readJsonlLines(join3(getProjectsDir(), encodedPath, `${sessionId}.jsonl`));
|
|
158
|
+
// src/server/plugins/claude-code/parser.ts
|
|
159
|
+
async function loadClaudeSession(nativeId, sessionId) {
|
|
160
|
+
const { rawLines, parseErrors } = await readJsonlLines(join2(getProjectsDir(), nativeId, `${sessionId}.jsonl`));
|
|
252
161
|
const subAgentMap = extractSubAgentMap(rawLines);
|
|
253
162
|
const slug = extractSlug(rawLines);
|
|
254
163
|
const turns = buildTurns(rawLines, parseErrors);
|
|
@@ -267,19 +176,20 @@ async function parseSession(sessionId, encodedPath) {
|
|
|
267
176
|
return {
|
|
268
177
|
session: {
|
|
269
178
|
sessionId,
|
|
270
|
-
project:
|
|
271
|
-
turns
|
|
179
|
+
project: nativeId,
|
|
180
|
+
turns,
|
|
181
|
+
pluginId: "claude-code"
|
|
272
182
|
},
|
|
273
183
|
slug
|
|
274
184
|
};
|
|
275
185
|
}
|
|
276
186
|
async function parseSubAgentSession(sessionId, encodedPath, agentId) {
|
|
277
|
-
const filePath =
|
|
187
|
+
const filePath = join2(getProjectsDir(), encodedPath, sessionId, "subagents", `agent-${agentId}.jsonl`);
|
|
278
188
|
let parsed;
|
|
279
189
|
try {
|
|
280
190
|
parsed = await readJsonlLines(filePath);
|
|
281
191
|
} catch {
|
|
282
|
-
return { sessionId, project: encodedPath, turns: [] };
|
|
192
|
+
return { sessionId, project: encodedPath, turns: [], pluginId: "claude-code" };
|
|
283
193
|
}
|
|
284
194
|
const subAgentMap = extractSubAgentMap(parsed.rawLines);
|
|
285
195
|
const turns = buildTurns(parsed.rawLines, parsed.parseErrors);
|
|
@@ -295,7 +205,7 @@ async function parseSubAgentSession(sessionId, encodedPath, agentId) {
|
|
|
295
205
|
}
|
|
296
206
|
}
|
|
297
207
|
}
|
|
298
|
-
return { sessionId, project: encodedPath, turns };
|
|
208
|
+
return { sessionId, project: encodedPath, turns, pluginId: "claude-code" };
|
|
299
209
|
}
|
|
300
210
|
var AGENT_ID_RE = /agentId:\s*(\w+)/;
|
|
301
211
|
function extractFromProgressEvent(line, map) {
|
|
@@ -339,11 +249,11 @@ function extractSlug(lines) {
|
|
|
339
249
|
}
|
|
340
250
|
return;
|
|
341
251
|
}
|
|
342
|
-
var
|
|
252
|
+
var PLAN_PREFIX = "Implement the following plan";
|
|
343
253
|
var STATUS_RE = /^\[.+\]$/;
|
|
344
254
|
function findPlanSessionId(turns, slug, sessions, currentSessionId) {
|
|
345
255
|
const planTurn = turns.find((t) => t.kind === "user" && !STATUS_RE.test(t.text.trim()));
|
|
346
|
-
if (!planTurn || !planTurn.text.startsWith(
|
|
256
|
+
if (!planTurn || !planTurn.text.startsWith(PLAN_PREFIX))
|
|
347
257
|
return;
|
|
348
258
|
if (!slug)
|
|
349
259
|
return;
|
|
@@ -353,34 +263,29 @@ function findPlanSessionId(turns, slug, sessions, currentSessionId) {
|
|
|
353
263
|
function findImplSessionId(slug, sessions, currentSessionId) {
|
|
354
264
|
if (!slug)
|
|
355
265
|
return;
|
|
356
|
-
const match = sessions.find((s) => s.slug === slug && s.sessionId !== currentSessionId && s.firstMessage.startsWith(
|
|
266
|
+
const match = sessions.find((s) => s.slug === slug && s.sessionId !== currentSessionId && s.firstMessage.startsWith(PLAN_PREFIX));
|
|
357
267
|
return match?.sessionId;
|
|
358
268
|
}
|
|
359
269
|
async function readJsonlLines(filePath) {
|
|
360
|
-
const { readFile
|
|
361
|
-
const text = await
|
|
362
|
-
const lines = text.split(`
|
|
363
|
-
`);
|
|
270
|
+
const { readFile } = await import("node:fs/promises");
|
|
271
|
+
const text = await readFile(filePath, "utf-8");
|
|
364
272
|
const rawLines = [];
|
|
365
273
|
const parseErrors = [];
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
try {
|
|
371
|
-
rawLines.push(JSON.parse(line));
|
|
372
|
-
} catch (err) {
|
|
274
|
+
iterateJsonl(text, ({ parsed }) => {
|
|
275
|
+
rawLines.push(parsed);
|
|
276
|
+
}, {
|
|
277
|
+
onMalformed: (line, lineNumber, error) => {
|
|
373
278
|
parseErrors.push({
|
|
374
279
|
kind: "parse_error",
|
|
375
|
-
uuid: `parse-error-line-${
|
|
280
|
+
uuid: `parse-error-line-${lineNumber}`,
|
|
376
281
|
timestamp: rawLines[rawLines.length - 1]?.timestamp ?? "",
|
|
377
|
-
lineNumber
|
|
282
|
+
lineNumber,
|
|
378
283
|
rawLine: line.length > 500 ? `${line.slice(0, 500)}… (truncated)` : line,
|
|
379
284
|
errorType: "json_parse",
|
|
380
|
-
errorDetails:
|
|
285
|
+
errorDetails: error instanceof Error ? error.message : undefined
|
|
381
286
|
});
|
|
382
287
|
}
|
|
383
|
-
}
|
|
288
|
+
});
|
|
384
289
|
return { rawLines, parseErrors };
|
|
385
290
|
}
|
|
386
291
|
function isDisplayableLine(l) {
|
|
@@ -619,152 +524,1445 @@ function extractToolResult(tr) {
|
|
|
619
524
|
}
|
|
620
525
|
|
|
621
526
|
// src/server/api/session.ts
|
|
622
|
-
async function handleSession(sessionId, encodedPath) {
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
527
|
+
async function handleSession(sessionId, encodedPath, registry) {
|
|
528
|
+
const parsed = parseSessionId(sessionId);
|
|
529
|
+
if (!parsed.pluginId) {
|
|
530
|
+
return Response.json({ error: "sessionId must include plugin prefix (e.g. claude-code::<id>)" }, { status: 400 });
|
|
531
|
+
}
|
|
532
|
+
if (!parsed.rawSessionId) {
|
|
533
|
+
return Response.json({ error: "sessionId must include a raw session id after <plugin>::" }, { status: 400 });
|
|
534
|
+
}
|
|
535
|
+
const pluginId = parsed.pluginId;
|
|
536
|
+
const rawSessionId = parsed.rawSessionId;
|
|
537
|
+
const projects = await registry.discoverAllProjects();
|
|
538
|
+
const project = projects.find((p) => p.encodedPath === encodedPath);
|
|
539
|
+
if (!project)
|
|
540
|
+
return Response.json({ error: "Project not found" }, { status: 404 });
|
|
541
|
+
const source = project.sources.find((s) => s.pluginId === pluginId);
|
|
542
|
+
if (!source)
|
|
543
|
+
return Response.json({ error: "Plugin source not found" }, { status: 404 });
|
|
544
|
+
const plugin = registry.getPlugin(pluginId);
|
|
545
|
+
if (pluginId === "claude-code") {
|
|
546
|
+
const [{ session: session2, slug }, sessions] = await Promise.all([
|
|
547
|
+
loadClaudeSession(source.nativeId, rawSessionId),
|
|
548
|
+
plugin.listSessions(source.nativeId)
|
|
549
|
+
]);
|
|
550
|
+
const planRawId = findPlanSessionId(session2.turns, slug, sessions, rawSessionId);
|
|
551
|
+
const implRawId = findImplSessionId(slug, sessions, rawSessionId);
|
|
552
|
+
session2.sessionId = encodeSessionId(pluginId, rawSessionId);
|
|
553
|
+
session2.planSessionId = planRawId ? encodeSessionId(pluginId, planRawId) : undefined;
|
|
554
|
+
session2.implSessionId = implRawId ? encodeSessionId(pluginId, implRawId) : undefined;
|
|
555
|
+
return Response.json({ session: session2 });
|
|
556
|
+
}
|
|
557
|
+
const session = await plugin.loadSession(source.nativeId, rawSessionId);
|
|
558
|
+
session.sessionId = encodeSessionId(pluginId, rawSessionId);
|
|
559
|
+
session.pluginId = pluginId;
|
|
629
560
|
return Response.json({ session });
|
|
630
561
|
}
|
|
631
562
|
|
|
632
563
|
// src/server/api/sessions.ts
|
|
633
|
-
async function handleSessions(encodedPath) {
|
|
634
|
-
const
|
|
564
|
+
async function handleSessions(encodedPath, registry) {
|
|
565
|
+
const projects = await registry.discoverAllProjects();
|
|
566
|
+
const project = projects.find((p) => p.encodedPath === encodedPath);
|
|
567
|
+
if (!project)
|
|
568
|
+
return Response.json({ sessions: [] });
|
|
569
|
+
const sessions = await registry.listAllSessions(project);
|
|
635
570
|
return Response.json({ sessions });
|
|
636
571
|
}
|
|
637
572
|
|
|
638
|
-
// src/server/
|
|
639
|
-
import {
|
|
573
|
+
// src/server/registry.ts
|
|
574
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
575
|
+
import { join as join7 } from "node:path";
|
|
576
|
+
|
|
577
|
+
// src/server/plugin-registry.ts
|
|
578
|
+
function encodeResolvedPath(resolvedPath) {
|
|
579
|
+
if (resolvedPath.startsWith("/")) {
|
|
580
|
+
return resolvedPath.replace(/\//g, "-");
|
|
581
|
+
}
|
|
582
|
+
return resolvedPath.replace(/[/\\:]/g, "-");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
class PluginRegistry {
|
|
586
|
+
plugins = new Map;
|
|
587
|
+
register(plugin) {
|
|
588
|
+
this.plugins.set(plugin.id, plugin);
|
|
589
|
+
}
|
|
590
|
+
getPlugin(id) {
|
|
591
|
+
const plugin = this.plugins.get(id);
|
|
592
|
+
if (!plugin)
|
|
593
|
+
throw new Error(`Plugin not found: ${id}`);
|
|
594
|
+
return plugin;
|
|
595
|
+
}
|
|
596
|
+
getAllPlugins() {
|
|
597
|
+
return [...this.plugins.values()];
|
|
598
|
+
}
|
|
599
|
+
async discoverAllProjects() {
|
|
600
|
+
const allProjects = [];
|
|
601
|
+
for (const plugin of this.plugins.values()) {
|
|
602
|
+
try {
|
|
603
|
+
const projects = await plugin.discoverProjects();
|
|
604
|
+
allProjects.push(...projects);
|
|
605
|
+
} catch {}
|
|
606
|
+
}
|
|
607
|
+
const byPath = new Map;
|
|
608
|
+
for (const p of allProjects) {
|
|
609
|
+
const existing = byPath.get(p.resolvedPath) ?? [];
|
|
610
|
+
existing.push(p);
|
|
611
|
+
byPath.set(p.resolvedPath, existing);
|
|
612
|
+
}
|
|
613
|
+
const merged = [];
|
|
614
|
+
for (const [resolvedPath, projects] of byPath) {
|
|
615
|
+
const totalSessions = projects.reduce((sum, p) => sum + p.sessionCount, 0);
|
|
616
|
+
const latestActivity = maxIso(projects.map((p) => p.lastActivity));
|
|
617
|
+
merged.push({
|
|
618
|
+
encodedPath: encodeResolvedPath(resolvedPath),
|
|
619
|
+
resolvedPath,
|
|
620
|
+
name: resolvedPath,
|
|
621
|
+
fullPath: resolvedPath,
|
|
622
|
+
sessionCount: totalSessions,
|
|
623
|
+
lastActivity: latestActivity,
|
|
624
|
+
sources: projects.map((p) => ({
|
|
625
|
+
pluginId: p.pluginId,
|
|
626
|
+
nativeId: p.nativeId
|
|
627
|
+
}))
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
sortByIsoDesc(merged, (project) => project.lastActivity);
|
|
631
|
+
return merged;
|
|
632
|
+
}
|
|
633
|
+
async listAllSessions(project) {
|
|
634
|
+
const allSessions = [];
|
|
635
|
+
for (const source of project.sources) {
|
|
636
|
+
const plugin = this.plugins.get(source.pluginId);
|
|
637
|
+
if (!plugin)
|
|
638
|
+
continue;
|
|
639
|
+
try {
|
|
640
|
+
const sessions = await plugin.listSessions(source.nativeId);
|
|
641
|
+
allSessions.push(...sessions.map((session) => ({
|
|
642
|
+
...session,
|
|
643
|
+
sessionId: encodeSessionId(source.pluginId, session.sessionId),
|
|
644
|
+
pluginId: source.pluginId
|
|
645
|
+
})));
|
|
646
|
+
} catch {}
|
|
647
|
+
}
|
|
648
|
+
sortByIsoDesc(allSessions, (session) => session.timestamp);
|
|
649
|
+
return allSessions;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/server/plugins/claude-code/discovery.ts
|
|
640
654
|
import { join as join4 } from "node:path";
|
|
641
|
-
|
|
655
|
+
|
|
656
|
+
// src/server/plugins/shared/discovery-utils.ts
|
|
657
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
658
|
+
import { join as join3 } from "node:path";
|
|
659
|
+
async function readDirEntriesSafe(dir) {
|
|
642
660
|
try {
|
|
643
|
-
|
|
644
|
-
const data = JSON.parse(text);
|
|
645
|
-
if (data.version !== 2)
|
|
646
|
-
return null;
|
|
647
|
-
return data;
|
|
661
|
+
return await readdir(dir, { withFileTypes: true });
|
|
648
662
|
} catch {
|
|
649
|
-
return
|
|
663
|
+
return [];
|
|
650
664
|
}
|
|
651
665
|
}
|
|
652
|
-
async function
|
|
666
|
+
async function listFilesBySuffix(dir, suffix) {
|
|
653
667
|
try {
|
|
654
|
-
const
|
|
655
|
-
return
|
|
668
|
+
const files = await readdir(dir);
|
|
669
|
+
return files.filter((file) => file.endsWith(suffix));
|
|
656
670
|
} catch {
|
|
657
|
-
return
|
|
671
|
+
return [];
|
|
658
672
|
}
|
|
659
673
|
}
|
|
660
|
-
function
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const weekAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7);
|
|
669
|
-
const weekAgoStr = `${weekAgo.getFullYear()}-${String(weekAgo.getMonth() + 1).padStart(2, "0")}-${String(weekAgo.getDate()).padStart(2, "0")}`;
|
|
670
|
-
return dateStr >= weekAgoStr;
|
|
671
|
-
}
|
|
672
|
-
async function countRecentSessions() {
|
|
673
|
-
const today = todayDateString();
|
|
674
|
-
const projectsDir = getProjectsDir();
|
|
675
|
-
let todaySessions = 0;
|
|
676
|
-
let thisWeekSessions = 0;
|
|
677
|
-
try {
|
|
678
|
-
const projectDirs = await readdir2(projectsDir, { withFileTypes: true });
|
|
679
|
-
for (const dir of projectDirs) {
|
|
680
|
-
if (!dir.isDirectory())
|
|
681
|
-
continue;
|
|
682
|
-
const projectPath = join4(projectsDir, dir.name);
|
|
683
|
-
const files = (await readdir2(projectPath)).filter((f) => f.endsWith(".jsonl"));
|
|
684
|
-
for (const file of files) {
|
|
685
|
-
const fileStat = await stat2(join4(projectPath, file));
|
|
686
|
-
const mtimeDate = toDateString(fileStat.mtime);
|
|
687
|
-
if (mtimeDate === today)
|
|
688
|
-
todaySessions++;
|
|
689
|
-
if (isWithinLastWeek(mtimeDate))
|
|
690
|
-
thisWeekSessions++;
|
|
691
|
-
}
|
|
674
|
+
async function listFilesWithMtime(dir, suffix) {
|
|
675
|
+
const files = await listFilesBySuffix(dir, suffix);
|
|
676
|
+
const results = [];
|
|
677
|
+
for (const fileName of files) {
|
|
678
|
+
const fileStat = await stat(join3(dir, fileName)).catch(() => null);
|
|
679
|
+
const mtime = fileStat?.mtime.toISOString();
|
|
680
|
+
if (mtime) {
|
|
681
|
+
results.push({ fileName, mtime });
|
|
692
682
|
}
|
|
693
|
-
}
|
|
694
|
-
|
|
683
|
+
}
|
|
684
|
+
sortByIsoDesc(results, (item) => item.mtime);
|
|
685
|
+
return results;
|
|
695
686
|
}
|
|
696
|
-
function
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const models = {};
|
|
705
|
-
for (const [model, usage] of Object.entries(cache.modelUsage)) {
|
|
706
|
-
inputTokens += usage.inputTokens;
|
|
707
|
-
outputTokens += usage.outputTokens;
|
|
708
|
-
cacheReadTokens += usage.cacheReadInputTokens;
|
|
709
|
-
cacheCreationTokens += usage.cacheCreationInputTokens;
|
|
710
|
-
models[model] = {
|
|
711
|
-
inputTokens: usage.inputTokens,
|
|
712
|
-
outputTokens: usage.outputTokens,
|
|
713
|
-
cacheReadTokens: usage.cacheReadInputTokens,
|
|
714
|
-
cacheCreationTokens: usage.cacheCreationInputTokens
|
|
715
|
-
};
|
|
687
|
+
async function readTextPrefix(filePath, maxBytes) {
|
|
688
|
+
const handle = await open(filePath, "r");
|
|
689
|
+
try {
|
|
690
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
691
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
692
|
+
return buffer.toString("utf-8", 0, bytesRead);
|
|
693
|
+
} finally {
|
|
694
|
+
await handle.close();
|
|
716
695
|
}
|
|
717
|
-
const toolCalls = cache.dailyActivity.reduce((sum, d) => sum + d.toolCallCount, 0);
|
|
718
|
-
return {
|
|
719
|
-
projects,
|
|
720
|
-
sessions: cache.totalSessions,
|
|
721
|
-
messages: cache.totalMessages,
|
|
722
|
-
todaySessions: todayEntry?.sessionCount ?? 0,
|
|
723
|
-
thisWeekSessions: thisWeekEntries.reduce((sum, d) => sum + d.sessionCount, 0),
|
|
724
|
-
inputTokens,
|
|
725
|
-
outputTokens,
|
|
726
|
-
cacheReadTokens,
|
|
727
|
-
cacheCreationTokens,
|
|
728
|
-
toolCalls,
|
|
729
|
-
models
|
|
730
|
-
};
|
|
731
696
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
messages: 0,
|
|
742
|
-
todaySessions: 0,
|
|
743
|
-
thisWeekSessions: 0,
|
|
744
|
-
inputTokens: 0,
|
|
745
|
-
outputTokens: 0,
|
|
746
|
-
cacheReadTokens: 0,
|
|
747
|
-
cacheCreationTokens: 0,
|
|
748
|
-
toolCalls: 0,
|
|
749
|
-
models: {}
|
|
750
|
-
};
|
|
751
|
-
return {
|
|
752
|
-
...base,
|
|
753
|
-
todaySessions: recent.todaySessions,
|
|
754
|
-
thisWeekSessions: recent.thisWeekSessions
|
|
755
|
-
};
|
|
697
|
+
function decodeEncodedPath(encoded) {
|
|
698
|
+
if (encoded.startsWith("-")) {
|
|
699
|
+
const withSlashes = encoded.slice(1).replace(/-/g, "/");
|
|
700
|
+
if (process.platform === "win32" && /^[A-Za-z]\//.test(withSlashes)) {
|
|
701
|
+
return `${withSlashes[0]}:${withSlashes.slice(1)}`;
|
|
702
|
+
}
|
|
703
|
+
return `/${withSlashes}`;
|
|
704
|
+
}
|
|
705
|
+
return encoded.replace(/-/g, "/");
|
|
756
706
|
}
|
|
757
707
|
|
|
758
|
-
// src/server/
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
708
|
+
// src/server/plugins/claude-code/discovery.ts
|
|
709
|
+
var CWD_SCAN_BYTES = 64 * 1024;
|
|
710
|
+
var SESSION_META_SCAN_BYTES = 1024 * 1024;
|
|
711
|
+
async function inspectProjectSessions(projectDir, sessionFiles) {
|
|
712
|
+
const lastActivity = sessionFiles[0]?.mtime || "";
|
|
713
|
+
let resolvedPath = "";
|
|
714
|
+
for (const sessionFile of sessionFiles) {
|
|
715
|
+
const filePath = join4(projectDir, sessionFile.fileName);
|
|
716
|
+
if (!resolvedPath) {
|
|
717
|
+
resolvedPath = await extractCwd(filePath);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return { lastActivity, resolvedPath };
|
|
762
721
|
}
|
|
763
|
-
|
|
722
|
+
async function discoverClaudeProjects() {
|
|
723
|
+
const projectsDir = getProjectsDir();
|
|
724
|
+
const entries = await readDirEntriesSafe(projectsDir);
|
|
725
|
+
const projects = [];
|
|
726
|
+
for (const entry of entries) {
|
|
727
|
+
if (!entry.isDirectory())
|
|
728
|
+
continue;
|
|
729
|
+
const projectDir = join4(projectsDir, entry.name);
|
|
730
|
+
const sessionFiles = await listFilesWithMtime(projectDir, ".jsonl");
|
|
731
|
+
if (sessionFiles.length === 0)
|
|
732
|
+
continue;
|
|
733
|
+
const projectInfo = await inspectProjectSessions(projectDir, sessionFiles);
|
|
734
|
+
const resolvedPath = projectInfo.resolvedPath || decodeEncodedPath(entry.name);
|
|
735
|
+
projects.push({
|
|
736
|
+
pluginId: "claude-code",
|
|
737
|
+
nativeId: entry.name,
|
|
738
|
+
resolvedPath,
|
|
739
|
+
displayName: resolvedPath,
|
|
740
|
+
sessionCount: sessionFiles.length,
|
|
741
|
+
lastActivity: projectInfo.lastActivity
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
sortByIsoDesc(projects, (project) => project.lastActivity);
|
|
745
|
+
return projects;
|
|
746
|
+
}
|
|
747
|
+
var PLAN_PREFIX2 = "Implement the following plan";
|
|
748
|
+
async function listClaudeSessions(nativeId) {
|
|
749
|
+
const projectDir = join4(getProjectsDir(), nativeId);
|
|
750
|
+
const files = await listFilesBySuffix(projectDir, ".jsonl");
|
|
751
|
+
const sessions = [];
|
|
752
|
+
for (const file of files) {
|
|
753
|
+
const filePath = join4(projectDir, file);
|
|
754
|
+
const sessionId = file.replace(".jsonl", "");
|
|
755
|
+
const meta = await extractSessionMeta(filePath);
|
|
756
|
+
if (meta)
|
|
757
|
+
sessions.push({ sessionId, pluginId: "claude-code", ...meta });
|
|
758
|
+
}
|
|
759
|
+
classifySessionTypes(sessions);
|
|
760
|
+
sortByIsoDesc(sessions, (session) => session.timestamp);
|
|
761
|
+
return sessions;
|
|
762
|
+
}
|
|
763
|
+
function classifySessionTypes(sessions) {
|
|
764
|
+
const implSlugs = new Set;
|
|
765
|
+
for (const session of sessions) {
|
|
766
|
+
if (session.firstMessage.startsWith(PLAN_PREFIX2)) {
|
|
767
|
+
session.sessionType = "implementation";
|
|
768
|
+
if (session.slug)
|
|
769
|
+
implSlugs.add(session.slug);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
for (const session of sessions) {
|
|
773
|
+
if (!session.sessionType && session.slug && implSlugs.has(session.slug)) {
|
|
774
|
+
session.sessionType = "plan";
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
async function extractCwd(filePath) {
|
|
779
|
+
try {
|
|
780
|
+
const text = await readTextPrefix(filePath, CWD_SCAN_BYTES);
|
|
781
|
+
let cwd = "";
|
|
782
|
+
iterateJsonl(text, ({ parsed }) => {
|
|
783
|
+
const obj = parsed;
|
|
784
|
+
if (obj.cwd) {
|
|
785
|
+
cwd = obj.cwd;
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
}, { maxLines: 20 });
|
|
789
|
+
return cwd;
|
|
790
|
+
} catch {
|
|
791
|
+
return "";
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function extractTextFromContent(content) {
|
|
795
|
+
if (typeof content === "string")
|
|
796
|
+
return content;
|
|
797
|
+
if (Array.isArray(content)) {
|
|
798
|
+
for (const block of content) {
|
|
799
|
+
if (block.type === "text" && "text" in block)
|
|
800
|
+
return block.text;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return "";
|
|
804
|
+
}
|
|
805
|
+
function isInternalMessage(text) {
|
|
806
|
+
return text.startsWith("<local-command") || text.startsWith("<command-name") || /^\[.+\]$/.test(text.trim());
|
|
807
|
+
}
|
|
808
|
+
function isMetaComplete(meta) {
|
|
809
|
+
return !!(meta.timestamp && meta.slug && meta.firstMessage && meta.model && meta.gitBranch);
|
|
810
|
+
}
|
|
811
|
+
function processMetaLine(obj, meta) {
|
|
812
|
+
if (obj.timestamp && !meta.timestamp)
|
|
813
|
+
meta.timestamp = obj.timestamp;
|
|
814
|
+
if (obj.slug && !meta.slug)
|
|
815
|
+
meta.slug = obj.slug;
|
|
816
|
+
if (obj.gitBranch && !meta.gitBranch)
|
|
817
|
+
meta.gitBranch = obj.gitBranch;
|
|
818
|
+
if (obj.message?.model && !meta.model)
|
|
819
|
+
meta.model = obj.message.model;
|
|
820
|
+
if (!meta.firstMessage && obj.type === "user" && !obj.isMeta && obj.message) {
|
|
821
|
+
const raw = extractTextFromContent(obj.message.content);
|
|
822
|
+
if (raw && !isInternalMessage(raw)) {
|
|
823
|
+
meta.firstMessage = cleanCommandMessage(raw).slice(0, 200);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
async function extractSessionMeta(filePath) {
|
|
828
|
+
try {
|
|
829
|
+
const text = await readTextPrefix(filePath, SESSION_META_SCAN_BYTES);
|
|
830
|
+
const meta = {
|
|
831
|
+
timestamp: "",
|
|
832
|
+
slug: "",
|
|
833
|
+
firstMessage: "",
|
|
834
|
+
model: "",
|
|
835
|
+
gitBranch: ""
|
|
836
|
+
};
|
|
837
|
+
iterateJsonl(text, ({ parsed }) => {
|
|
838
|
+
const obj = parsed;
|
|
839
|
+
processMetaLine(obj, meta);
|
|
840
|
+
if (isMetaComplete(meta))
|
|
841
|
+
return false;
|
|
842
|
+
}, {
|
|
843
|
+
maxLines: 50,
|
|
844
|
+
onMalformed: () => {}
|
|
845
|
+
});
|
|
846
|
+
if (!meta.timestamp || !meta.firstMessage)
|
|
847
|
+
return null;
|
|
848
|
+
return {
|
|
849
|
+
timestamp: meta.timestamp,
|
|
850
|
+
slug: meta.slug || "unknown",
|
|
851
|
+
firstMessage: meta.firstMessage,
|
|
852
|
+
model: meta.model || "unknown",
|
|
853
|
+
gitBranch: meta.gitBranch || ""
|
|
854
|
+
};
|
|
855
|
+
} catch {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/server/plugins/claude-code/index.ts
|
|
861
|
+
var claudeCodePlugin = {
|
|
862
|
+
id: "claude-code",
|
|
863
|
+
displayName: "Claude Code",
|
|
864
|
+
getDefaultDataDir: () => getClaudeCodeDir(),
|
|
865
|
+
discoverProjects: () => discoverClaudeProjects(),
|
|
866
|
+
listSessions: (nativeId) => listClaudeSessions(nativeId),
|
|
867
|
+
loadSession: (nativeId, sessionId) => loadClaudeSession(nativeId, sessionId).then((r) => r.session),
|
|
868
|
+
getResumeCommand: (sessionId) => `claude --resume ${sessionId}`
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
// src/server/plugins/codex-cli/discovery.ts
|
|
872
|
+
import { readFile } from "node:fs/promises";
|
|
873
|
+
|
|
874
|
+
// src/server/plugins/codex-cli/session-index.ts
|
|
875
|
+
import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
|
|
876
|
+
import { join as join5 } from "node:path";
|
|
877
|
+
var FIRST_LINE_SCAN_BYTES = 64 * 1024;
|
|
878
|
+
function isCodexSessionMeta(obj) {
|
|
879
|
+
return typeof obj === "object" && obj !== null && "uuid" in obj && "cwd" in obj && "timestamps" in obj && typeof obj.uuid === "string" && typeof obj.cwd === "string";
|
|
880
|
+
}
|
|
881
|
+
function isNewFormatMeta(obj) {
|
|
882
|
+
return typeof obj === "object" && obj !== null && "type" in obj && obj.type === "session_meta" && "payload" in obj && typeof obj.payload === "object" && obj.payload !== null && typeof obj.payload.id === "string" && typeof obj.payload.cwd === "string";
|
|
883
|
+
}
|
|
884
|
+
function normalizeSessionMeta(parsed, fileMtimeEpoch) {
|
|
885
|
+
if (isCodexSessionMeta(parsed))
|
|
886
|
+
return parsed;
|
|
887
|
+
if (isNewFormatMeta(parsed)) {
|
|
888
|
+
const { payload } = parsed;
|
|
889
|
+
const isoTimestamp = payload.timestamp || parsed.timestamp;
|
|
890
|
+
const createdEpoch = isoTimestamp ? new Date(isoTimestamp).getTime() / 1000 : 0;
|
|
891
|
+
const updatedEpoch = fileMtimeEpoch ?? createdEpoch;
|
|
892
|
+
return {
|
|
893
|
+
uuid: payload.id,
|
|
894
|
+
cwd: payload.cwd,
|
|
895
|
+
timestamps: { created: createdEpoch, updated: updatedEpoch },
|
|
896
|
+
model: payload.model || payload.model_provider || "unknown",
|
|
897
|
+
provider_id: payload.model_provider || "unknown"
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
async function readFirstLine(filePath) {
|
|
903
|
+
const text = await readTextPrefix(filePath, FIRST_LINE_SCAN_BYTES);
|
|
904
|
+
const firstNewline = text.indexOf(`
|
|
905
|
+
`);
|
|
906
|
+
const firstLine = firstNewline === -1 ? text : text.slice(0, firstNewline);
|
|
907
|
+
return firstLine.trim() ? firstLine : null;
|
|
908
|
+
}
|
|
909
|
+
async function parseSessionMeta(filePath) {
|
|
910
|
+
const firstLine = await readFirstLine(filePath);
|
|
911
|
+
if (!firstLine)
|
|
912
|
+
return null;
|
|
913
|
+
try {
|
|
914
|
+
const parsed = JSON.parse(firstLine);
|
|
915
|
+
const fileStat = await stat2(filePath).catch(() => null);
|
|
916
|
+
const fileMtimeEpoch = fileStat ? fileStat.mtime.getTime() / 1000 : undefined;
|
|
917
|
+
return normalizeSessionMeta(parsed, fileMtimeEpoch);
|
|
918
|
+
} catch {}
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
async function walkJsonlFiles(dir, visit) {
|
|
922
|
+
let entries;
|
|
923
|
+
try {
|
|
924
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
925
|
+
} catch {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
for (const entry of entries) {
|
|
929
|
+
const fullPath = join5(dir, entry.name);
|
|
930
|
+
if (entry.isDirectory()) {
|
|
931
|
+
await walkJsonlFiles(fullPath, visit);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (entry.name.endsWith(".jsonl")) {
|
|
935
|
+
await visit(fullPath, entry.name);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async function scanCodexSessions() {
|
|
940
|
+
const sessionsDir = join5(getCodexCliDir(), "sessions");
|
|
941
|
+
const sessions = [];
|
|
942
|
+
await walkJsonlFiles(sessionsDir, async (filePath) => {
|
|
943
|
+
const meta = await parseSessionMeta(filePath);
|
|
944
|
+
if (!meta)
|
|
945
|
+
return;
|
|
946
|
+
const fileStat = await stat2(filePath).catch(() => null);
|
|
947
|
+
if (!fileStat)
|
|
948
|
+
return;
|
|
949
|
+
sessions.push({
|
|
950
|
+
filePath,
|
|
951
|
+
meta,
|
|
952
|
+
mtime: fileStat.mtime.toISOString()
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
return sessions;
|
|
956
|
+
}
|
|
957
|
+
async function walkForFile(dir, match) {
|
|
958
|
+
let entries;
|
|
959
|
+
try {
|
|
960
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
961
|
+
} catch {
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
for (const entry of entries) {
|
|
965
|
+
const fullPath = join5(dir, entry.name);
|
|
966
|
+
if (!entry.isDirectory() && match(entry.name))
|
|
967
|
+
return fullPath;
|
|
968
|
+
if (entry.isDirectory()) {
|
|
969
|
+
const found = await walkForFile(fullPath, match);
|
|
970
|
+
if (found)
|
|
971
|
+
return found;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
async function findCodexSessionFileById(sessionId) {
|
|
977
|
+
const sessionsDir = join5(getCodexCliDir(), "sessions");
|
|
978
|
+
const exactName = `${sessionId}.jsonl`;
|
|
979
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
980
|
+
const filePath = await walkForFile(sessionsDir, (name) => name === exactName || name.endsWith(suffix));
|
|
981
|
+
if (!filePath)
|
|
982
|
+
return null;
|
|
983
|
+
const fileStat = await stat2(filePath).catch(() => null);
|
|
984
|
+
return fileStat?.isFile() ? filePath : null;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// src/server/plugins/codex-cli/discovery.ts
|
|
988
|
+
var SESSION_TITLE_SCAN_BYTES = 256 * 1024;
|
|
989
|
+
async function discoverCodexProjects() {
|
|
990
|
+
const sessions = await scanCodexSessions();
|
|
991
|
+
const byCwd = new Map;
|
|
992
|
+
for (const session of sessions) {
|
|
993
|
+
const existing = byCwd.get(session.meta.cwd);
|
|
994
|
+
if (existing) {
|
|
995
|
+
existing.push(session);
|
|
996
|
+
} else {
|
|
997
|
+
byCwd.set(session.meta.cwd, [session]);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const projects = [];
|
|
1001
|
+
for (const [cwd, cwdSessions] of byCwd) {
|
|
1002
|
+
let lastActivity = "";
|
|
1003
|
+
for (const s of cwdSessions) {
|
|
1004
|
+
if (s.mtime > lastActivity)
|
|
1005
|
+
lastActivity = s.mtime;
|
|
1006
|
+
}
|
|
1007
|
+
projects.push({
|
|
1008
|
+
pluginId: "codex-cli",
|
|
1009
|
+
nativeId: cwd,
|
|
1010
|
+
resolvedPath: cwd,
|
|
1011
|
+
displayName: cwd,
|
|
1012
|
+
sessionCount: cwdSessions.length,
|
|
1013
|
+
lastActivity
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
sortByIsoDesc(projects, (project) => project.lastActivity);
|
|
1017
|
+
return projects;
|
|
1018
|
+
}
|
|
1019
|
+
function extractFirstUserMessage(text) {
|
|
1020
|
+
let message = null;
|
|
1021
|
+
iterateJsonl(text, ({ parsed }) => {
|
|
1022
|
+
const event = parsed;
|
|
1023
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.text) {
|
|
1024
|
+
message = event.item.text.slice(0, 200);
|
|
1025
|
+
return false;
|
|
1026
|
+
}
|
|
1027
|
+
if (event.type === "event_msg" && event.payload?.type === "user_message") {
|
|
1028
|
+
const text2 = event.payload.message || event.payload.text;
|
|
1029
|
+
if (typeof text2 === "string" && text2) {
|
|
1030
|
+
message = text2.slice(0, 200);
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}, { startAt: 1 });
|
|
1035
|
+
return message;
|
|
1036
|
+
}
|
|
1037
|
+
async function listCodexSessions(nativeId) {
|
|
1038
|
+
const allSessions = await scanCodexSessions();
|
|
1039
|
+
const matching = allSessions.filter((s) => s.meta.cwd === nativeId);
|
|
1040
|
+
const sessions = [];
|
|
1041
|
+
for (const s of matching) {
|
|
1042
|
+
let firstMessage = s.meta.name || "";
|
|
1043
|
+
if (!firstMessage) {
|
|
1044
|
+
const prefix = await readTextPrefix(s.filePath, SESSION_TITLE_SCAN_BYTES);
|
|
1045
|
+
firstMessage = extractFirstUserMessage(prefix) || "";
|
|
1046
|
+
if (!firstMessage) {
|
|
1047
|
+
const fullText = await readFile(s.filePath, "utf-8");
|
|
1048
|
+
firstMessage = extractFirstUserMessage(fullText) || "";
|
|
1049
|
+
}
|
|
1050
|
+
firstMessage ||= "Codex session";
|
|
1051
|
+
}
|
|
1052
|
+
const timestamp = epochSecondsToIso(s.meta.timestamps.created);
|
|
1053
|
+
sessions.push({
|
|
1054
|
+
sessionId: s.meta.uuid,
|
|
1055
|
+
timestamp,
|
|
1056
|
+
slug: s.meta.uuid,
|
|
1057
|
+
firstMessage,
|
|
1058
|
+
model: s.meta.model || "unknown",
|
|
1059
|
+
gitBranch: "",
|
|
1060
|
+
pluginId: "codex-cli"
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
sortByIsoDesc(sessions, (session) => session.timestamp);
|
|
1064
|
+
return sessions;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// src/server/plugins/codex-cli/parser.ts
|
|
1068
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
1069
|
+
function normalizeEventMsg(payload) {
|
|
1070
|
+
switch (payload.type) {
|
|
1071
|
+
case "task_started":
|
|
1072
|
+
return { type: "turn.started" };
|
|
1073
|
+
case "user_message":
|
|
1074
|
+
return { type: "user_message", text: payload.message || payload.text || "" };
|
|
1075
|
+
case "agent_message":
|
|
1076
|
+
return {
|
|
1077
|
+
type: "item.completed",
|
|
1078
|
+
item: { type: "agent_message", text: payload.message || payload.text || "" }
|
|
1079
|
+
};
|
|
1080
|
+
case "agent_reasoning":
|
|
1081
|
+
return {
|
|
1082
|
+
type: "item.completed",
|
|
1083
|
+
item: { type: "reasoning", text: payload.text || "" }
|
|
1084
|
+
};
|
|
1085
|
+
case "token_count":
|
|
1086
|
+
return {
|
|
1087
|
+
type: "turn.completed",
|
|
1088
|
+
usage: {
|
|
1089
|
+
input_tokens: payload.input_tokens,
|
|
1090
|
+
cached_input_tokens: payload.cached_input_tokens,
|
|
1091
|
+
output_tokens: payload.output_tokens
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
case "task_complete":
|
|
1095
|
+
return { type: "turn.completed" };
|
|
1096
|
+
default:
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
function parseArguments(args) {
|
|
1101
|
+
if (!args)
|
|
1102
|
+
return {};
|
|
1103
|
+
if (typeof args === "string") {
|
|
1104
|
+
try {
|
|
1105
|
+
return JSON.parse(args);
|
|
1106
|
+
} catch {
|
|
1107
|
+
return { raw: args };
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return args;
|
|
1111
|
+
}
|
|
1112
|
+
function normalizeResponseItem(payload) {
|
|
1113
|
+
if (payload.type === "function_call" || payload.type === "custom_tool_call") {
|
|
1114
|
+
const name = payload.name || "unknown";
|
|
1115
|
+
const args = parseArguments(payload.arguments);
|
|
1116
|
+
return {
|
|
1117
|
+
type: "item.completed",
|
|
1118
|
+
item: {
|
|
1119
|
+
type: "command_execution",
|
|
1120
|
+
command: name,
|
|
1121
|
+
aggregated_output: "",
|
|
1122
|
+
exit_code: 0
|
|
1123
|
+
},
|
|
1124
|
+
callId: payload.call_id,
|
|
1125
|
+
toolName: name,
|
|
1126
|
+
toolInput: args
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
if (payload.type === "function_call_output" || payload.type === "custom_tool_call_output") {
|
|
1130
|
+
return {
|
|
1131
|
+
type: "tool_output",
|
|
1132
|
+
callId: payload.call_id,
|
|
1133
|
+
text: payload.output || ""
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
var OLD_FORMAT_TYPES = new Set([
|
|
1139
|
+
"turn.started",
|
|
1140
|
+
"turn.completed",
|
|
1141
|
+
"item.completed",
|
|
1142
|
+
"thread.started"
|
|
1143
|
+
]);
|
|
1144
|
+
function normalizeEvent(raw) {
|
|
1145
|
+
if (typeof raw !== "object" || raw === null || !("type" in raw))
|
|
1146
|
+
return null;
|
|
1147
|
+
const obj = raw;
|
|
1148
|
+
if (OLD_FORMAT_TYPES.has(obj.type))
|
|
1149
|
+
return raw;
|
|
1150
|
+
const payload = obj.payload;
|
|
1151
|
+
if (!payload)
|
|
1152
|
+
return null;
|
|
1153
|
+
if (obj.type === "event_msg")
|
|
1154
|
+
return normalizeEventMsg(payload);
|
|
1155
|
+
if (obj.type === "response_item")
|
|
1156
|
+
return normalizeResponseItem(payload);
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
function buildToolCallFromItem(item, nextToolUseId) {
|
|
1160
|
+
switch (item.type) {
|
|
1161
|
+
case "command_execution":
|
|
1162
|
+
return {
|
|
1163
|
+
toolUseId: nextToolUseId(),
|
|
1164
|
+
name: "command_execution",
|
|
1165
|
+
input: { command: item.command },
|
|
1166
|
+
result: item.aggregated_output || "",
|
|
1167
|
+
isError: item.exit_code !== undefined && item.exit_code !== 0
|
|
1168
|
+
};
|
|
1169
|
+
case "file_change":
|
|
1170
|
+
return {
|
|
1171
|
+
toolUseId: nextToolUseId(),
|
|
1172
|
+
name: "file_change",
|
|
1173
|
+
input: { changes: item.changes },
|
|
1174
|
+
result: "",
|
|
1175
|
+
isError: false
|
|
1176
|
+
};
|
|
1177
|
+
case "mcp_tool_call":
|
|
1178
|
+
return {
|
|
1179
|
+
toolUseId: nextToolUseId(),
|
|
1180
|
+
name: item.tool,
|
|
1181
|
+
input: item.arguments,
|
|
1182
|
+
result: item.result || "",
|
|
1183
|
+
isError: false
|
|
1184
|
+
};
|
|
1185
|
+
case "web_search":
|
|
1186
|
+
return {
|
|
1187
|
+
toolUseId: nextToolUseId(),
|
|
1188
|
+
name: "web_search",
|
|
1189
|
+
input: { query: item.query },
|
|
1190
|
+
result: "",
|
|
1191
|
+
isError: false
|
|
1192
|
+
};
|
|
1193
|
+
default:
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
function createUserTurn(text, timestamp, uuid) {
|
|
1198
|
+
return {
|
|
1199
|
+
kind: "user",
|
|
1200
|
+
uuid,
|
|
1201
|
+
timestamp,
|
|
1202
|
+
text
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
function createAssistantTurn2(model, timestamp, uuid) {
|
|
1206
|
+
return {
|
|
1207
|
+
kind: "assistant",
|
|
1208
|
+
uuid,
|
|
1209
|
+
timestamp,
|
|
1210
|
+
model,
|
|
1211
|
+
contentBlocks: []
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
function itemToContentBlock(item, nextToolUseId) {
|
|
1215
|
+
if (item.type === "agent_message") {
|
|
1216
|
+
return { type: "text", text: item.text };
|
|
1217
|
+
}
|
|
1218
|
+
if (item.type === "reasoning") {
|
|
1219
|
+
return { type: "thinking", block: { text: item.text } };
|
|
1220
|
+
}
|
|
1221
|
+
const toolCall = buildToolCallFromItem(item, nextToolUseId);
|
|
1222
|
+
if (toolCall) {
|
|
1223
|
+
return { type: "tool_call", call: toolCall };
|
|
1224
|
+
}
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
function handleTurnStarted(state, event, timestamp, nextUserTurnId) {
|
|
1228
|
+
flushAssistant2(state);
|
|
1229
|
+
state.turnCount++;
|
|
1230
|
+
if (state.turnCount > 1) {
|
|
1231
|
+
state.turns.push(createUserTurn(event.text || "", timestamp, nextUserTurnId()));
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
function handleTurnCompleted(state, event) {
|
|
1235
|
+
if (state.currentAssistant && event.usage) {
|
|
1236
|
+
const usage = {
|
|
1237
|
+
inputTokens: event.usage.input_tokens ?? 0,
|
|
1238
|
+
outputTokens: event.usage.output_tokens ?? 0,
|
|
1239
|
+
cacheReadTokens: event.usage.cached_input_tokens
|
|
1240
|
+
};
|
|
1241
|
+
state.currentAssistant.usage = usage;
|
|
1242
|
+
}
|
|
1243
|
+
flushAssistant2(state);
|
|
1244
|
+
}
|
|
1245
|
+
function handleItemCompleted(state, event, model, timestamp, nextToolUseId, nextAssistantTurnId) {
|
|
1246
|
+
if (!event.item)
|
|
1247
|
+
return;
|
|
1248
|
+
if (!state.currentAssistant) {
|
|
1249
|
+
state.currentAssistant = createAssistantTurn2(model, timestamp, nextAssistantTurnId());
|
|
1250
|
+
}
|
|
1251
|
+
const block = itemToContentBlock(event.item, nextToolUseId);
|
|
1252
|
+
if (block) {
|
|
1253
|
+
state.currentAssistant.contentBlocks.push(block);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
function flushAssistant2(state) {
|
|
1257
|
+
if (state.currentAssistant && state.currentAssistant.contentBlocks.length > 0) {
|
|
1258
|
+
state.turns.push(state.currentAssistant);
|
|
1259
|
+
}
|
|
1260
|
+
state.currentAssistant = null;
|
|
1261
|
+
}
|
|
1262
|
+
function handleUserMessage(state, event) {
|
|
1263
|
+
for (let i = state.turns.length - 1;i >= 0; i--) {
|
|
1264
|
+
const turn = state.turns[i];
|
|
1265
|
+
if (turn.kind === "user" && !turn.text) {
|
|
1266
|
+
turn.text = event.text || "";
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (state.turnCount === 0) {
|
|
1271
|
+
state.turnCount++;
|
|
1272
|
+
}
|
|
1273
|
+
state.turns.push(createUserTurn(event.text || "", "", "codex-user-first"));
|
|
1274
|
+
}
|
|
1275
|
+
function handleToolOutput(state, event) {
|
|
1276
|
+
if (!event.callId)
|
|
1277
|
+
return;
|
|
1278
|
+
const toolCall = state.pendingToolCalls.get(event.callId);
|
|
1279
|
+
if (toolCall) {
|
|
1280
|
+
toolCall.result = event.text || "";
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
function handleGenericToolCall(state, event, model, timestamp, nextToolUseId, nextAssistantTurnId) {
|
|
1284
|
+
if (!state.currentAssistant) {
|
|
1285
|
+
state.currentAssistant = createAssistantTurn2(model, timestamp, nextAssistantTurnId());
|
|
1286
|
+
}
|
|
1287
|
+
const toolCall = {
|
|
1288
|
+
toolUseId: event.callId || nextToolUseId(),
|
|
1289
|
+
name: event.toolName || "unknown",
|
|
1290
|
+
input: event.toolInput || {},
|
|
1291
|
+
result: "",
|
|
1292
|
+
isError: false
|
|
1293
|
+
};
|
|
1294
|
+
if (event.callId) {
|
|
1295
|
+
state.pendingToolCalls.set(event.callId, toolCall);
|
|
1296
|
+
}
|
|
1297
|
+
state.currentAssistant.contentBlocks.push({ type: "tool_call", call: toolCall });
|
|
1298
|
+
}
|
|
1299
|
+
function dispatchEvent(state, event, model, timestamp, nextToolUseId, nextUserTurnId, nextAssistantTurnId) {
|
|
1300
|
+
switch (event.type) {
|
|
1301
|
+
case "turn.started":
|
|
1302
|
+
handleTurnStarted(state, event, timestamp, nextUserTurnId);
|
|
1303
|
+
break;
|
|
1304
|
+
case "turn.completed":
|
|
1305
|
+
handleTurnCompleted(state, event);
|
|
1306
|
+
break;
|
|
1307
|
+
case "item.completed":
|
|
1308
|
+
if (event.toolName) {
|
|
1309
|
+
handleGenericToolCall(state, event, model, timestamp, nextToolUseId, nextAssistantTurnId);
|
|
1310
|
+
} else {
|
|
1311
|
+
handleItemCompleted(state, event, model, timestamp, nextToolUseId, nextAssistantTurnId);
|
|
1312
|
+
}
|
|
1313
|
+
break;
|
|
1314
|
+
case "user_message":
|
|
1315
|
+
handleUserMessage(state, event);
|
|
1316
|
+
break;
|
|
1317
|
+
case "tool_output":
|
|
1318
|
+
handleToolOutput(state, event);
|
|
1319
|
+
break;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
function buildCodexTurns(events, model, timestamp) {
|
|
1323
|
+
let toolUseCounter = 0;
|
|
1324
|
+
let userTurnCounter = 0;
|
|
1325
|
+
let assistantTurnCounter = 0;
|
|
1326
|
+
const nextToolUseId = () => {
|
|
1327
|
+
toolUseCounter++;
|
|
1328
|
+
return `codex-tool-${toolUseCounter}`;
|
|
1329
|
+
};
|
|
1330
|
+
const nextUserTurnId = () => {
|
|
1331
|
+
userTurnCounter++;
|
|
1332
|
+
return `codex-user-${userTurnCounter}`;
|
|
1333
|
+
};
|
|
1334
|
+
const nextAssistantTurnId = () => {
|
|
1335
|
+
assistantTurnCounter++;
|
|
1336
|
+
return `codex-assistant-${assistantTurnCounter}`;
|
|
1337
|
+
};
|
|
1338
|
+
const state = {
|
|
1339
|
+
turns: [],
|
|
1340
|
+
currentAssistant: null,
|
|
1341
|
+
turnCount: 0,
|
|
1342
|
+
pendingToolCalls: new Map
|
|
1343
|
+
};
|
|
1344
|
+
for (const event of events) {
|
|
1345
|
+
dispatchEvent(state, event, model, timestamp, nextToolUseId, nextUserTurnId, nextAssistantTurnId);
|
|
1346
|
+
}
|
|
1347
|
+
flushAssistant2(state);
|
|
1348
|
+
return state.turns;
|
|
1349
|
+
}
|
|
1350
|
+
async function loadCodexSession(_nativeId, sessionId) {
|
|
1351
|
+
const filePath = await findCodexSessionFileById(sessionId);
|
|
1352
|
+
if (!filePath) {
|
|
1353
|
+
return {
|
|
1354
|
+
sessionId,
|
|
1355
|
+
project: _nativeId,
|
|
1356
|
+
turns: [],
|
|
1357
|
+
pluginId: "codex-cli"
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
const text = await readFile2(filePath, "utf-8");
|
|
1361
|
+
let meta = null;
|
|
1362
|
+
const events = [];
|
|
1363
|
+
iterateJsonl(text, ({ parsed, lineIndex }) => {
|
|
1364
|
+
if (lineIndex === 0) {
|
|
1365
|
+
const normalized = normalizeSessionMeta(parsed);
|
|
1366
|
+
if (normalized) {
|
|
1367
|
+
meta = normalized;
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
const event = normalizeEvent(parsed);
|
|
1372
|
+
if (event) {
|
|
1373
|
+
events.push(event);
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
const metaInfo = normalizeSessionMeta(meta);
|
|
1377
|
+
const model = metaInfo?.model || "unknown";
|
|
1378
|
+
const timestamp = metaInfo ? epochSecondsToIso(metaInfo.timestamps.created) : "";
|
|
1379
|
+
const turns = buildCodexTurns(events, model, timestamp);
|
|
1380
|
+
return {
|
|
1381
|
+
sessionId,
|
|
1382
|
+
project: _nativeId,
|
|
1383
|
+
turns,
|
|
1384
|
+
pluginId: "codex-cli"
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// src/server/plugins/codex-cli/index.ts
|
|
1389
|
+
var codexCliPlugin = {
|
|
1390
|
+
id: "codex-cli",
|
|
1391
|
+
displayName: "Codex",
|
|
1392
|
+
getDefaultDataDir: () => getCodexCliDir(),
|
|
1393
|
+
discoverProjects: () => discoverCodexProjects(),
|
|
1394
|
+
listSessions: (nativeId) => listCodexSessions(nativeId),
|
|
1395
|
+
loadSession: (nativeId, sessionId) => loadCodexSession(nativeId, sessionId),
|
|
1396
|
+
getResumeCommand: (sessionId) => `codex resume ${sessionId}`
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
// src/server/plugins/shared/json-utils.ts
|
|
1400
|
+
function tryParseJson(value) {
|
|
1401
|
+
try {
|
|
1402
|
+
return JSON.parse(value);
|
|
1403
|
+
} catch {
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// src/server/plugins/opencode/db.ts
|
|
1409
|
+
import { existsSync } from "node:fs";
|
|
1410
|
+
import { join as join6 } from "node:path";
|
|
1411
|
+
function getOpenCodeDbPath() {
|
|
1412
|
+
return join6(getOpenCodeDir(), "opencode.db");
|
|
1413
|
+
}
|
|
1414
|
+
async function openOpenCodeDb() {
|
|
1415
|
+
const dbPath = getOpenCodeDbPath();
|
|
1416
|
+
if (!existsSync(dbPath))
|
|
1417
|
+
return null;
|
|
1418
|
+
try {
|
|
1419
|
+
const sqlite = await import("bun:sqlite");
|
|
1420
|
+
return new sqlite.Database(dbPath, { readonly: true });
|
|
1421
|
+
} catch {
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// src/server/plugins/opencode/discovery.ts
|
|
1427
|
+
function getColumns(db, tableName) {
|
|
1428
|
+
const rows = db.query(`PRAGMA table_info(${tableName})`).all();
|
|
1429
|
+
return new Set(rows.map((r) => r.name));
|
|
1430
|
+
}
|
|
1431
|
+
function inspectSchema(db) {
|
|
1432
|
+
const tableRows = db.query("SELECT name FROM sqlite_master WHERE type='table'").all();
|
|
1433
|
+
const tables = new Set(tableRows.map((row) => row.name));
|
|
1434
|
+
const hasSessionTable = tables.has("session");
|
|
1435
|
+
const hasMessageTable = tables.has("message");
|
|
1436
|
+
const hasPartTable = tables.has("part");
|
|
1437
|
+
const hasProjectTable = tables.has("project");
|
|
1438
|
+
const hasRequiredTables = hasSessionTable && hasMessageTable && hasPartTable;
|
|
1439
|
+
return {
|
|
1440
|
+
hasProjectTable,
|
|
1441
|
+
hasRequiredTables,
|
|
1442
|
+
projectColumns: hasProjectTable ? getColumns(db, "project") : new Set,
|
|
1443
|
+
sessionColumns: hasSessionTable ? getColumns(db, "session") : new Set
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
async function discoverOpenCodeProjects() {
|
|
1447
|
+
const db = await openOpenCodeDb();
|
|
1448
|
+
if (!db)
|
|
1449
|
+
return [];
|
|
1450
|
+
try {
|
|
1451
|
+
const schema = inspectSchema(db);
|
|
1452
|
+
if (!schema.hasRequiredTables)
|
|
1453
|
+
return [];
|
|
1454
|
+
if (schema.hasProjectTable) {
|
|
1455
|
+
return discoverFromProjectTable(db, schema);
|
|
1456
|
+
}
|
|
1457
|
+
return discoverFromSessions(db, schema);
|
|
1458
|
+
} catch {
|
|
1459
|
+
return [];
|
|
1460
|
+
} finally {
|
|
1461
|
+
db.close();
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
function discoverFromProjectTable(db, schema) {
|
|
1465
|
+
const hasWorktree = schema.projectColumns.has("worktree");
|
|
1466
|
+
const hasName = schema.projectColumns.has("name");
|
|
1467
|
+
if (!hasWorktree) {
|
|
1468
|
+
return discoverFromSessions(db, schema);
|
|
1469
|
+
}
|
|
1470
|
+
const selectName = hasName ? "p.name" : "NULL as name";
|
|
1471
|
+
const rows = db.query(`SELECT p.id, p.worktree, ${selectName},
|
|
1472
|
+
count(s.id) as session_count,
|
|
1473
|
+
coalesce(max(s.time_updated), max(s.time_created), p.time_created) as last_activity
|
|
1474
|
+
FROM project p
|
|
1475
|
+
LEFT JOIN session s ON s.project_id = p.id
|
|
1476
|
+
GROUP BY p.id
|
|
1477
|
+
HAVING session_count > 0
|
|
1478
|
+
ORDER BY last_activity DESC`).all();
|
|
1479
|
+
return rows.map((row) => ({
|
|
1480
|
+
pluginId: "opencode",
|
|
1481
|
+
nativeId: row.id,
|
|
1482
|
+
resolvedPath: row.worktree,
|
|
1483
|
+
displayName: row.name || row.worktree,
|
|
1484
|
+
sessionCount: row.session_count,
|
|
1485
|
+
lastActivity: epochMsToIso(row.last_activity)
|
|
1486
|
+
}));
|
|
1487
|
+
}
|
|
1488
|
+
function discoverFromSessions(db, schema) {
|
|
1489
|
+
const hasDirectory = schema.sessionColumns.has("directory");
|
|
1490
|
+
const hasProjectId = schema.sessionColumns.has("project_id");
|
|
1491
|
+
if (!hasDirectory && !hasProjectId)
|
|
1492
|
+
return [];
|
|
1493
|
+
const groupCol = hasDirectory ? "directory" : "project_id";
|
|
1494
|
+
const rows = db.query(`SELECT ${groupCol} as group_key,
|
|
1495
|
+
count(*) as session_count,
|
|
1496
|
+
coalesce(max(time_updated), max(time_created)) as last_activity
|
|
1497
|
+
FROM session
|
|
1498
|
+
GROUP BY ${groupCol}
|
|
1499
|
+
ORDER BY last_activity DESC`).all();
|
|
1500
|
+
return rows.map((row) => ({
|
|
1501
|
+
pluginId: "opencode",
|
|
1502
|
+
nativeId: row.group_key,
|
|
1503
|
+
resolvedPath: row.group_key,
|
|
1504
|
+
displayName: row.group_key,
|
|
1505
|
+
sessionCount: row.session_count,
|
|
1506
|
+
lastActivity: epochMsToIso(row.last_activity)
|
|
1507
|
+
}));
|
|
1508
|
+
}
|
|
1509
|
+
function querySessionRows(db, schema, nativeId) {
|
|
1510
|
+
const titleCol = schema.sessionColumns.has("title") ? "title" : "''";
|
|
1511
|
+
const slugCol = schema.sessionColumns.has("slug") ? "slug" : "id";
|
|
1512
|
+
const whereCol = schema.hasProjectTable ? "project_id" : "directory";
|
|
1513
|
+
return db.query(`SELECT id, project_id, directory, ${titleCol} as title,
|
|
1514
|
+
${slugCol} as slug, time_created, time_updated
|
|
1515
|
+
FROM session
|
|
1516
|
+
WHERE ${whereCol} = ?
|
|
1517
|
+
ORDER BY time_created DESC`).all(nativeId);
|
|
1518
|
+
}
|
|
1519
|
+
function sessionRowToSummary(db, row) {
|
|
1520
|
+
const preview = getSessionPreview(db, row.id);
|
|
1521
|
+
const firstMessage = row.title || preview.firstMessage || "OpenCode session";
|
|
1522
|
+
return {
|
|
1523
|
+
sessionId: row.id,
|
|
1524
|
+
timestamp: epochMsToIso(row.time_created),
|
|
1525
|
+
slug: row.slug,
|
|
1526
|
+
firstMessage,
|
|
1527
|
+
model: preview.model || "unknown",
|
|
1528
|
+
gitBranch: "",
|
|
1529
|
+
pluginId: "opencode"
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
async function listOpenCodeSessions(nativeId) {
|
|
1533
|
+
const db = await openOpenCodeDb();
|
|
1534
|
+
if (!db)
|
|
1535
|
+
return [];
|
|
1536
|
+
try {
|
|
1537
|
+
const schema = inspectSchema(db);
|
|
1538
|
+
if (!schema.hasRequiredTables)
|
|
1539
|
+
return [];
|
|
1540
|
+
const sessionRows = querySessionRows(db, schema, nativeId);
|
|
1541
|
+
return sessionRows.map((row) => sessionRowToSummary(db, row));
|
|
1542
|
+
} catch {
|
|
1543
|
+
return [];
|
|
1544
|
+
} finally {
|
|
1545
|
+
db.close();
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
function getSessionPreview(db, sessionId) {
|
|
1549
|
+
const preview = { firstMessage: "", model: "" };
|
|
1550
|
+
const msgRow = db.query(`SELECT id, session_id, time_created, data FROM message
|
|
1551
|
+
WHERE session_id = ?
|
|
1552
|
+
ORDER BY time_created ASC
|
|
1553
|
+
LIMIT 10`).all(sessionId);
|
|
1554
|
+
for (const msg of msgRow) {
|
|
1555
|
+
const data = tryParseJson(msg.data);
|
|
1556
|
+
if (!data)
|
|
1557
|
+
continue;
|
|
1558
|
+
assignPreviewModel(preview, data);
|
|
1559
|
+
assignPreviewFirstMessage(preview, data, db, msg.id);
|
|
1560
|
+
if (preview.firstMessage && preview.model)
|
|
1561
|
+
break;
|
|
1562
|
+
}
|
|
1563
|
+
return preview;
|
|
1564
|
+
}
|
|
1565
|
+
function assignPreviewModel(preview, data) {
|
|
1566
|
+
if (preview.model || data.role !== "assistant" || !data.modelID)
|
|
1567
|
+
return;
|
|
1568
|
+
preview.model = data.modelID;
|
|
1569
|
+
}
|
|
1570
|
+
function assignPreviewFirstMessage(preview, data, db, messageId) {
|
|
1571
|
+
if (preview.firstMessage || data.role !== "user")
|
|
1572
|
+
return;
|
|
1573
|
+
preview.firstMessage = getFirstUserTextPart(db, messageId);
|
|
1574
|
+
}
|
|
1575
|
+
function getFirstUserTextPart(db, messageId) {
|
|
1576
|
+
const parts = db.query("SELECT data FROM part WHERE message_id = ? ORDER BY id ASC").all(messageId);
|
|
1577
|
+
for (const part of parts) {
|
|
1578
|
+
const partData = tryParseJson(part.data);
|
|
1579
|
+
if (partData?.type === "text" && partData.text) {
|
|
1580
|
+
return partData.text.slice(0, 200);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
return "";
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// src/server/plugins/opencode/parser.ts
|
|
1587
|
+
function partToContentBlock(partData, nextToolUseId) {
|
|
1588
|
+
switch (partData.type) {
|
|
1589
|
+
case "text": {
|
|
1590
|
+
const textPart = partData;
|
|
1591
|
+
if (textPart.ignored)
|
|
1592
|
+
return null;
|
|
1593
|
+
return { type: "text", text: textPart.text };
|
|
1594
|
+
}
|
|
1595
|
+
case "reasoning": {
|
|
1596
|
+
const reasoningPart = partData;
|
|
1597
|
+
return { type: "thinking", block: { text: reasoningPart.text } };
|
|
1598
|
+
}
|
|
1599
|
+
case "tool": {
|
|
1600
|
+
const toolPart = partData;
|
|
1601
|
+
return { type: "tool_call", call: buildToolCall2(toolPart, nextToolUseId) };
|
|
1602
|
+
}
|
|
1603
|
+
default:
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
function buildToolCall2(toolPart, nextToolUseId) {
|
|
1608
|
+
const state = toolPart.state;
|
|
1609
|
+
const toolId = toolPart.callID || nextToolUseId();
|
|
1610
|
+
switch (state.status) {
|
|
1611
|
+
case "completed":
|
|
1612
|
+
return {
|
|
1613
|
+
toolUseId: toolId,
|
|
1614
|
+
name: toolPart.tool,
|
|
1615
|
+
input: state.input,
|
|
1616
|
+
result: state.output,
|
|
1617
|
+
isError: false
|
|
1618
|
+
};
|
|
1619
|
+
case "error":
|
|
1620
|
+
return {
|
|
1621
|
+
toolUseId: toolId,
|
|
1622
|
+
name: toolPart.tool,
|
|
1623
|
+
input: state.input,
|
|
1624
|
+
result: state.error,
|
|
1625
|
+
isError: true
|
|
1626
|
+
};
|
|
1627
|
+
case "pending":
|
|
1628
|
+
case "running":
|
|
1629
|
+
return {
|
|
1630
|
+
toolUseId: toolId,
|
|
1631
|
+
name: toolPart.tool,
|
|
1632
|
+
input: state.input,
|
|
1633
|
+
result: "[Tool execution was interrupted]",
|
|
1634
|
+
isError: true
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
function createUserTurn2(text, timestamp, uuid) {
|
|
1639
|
+
return {
|
|
1640
|
+
kind: "user",
|
|
1641
|
+
uuid,
|
|
1642
|
+
timestamp,
|
|
1643
|
+
text
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
function createAssistantTurn3(model, timestamp, uuid, contentBlocks, usage, stopReason) {
|
|
1647
|
+
return {
|
|
1648
|
+
kind: "assistant",
|
|
1649
|
+
uuid,
|
|
1650
|
+
timestamp,
|
|
1651
|
+
model,
|
|
1652
|
+
contentBlocks,
|
|
1653
|
+
usage,
|
|
1654
|
+
stopReason
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
function collectUserText(parts) {
|
|
1658
|
+
const texts = [];
|
|
1659
|
+
for (const part of parts) {
|
|
1660
|
+
if (part.type === "text" && !part.ignored) {
|
|
1661
|
+
texts.push(part.text);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return texts.join(`
|
|
1665
|
+
`);
|
|
1666
|
+
}
|
|
1667
|
+
function collectContentBlocks(parts, nextToolUseId) {
|
|
1668
|
+
const blocks = [];
|
|
1669
|
+
for (const part of parts) {
|
|
1670
|
+
const block = partToContentBlock(part, nextToolUseId);
|
|
1671
|
+
if (block)
|
|
1672
|
+
blocks.push(block);
|
|
1673
|
+
}
|
|
1674
|
+
return blocks;
|
|
1675
|
+
}
|
|
1676
|
+
function tokensToUsage(tokens) {
|
|
1677
|
+
if (!tokens)
|
|
1678
|
+
return;
|
|
1679
|
+
return {
|
|
1680
|
+
inputTokens: tokens.input,
|
|
1681
|
+
outputTokens: tokens.output,
|
|
1682
|
+
cacheReadTokens: tokens.cache?.read,
|
|
1683
|
+
cacheCreationTokens: tokens.cache?.write
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
function extractStepFinishUsage(parts) {
|
|
1687
|
+
for (const part of parts) {
|
|
1688
|
+
if (part.type === "step-finish") {
|
|
1689
|
+
const sf = part;
|
|
1690
|
+
return {
|
|
1691
|
+
inputTokens: sf.tokens.input,
|
|
1692
|
+
outputTokens: sf.tokens.output,
|
|
1693
|
+
cacheReadTokens: sf.tokens.cache?.read,
|
|
1694
|
+
cacheCreationTokens: sf.tokens.cache?.write
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
function buildUserTurn(msg, timestamp) {
|
|
1701
|
+
return createUserTurn2(collectUserText(msg.parts), timestamp, msg.id);
|
|
1702
|
+
}
|
|
1703
|
+
function buildAssistantTurnFromMsg(msg, timestamp, nextToolUseId) {
|
|
1704
|
+
const data = msg.data;
|
|
1705
|
+
const model = data.modelID || "unknown";
|
|
1706
|
+
const contentBlocks = collectContentBlocks(msg.parts, nextToolUseId);
|
|
1707
|
+
const usage = tokensToUsage(data.tokens) ?? extractStepFinishUsage(msg.parts);
|
|
1708
|
+
return createAssistantTurn3(model, timestamp, msg.id, contentBlocks, usage, data.finish);
|
|
1709
|
+
}
|
|
1710
|
+
function buildOpenCodeTurns(messages) {
|
|
1711
|
+
let toolUseCounter = 0;
|
|
1712
|
+
const nextToolUseId = () => {
|
|
1713
|
+
toolUseCounter++;
|
|
1714
|
+
return `opencode-tool-${toolUseCounter}`;
|
|
1715
|
+
};
|
|
1716
|
+
const turns = [];
|
|
1717
|
+
for (const msg of messages) {
|
|
1718
|
+
const timestamp = epochMsToIso(msg.timeCreated);
|
|
1719
|
+
if (msg.data.role === "user") {
|
|
1720
|
+
turns.push(buildUserTurn(msg, timestamp));
|
|
1721
|
+
} else if (msg.data.role === "assistant") {
|
|
1722
|
+
turns.push(buildAssistantTurnFromMsg(msg, timestamp, nextToolUseId));
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return turns;
|
|
1726
|
+
}
|
|
1727
|
+
function groupPartsByMessage(partRows) {
|
|
1728
|
+
const map = new Map;
|
|
1729
|
+
for (const part of partRows) {
|
|
1730
|
+
const existing = map.get(part.message_id);
|
|
1731
|
+
if (existing) {
|
|
1732
|
+
existing.push(part);
|
|
1733
|
+
} else {
|
|
1734
|
+
map.set(part.message_id, [part]);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
return map;
|
|
1738
|
+
}
|
|
1739
|
+
function parsePartRows(rawParts) {
|
|
1740
|
+
const parts = [];
|
|
1741
|
+
for (const rawPart of rawParts) {
|
|
1742
|
+
const parsedPart = tryParseJson(rawPart.data);
|
|
1743
|
+
if (parsedPart) {
|
|
1744
|
+
parts.push(parsedPart);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
return parts;
|
|
1748
|
+
}
|
|
1749
|
+
function buildMessagesFromRows(messageRows, partsByMessage) {
|
|
1750
|
+
const messages = [];
|
|
1751
|
+
for (const row of messageRows) {
|
|
1752
|
+
const data = tryParseJson(row.data);
|
|
1753
|
+
if (data) {
|
|
1754
|
+
const rawParts = partsByMessage.get(row.id) ?? [];
|
|
1755
|
+
messages.push({
|
|
1756
|
+
id: row.id,
|
|
1757
|
+
data,
|
|
1758
|
+
timeCreated: row.time_created,
|
|
1759
|
+
parts: parsePartRows(rawParts)
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
return messages;
|
|
1764
|
+
}
|
|
1765
|
+
function loadSessionFromDb(db, nativeId, sessionId) {
|
|
1766
|
+
const sessionRow = db.query("SELECT directory FROM session WHERE id = ?").get(sessionId);
|
|
1767
|
+
const project = sessionRow?.directory || nativeId;
|
|
1768
|
+
const messageRows = db.query(`SELECT id, session_id, time_created, data
|
|
1769
|
+
FROM message WHERE session_id = ? ORDER BY time_created ASC`).all(sessionId);
|
|
1770
|
+
if (messageRows.length === 0) {
|
|
1771
|
+
return emptySession(project, sessionId);
|
|
1772
|
+
}
|
|
1773
|
+
const partRows = db.query(`SELECT id, message_id, session_id, time_created, data
|
|
1774
|
+
FROM part WHERE session_id = ? ORDER BY message_id, id ASC`).all(sessionId);
|
|
1775
|
+
const partsByMessage = groupPartsByMessage(partRows);
|
|
1776
|
+
const messages = buildMessagesFromRows(messageRows, partsByMessage);
|
|
1777
|
+
const turns = buildOpenCodeTurns(messages);
|
|
1778
|
+
return { sessionId, project, turns, pluginId: "opencode" };
|
|
1779
|
+
}
|
|
1780
|
+
async function loadOpenCodeSession(nativeId, sessionId) {
|
|
1781
|
+
const db = await openOpenCodeDb();
|
|
1782
|
+
if (!db) {
|
|
1783
|
+
return emptySession(nativeId, sessionId);
|
|
1784
|
+
}
|
|
1785
|
+
try {
|
|
1786
|
+
return loadSessionFromDb(db, nativeId, sessionId);
|
|
1787
|
+
} catch {
|
|
1788
|
+
return emptySession(nativeId, sessionId);
|
|
1789
|
+
} finally {
|
|
1790
|
+
db.close();
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
function emptySession(project, sessionId) {
|
|
1794
|
+
return {
|
|
1795
|
+
sessionId,
|
|
1796
|
+
project,
|
|
1797
|
+
turns: [],
|
|
1798
|
+
pluginId: "opencode"
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// src/server/plugins/opencode/index.ts
|
|
1803
|
+
var openCodePlugin = {
|
|
1804
|
+
id: "opencode",
|
|
1805
|
+
displayName: "OpenCode",
|
|
1806
|
+
getDefaultDataDir: () => getOpenCodeDir(),
|
|
1807
|
+
discoverProjects: () => discoverOpenCodeProjects(),
|
|
1808
|
+
listSessions: (nativeId) => listOpenCodeSessions(nativeId),
|
|
1809
|
+
loadSession: (nativeId, sessionId) => loadOpenCodeSession(nativeId, sessionId)
|
|
1810
|
+
};
|
|
1811
|
+
|
|
1812
|
+
// src/server/registry.ts
|
|
1813
|
+
function createRegistry() {
|
|
1814
|
+
const registry = new PluginRegistry;
|
|
1815
|
+
const claudeDir = claudeCodePlugin.getDefaultDataDir();
|
|
1816
|
+
if (claudeDir && existsSync2(claudeDir)) {
|
|
1817
|
+
registry.register(claudeCodePlugin);
|
|
1818
|
+
}
|
|
1819
|
+
const codexDir = codexCliPlugin.getDefaultDataDir();
|
|
1820
|
+
if (codexDir && existsSync2(codexDir)) {
|
|
1821
|
+
registry.register(codexCliPlugin);
|
|
1822
|
+
}
|
|
1823
|
+
const openCodeDir2 = openCodePlugin.getDefaultDataDir();
|
|
1824
|
+
if (openCodeDir2 && existsSync2(join7(openCodeDir2, "opencode.db"))) {
|
|
1825
|
+
registry.register(openCodePlugin);
|
|
1826
|
+
}
|
|
1827
|
+
return registry;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// src/server/parser/stats.ts
|
|
1831
|
+
function emptyStats(projects = 0) {
|
|
1832
|
+
return {
|
|
1833
|
+
projects,
|
|
1834
|
+
sessions: 0,
|
|
1835
|
+
messages: 0,
|
|
1836
|
+
todaySessions: 0,
|
|
1837
|
+
thisWeekSessions: 0,
|
|
1838
|
+
inputTokens: 0,
|
|
1839
|
+
outputTokens: 0,
|
|
1840
|
+
cacheReadTokens: 0,
|
|
1841
|
+
cacheCreationTokens: 0,
|
|
1842
|
+
toolCalls: 0,
|
|
1843
|
+
models: {}
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
function toDateString(d) {
|
|
1847
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1848
|
+
}
|
|
1849
|
+
function countRecentSessions(sessions) {
|
|
1850
|
+
const today = toDateString(new Date);
|
|
1851
|
+
const now = new Date;
|
|
1852
|
+
const weekAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7);
|
|
1853
|
+
const weekAgoStr = toDateString(weekAgo);
|
|
1854
|
+
let todaySessions = 0;
|
|
1855
|
+
let thisWeekSessions = 0;
|
|
1856
|
+
for (const session of sessions) {
|
|
1857
|
+
const d = new Date(session.timestamp);
|
|
1858
|
+
if (Number.isNaN(d.getTime()))
|
|
1859
|
+
continue;
|
|
1860
|
+
const sessionDay = toDateString(d);
|
|
1861
|
+
if (sessionDay === today)
|
|
1862
|
+
todaySessions++;
|
|
1863
|
+
if (sessionDay >= weekAgoStr)
|
|
1864
|
+
thisWeekSessions++;
|
|
1865
|
+
}
|
|
1866
|
+
return { todaySessions, thisWeekSessions };
|
|
1867
|
+
}
|
|
1868
|
+
function ensureModelUsage(models, model) {
|
|
1869
|
+
if (!models[model]) {
|
|
1870
|
+
models[model] = {
|
|
1871
|
+
inputTokens: 0,
|
|
1872
|
+
outputTokens: 0,
|
|
1873
|
+
cacheReadTokens: 0,
|
|
1874
|
+
cacheCreationTokens: 0
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
return models[model];
|
|
1878
|
+
}
|
|
1879
|
+
function countVisibleMessages(turns) {
|
|
1880
|
+
return turns.filter((turn) => turn.kind !== "parse_error").length;
|
|
1881
|
+
}
|
|
1882
|
+
async function collectSessionsWithProjects(registry, stats) {
|
|
1883
|
+
const projects = await registry.discoverAllProjects().catch(() => []);
|
|
1884
|
+
stats.projects = projects.length;
|
|
1885
|
+
const sessionsWithProject = [];
|
|
1886
|
+
for (const project of projects) {
|
|
1887
|
+
const sessions = await registry.listAllSessions(project).catch(() => []);
|
|
1888
|
+
stats.sessions += sessions.length;
|
|
1889
|
+
for (const session of sessions) {
|
|
1890
|
+
sessionsWithProject.push({ project, session });
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
return sessionsWithProject;
|
|
1894
|
+
}
|
|
1895
|
+
function applyRecentSessionStats(stats, sessionsWithProject) {
|
|
1896
|
+
const recent = countRecentSessions(sessionsWithProject.map((item) => item.session));
|
|
1897
|
+
stats.todaySessions = recent.todaySessions;
|
|
1898
|
+
stats.thisWeekSessions = recent.thisWeekSessions;
|
|
1899
|
+
}
|
|
1900
|
+
async function loadSessionForStats(registry, project, session) {
|
|
1901
|
+
if (!session.pluginId)
|
|
1902
|
+
return null;
|
|
1903
|
+
const source = project.sources.find((item) => item.pluginId === session.pluginId);
|
|
1904
|
+
if (!source)
|
|
1905
|
+
return null;
|
|
1906
|
+
const plugin = registry.getPlugin(session.pluginId);
|
|
1907
|
+
const { rawSessionId } = parseSessionId(session.sessionId);
|
|
1908
|
+
const loaded = await plugin.loadSession(source.nativeId, rawSessionId).catch(() => null);
|
|
1909
|
+
return loaded?.turns ?? null;
|
|
1910
|
+
}
|
|
1911
|
+
function applyUsageStats(stats, modelUsage, usage) {
|
|
1912
|
+
stats.inputTokens += usage.inputTokens;
|
|
1913
|
+
stats.outputTokens += usage.outputTokens;
|
|
1914
|
+
stats.cacheReadTokens += usage.cacheReadTokens ?? 0;
|
|
1915
|
+
stats.cacheCreationTokens += usage.cacheCreationTokens ?? 0;
|
|
1916
|
+
modelUsage.inputTokens += usage.inputTokens;
|
|
1917
|
+
modelUsage.outputTokens += usage.outputTokens;
|
|
1918
|
+
modelUsage.cacheReadTokens += usage.cacheReadTokens ?? 0;
|
|
1919
|
+
modelUsage.cacheCreationTokens += usage.cacheCreationTokens ?? 0;
|
|
1920
|
+
}
|
|
1921
|
+
function applyTurnStats(stats, turns, fallbackModel) {
|
|
1922
|
+
stats.messages += countVisibleMessages(turns);
|
|
1923
|
+
for (const turn of turns) {
|
|
1924
|
+
if (turn.kind !== "assistant")
|
|
1925
|
+
continue;
|
|
1926
|
+
stats.toolCalls += turn.contentBlocks.filter((block) => block.type === "tool_call").length;
|
|
1927
|
+
const modelUsage = ensureModelUsage(stats.models, turn.model || fallbackModel || "unknown");
|
|
1928
|
+
if (!turn.usage)
|
|
1929
|
+
continue;
|
|
1930
|
+
applyUsageStats(stats, modelUsage, turn.usage);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
async function computeStats(registry) {
|
|
1934
|
+
const stats = emptyStats();
|
|
1935
|
+
const sessionsWithProject = await collectSessionsWithProjects(registry, stats);
|
|
1936
|
+
applyRecentSessionStats(stats, sessionsWithProject);
|
|
1937
|
+
for (const item of sessionsWithProject) {
|
|
1938
|
+
const turns = await loadSessionForStats(registry, item.project, item.session);
|
|
1939
|
+
if (!turns)
|
|
1940
|
+
continue;
|
|
1941
|
+
applyTurnStats(stats, turns, item.session.model);
|
|
1942
|
+
}
|
|
1943
|
+
return stats;
|
|
1944
|
+
}
|
|
1945
|
+
async function scanStats(registry = createRegistry()) {
|
|
1946
|
+
return computeStats(registry);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// src/server/api/stats.ts
|
|
1950
|
+
async function handleStats() {
|
|
1951
|
+
const stats = await scanStats();
|
|
1952
|
+
return Response.json({ stats });
|
|
1953
|
+
}
|
|
1954
|
+
|
|
764
1955
|
// src/server/api/subagent.ts
|
|
765
1956
|
async function handleSubAgent(sessionId, agentId, encodedPath) {
|
|
1957
|
+
const parsed = parseSessionId(sessionId);
|
|
1958
|
+
if (parsed.pluginId !== "claude-code") {
|
|
1959
|
+
return Response.json({ error: "sessionId must include claude-code prefix for sub-agent sessions" }, { status: 400 });
|
|
1960
|
+
}
|
|
1961
|
+
if (!parsed.rawSessionId) {
|
|
1962
|
+
return Response.json({ error: "sessionId must include a raw session id after claude-code::" }, { status: 400 });
|
|
1963
|
+
}
|
|
766
1964
|
try {
|
|
767
|
-
const session = await parseSubAgentSession(
|
|
1965
|
+
const session = await parseSubAgentSession(parsed.rawSessionId, encodedPath, agentId);
|
|
768
1966
|
return Response.json({ session });
|
|
769
1967
|
} catch {
|
|
770
1968
|
return Response.json({ error: "Sub-agent not found" }, { status: 404 });
|
|
@@ -773,8 +1971,8 @@ async function handleSubAgent(sessionId, agentId, encodedPath) {
|
|
|
773
1971
|
|
|
774
1972
|
// src/server/version.ts
|
|
775
1973
|
var appVersion = {
|
|
776
|
-
version: "
|
|
777
|
-
commitHash: "
|
|
1974
|
+
version: "2.0.0",
|
|
1975
|
+
commitHash: "834a598"
|
|
778
1976
|
};
|
|
779
1977
|
|
|
780
1978
|
// src/server/api/version.ts
|
|
@@ -783,6 +1981,17 @@ function handleVersion() {
|
|
|
783
1981
|
}
|
|
784
1982
|
|
|
785
1983
|
// src/server/cli.ts
|
|
1984
|
+
function parseDirFlag(argv, flag, setter) {
|
|
1985
|
+
const idx = argv.indexOf(flag);
|
|
1986
|
+
if (idx === -1)
|
|
1987
|
+
return;
|
|
1988
|
+
const dir = argv[idx + 1];
|
|
1989
|
+
if (!dir || dir.startsWith("-")) {
|
|
1990
|
+
console.error(`Error: ${flag} requires a path argument.`);
|
|
1991
|
+
process.exit(1);
|
|
1992
|
+
}
|
|
1993
|
+
setter(dir);
|
|
1994
|
+
}
|
|
786
1995
|
function parseCliArgs(argv) {
|
|
787
1996
|
const portIdx = argv.indexOf("--port");
|
|
788
1997
|
let port = 3583;
|
|
@@ -798,45 +2007,52 @@ function parseCliArgs(argv) {
|
|
|
798
2007
|
process.exit(1);
|
|
799
2008
|
}
|
|
800
2009
|
}
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
console.error("Error: --claude-code-dir requires a path argument.");
|
|
2010
|
+
const hostIdx = argv.indexOf("--host");
|
|
2011
|
+
let host = "127.0.0.1";
|
|
2012
|
+
if (hostIdx !== -1) {
|
|
2013
|
+
const val = argv[hostIdx + 1];
|
|
2014
|
+
if (!val || val.startsWith("-")) {
|
|
2015
|
+
console.error("Error: --host requires an address argument.");
|
|
808
2016
|
process.exit(1);
|
|
809
2017
|
}
|
|
810
|
-
|
|
2018
|
+
host = val;
|
|
811
2019
|
}
|
|
812
|
-
|
|
2020
|
+
const acceptRisks = argv.includes("--accept-risks");
|
|
2021
|
+
const showHelp = argv.includes("--help") || argv.includes("-h");
|
|
2022
|
+
parseDirFlag(argv, "--claude-code-dir", setClaudeCodeDir);
|
|
2023
|
+
parseDirFlag(argv, "--codex-cli-dir", setCodexCliDir);
|
|
2024
|
+
parseDirFlag(argv, "--opencode-dir", setOpenCodeDir);
|
|
2025
|
+
return { port, host, acceptRisks, showHelp };
|
|
813
2026
|
}
|
|
814
2027
|
function showHelpText() {
|
|
815
2028
|
const dim = "\x1B[2m";
|
|
816
2029
|
const reset = "\x1B[0m";
|
|
817
2030
|
console.log(`
|
|
818
|
-
Klovi — a web viewer for
|
|
2031
|
+
Klovi — a web viewer for AI coding sessions
|
|
819
2032
|
${dim}by cookielab.io${reset}
|
|
820
2033
|
|
|
821
2034
|
Usage:
|
|
822
2035
|
klovi [options]
|
|
823
2036
|
|
|
824
2037
|
Options:
|
|
825
|
-
--accept-risks
|
|
826
|
-
--
|
|
2038
|
+
--accept-risks Skip the startup security warning
|
|
2039
|
+
--host <address> Bind address (default: 127.0.0.1)
|
|
2040
|
+
--port <number> Server port (default: 3583)
|
|
827
2041
|
--claude-code-dir <path> Path to Claude Code data directory
|
|
828
|
-
-
|
|
2042
|
+
--codex-cli-dir <path> Path to Codex CLI data directory
|
|
2043
|
+
--opencode-dir <path> Path to OpenCode data directory
|
|
2044
|
+
-h, --help Show this help message
|
|
829
2045
|
|
|
830
|
-
The server
|
|
2046
|
+
The server binds to 127.0.0.1:3583 by default.
|
|
831
2047
|
`);
|
|
832
2048
|
}
|
|
833
|
-
function printStartupBanner(port) {
|
|
2049
|
+
function printStartupBanner(port, host) {
|
|
834
2050
|
const dim = "\x1B[2m";
|
|
835
2051
|
const bold = "\x1B[1m";
|
|
836
2052
|
const green = "\x1B[32m";
|
|
837
2053
|
const reset = "\x1B[0m";
|
|
838
2054
|
const version = appVersion.version;
|
|
839
|
-
const url = `http
|
|
2055
|
+
const url = `http://${host}:${port}`;
|
|
840
2056
|
const line1 = ` ${bold}${green}{K> Klovi${reset} ${dim}v${version}${reset}`;
|
|
841
2057
|
const line2 = ` ${dim}by cookielab.io${reset}`;
|
|
842
2058
|
const line3 = ` Running at ${bold}${url}${reset}`;
|
|
@@ -859,8 +2075,10 @@ function printStartupBanner(port) {
|
|
|
859
2075
|
console.log(empty);
|
|
860
2076
|
console.log(bot);
|
|
861
2077
|
}
|
|
862
|
-
function promptSecurityWarning(port) {
|
|
863
|
-
const
|
|
2078
|
+
function promptSecurityWarning(port, host) {
|
|
2079
|
+
const claudeDir = getClaudeCodeDir();
|
|
2080
|
+
const codexDir = getCodexCliDir();
|
|
2081
|
+
const openDir = getOpenCodeDir();
|
|
864
2082
|
const yellow = "\x1B[33m";
|
|
865
2083
|
const bold = "\x1B[1m";
|
|
866
2084
|
const reset = "\x1B[0m";
|
|
@@ -868,11 +2086,15 @@ function promptSecurityWarning(port) {
|
|
|
868
2086
|
console.log("");
|
|
869
2087
|
console.log(`${yellow}${bold} ⚠ WARNING${reset}`);
|
|
870
2088
|
console.log("");
|
|
871
|
-
console.log(
|
|
2089
|
+
console.log(" Klovi reads AI coding session history from:");
|
|
2090
|
+
console.log(` - Claude Code: ${claudeDir}`);
|
|
2091
|
+
console.log(` - Codex CLI: ${codexDir}`);
|
|
2092
|
+
console.log(` - OpenCode: ${openDir}`);
|
|
2093
|
+
console.log("");
|
|
872
2094
|
console.log(" Session data may contain sensitive information such as API keys,");
|
|
873
2095
|
console.log(" credentials, or private code snippets.");
|
|
874
2096
|
console.log("");
|
|
875
|
-
console.log(` The server will expose this data on ${bold}http
|
|
2097
|
+
console.log(` The server will expose this data on ${bold}http://${host}:${port}${reset}.`);
|
|
876
2098
|
console.log("");
|
|
877
2099
|
console.log(` ${dim}To skip this prompt, pass --accept-risks${reset}`);
|
|
878
2100
|
console.log("");
|
|
@@ -887,14 +2109,15 @@ function promptSecurityWarning(port) {
|
|
|
887
2109
|
console.log("");
|
|
888
2110
|
}
|
|
889
2111
|
function createRoutes() {
|
|
2112
|
+
const registry = createRegistry();
|
|
890
2113
|
return [
|
|
891
2114
|
{ pattern: "/api/version", handler: () => handleVersion() },
|
|
892
2115
|
{ pattern: "/api/stats", handler: () => handleStats() },
|
|
893
|
-
{ pattern: "/api/search/sessions", handler: () => handleSearchSessions() },
|
|
894
|
-
{ pattern: "/api/projects", handler: () => handleProjects() },
|
|
2116
|
+
{ pattern: "/api/search/sessions", handler: () => handleSearchSessions(registry) },
|
|
2117
|
+
{ pattern: "/api/projects", handler: () => handleProjects(registry) },
|
|
895
2118
|
{
|
|
896
2119
|
pattern: "/api/projects/:encodedPath/sessions",
|
|
897
|
-
handler: (_req, p) => handleSessions(p.encodedPath)
|
|
2120
|
+
handler: (_req, p) => handleSessions(p.encodedPath, registry)
|
|
898
2121
|
},
|
|
899
2122
|
{
|
|
900
2123
|
pattern: "/api/sessions/:sessionId",
|
|
@@ -903,7 +2126,7 @@ function createRoutes() {
|
|
|
903
2126
|
if (!project) {
|
|
904
2127
|
return Response.json({ error: "project query parameter required" }, { status: 400 });
|
|
905
2128
|
}
|
|
906
|
-
return handleSession(p.sessionId, project);
|
|
2129
|
+
return handleSession(p.sessionId, project, registry);
|
|
907
2130
|
}
|
|
908
2131
|
},
|
|
909
2132
|
{
|
|
@@ -920,7 +2143,7 @@ function createRoutes() {
|
|
|
920
2143
|
}
|
|
921
2144
|
|
|
922
2145
|
// src/server/http.ts
|
|
923
|
-
import { existsSync } from "node:fs";
|
|
2146
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
924
2147
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
925
2148
|
import { createServer } from "node:http";
|
|
926
2149
|
import { extname, resolve, sep } from "node:path";
|
|
@@ -934,7 +2157,7 @@ function matchRoute(pattern, pathname) {
|
|
|
934
2157
|
const pat = patternParts[i];
|
|
935
2158
|
const val = pathParts[i];
|
|
936
2159
|
if (pat.startsWith(":")) {
|
|
937
|
-
params[pat.slice(1)] = val;
|
|
2160
|
+
params[pat.slice(1)] = decodeURIComponent(val);
|
|
938
2161
|
} else if (pat !== val) {
|
|
939
2162
|
return null;
|
|
940
2163
|
}
|
|
@@ -1019,8 +2242,8 @@ async function handleRequest(pathname, url, method, routes, staticDir, hasStatic
|
|
|
1019
2242
|
headers: { "content-type": "text/plain" }
|
|
1020
2243
|
});
|
|
1021
2244
|
}
|
|
1022
|
-
function startServer(port, routes, staticDir, embeddedAssets) {
|
|
1023
|
-
const hasStaticDir = !embeddedAssets &&
|
|
2245
|
+
function startServer(port, host, routes, staticDir, embeddedAssets) {
|
|
2246
|
+
const hasStaticDir = !embeddedAssets && existsSync3(staticDir);
|
|
1024
2247
|
const server = createServer(async (req, res) => {
|
|
1025
2248
|
try {
|
|
1026
2249
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
@@ -1032,7 +2255,7 @@ function startServer(port, routes, staticDir, embeddedAssets) {
|
|
|
1032
2255
|
res.end("Internal Server Error");
|
|
1033
2256
|
}
|
|
1034
2257
|
});
|
|
1035
|
-
server.listen(port);
|
|
2258
|
+
server.listen(port, host);
|
|
1036
2259
|
}
|
|
1037
2260
|
async function writeResponse(res, webResponse) {
|
|
1038
2261
|
const headers = {};
|
|
@@ -1045,21 +2268,19 @@ async function writeResponse(res, webResponse) {
|
|
|
1045
2268
|
}
|
|
1046
2269
|
|
|
1047
2270
|
// index.ts
|
|
1048
|
-
var { port, acceptRisks, showHelp } = parseCliArgs(process.argv);
|
|
2271
|
+
var { port, host, acceptRisks, showHelp } = parseCliArgs(process.argv);
|
|
1049
2272
|
if (showHelp) {
|
|
1050
2273
|
showHelpText();
|
|
1051
2274
|
process.exit(0);
|
|
1052
2275
|
}
|
|
1053
2276
|
var resolvedDir = getClaudeCodeDir();
|
|
1054
|
-
if (!
|
|
1055
|
-
console.
|
|
1056
|
-
console.error("Hint: use --claude-code-dir <path> to specify a custom location.");
|
|
1057
|
-
process.exit(1);
|
|
2277
|
+
if (!existsSync4(resolvedDir)) {
|
|
2278
|
+
console.warn(`Warning: Claude Code directory not found: ${resolvedDir}`);
|
|
1058
2279
|
}
|
|
1059
2280
|
if (!acceptRisks) {
|
|
1060
|
-
promptSecurityWarning(port);
|
|
2281
|
+
promptSecurityWarning(port, host);
|
|
1061
2282
|
}
|
|
1062
2283
|
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
1063
|
-
var staticDir =
|
|
1064
|
-
startServer(port, createRoutes(), staticDir);
|
|
1065
|
-
printStartupBanner(port);
|
|
2284
|
+
var staticDir = existsSync4(join8(__dirname2, "public", "index.html")) ? join8(__dirname2, "public") : join8(__dirname2, "dist", "public");
|
|
2285
|
+
startServer(port, host, createRoutes(), staticDir);
|
|
2286
|
+
printStartupBanner(port, host);
|