@juspay/shooter 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/.claude/hooks/codex-hooks.example.json +75 -0
  2. package/.claude/hooks/notifier.cjs +158 -8
  3. package/build/client/_app/immutable/assets/{0.BZLcOr5z.css → 0.B0O0vCnX.css} +1 -1
  4. package/build/client/_app/immutable/assets/0.B0O0vCnX.css.br +0 -0
  5. package/build/client/_app/immutable/assets/0.B0O0vCnX.css.gz +0 -0
  6. package/build/client/_app/immutable/chunks/{X-tVU_3P.js → BctvtE4d.js} +1 -1
  7. package/build/client/_app/immutable/chunks/BctvtE4d.js.br +0 -0
  8. package/build/client/_app/immutable/chunks/BctvtE4d.js.gz +0 -0
  9. package/build/client/_app/immutable/chunks/BxFShcQO.js +1 -0
  10. package/build/client/_app/immutable/chunks/BxFShcQO.js.br +0 -0
  11. package/build/client/_app/immutable/chunks/BxFShcQO.js.gz +0 -0
  12. package/build/client/_app/immutable/chunks/{gxvWeAns.js → ByzqAuXw.js} +1 -1
  13. package/build/client/_app/immutable/chunks/ByzqAuXw.js.br +0 -0
  14. package/build/client/_app/immutable/chunks/ByzqAuXw.js.gz +0 -0
  15. package/build/client/_app/immutable/chunks/{pMo6RVvN.js → CjfxuHdN.js} +1 -1
  16. package/build/client/_app/immutable/chunks/CjfxuHdN.js.br +0 -0
  17. package/build/client/_app/immutable/chunks/CjfxuHdN.js.gz +0 -0
  18. package/build/client/_app/immutable/entry/{app.B0PrrcUG.js → app.CNaTe-zm.js} +2 -2
  19. package/build/client/_app/immutable/entry/app.CNaTe-zm.js.br +0 -0
  20. package/build/client/_app/immutable/entry/app.CNaTe-zm.js.gz +0 -0
  21. package/build/client/_app/immutable/entry/start.hxYnjcDu.js +1 -0
  22. package/build/client/_app/immutable/entry/start.hxYnjcDu.js.br +0 -0
  23. package/build/client/_app/immutable/entry/start.hxYnjcDu.js.gz +0 -0
  24. package/build/client/_app/immutable/nodes/{0.D4GLHqPM.js → 0.C3ELOf4c.js} +1 -1
  25. package/build/client/_app/immutable/nodes/0.C3ELOf4c.js.br +0 -0
  26. package/build/client/_app/immutable/nodes/0.C3ELOf4c.js.gz +0 -0
  27. package/build/client/_app/immutable/nodes/{1.nJde5z5O.js → 1.Fqso94b3.js} +1 -1
  28. package/build/client/_app/immutable/nodes/1.Fqso94b3.js.br +0 -0
  29. package/build/client/_app/immutable/nodes/1.Fqso94b3.js.gz +0 -0
  30. package/build/client/_app/immutable/nodes/{2.CLtsjLeG.js → 2.BusCVJWk.js} +1 -1
  31. package/build/client/_app/immutable/nodes/2.BusCVJWk.js.br +0 -0
  32. package/build/client/_app/immutable/nodes/2.BusCVJWk.js.gz +0 -0
  33. package/build/client/_app/immutable/nodes/{3.CKTUHtnx.js → 3.DUlpocIc.js} +1 -1
  34. package/build/client/_app/immutable/nodes/3.DUlpocIc.js.br +0 -0
  35. package/build/client/_app/immutable/nodes/3.DUlpocIc.js.gz +0 -0
  36. package/build/client/_app/immutable/nodes/6.CG4eKRH0.js +1 -0
  37. package/build/client/_app/immutable/nodes/6.CG4eKRH0.js.br +0 -0
  38. package/build/client/_app/immutable/nodes/6.CG4eKRH0.js.gz +0 -0
  39. package/build/client/_app/immutable/nodes/7.DHilxD1o.js +4 -0
  40. package/build/client/_app/immutable/nodes/7.DHilxD1o.js.br +0 -0
  41. package/build/client/_app/immutable/nodes/7.DHilxD1o.js.gz +0 -0
  42. package/build/client/_app/immutable/nodes/8.BjKgvSie.js +2 -0
  43. package/build/client/_app/immutable/nodes/8.BjKgvSie.js.br +0 -0
  44. package/build/client/_app/immutable/nodes/8.BjKgvSie.js.gz +0 -0
  45. package/build/client/_app/immutable/nodes/9.BRT6HOXB.js +2 -0
  46. package/build/client/_app/immutable/nodes/9.BRT6HOXB.js.br +0 -0
  47. package/build/client/_app/immutable/nodes/9.BRT6HOXB.js.gz +0 -0
  48. package/build/client/_app/version.json +1 -1
  49. package/build/client/_app/version.json.br +0 -0
  50. package/build/client/_app/version.json.gz +0 -0
  51. package/build/server/chunks/{0-BUSWGJr9.js → 0-BWFSL107.js} +3 -3
  52. package/build/server/chunks/{0-BUSWGJr9.js.map → 0-BWFSL107.js.map} +1 -1
  53. package/build/server/chunks/{1-DjiQE1K0.js → 1-Bw5KlAjL.js} +2 -2
  54. package/build/server/chunks/{1-DjiQE1K0.js.map → 1-Bw5KlAjL.js.map} +1 -1
  55. package/build/server/chunks/{2-ThgVrRKa.js → 2-CQ3yYSVK.js} +2 -2
  56. package/build/server/chunks/{2-ThgVrRKa.js.map → 2-CQ3yYSVK.js.map} +1 -1
  57. package/build/server/chunks/{3-G5LiDFQ9.js → 3-DZ4H9hPs.js} +2 -2
  58. package/build/server/chunks/{3-G5LiDFQ9.js.map → 3-DZ4H9hPs.js.map} +1 -1
  59. package/build/server/chunks/{6--I7fF3Bx.js → 6-BZ0enR6b.js} +2 -2
  60. package/build/server/chunks/6-BZ0enR6b.js.map +1 -0
  61. package/build/server/chunks/{7-BwPLVwOR.js → 7-Lg8imTZn.js} +2 -2
  62. package/build/server/chunks/7-Lg8imTZn.js.map +1 -0
  63. package/build/server/chunks/{8-BwOMHaoQ.js → 8-DKs4yOL7.js} +2 -2
  64. package/build/server/chunks/8-DKs4yOL7.js.map +1 -0
  65. package/build/server/chunks/{9-DkO6aJIB.js → 9-UNmpUWDY.js} +2 -2
  66. package/build/server/chunks/9-UNmpUWDY.js.map +1 -0
  67. package/build/server/chunks/{_server.ts-BuYyCrnF.js → _server.ts-5wx4ZppI.js} +4 -3
  68. package/build/server/chunks/_server.ts-5wx4ZppI.js.map +1 -0
  69. package/build/server/chunks/{_server.ts-40c_epk8.js → _server.ts-B1z0q6qZ.js} +10 -8
  70. package/build/server/chunks/_server.ts-B1z0q6qZ.js.map +1 -0
  71. package/build/server/chunks/{_server.ts-ByPExYfO.js → _server.ts-BLNDdFWC.js} +3 -3
  72. package/build/server/chunks/_server.ts-BLNDdFWC.js.map +1 -0
  73. package/build/server/chunks/_server.ts-BMMTS86y.js +82 -0
  74. package/build/server/chunks/_server.ts-BMMTS86y.js.map +1 -0
  75. package/build/server/chunks/{_server.ts-CjpQ10xh.js → _server.ts-Bt7EAfjo.js} +50 -2
  76. package/build/server/chunks/_server.ts-Bt7EAfjo.js.map +1 -0
  77. package/build/server/chunks/{_server.ts-0Xr2fWaq.js → _server.ts-CKXVBbwb.js} +18 -8
  78. package/build/server/chunks/_server.ts-CKXVBbwb.js.map +1 -0
  79. package/build/server/chunks/{_server.ts-2ixC-X3K.js → _server.ts-CgHc1Zpx.js} +4 -3
  80. package/build/server/chunks/_server.ts-CgHc1Zpx.js.map +1 -0
  81. package/build/server/chunks/{_server.ts-CFX-S_8q.js → _server.ts-DZ5naqSL.js} +2 -2
  82. package/build/server/chunks/{_server.ts-CFX-S_8q.js.map → _server.ts-DZ5naqSL.js.map} +1 -1
  83. package/build/server/chunks/opencode-db-path-BwaPufWf.js +411 -0
  84. package/build/server/chunks/opencode-db-path-BwaPufWf.js.map +1 -0
  85. package/build/server/chunks/{pty-manager-TyMUpDA9.js → pty-manager-RmhVe2Ez.js} +35 -2
  86. package/build/server/chunks/pty-manager-RmhVe2Ez.js.map +1 -0
  87. package/build/server/chunks/qwen-reader-2fTFuC_D.js +622 -0
  88. package/build/server/chunks/qwen-reader-2fTFuC_D.js.map +1 -0
  89. package/build/server/chunks/{_server.ts-CilRds58.js → registry-DzJj2E6I.js} +95 -92
  90. package/build/server/chunks/registry-DzJj2E6I.js.map +1 -0
  91. package/build/server/index.js +1 -1
  92. package/build/server/index.js.map +1 -1
  93. package/build/server/manifest.js +17 -17
  94. package/build/server/manifest.js.map +1 -1
  95. package/package.json +2 -2
  96. package/server.ts +12 -0
  97. package/src/lib/modules/client/common/index.ts +1 -0
  98. package/src/lib/modules/client/common/provider.ts +43 -0
  99. package/src/lib/modules/client/terminal/LaunchSheet.svelte +3 -0
  100. package/src/lib/modules/server/sessions/codex-parser.ts +286 -0
  101. package/src/lib/modules/server/sessions/codex-reader.ts +294 -0
  102. package/src/lib/modules/server/sessions/gemini-reader.ts +571 -0
  103. package/src/lib/modules/server/sessions/opencode-db-path.ts +19 -10
  104. package/src/lib/modules/server/sessions/process-detector.ts +67 -0
  105. package/src/lib/modules/server/sessions/qwen-reader.ts +310 -0
  106. package/src/lib/modules/server/sessions/registry.ts +137 -0
  107. package/src/lib/modules/server/terminal/codex-watcher.ts +182 -0
  108. package/src/lib/modules/server/terminal/pty-manager.ts +41 -0
  109. package/src/lib/modules/server/ws/session-handler.ts +23 -19
  110. package/src/lib/theme.css +54 -1
  111. package/src/lib/types/codex.ts +21 -0
  112. package/src/lib/types/gemini.ts +100 -0
  113. package/src/lib/types/generated/Sessions.ts +24 -1
  114. package/src/lib/types/index.ts +2 -0
  115. package/src/lib/types/server.ts +18 -5
  116. package/src/lib/types/sessions.ts +23 -2
  117. package/src/routes/api/device-token/+server.ts +7 -3
  118. package/src/routes/api/sessions/+server.ts +5 -40
  119. package/src/routes/api/sessions/connect/+server.ts +22 -11
  120. package/src/routes/api/terminals/+server.ts +7 -5
  121. package/src/routes/project/+page.svelte +7 -23
  122. package/src/routes/session/[id]/+page.svelte +3 -3
  123. package/src/routes/terminals/+page.svelte +7 -2
  124. package/src/routes/terminals/[id]/+page.svelte +1 -2
  125. package/build/client/_app/immutable/assets/0.BZLcOr5z.css.br +0 -0
  126. package/build/client/_app/immutable/assets/0.BZLcOr5z.css.gz +0 -0
  127. package/build/client/_app/immutable/chunks/X-tVU_3P.js.br +0 -0
  128. package/build/client/_app/immutable/chunks/X-tVU_3P.js.gz +0 -0
  129. package/build/client/_app/immutable/chunks/gxvWeAns.js.br +0 -0
  130. package/build/client/_app/immutable/chunks/gxvWeAns.js.gz +0 -0
  131. package/build/client/_app/immutable/chunks/pMo6RVvN.js.br +0 -0
  132. package/build/client/_app/immutable/chunks/pMo6RVvN.js.gz +0 -0
  133. package/build/client/_app/immutable/entry/app.B0PrrcUG.js.br +0 -0
  134. package/build/client/_app/immutable/entry/app.B0PrrcUG.js.gz +0 -0
  135. package/build/client/_app/immutable/entry/start.B1obDjVk.js +0 -1
  136. package/build/client/_app/immutable/entry/start.B1obDjVk.js.br +0 -0
  137. package/build/client/_app/immutable/entry/start.B1obDjVk.js.gz +0 -0
  138. package/build/client/_app/immutable/nodes/0.D4GLHqPM.js.br +0 -0
  139. package/build/client/_app/immutable/nodes/0.D4GLHqPM.js.gz +0 -0
  140. package/build/client/_app/immutable/nodes/1.nJde5z5O.js.br +0 -0
  141. package/build/client/_app/immutable/nodes/1.nJde5z5O.js.gz +0 -0
  142. package/build/client/_app/immutable/nodes/2.CLtsjLeG.js.br +0 -0
  143. package/build/client/_app/immutable/nodes/2.CLtsjLeG.js.gz +0 -0
  144. package/build/client/_app/immutable/nodes/3.CKTUHtnx.js.br +0 -0
  145. package/build/client/_app/immutable/nodes/3.CKTUHtnx.js.gz +0 -0
  146. package/build/client/_app/immutable/nodes/6.Cgc5iTlM.js +0 -1
  147. package/build/client/_app/immutable/nodes/6.Cgc5iTlM.js.br +0 -0
  148. package/build/client/_app/immutable/nodes/6.Cgc5iTlM.js.gz +0 -0
  149. package/build/client/_app/immutable/nodes/7.BXKvUopV.js +0 -4
  150. package/build/client/_app/immutable/nodes/7.BXKvUopV.js.br +0 -0
  151. package/build/client/_app/immutable/nodes/7.BXKvUopV.js.gz +0 -0
  152. package/build/client/_app/immutable/nodes/8.Df0leW0d.js +0 -2
  153. package/build/client/_app/immutable/nodes/8.Df0leW0d.js.br +0 -0
  154. package/build/client/_app/immutable/nodes/8.Df0leW0d.js.gz +0 -0
  155. package/build/client/_app/immutable/nodes/9.C4-N3geF.js +0 -2
  156. package/build/client/_app/immutable/nodes/9.C4-N3geF.js.br +0 -0
  157. package/build/client/_app/immutable/nodes/9.C4-N3geF.js.gz +0 -0
  158. package/build/server/chunks/6--I7fF3Bx.js.map +0 -1
  159. package/build/server/chunks/7-BwPLVwOR.js.map +0 -1
  160. package/build/server/chunks/8-BwOMHaoQ.js.map +0 -1
  161. package/build/server/chunks/9-DkO6aJIB.js.map +0 -1
  162. package/build/server/chunks/_server.ts-0Xr2fWaq.js.map +0 -1
  163. package/build/server/chunks/_server.ts-2ixC-X3K.js.map +0 -1
  164. package/build/server/chunks/_server.ts-40c_epk8.js.map +0 -1
  165. package/build/server/chunks/_server.ts-BuYyCrnF.js.map +0 -1
  166. package/build/server/chunks/_server.ts-ByPExYfO.js.map +0 -1
  167. package/build/server/chunks/_server.ts-CilRds58.js.map +0 -1
  168. package/build/server/chunks/_server.ts-CjpQ10xh.js.map +0 -1
  169. package/build/server/chunks/opencode-db-path-DcfhJtJy.js +0 -15
  170. package/build/server/chunks/opencode-db-path-DcfhJtJy.js.map +0 -1
  171. package/build/server/chunks/pty-manager-TyMUpDA9.js.map +0 -1
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Gemini CLI session reader — listing + conversation retrieval.
3
+ *
4
+ * Mirrors codex-reader.ts (Codex) for structure/conventions: produces the
5
+ * same SessionInfo / ProjectGroup / ConversationMessage shapes so the rest of
6
+ * the app is provider-agnostic.
7
+ *
8
+ * Data sources (in preference order per session):
9
+ * 1. ~/.gemini/tmp/<projectHash>/chats/session-*.json
10
+ * Full ConversationRecord (user + model + tool calls + thoughts).
11
+ * Present only in newer gemini-cli versions; NOT present on older installs.
12
+ * 2. ~/.gemini/tmp/<projectHash>/logs.json
13
+ * User-messages-only JSON array (LogEntry[]). Always present.
14
+ *
15
+ * Project hash → CWD reverse-lookup:
16
+ * - Read ~/.gemini/projects.json if present (slug → absolute path, new format).
17
+ * - For SHA-256 hash dirs (old format), the hash is irreversible without
18
+ * brute-force; we surface the hash itself as the projectPath in that case.
19
+ */
20
+
21
+ import type {
22
+ ConversationMessage,
23
+ GeminiConversationRecord,
24
+ GeminiLogEntry,
25
+ GeminiMessageRecord,
26
+ GeminiProjectsJson,
27
+ MessagePart,
28
+ ProjectGroup,
29
+ SessionInfo,
30
+ } from '$lib/types';
31
+
32
+ import * as crypto from 'crypto';
33
+ import * as fs from 'fs';
34
+ import { homedir } from 'os';
35
+ import * as path from 'path';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Constants
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Files above this size are SKIPPED, not truncated — these are single JSON
43
+ * documents/arrays, so a partial read would be invalid JSON. Gemini session
44
+ * files are small in practice; this is only an OOM backstop.
45
+ */
46
+ const MAX_GEMINI_FILE_BYTES = 64 * 1024 * 1024; // 64 MB
47
+
48
+ /** Gemini tmp root. */
49
+ const GEMINI_TMP = path.join(homedir(), '.gemini', 'tmp');
50
+
51
+ /** Gemini projects registry (slug → absolute path). */
52
+ const GEMINI_PROJECTS_JSON = path.join(homedir(), '.gemini', 'projects.json');
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Public API
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Return sessions whose logs.json or chat file was written within `thresholdMs`
60
+ * of now — i.e. sessions that are currently (or very recently) active.
61
+ */
62
+ export function detectActiveGeminiSessions(
63
+ thresholdMs: number
64
+ ): { cwd: string; id: string; startedAt: number }[] {
65
+ const cutoff = Date.now() - thresholdMs;
66
+ const projectHashToCwd = buildHashToCwdMap();
67
+ const projectDirs = collectProjectHashDirs();
68
+ const out: { cwd: string; id: string; startedAt: number }[] = [];
69
+
70
+ for (const hashDir of projectDirs) {
71
+ const hash = path.basename(hashDir);
72
+ const cwd = projectHashToCwd.get(hash) ?? hash;
73
+
74
+ // Check chats/session-*.json files.
75
+ const chatFiles = collectChatFiles(hashDir);
76
+ for (const chatFile of chatFiles) {
77
+ try {
78
+ const stat = fs.statSync(chatFile);
79
+ if (stat.mtimeMs < cutoff) {
80
+ continue;
81
+ }
82
+ const record = readChatFile(chatFile);
83
+ if (record) {
84
+ out.push({
85
+ cwd,
86
+ id: record.sessionId,
87
+ startedAt: new Date(record.startTime).getTime(),
88
+ });
89
+ }
90
+ } catch {
91
+ // skip unreadable files
92
+ }
93
+ }
94
+
95
+ // Also check logs.json mtime (covers old-format sessions).
96
+ const logsPath = path.join(hashDir, 'logs.json');
97
+ try {
98
+ const stat = fs.statSync(logsPath);
99
+ if (stat.mtimeMs < cutoff) {
100
+ continue;
101
+ }
102
+ // Surface the most recent session in this logs.json.
103
+ const entries = readLogsJson(logsPath);
104
+ if (entries.length > 0) {
105
+ const last = entries[entries.length - 1];
106
+ if (last) {
107
+ // Only add if not already captured from a chat file.
108
+ const alreadyAdded = out.some((o) => o.id === last.sessionId && o.cwd === cwd);
109
+ if (!alreadyAdded) {
110
+ out.push({
111
+ cwd,
112
+ id: last.sessionId,
113
+ startedAt: new Date(entries[0]?.timestamp ?? last.timestamp).getTime(),
114
+ });
115
+ }
116
+ }
117
+ }
118
+ } catch {
119
+ // logs.json missing or unreadable — skip
120
+ }
121
+ }
122
+
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Return the conversation messages for a given Gemini session ID.
128
+ * Prefers chats/session-*.json (full record) over logs.json (user-only).
129
+ * Mirrors getCodexConversation() pagination behaviour.
130
+ */
131
+ export function getGeminiConversation(
132
+ sessionId: string,
133
+ offset = 0,
134
+ limit = 200
135
+ ): ConversationMessage[] {
136
+ const projectDirs = collectProjectHashDirs();
137
+
138
+ for (const hashDir of projectDirs) {
139
+ // Try full chat record first.
140
+ const chatFile = findChatFileForSession(hashDir, sessionId);
141
+ if (chatFile) {
142
+ return conversationFromChatFile(chatFile, offset, limit);
143
+ }
144
+ }
145
+
146
+ // Fall back to logs.json user-only messages.
147
+ for (const hashDir of projectDirs) {
148
+ const messages = conversationFromLogsJson(hashDir, sessionId);
149
+ if (messages.length > 0) {
150
+ if (offset === 0 && messages.length > limit) {
151
+ return messages.slice(messages.length - limit);
152
+ }
153
+ return messages.slice(offset, offset + limit);
154
+ }
155
+ }
156
+
157
+ return [];
158
+ }
159
+
160
+ /**
161
+ * List all Gemini CLI sessions grouped by project, sorted by most-recently-
162
+ * modified first. Mirrors listCodexProjects() from codex-reader.ts.
163
+ */
164
+ export function listGeminiProjects(): ProjectGroup[] {
165
+ const projectHashToCwd = buildHashToCwdMap();
166
+ const projectDirs = collectProjectHashDirs();
167
+
168
+ const byCwd = new Map<string, SessionInfo[]>();
169
+
170
+ for (const hashDir of projectDirs) {
171
+ const hash = path.basename(hashDir);
172
+ const cwd = projectHashToCwd.get(hash) ?? hash;
173
+
174
+ // Prefer full chat records if any exist; fall back to logs.json.
175
+ const chatSessions = collectChatSessions(hashDir, cwd);
176
+ if (chatSessions.length > 0) {
177
+ for (const session of chatSessions) {
178
+ appendToMap(byCwd, cwd, session);
179
+ }
180
+ continue;
181
+ }
182
+
183
+ // Fall back: derive sessions from logs.json.
184
+ const logSessions = sessionsFromLogsJson(hashDir, cwd);
185
+ for (const session of logSessions) {
186
+ appendToMap(byCwd, cwd, session);
187
+ }
188
+ }
189
+
190
+ const projects: ProjectGroup[] = [];
191
+ for (const [cwd, sessions] of byCwd) {
192
+ sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
193
+ const segments = cwd.split('/').filter(Boolean);
194
+ // When the project hash couldn't be reverse-mapped to a real cwd, show a
195
+ // short friendly label instead of the raw 64-char SHA-256 hash.
196
+ const isRawHash = /^[0-9a-f]{64}$/i.test(cwd);
197
+ projects.push({
198
+ fullPath: cwd,
199
+ id: shortHash(cwd),
200
+ lastModified: sessions[0]?.modified ?? '',
201
+ name: isRawHash ? `Gemini (${cwd.slice(0, 8)})` : segments.slice(-2).join('/'),
202
+ sessionCount: sessions.length,
203
+ sessions,
204
+ });
205
+ }
206
+
207
+ return projects.sort(
208
+ (a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
209
+ );
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Session building helpers
214
+ // ---------------------------------------------------------------------------
215
+
216
+ function appendToMap<V>(map: Map<string, V[]>, key: string, value: V): void {
217
+ let bucket = map.get(key);
218
+ if (!bucket) {
219
+ bucket = [];
220
+ map.set(key, bucket);
221
+ }
222
+ bucket.push(value);
223
+ }
224
+
225
+ /**
226
+ * Build a SHA-256 projectHash → cwd map by reading ~/.gemini/projects.json.
227
+ * Returns an empty map if the file is absent (old gemini-cli installs).
228
+ */
229
+ function buildHashToCwdMap(): Map<string, string> {
230
+ const map = new Map<string, string>();
231
+ try {
232
+ const raw = fs.readFileSync(GEMINI_PROJECTS_JSON, 'utf-8');
233
+ const data = JSON.parse(raw) as GeminiProjectsJson;
234
+ for (const [slugOrHash, absolutePath] of Object.entries(data)) {
235
+ // projects.json may use either the slug or the full SHA-256 hash as key.
236
+ map.set(slugOrHash, absolutePath);
237
+ // Pre-compute SHA-256(absolutePath) → absolutePath as well so that
238
+ // old hash-named directories match even when projects.json uses slugs.
239
+ const computed = crypto.createHash('sha256').update(absolutePath).digest('hex');
240
+ map.set(computed, absolutePath);
241
+ }
242
+ } catch {
243
+ // File absent or malformed — that's fine for old gemini-cli installs.
244
+ }
245
+ return map;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Conversation building helpers
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function cleanTitle(text: string): string {
253
+ const first = text.replace(/\s+/g, ' ').trim().split('\n')[0]?.trim() ?? '';
254
+ if (!first) {
255
+ return 'Untitled Session';
256
+ }
257
+ return first.length > 80 ? `${first.slice(0, 77)}...` : first;
258
+ }
259
+
260
+ /** Enumerate all chats/session-*.json files under a project hash dir. */
261
+ function collectChatFiles(hashDir: string): string[] {
262
+ const chatsDir = path.join(hashDir, 'chats');
263
+ try {
264
+ return fs
265
+ .readdirSync(chatsDir, { withFileTypes: true })
266
+ .filter((e) => e.isFile() && e.name.startsWith('session-') && e.name.endsWith('.json'))
267
+ .map((e) => path.join(chatsDir, e.name));
268
+ } catch {
269
+ return [];
270
+ }
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // ConversationRecord → ConversationMessage mapping
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /**
278
+ * Build SessionInfo records for each distinct sessionId found in a
279
+ * chats/session-*.json file under hashDir.
280
+ */
281
+ function collectChatSessions(hashDir: string, cwd: string): SessionInfo[] {
282
+ const chatFiles = collectChatFiles(hashDir);
283
+ const sessions: SessionInfo[] = [];
284
+
285
+ for (const chatFile of chatFiles) {
286
+ try {
287
+ const stat = fs.statSync(chatFile);
288
+ const record = readChatFile(chatFile);
289
+ if (!record) {
290
+ continue;
291
+ }
292
+
293
+ const userMessages = record.messages.filter((m) => m.type === 'user');
294
+ const firstUserMsg = userMessages[0];
295
+ let title = 'Untitled Session';
296
+ if (firstUserMsg) {
297
+ title = cleanTitle(extractTextFromContent(firstUserMsg.content));
298
+ }
299
+
300
+ sessions.push({
301
+ created: record.startTime,
302
+ gitBranch: '',
303
+ id: record.sessionId,
304
+ messageCount: record.messages.length,
305
+ modified: stat.mtime.toISOString(),
306
+ projectPath: cwd,
307
+ source: 'gemini' as const,
308
+ summary: record.summary ?? '',
309
+ title,
310
+ });
311
+ } catch {
312
+ // skip unreadable chat files
313
+ }
314
+ }
315
+
316
+ return sessions;
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // File I/O helpers
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /** Enumerate all ~/.gemini/tmp/<hash>/ directories. */
324
+ function collectProjectHashDirs(): string[] {
325
+ try {
326
+ return fs
327
+ .readdirSync(GEMINI_TMP, { withFileTypes: true })
328
+ .filter((e) => e.isDirectory())
329
+ .map((e) => path.join(GEMINI_TMP, e.name));
330
+ } catch {
331
+ return [];
332
+ }
333
+ }
334
+
335
+ function conversationFromChatFile(
336
+ chatFile: string,
337
+ offset: number,
338
+ limit: number
339
+ ): ConversationMessage[] {
340
+ try {
341
+ const record = readChatFile(chatFile);
342
+ if (!record) {
343
+ return [];
344
+ }
345
+
346
+ const messages = record.messages
347
+ .filter((m) => m.type === 'user' || m.type === 'gemini')
348
+ .map(messageRecordToMessage);
349
+
350
+ if (offset === 0 && messages.length > limit) {
351
+ let startIdx = messages.length - limit;
352
+ while (startIdx > 0 && messages[startIdx]?.role !== 'user') {
353
+ startIdx--;
354
+ }
355
+ return messages.slice(startIdx);
356
+ }
357
+ return messages.slice(offset, offset + limit);
358
+ } catch (err) {
359
+ console.error('[gemini] Failed to read chat file:', err);
360
+ return [];
361
+ }
362
+ }
363
+
364
+ function conversationFromLogsJson(hashDir: string, sessionId: string): ConversationMessage[] {
365
+ const logsPath = path.join(hashDir, 'logs.json');
366
+ const entries = readLogsJson(logsPath);
367
+ return entries
368
+ .filter((e) => e.sessionId === sessionId)
369
+ .map(
370
+ (entry): ConversationMessage => ({
371
+ id: `${entry.sessionId}-${String(entry.messageId)}`,
372
+ parts: [{ content: entry.message, type: 'text' }],
373
+ role: 'user',
374
+ timestamp: entry.timestamp,
375
+ })
376
+ );
377
+ }
378
+
379
+ /** Extract plain-text from a GeminiMessageRecord content field. */
380
+ function extractTextFromContent(
381
+ content: GeminiConversationRecord['messages'][0]['content']
382
+ ): string {
383
+ if (typeof content === 'string') {
384
+ return content;
385
+ }
386
+ return content
387
+ .filter((p): p is { text: string } => 'text' in p)
388
+ .map((p) => p.text)
389
+ .join(' ');
390
+ }
391
+
392
+ /**
393
+ * Find the chats/session-*.json file for a specific sessionId.
394
+ * The filename embeds the first 8 chars of the UUID; we fall back to reading
395
+ * every file in the directory if necessary.
396
+ */
397
+ function findChatFileForSession(hashDir: string, sessionId: string): null | string {
398
+ const shortId = sessionId.slice(0, 8);
399
+ const chatsDir = path.join(hashDir, 'chats');
400
+ try {
401
+ const entries = fs.readdirSync(chatsDir, { withFileTypes: true });
402
+ for (const entry of entries) {
403
+ if (!entry.isFile() || !entry.name.startsWith('session-') || !entry.name.endsWith('.json')) {
404
+ continue;
405
+ }
406
+ // Fast path: filename contains shortId.
407
+ if (entry.name.includes(shortId)) {
408
+ return path.join(chatsDir, entry.name);
409
+ }
410
+ }
411
+ // Slow path: read each file and check the sessionId field.
412
+ for (const entry of entries) {
413
+ if (!entry.isFile() || !entry.name.startsWith('session-') || !entry.name.endsWith('.json')) {
414
+ continue;
415
+ }
416
+ const record = readChatFile(path.join(chatsDir, entry.name));
417
+ if (record?.sessionId === sessionId) {
418
+ return path.join(chatsDir, entry.name);
419
+ }
420
+ }
421
+ } catch {
422
+ // chats dir missing or unreadable
423
+ }
424
+ return null;
425
+ }
426
+
427
+ function isGeminiLogEntry(v: unknown): v is GeminiLogEntry {
428
+ if (typeof v !== 'object' || v === null) {
429
+ return false;
430
+ }
431
+ const obj = v as Record<string, unknown>;
432
+ return (
433
+ typeof obj.sessionId === 'string' &&
434
+ typeof obj.messageId === 'number' &&
435
+ typeof obj.message === 'string' &&
436
+ typeof obj.timestamp === 'string'
437
+ );
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Utility helpers
442
+ // ---------------------------------------------------------------------------
443
+
444
+ function messageRecordToMessage(record: GeminiMessageRecord): ConversationMessage {
445
+ const role: 'assistant' | 'user' = record.type === 'user' ? 'user' : 'assistant';
446
+ const parts: MessagePart[] = [];
447
+
448
+ // 1. Thought summaries (only on 'gemini'-type messages).
449
+ if (record.type === 'gemini' && record.thoughts) {
450
+ for (const thought of record.thoughts) {
451
+ parts.push({ content: thought.summary ?? '', type: 'thinking' });
452
+ }
453
+ }
454
+
455
+ // 2. Content parts.
456
+ const rawContent = record.content;
457
+ const contentParts = typeof rawContent === 'string' ? [{ text: rawContent }] : rawContent;
458
+ for (const part of contentParts) {
459
+ if ('functionCall' in part) {
460
+ parts.push({
461
+ id: part.functionCall.id ?? part.functionCall.name,
462
+ input: part.functionCall.args,
463
+ toolName: part.functionCall.name,
464
+ type: 'tool_use',
465
+ });
466
+ } else if ('thought' in part && part.thought === true) {
467
+ parts.push({ content: part.text, type: 'thinking' });
468
+ } else if ('text' in part) {
469
+ parts.push({ content: part.text, type: 'text' });
470
+ }
471
+ }
472
+
473
+ // 3. Tool calls array (avoid duplicating entries already in content parts).
474
+ if (record.type === 'gemini' && record.toolCalls) {
475
+ for (const tc of record.toolCalls) {
476
+ const alreadyInParts = parts.some((p) => p.type === 'tool_use' && p.id === tc.id);
477
+ if (!alreadyInParts) {
478
+ parts.push({
479
+ id: tc.id,
480
+ input: tc.args,
481
+ toolName: tc.name,
482
+ type: 'tool_use',
483
+ });
484
+ }
485
+ }
486
+ }
487
+
488
+ return {
489
+ id: record.id,
490
+ parts,
491
+ role,
492
+ timestamp: record.timestamp,
493
+ };
494
+ }
495
+
496
+ /** Read and parse a chats/session-*.json file (a single JSON object). */
497
+ function readChatFile(filePath: string): GeminiConversationRecord | null {
498
+ try {
499
+ if (fs.statSync(filePath).size > MAX_GEMINI_FILE_BYTES) {
500
+ return null; // too large to parse safely as one JSON document
501
+ }
502
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GeminiConversationRecord;
503
+ } catch {
504
+ return null;
505
+ }
506
+ }
507
+
508
+ /** Read and parse ~/.gemini/tmp/<hash>/logs.json (a single JSON array). */
509
+ function readLogsJson(logsPath: string): GeminiLogEntry[] {
510
+ try {
511
+ if (fs.statSync(logsPath).size > MAX_GEMINI_FILE_BYTES) {
512
+ return [];
513
+ }
514
+ const parsed: unknown = JSON.parse(fs.readFileSync(logsPath, 'utf-8'));
515
+ return Array.isArray(parsed) ? parsed.filter(isGeminiLogEntry) : [];
516
+ } catch {
517
+ return [];
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Build SessionInfo records for each distinct sessionId found in logs.json.
523
+ */
524
+ function sessionsFromLogsJson(hashDir: string, cwd: string): SessionInfo[] {
525
+ const logsPath = path.join(hashDir, 'logs.json');
526
+ let stat: fs.Stats;
527
+ try {
528
+ stat = fs.statSync(logsPath);
529
+ } catch {
530
+ return [];
531
+ }
532
+
533
+ const entries = readLogsJson(logsPath);
534
+ if (entries.length === 0) {
535
+ return [];
536
+ }
537
+
538
+ // Group by sessionId; preserve insertion order (chronological).
539
+ const bySession = new Map<string, GeminiLogEntry[]>();
540
+ for (const entry of entries) {
541
+ let bucket = bySession.get(entry.sessionId);
542
+ if (!bucket) {
543
+ bucket = [];
544
+ bySession.set(entry.sessionId, bucket);
545
+ }
546
+ bucket.push(entry);
547
+ }
548
+
549
+ const sessions: SessionInfo[] = [];
550
+ for (const [sessionId, sessionEntries] of bySession) {
551
+ const first = sessionEntries[0];
552
+ const last = sessionEntries[sessionEntries.length - 1];
553
+ sessions.push({
554
+ created: first?.timestamp ?? stat.birthtime.toISOString(),
555
+ gitBranch: '',
556
+ id: sessionId,
557
+ messageCount: sessionEntries.length,
558
+ modified: last?.timestamp ?? stat.mtime.toISOString(),
559
+ projectPath: cwd,
560
+ source: 'gemini' as const,
561
+ summary: '',
562
+ title: cleanTitle(first?.message ?? ''),
563
+ });
564
+ }
565
+
566
+ return sessions;
567
+ }
568
+
569
+ function shortHash(input: string): string {
570
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
571
+ }
@@ -1,26 +1,35 @@
1
+ import { existsSync } from 'fs';
1
2
  import * as path from 'path';
2
3
 
3
4
  /**
4
5
  * Resolve the path to the OpenCode SQLite database.
5
6
  *
6
- * Checks in this order:
7
+ * Probes candidate locations and returns the first that EXISTS (the previous
8
+ * version returned the macOS path unconditionally on darwin, which hid the DB
9
+ * for installs that use the XDG ~/.local/share path — observed in the wild).
10
+ * Candidate order:
7
11
  * 1. XDG_DATA_HOME/opencode/opencode.db (honours XDG override)
8
- * 2. ~/Library/Application Support/opencode/opencode.db (legacy macOS path)
9
- * 3. ~/.local/share/opencode/opencode.db (XDG default)
12
+ * 2. ~/.local/share/opencode/opencode.db (XDG default — common even on macOS)
13
+ * 3. ~/Library/Application Support/opencode/opencode.db (legacy macOS path)
10
14
  */
11
15
  export function resolveOpenCodeDbPath(): string {
12
16
  const home = process.env.HOME || '';
17
+ const candidates: string[] = [];
13
18
 
14
- // 1. If XDG_DATA_HOME is explicitly set, use it
15
19
  if (process.env.XDG_DATA_HOME) {
16
- return path.join(process.env.XDG_DATA_HOME, 'opencode', 'opencode.db');
20
+ candidates.push(path.join(process.env.XDG_DATA_HOME, 'opencode', 'opencode.db'));
17
21
  }
18
-
19
- // 2. Legacy macOS path (~/Library/Application Support/opencode/)
22
+ candidates.push(path.join(home, '.local', 'share', 'opencode', 'opencode.db'));
20
23
  if (process.platform === 'darwin') {
21
- return path.join(home, 'Library', 'Application Support', 'opencode', 'opencode.db');
24
+ candidates.push(path.join(home, 'Library', 'Application Support', 'opencode', 'opencode.db'));
25
+ }
26
+
27
+ for (const candidate of candidates) {
28
+ if (existsSync(candidate)) {
29
+ return candidate;
30
+ }
22
31
  }
23
32
 
24
- // 3. XDG default (~/.local/share/opencode/)
25
- return path.join(home, '.local', 'share', 'opencode', 'opencode.db');
33
+ // None exist yet — return the preferred default; callers tolerate a missing file.
34
+ return candidates[0] ?? path.join(home, '.local', 'share', 'opencode', 'opencode.db');
26
35
  }
@@ -1,6 +1,9 @@
1
1
  import type { ClaudeSessionFile, DetectedProcess } from '$lib/types';
2
2
 
3
+ import { detectActiveCodexSessions } from '$lib/modules/server/sessions/codex-reader';
4
+ import { detectActiveGeminiSessions } from '$lib/modules/server/sessions/gemini-reader';
3
5
  import { resolveOpenCodeDbPath } from '$lib/modules/server/sessions/opencode-db-path';
6
+ import { detectActiveQwenSessions } from '$lib/modules/server/sessions/qwen-reader';
4
7
  import Database from 'better-sqlite3';
5
8
  import { execSync } from 'child_process';
6
9
  import { existsSync, readdirSync, readFileSync } from 'fs';
@@ -45,6 +48,15 @@ const CLAUDE_SESSIONS_DIR = join(homedir(), '.claude', 'sessions');
45
48
  // OpenCode sessions updated within this window are considered "live"
46
49
  const OPENCODE_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
47
50
 
51
+ // Codex rollout files written within this window are considered "live"
52
+ const CODEX_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
53
+
54
+ // Gemini session files written within this window are considered "live"
55
+ const GEMINI_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
56
+
57
+ // Qwen session files written within this window are considered "live"
58
+ const QWEN_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
59
+
48
60
  /**
49
61
  * Scan ~/.claude/sessions/*.json to find running Claude Code processes,
50
62
  * and query the OpenCode SQLite DB for recently active sessions.
@@ -137,6 +149,61 @@ export function detectRunningAISessions(): DetectedProcess[] {
137
149
  }
138
150
  }
139
151
 
152
+ // --- Codex sessions ---
153
+ // Codex has no PID file; a rollout file written in the last few minutes
154
+ // indicates an active session. cwd/id come from its session_meta line.
155
+ try {
156
+ for (const s of detectActiveCodexSessions(CODEX_ACTIVE_THRESHOLD_MS)) {
157
+ results.push({
158
+ command: 'codex',
159
+ cwd: s.cwd,
160
+ kind: 'interactive',
161
+ pid: 0, // Codex doesn't expose a per-session PID
162
+ projectPath: cwdToProjectPath(s.cwd),
163
+ sessionId: s.id,
164
+ startedAt: s.startedAt,
165
+ });
166
+ }
167
+ } catch {
168
+ // ~/.codex/sessions missing or unreadable — skip silently
169
+ }
170
+
171
+ // --- Gemini sessions ---
172
+ // Gemini has no PID file; a logs.json / chat file written in the last few
173
+ // minutes indicates an active session. cwd is reverse-mapped where possible.
174
+ try {
175
+ for (const s of detectActiveGeminiSessions(GEMINI_ACTIVE_THRESHOLD_MS)) {
176
+ results.push({
177
+ command: 'gemini',
178
+ cwd: s.cwd,
179
+ kind: 'interactive',
180
+ pid: 0, // Gemini doesn't expose a per-session PID
181
+ projectPath: cwdToProjectPath(s.cwd),
182
+ sessionId: s.id,
183
+ startedAt: s.startedAt,
184
+ });
185
+ }
186
+ } catch {
187
+ // ~/.gemini/tmp missing or unreadable — skip silently
188
+ }
189
+
190
+ // --- Qwen sessions ---
191
+ try {
192
+ for (const s of detectActiveQwenSessions(QWEN_ACTIVE_THRESHOLD_MS)) {
193
+ results.push({
194
+ command: 'qwen',
195
+ cwd: s.cwd,
196
+ kind: 'interactive',
197
+ pid: 0,
198
+ projectPath: cwdToProjectPath(s.cwd),
199
+ sessionId: s.id,
200
+ startedAt: s.startedAt,
201
+ });
202
+ }
203
+ } catch {
204
+ // ~/.qwen/projects missing or unreadable — skip silently
205
+ }
206
+
140
207
  // Sort by startedAt descending (most recent first)
141
208
  results.sort((a, b) => b.startedAt - a.startedAt);
142
209