@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 existsSync2 } from "node:fs";
7
- import { dirname, join as join5 } from "node:path";
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/parser/claude-dir.ts
14
- import { readdir, readFile, stat } from "node:fs/promises";
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
- function getStatsCachePath() {
31
- return join(claudeCodeDir, "stats-cache.json");
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/parser/claude-dir.ts
59
- async function discoverProjects() {
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 meta = { timestamp: "", slug: "", firstMessage: "", model: "", gitBranch: "" };
176
- for (const line of lines.slice(0, 50)) {
177
- if (!line.trim())
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 obj = JSON.parse(line);
181
- processMetaLine(obj, meta);
182
- if (isMetaComplete(meta))
183
- break;
184
- } catch {}
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/session.ts
249
- import { join as join3 } from "node:path";
250
- async function parseSession(sessionId, encodedPath) {
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: encodedPath,
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 = join3(getProjectsDir(), encodedPath, sessionId, "subagents", `agent-${agentId}.jsonl`);
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 PLAN_PREFIX2 = "Implement the following plan";
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(PLAN_PREFIX2))
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(PLAN_PREFIX2));
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: readFile2 } = await import("node:fs/promises");
361
- const text = await readFile2(filePath, "utf-8");
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
- for (let i = 0;i < lines.length; i++) {
367
- const line = lines[i];
368
- if (!line.trim())
369
- continue;
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-${i + 1}`,
280
+ uuid: `parse-error-line-${lineNumber}`,
376
281
  timestamp: rawLines[rawLines.length - 1]?.timestamp ?? "",
377
- lineNumber: i + 1,
282
+ lineNumber,
378
283
  rawLine: line.length > 500 ? `${line.slice(0, 500)}… (truncated)` : line,
379
284
  errorType: "json_parse",
380
- errorDetails: err instanceof Error ? err.message : undefined
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 [{ session, slug }, sessions] = await Promise.all([
624
- parseSession(sessionId, encodedPath),
625
- listSessions(encodedPath)
626
- ]);
627
- session.planSessionId = findPlanSessionId(session.turns, slug, sessions, sessionId);
628
- session.implSessionId = findImplSessionId(slug, sessions, sessionId);
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 sessions = await listSessions(encodedPath);
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/parser/stats.ts
639
- import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
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
- async function loadStatsCache() {
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
- const text = await readFile2(getStatsCachePath(), "utf-8");
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 null;
663
+ return [];
650
664
  }
651
665
  }
652
- async function countProjects() {
666
+ async function listFilesBySuffix(dir, suffix) {
653
667
  try {
654
- const entries = await readdir2(getProjectsDir(), { withFileTypes: true });
655
- return entries.filter((e) => e.isDirectory()).length;
668
+ const files = await readdir(dir);
669
+ return files.filter((file) => file.endsWith(suffix));
656
670
  } catch {
657
- return 0;
671
+ return [];
658
672
  }
659
673
  }
660
- function toDateString(d) {
661
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
662
- }
663
- function todayDateString() {
664
- return toDateString(new Date);
665
- }
666
- function isWithinLastWeek(dateStr) {
667
- const now = new Date;
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
- } catch {}
694
- return { todaySessions, thisWeekSessions };
683
+ }
684
+ sortByIsoDesc(results, (item) => item.mtime);
685
+ return results;
695
686
  }
696
- function buildFromCache(cache, projects) {
697
- const today = todayDateString();
698
- const todayEntry = cache.dailyActivity.find((d) => d.date === today);
699
- const thisWeekEntries = cache.dailyActivity.filter((d) => isWithinLastWeek(d.date));
700
- let inputTokens = 0;
701
- let outputTokens = 0;
702
- let cacheReadTokens = 0;
703
- let cacheCreationTokens = 0;
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
- async function scanStats() {
733
- const [cache, projects, recent] = await Promise.all([
734
- loadStatsCache(),
735
- countProjects(),
736
- countRecentSessions()
737
- ]);
738
- const base = cache ? buildFromCache(cache, projects) : {
739
- projects,
740
- sessions: 0,
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/api/stats.ts
759
- async function handleStats() {
760
- const stats = await scanStats();
761
- return Response.json({ stats });
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(sessionId, encodedPath, agentId);
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: "1.1.0",
777
- commitHash: "b126d9d"
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 acceptRisks = argv.includes("--accept-risks");
802
- const showHelp = argv.includes("--help") || argv.includes("-h");
803
- const claudeCodeDirIdx = argv.indexOf("--claude-code-dir");
804
- if (claudeCodeDirIdx !== -1) {
805
- const dir = argv[claudeCodeDirIdx + 1];
806
- if (!dir || dir.startsWith("-")) {
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
- setClaudeCodeDir(dir);
2018
+ host = val;
811
2019
  }
812
- return { port, acceptRisks, showHelp };
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 Claude Code sessions
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 Skip the startup security warning
826
- --port <number> Server port (default: 3583)
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
- -h, --help Show this help message
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 runs on http://localhost:3583 by default.
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://localhost:${port}`;
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 resolvedDir = getClaudeCodeDir();
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(` Klovi reads Claude Code session history from ${resolvedDir}.`);
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://localhost:${port}${reset}.`);
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 && existsSync(staticDir);
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 (!existsSync2(resolvedDir)) {
1055
- console.error(`Error: Claude Code directory not found: ${resolvedDir}`);
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 = existsSync2(join5(__dirname2, "public", "index.html")) ? join5(__dirname2, "public") : join5(__dirname2, "dist", "public");
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);