@juspay/shooter 1.14.0 → 1.16.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 (151) hide show
  1. package/build/client/_app/immutable/assets/{0.BZLcOr5z.css → 0.DEfoFaGR.css} +1 -1
  2. package/build/client/_app/immutable/assets/0.DEfoFaGR.css.br +0 -0
  3. package/build/client/_app/immutable/assets/0.DEfoFaGR.css.gz +0 -0
  4. package/build/client/_app/immutable/chunks/Bkqjn62J.js +1 -0
  5. package/build/client/_app/immutable/chunks/Bkqjn62J.js.br +1 -0
  6. package/build/client/_app/immutable/chunks/Bkqjn62J.js.gz +0 -0
  7. package/build/client/_app/immutable/chunks/{D8sAtVC-.js → DOHhmtDH.js} +1 -1
  8. package/build/client/_app/immutable/chunks/DOHhmtDH.js.br +0 -0
  9. package/build/client/_app/immutable/chunks/DOHhmtDH.js.gz +0 -0
  10. package/build/client/_app/immutable/chunks/{Bfg0k16X.js → DlS3abGJ.js} +1 -1
  11. package/build/client/_app/immutable/chunks/DlS3abGJ.js.br +0 -0
  12. package/build/client/_app/immutable/chunks/DlS3abGJ.js.gz +0 -0
  13. package/build/client/_app/immutable/entry/{app.rri2K7zq.js → app.CSJG7N9H.js} +2 -2
  14. package/build/client/_app/immutable/entry/app.CSJG7N9H.js.br +0 -0
  15. package/build/client/_app/immutable/entry/app.CSJG7N9H.js.gz +0 -0
  16. package/build/client/_app/immutable/entry/start.CTt1901T.js +1 -0
  17. package/build/client/_app/immutable/entry/start.CTt1901T.js.br +2 -0
  18. package/build/client/_app/immutable/entry/start.CTt1901T.js.gz +0 -0
  19. package/build/client/_app/immutable/nodes/{0.DoDuvMbN.js → 0.qOL7xtFn.js} +1 -1
  20. package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.br +0 -0
  21. package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.gz +0 -0
  22. package/build/client/_app/immutable/nodes/{1.DZ0g9EOo.js → 1.Di708Ago.js} +1 -1
  23. package/build/client/_app/immutable/nodes/1.Di708Ago.js.br +0 -0
  24. package/build/client/_app/immutable/nodes/1.Di708Ago.js.gz +0 -0
  25. package/build/client/_app/immutable/nodes/{2.COrzaySY.js → 2.DSM1znqa.js} +1 -1
  26. package/build/client/_app/immutable/nodes/2.DSM1znqa.js.br +0 -0
  27. package/build/client/_app/immutable/nodes/2.DSM1znqa.js.gz +0 -0
  28. package/build/client/_app/immutable/nodes/{3.sGijgjBd.js → 3.BPa5fh75.js} +1 -1
  29. package/build/client/_app/immutable/nodes/3.BPa5fh75.js.br +0 -0
  30. package/build/client/_app/immutable/nodes/3.BPa5fh75.js.gz +0 -0
  31. package/build/client/_app/immutable/nodes/6.B1LwwEF-.js +1 -0
  32. package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.br +0 -0
  33. package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.gz +0 -0
  34. package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js +4 -0
  35. package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.br +0 -0
  36. package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.gz +0 -0
  37. package/build/client/_app/immutable/nodes/8.CG0mrgBU.js +2 -0
  38. package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.br +0 -0
  39. package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.gz +0 -0
  40. package/build/client/_app/immutable/nodes/9.KwzWaMHj.js +2 -0
  41. package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.br +0 -0
  42. package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.gz +0 -0
  43. package/build/client/_app/version.json +1 -1
  44. package/build/client/_app/version.json.br +0 -0
  45. package/build/client/_app/version.json.gz +0 -0
  46. package/build/server/chunks/{0-Cs7l_or5.js → 0-D8uPamNd.js} +3 -3
  47. package/build/server/chunks/{0-Cs7l_or5.js.map → 0-D8uPamNd.js.map} +1 -1
  48. package/build/server/chunks/{1-DrEnteaQ.js → 1-DhtioHbs.js} +2 -2
  49. package/build/server/chunks/{1-DrEnteaQ.js.map → 1-DhtioHbs.js.map} +1 -1
  50. package/build/server/chunks/{2-BxYq6tAd.js → 2-Cgh7ZFgE.js} +2 -2
  51. package/build/server/chunks/{2-BxYq6tAd.js.map → 2-Cgh7ZFgE.js.map} +1 -1
  52. package/build/server/chunks/{3-DvOZrxt7.js → 3-I6hnjssH.js} +2 -2
  53. package/build/server/chunks/{3-DvOZrxt7.js.map → 3-I6hnjssH.js.map} +1 -1
  54. package/build/server/chunks/{6-DyP4lraz.js → 6-bPDbH1_W.js} +2 -2
  55. package/build/server/chunks/{6-DyP4lraz.js.map → 6-bPDbH1_W.js.map} +1 -1
  56. package/build/server/chunks/{7-CS9SCFyS.js → 7-CpBrOkxQ.js} +2 -2
  57. package/build/server/chunks/{7-CS9SCFyS.js.map → 7-CpBrOkxQ.js.map} +1 -1
  58. package/build/server/chunks/{8-Np7VqiBA.js → 8-BRGAVfze.js} +2 -2
  59. package/build/server/chunks/{8-Np7VqiBA.js.map → 8-BRGAVfze.js.map} +1 -1
  60. package/build/server/chunks/{9-C55g3WsT.js → 9-C6xuAb_Y.js} +2 -2
  61. package/build/server/chunks/{9-C55g3WsT.js.map → 9-C6xuAb_Y.js.map} +1 -1
  62. package/build/server/chunks/{_server.ts-ByPExYfO.js → _server.ts-BLNDdFWC.js} +3 -3
  63. package/build/server/chunks/_server.ts-BLNDdFWC.js.map +1 -0
  64. package/build/server/chunks/{_server.ts-40c_epk8.js → _server.ts-BrRZXr-8.js} +5 -4
  65. package/build/server/chunks/_server.ts-BrRZXr-8.js.map +1 -0
  66. package/build/server/chunks/{_server.ts-BuYyCrnF.js → _server.ts-C6xbNz6d.js} +4 -3
  67. package/build/server/chunks/_server.ts-C6xbNz6d.js.map +1 -0
  68. package/build/server/chunks/{_server.ts-CjpQ10xh.js → _server.ts-CjK0g9dO.js} +18 -2
  69. package/build/server/chunks/_server.ts-CjK0g9dO.js.map +1 -0
  70. package/build/server/chunks/{_server.ts-0Xr2fWaq.js → _server.ts-Cq9_scaV.js} +17 -7
  71. package/build/server/chunks/_server.ts-Cq9_scaV.js.map +1 -0
  72. package/build/server/chunks/{_server.ts-CilRds58.js → _server.ts-D--_NXt2.js} +25 -16
  73. package/build/server/chunks/_server.ts-D--_NXt2.js.map +1 -0
  74. package/build/server/chunks/{_server.ts-2ixC-X3K.js → _server.ts-Dekgb6Hx.js} +4 -3
  75. package/build/server/chunks/_server.ts-Dekgb6Hx.js.map +1 -0
  76. package/build/server/chunks/opencode-db-path-CRgzBK5U.js +402 -0
  77. package/build/server/chunks/opencode-db-path-CRgzBK5U.js.map +1 -0
  78. package/build/server/chunks/{pty-manager-TyMUpDA9.js → pty-manager-aFpChJah.js} +35 -2
  79. package/build/server/chunks/pty-manager-aFpChJah.js.map +1 -0
  80. package/build/server/index.js +1 -1
  81. package/build/server/index.js.map +1 -1
  82. package/build/server/manifest.js +16 -16
  83. package/build/server/manifest.js.map +1 -1
  84. package/package.json +2 -2
  85. package/server.ts +12 -0
  86. package/src/lib/modules/client/common/index.ts +1 -0
  87. package/src/lib/modules/client/common/provider.ts +30 -0
  88. package/src/lib/modules/client/terminal/LaunchSheet.svelte +2 -0
  89. package/src/lib/modules/server/sessions/codex-parser.ts +286 -0
  90. package/src/lib/modules/server/sessions/codex-reader.ts +293 -0
  91. package/src/lib/modules/server/sessions/process-detector.ts +23 -0
  92. package/src/lib/modules/server/terminal/codex-watcher.ts +179 -0
  93. package/src/lib/modules/server/terminal/pty-manager.ts +41 -0
  94. package/src/lib/theme.css +21 -1
  95. package/src/lib/types/codex.ts +21 -0
  96. package/src/lib/types/generated/Sessions.ts +5 -1
  97. package/src/lib/types/index.ts +1 -0
  98. package/src/lib/types/server.ts +18 -5
  99. package/src/lib/types/sessions.ts +1 -1
  100. package/src/routes/api/device-token/+server.ts +7 -3
  101. package/src/routes/api/sessions/+server.ts +27 -15
  102. package/src/routes/api/sessions/connect/+server.ts +19 -11
  103. package/src/routes/api/terminals/+server.ts +1 -1
  104. package/src/routes/project/+page.svelte +7 -23
  105. package/src/routes/session/[id]/+page.svelte +3 -3
  106. package/src/routes/terminals/+page.svelte +1 -1
  107. package/src/routes/terminals/[id]/+page.svelte +1 -1
  108. package/build/client/_app/immutable/assets/0.BZLcOr5z.css.br +0 -0
  109. package/build/client/_app/immutable/assets/0.BZLcOr5z.css.gz +0 -0
  110. package/build/client/_app/immutable/chunks/Bfg0k16X.js.br +0 -0
  111. package/build/client/_app/immutable/chunks/Bfg0k16X.js.gz +0 -0
  112. package/build/client/_app/immutable/chunks/CTchCNsn.js +0 -1
  113. package/build/client/_app/immutable/chunks/CTchCNsn.js.br +0 -0
  114. package/build/client/_app/immutable/chunks/CTchCNsn.js.gz +0 -0
  115. package/build/client/_app/immutable/chunks/D8sAtVC-.js.br +0 -0
  116. package/build/client/_app/immutable/chunks/D8sAtVC-.js.gz +0 -0
  117. package/build/client/_app/immutable/entry/app.rri2K7zq.js.br +0 -0
  118. package/build/client/_app/immutable/entry/app.rri2K7zq.js.gz +0 -0
  119. package/build/client/_app/immutable/entry/start.BRqv-XKD.js +0 -1
  120. package/build/client/_app/immutable/entry/start.BRqv-XKD.js.br +0 -2
  121. package/build/client/_app/immutable/entry/start.BRqv-XKD.js.gz +0 -0
  122. package/build/client/_app/immutable/nodes/0.DoDuvMbN.js.br +0 -0
  123. package/build/client/_app/immutable/nodes/0.DoDuvMbN.js.gz +0 -0
  124. package/build/client/_app/immutable/nodes/1.DZ0g9EOo.js.br +0 -0
  125. package/build/client/_app/immutable/nodes/1.DZ0g9EOo.js.gz +0 -0
  126. package/build/client/_app/immutable/nodes/2.COrzaySY.js.br +0 -0
  127. package/build/client/_app/immutable/nodes/2.COrzaySY.js.gz +0 -0
  128. package/build/client/_app/immutable/nodes/3.sGijgjBd.js.br +0 -0
  129. package/build/client/_app/immutable/nodes/3.sGijgjBd.js.gz +0 -0
  130. package/build/client/_app/immutable/nodes/6.DfVtwT6x.js +0 -1
  131. package/build/client/_app/immutable/nodes/6.DfVtwT6x.js.br +0 -0
  132. package/build/client/_app/immutable/nodes/6.DfVtwT6x.js.gz +0 -0
  133. package/build/client/_app/immutable/nodes/7.DFkQ9bmS.js +0 -4
  134. package/build/client/_app/immutable/nodes/7.DFkQ9bmS.js.br +0 -0
  135. package/build/client/_app/immutable/nodes/7.DFkQ9bmS.js.gz +0 -0
  136. package/build/client/_app/immutable/nodes/8.BkFDeNg9.js +0 -2
  137. package/build/client/_app/immutable/nodes/8.BkFDeNg9.js.br +0 -0
  138. package/build/client/_app/immutable/nodes/8.BkFDeNg9.js.gz +0 -0
  139. package/build/client/_app/immutable/nodes/9.g02G0hlL.js +0 -2
  140. package/build/client/_app/immutable/nodes/9.g02G0hlL.js.br +0 -0
  141. package/build/client/_app/immutable/nodes/9.g02G0hlL.js.gz +0 -0
  142. package/build/server/chunks/_server.ts-0Xr2fWaq.js.map +0 -1
  143. package/build/server/chunks/_server.ts-2ixC-X3K.js.map +0 -1
  144. package/build/server/chunks/_server.ts-40c_epk8.js.map +0 -1
  145. package/build/server/chunks/_server.ts-BuYyCrnF.js.map +0 -1
  146. package/build/server/chunks/_server.ts-ByPExYfO.js.map +0 -1
  147. package/build/server/chunks/_server.ts-CilRds58.js.map +0 -1
  148. package/build/server/chunks/_server.ts-CjpQ10xh.js.map +0 -1
  149. package/build/server/chunks/opencode-db-path-DcfhJtJy.js +0 -15
  150. package/build/server/chunks/opencode-db-path-DcfhJtJy.js.map +0 -1
  151. package/build/server/chunks/pty-manager-TyMUpDA9.js.map +0 -1
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Codex CLI session reader — listing + conversation retrieval.
3
+ *
4
+ * Mirrors jsonl-reader.ts (Claude) and opencode-reader.ts (OpenCode): produces
5
+ * the same SessionInfo / ProjectGroup / ConversationMessage shapes so the rest
6
+ * of the app is provider-agnostic.
7
+ *
8
+ * Codex sessions live at ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
9
+ * (and ~/.codex/archived_sessions/). These files can be very large (hundreds of
10
+ * MB), so listing only reads a bounded prefix of each file for metadata/title
11
+ * and estimates the message count from file size; the conversation reader bounds
12
+ * its read for oversized files.
13
+ */
14
+
15
+ import type { ConversationMessage, ProjectGroup, SessionInfo } from '$lib/types';
16
+
17
+ import * as crypto from 'crypto';
18
+ import * as fs from 'fs';
19
+ import { homedir } from 'os';
20
+ import * as path from 'path';
21
+
22
+ import { parseCodexMeta, parseCodexRollout } from './codex-parser';
23
+
24
+ /** Bytes read from the head of a rollout file when listing (enough for meta + first prompts). */
25
+ const LIST_PREFIX_BYTES = 256 * 1024;
26
+ /** Above this size, the conversation reader reads only the tail to bound memory. */
27
+ const MAX_FULL_READ_BYTES = 16 * 1024 * 1024;
28
+ /** Rough average bytes per Codex message line, used to estimate message counts cheaply. */
29
+ const APPROX_BYTES_PER_MESSAGE = 3000;
30
+
31
+ /** User-message wrappers that Codex injects automatically — not real prompts. */
32
+ const SYNTHETIC_PROMPT_PREFIXES = ['<environment_context>', '<user_instructions>', '<permissions'];
33
+
34
+ /**
35
+ * Find Codex sessions whose rollout file was written within `thresholdMs` —
36
+ * i.e. sessions that are currently (or very recently) active. Uses filesystem
37
+ * mtime rather than ~/.codex/state_5.sqlite, which is WAL-locked while Codex runs.
38
+ */
39
+ export function detectActiveCodexSessions(
40
+ thresholdMs: number
41
+ ): { cwd: string; id: string; startedAt: number }[] {
42
+ const cutoff = Date.now() - thresholdMs;
43
+ const out: { cwd: string; id: string; startedAt: number }[] = [];
44
+ for (const filePath of collectRolloutFiles()) {
45
+ try {
46
+ const stat = fs.statSync(filePath);
47
+ if (stat.mtimeMs < cutoff) {
48
+ continue;
49
+ }
50
+ const meta = parseCodexMeta(readPrefix(filePath).split('\n')[0] ?? '');
51
+ if (meta) {
52
+ out.push({ cwd: meta.cwd, id: meta.id, startedAt: stat.birthtimeMs });
53
+ }
54
+ } catch {
55
+ // skip unreadable files
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Find the rollout file for a Codex session launched in `cwd` after `sinceMs`
63
+ * (used by pty-manager to link a freshly-launched `codex` terminal to its file).
64
+ * Picks the newest matching file by creation time.
65
+ */
66
+ export function findCodexRolloutForCwd(cwd: string, sinceMs: number): null | string {
67
+ let best: null | { birthtime: number; path: string } = null;
68
+ for (const filePath of collectRolloutFiles()) {
69
+ try {
70
+ const stat = fs.statSync(filePath);
71
+ if (stat.birthtimeMs <= sinceMs) {
72
+ continue;
73
+ }
74
+ const meta = parseCodexMeta(readPrefix(filePath).split('\n')[0] ?? '');
75
+ if (meta?.cwd === cwd && (!best || stat.birthtimeMs > best.birthtime)) {
76
+ best = { birthtime: stat.birthtimeMs, path: filePath };
77
+ }
78
+ } catch {
79
+ // skip unreadable files
80
+ }
81
+ }
82
+ return best?.path ?? null;
83
+ }
84
+
85
+ /**
86
+ * Return a page of a Codex session's conversation. With `offset` 0 the most
87
+ * recent `limit` messages are returned, backed up to a user-message boundary so
88
+ * turns aren't clipped; otherwise the `offset`..`offset + limit` slice is returned.
89
+ */
90
+ export function getCodexConversation(
91
+ sessionId: string,
92
+ offset = 0,
93
+ limit = 200
94
+ ): ConversationMessage[] {
95
+ const filePath = findRolloutPath(sessionId);
96
+ if (!filePath || !fs.existsSync(filePath)) {
97
+ return [];
98
+ }
99
+
100
+ try {
101
+ const { messages } = parseCodexRollout(readRolloutText(filePath));
102
+
103
+ // Match the Claude reader: with no explicit offset, return the most recent
104
+ // `limit` messages, backing up to a user-message boundary so turns aren't clipped.
105
+ if (offset === 0 && messages.length > limit) {
106
+ let startIdx = messages.length - limit;
107
+ while (startIdx > 0 && messages[startIdx].role !== 'user') {
108
+ startIdx--;
109
+ }
110
+ return messages.slice(startIdx);
111
+ }
112
+ return messages.slice(offset, offset + limit);
113
+ } catch (error) {
114
+ console.error('[codex] Failed to read conversation:', error);
115
+ return [];
116
+ }
117
+ }
118
+
119
+ /** List all Codex sessions grouped by working directory, most-recently-modified first. */
120
+ export function listCodexProjects(): ProjectGroup[] {
121
+ const files = collectRolloutFiles();
122
+ const byCwd = new Map<string, SessionInfo[]>();
123
+
124
+ for (const filePath of files) {
125
+ let stat: fs.Stats;
126
+ try {
127
+ stat = fs.statSync(filePath);
128
+ } catch {
129
+ continue;
130
+ }
131
+ let prefix: string;
132
+ try {
133
+ prefix = readPrefix(filePath);
134
+ } catch {
135
+ continue;
136
+ }
137
+
138
+ const firstLine = prefix.slice(0, Math.max(0, prefix.indexOf('\n')) || prefix.length);
139
+ const meta = parseCodexMeta(firstLine);
140
+ if (!meta) {
141
+ continue;
142
+ }
143
+
144
+ const session: SessionInfo = {
145
+ created: meta.startedAt || stat.birthtime.toISOString(),
146
+ gitBranch: '',
147
+ id: meta.id,
148
+ messageCount: Math.max(1, Math.round(stat.size / APPROX_BYTES_PER_MESSAGE)),
149
+ modified: stat.mtime.toISOString(),
150
+ projectPath: meta.cwd,
151
+ source: 'codex' as const,
152
+ summary: '',
153
+ title: cleanTitle(firstUserPrompt(prefix)),
154
+ };
155
+
156
+ const bucket = byCwd.get(meta.cwd);
157
+ if (bucket) {
158
+ bucket.push(session);
159
+ } else {
160
+ byCwd.set(meta.cwd, [session]);
161
+ }
162
+ }
163
+
164
+ const projects: ProjectGroup[] = [];
165
+ for (const [cwd, sessions] of byCwd) {
166
+ sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
167
+ const segments = cwd.split('/').filter(Boolean);
168
+ projects.push({
169
+ fullPath: cwd,
170
+ id: shortHash(cwd),
171
+ lastModified: sessions[0]?.modified ?? '',
172
+ name: segments.slice(-2).join('/'),
173
+ sessionCount: sessions.length,
174
+ sessions,
175
+ });
176
+ }
177
+
178
+ return projects.sort(
179
+ (a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
180
+ );
181
+ }
182
+
183
+ function cleanTitle(prompt: string): string {
184
+ const firstLine = prompt.replace(/\s+/g, ' ').trim().split('\n')[0]?.trim() ?? '';
185
+ if (!firstLine) {
186
+ return 'Untitled Session';
187
+ }
188
+ return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
189
+ }
190
+
191
+ function codexSessionsDirs(): string[] {
192
+ const home = homedir();
193
+ return [path.join(home, '.codex', 'sessions'), path.join(home, '.codex', 'archived_sessions')];
194
+ }
195
+
196
+ /** Recursively collect all rollout-*.jsonl file paths under the Codex session roots. */
197
+ function collectRolloutFiles(): string[] {
198
+ const out: string[] = [];
199
+ const walk = (dir: string): void => {
200
+ let entries: fs.Dirent[];
201
+ try {
202
+ entries = fs.readdirSync(dir, { withFileTypes: true });
203
+ } catch {
204
+ return;
205
+ }
206
+ for (const entry of entries) {
207
+ const full = path.join(dir, entry.name);
208
+ if (entry.isDirectory()) {
209
+ walk(full);
210
+ } else if (entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) {
211
+ out.push(full);
212
+ }
213
+ }
214
+ };
215
+ for (const root of codexSessionsDirs()) {
216
+ walk(root);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /** Locate a rollout file by its session UUID (embedded in the filename and session_meta.id). */
222
+ function findRolloutPath(sessionId: string): null | string {
223
+ if (!/^[0-9a-f-]+$/i.test(sessionId)) {
224
+ return null;
225
+ }
226
+ const suffix = `-${sessionId}.jsonl`;
227
+ return collectRolloutFiles().find((p) => path.basename(p).endsWith(suffix)) ?? null;
228
+ }
229
+
230
+ /** Pull the first genuine user prompt (skipping Codex's auto-injected wrappers) for a title. */
231
+ function firstUserPrompt(prefixText: string): string {
232
+ for (const line of prefixText.split('\n')) {
233
+ const trimmed = line.trim();
234
+ if (!trimmed || !trimmed.includes('"type":"message"') || !trimmed.includes('"role":"user"')) {
235
+ continue;
236
+ }
237
+ try {
238
+ const entry = JSON.parse(trimmed) as {
239
+ payload?: { content?: { text?: string; type?: string }[] };
240
+ };
241
+ const text = (entry.payload?.content ?? [])
242
+ .filter((c) => c.type === 'input_text' && typeof c.text === 'string')
243
+ .map((c) => c.text ?? '')
244
+ .join('\n')
245
+ .trim();
246
+ if (text && !SYNTHETIC_PROMPT_PREFIXES.some((p) => text.startsWith(p))) {
247
+ return text;
248
+ }
249
+ } catch {
250
+ continue;
251
+ }
252
+ }
253
+ return '';
254
+ }
255
+
256
+ /** Read the first `LIST_PREFIX_BYTES` of a file as UTF-8 (complete lines only). */
257
+ function readPrefix(filePath: string): string {
258
+ const fd = fs.openSync(filePath, 'r');
259
+ try {
260
+ const buf = Buffer.alloc(LIST_PREFIX_BYTES);
261
+ const bytesRead = fs.readSync(fd, buf, 0, LIST_PREFIX_BYTES, 0);
262
+ const text = buf.toString('utf-8', 0, bytesRead);
263
+ const lastNl = text.lastIndexOf('\n');
264
+ return lastNl === -1 ? text : text.slice(0, lastNl);
265
+ } finally {
266
+ fs.closeSync(fd);
267
+ }
268
+ }
269
+
270
+ /** Read a rollout file's text, bounded to the tail for oversized files. */
271
+ function readRolloutText(filePath: string): string {
272
+ const stat = fs.statSync(filePath);
273
+ if (stat.size <= MAX_FULL_READ_BYTES) {
274
+ return fs.readFileSync(filePath, 'utf-8');
275
+ }
276
+ // Oversized: keep session_meta (first line) + the tail (most recent messages).
277
+ const head = readPrefix(filePath).split('\n')[0] ?? '';
278
+ const fd = fs.openSync(filePath, 'r');
279
+ try {
280
+ const start = stat.size - MAX_FULL_READ_BYTES;
281
+ const buf = Buffer.alloc(MAX_FULL_READ_BYTES);
282
+ const bytesRead = fs.readSync(fd, buf, 0, MAX_FULL_READ_BYTES, start);
283
+ const tail = buf.toString('utf-8', 0, bytesRead);
284
+ // Drop the first (likely partial) line of the tail.
285
+ return `${head}\n${tail.slice(tail.indexOf('\n') + 1)}`;
286
+ } finally {
287
+ fs.closeSync(fd);
288
+ }
289
+ }
290
+
291
+ function shortHash(input: string): string {
292
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
293
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ClaudeSessionFile, DetectedProcess } from '$lib/types';
2
2
 
3
+ import { detectActiveCodexSessions } from '$lib/modules/server/sessions/codex-reader';
3
4
  import { resolveOpenCodeDbPath } from '$lib/modules/server/sessions/opencode-db-path';
4
5
  import Database from 'better-sqlite3';
5
6
  import { execSync } from 'child_process';
@@ -45,6 +46,9 @@ const CLAUDE_SESSIONS_DIR = join(homedir(), '.claude', 'sessions');
45
46
  // OpenCode sessions updated within this window are considered "live"
46
47
  const OPENCODE_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
47
48
 
49
+ // Codex rollout files written within this window are considered "live"
50
+ const CODEX_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
51
+
48
52
  /**
49
53
  * Scan ~/.claude/sessions/*.json to find running Claude Code processes,
50
54
  * and query the OpenCode SQLite DB for recently active sessions.
@@ -137,6 +141,25 @@ export function detectRunningAISessions(): DetectedProcess[] {
137
141
  }
138
142
  }
139
143
 
144
+ // --- Codex sessions ---
145
+ // Codex has no PID file; a rollout file written in the last few minutes
146
+ // indicates an active session. cwd/id come from its session_meta line.
147
+ try {
148
+ for (const s of detectActiveCodexSessions(CODEX_ACTIVE_THRESHOLD_MS)) {
149
+ results.push({
150
+ command: 'codex',
151
+ cwd: s.cwd,
152
+ kind: 'interactive',
153
+ pid: 0, // Codex doesn't expose a per-session PID
154
+ projectPath: cwdToProjectPath(s.cwd),
155
+ sessionId: s.id,
156
+ startedAt: s.startedAt,
157
+ });
158
+ }
159
+ } catch {
160
+ // ~/.codex/sessions missing or unreadable — skip silently
161
+ }
162
+
140
163
  // Sort by startedAt descending (most recent first)
141
164
  results.sort((a, b) => b.startedAt - a.startedAt);
142
165
 
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Codex live session watcher.
3
+ *
4
+ * Codex rollout files are append-only JSONL (like Claude's), so this mirrors
5
+ * SessionWatcher: chokidar detects writes, only the newly-appended bytes are
6
+ * read, and lines are fed to a CodexStreamParser that emits messages as each
7
+ * conversation run completes. Because Codex's role-run grouping only flushes a
8
+ * run when the *next* run begins, an idle timer flushes the final open run when
9
+ * writing stops, so the last message isn't withheld.
10
+ */
11
+
12
+ import type { ConversationMessage, CodexWatchState as WatchState } from '$lib/types';
13
+
14
+ import { watch as chokidarWatch } from 'chokidar';
15
+ import * as fs from 'fs';
16
+
17
+ import { CodexStreamParser, parseCodexRollout } from '../sessions/codex-parser';
18
+
19
+ /** Flush the open run after this many ms without a write. */
20
+ const IDLE_FLUSH_MS = 1500;
21
+
22
+ class CodexWatcher {
23
+ private watched = new Map<string, WatchState>();
24
+
25
+ /** One-shot full-history read of a rollout file (bypasses the live watch). */
26
+ getHistory(filePath: string): ConversationMessage[] {
27
+ if (!fs.existsSync(filePath)) {
28
+ return [];
29
+ }
30
+ try {
31
+ return parseCodexRollout(fs.readFileSync(filePath, 'utf-8')).messages;
32
+ } catch (error) {
33
+ console.error(`[codex-watcher] Failed to read history for ${filePath}:`, error);
34
+ return [];
35
+ }
36
+ }
37
+
38
+ /** Detach one `callback` (closing the watcher only when none remain), or stop entirely if no callback is given. */
39
+ stop(filePath: string, callback?: (messages: ConversationMessage[]) => void): void {
40
+ const state = this.watched.get(filePath);
41
+ if (!state) {
42
+ return;
43
+ }
44
+ if (callback) {
45
+ state.callbacks.delete(callback);
46
+ if (state.callbacks.size > 0) {
47
+ return;
48
+ }
49
+ }
50
+ if (state.idleTimer) {
51
+ clearTimeout(state.idleTimer);
52
+ }
53
+ void state.watcher.close();
54
+ this.watched.delete(filePath);
55
+ console.log(`[codex-watcher] Stopped watching: ${filePath}`);
56
+ }
57
+
58
+ /** Stop watching every file. */
59
+ stopAll(): void {
60
+ for (const [filePath] of this.watched) {
61
+ this.stop(filePath);
62
+ }
63
+ }
64
+
65
+ /** Watch a rollout file for appended messages; returns an unsubscribe function. */
66
+ subscribe(filePath: string, callback: (messages: ConversationMessage[]) => void): () => void {
67
+ const existing = this.watched.get(filePath);
68
+ if (existing) {
69
+ existing.callbacks.add(callback);
70
+ return () => {
71
+ this.stop(filePath, callback);
72
+ };
73
+ }
74
+
75
+ const watcher = chokidarWatch(filePath, {
76
+ awaitWriteFinish: { pollInterval: 100, stabilityThreshold: 200 },
77
+ ignoreInitial: true,
78
+ });
79
+
80
+ const state: WatchState = {
81
+ callbacks: new Set([callback]),
82
+ idleTimer: null,
83
+ lineBuffer: '',
84
+ offset: fs.existsSync(filePath) ? fs.statSync(filePath).size : 0,
85
+ parser: new CodexStreamParser(),
86
+ watcher,
87
+ };
88
+
89
+ watcher.on('change', () => {
90
+ this.readNew(filePath);
91
+ });
92
+ watcher.on('add', () => {
93
+ this.readNew(filePath);
94
+ });
95
+ watcher.on('error', (err) => {
96
+ console.error(`[codex-watcher] watch error ${filePath}:`, err);
97
+ });
98
+
99
+ this.watched.set(filePath, state);
100
+ console.log(`[codex-watcher] Watching: ${filePath} (offset ${state.offset})`);
101
+ return () => {
102
+ this.stop(filePath, callback);
103
+ };
104
+ }
105
+
106
+ private emit(state: WatchState, messages: ConversationMessage[]): void {
107
+ if (messages.length === 0) {
108
+ return;
109
+ }
110
+ for (const cb of state.callbacks) {
111
+ try {
112
+ cb(messages);
113
+ } catch (err) {
114
+ console.error('[codex-watcher] callback error:', err);
115
+ }
116
+ }
117
+ }
118
+
119
+ private readNew(filePath: string): void {
120
+ const state = this.watched.get(filePath);
121
+ if (!state) {
122
+ return;
123
+ }
124
+
125
+ let stat: fs.Stats;
126
+ try {
127
+ stat = fs.statSync(filePath);
128
+ } catch {
129
+ return;
130
+ }
131
+ if (stat.size < state.offset) {
132
+ // Truncated/rotated — reset.
133
+ state.offset = 0;
134
+ state.lineBuffer = '';
135
+ state.parser = new CodexStreamParser();
136
+ }
137
+ if (stat.size <= state.offset) {
138
+ return;
139
+ }
140
+
141
+ const fd = fs.openSync(filePath, 'r');
142
+ try {
143
+ const buf = Buffer.alloc(stat.size - state.offset);
144
+ fs.readSync(fd, buf, 0, buf.length, state.offset);
145
+ state.offset = stat.size;
146
+
147
+ const combined = state.lineBuffer + buf.toString('utf-8');
148
+ const segments = combined.split('\n');
149
+ state.lineBuffer = combined.endsWith('\n') ? '' : (segments.pop() ?? '');
150
+
151
+ const emitted: ConversationMessage[] = [];
152
+ for (const line of segments) {
153
+ if (line.trim()) {
154
+ emitted.push(...state.parser.pushLine(line));
155
+ }
156
+ }
157
+ this.emit(state, emitted);
158
+ } finally {
159
+ fs.closeSync(fd);
160
+ }
161
+
162
+ // Reset the idle timer; flush the final open run once writes stop.
163
+ if (state.idleTimer) {
164
+ clearTimeout(state.idleTimer);
165
+ }
166
+ state.idleTimer = setTimeout(() => {
167
+ const current = this.watched.get(filePath);
168
+ if (current) {
169
+ this.emit(current, current.parser.flushOpen());
170
+ }
171
+ }, IDLE_FLUSH_MS);
172
+ }
173
+ }
174
+
175
+ // Single shared instance across module loaders (same pattern as the other watchers).
176
+ const CW_GLOBAL_KEY = '__shooter_codex_watcher';
177
+ export const codexWatcher: CodexWatcher =
178
+ ((globalThis as Record<string, unknown>)[CW_GLOBAL_KEY] as CodexWatcher) || new CodexWatcher();
179
+ (globalThis as Record<string, unknown>)[CW_GLOBAL_KEY] = codexWatcher;
@@ -12,6 +12,7 @@ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs'
12
12
  import path from 'path';
13
13
  import { fileURLToPath } from 'url';
14
14
 
15
+ import { findCodexRolloutForCwd } from '../sessions/codex-reader';
15
16
  import { broadcastEvent } from '../ws/server.js';
16
17
  import { HolderClient } from './holder-client';
17
18
  import { openCodeWatcher } from './opencode-watcher';
@@ -964,6 +965,46 @@ class PtyManager {
964
965
  5 * 60 * 1000
965
966
  );
966
967
  }
968
+
969
+ // For Codex: poll ~/.codex/sessions for the rollout file created after
970
+ // launch whose session_meta.cwd matches. Codex reuses the sessionFile
971
+ // field (a JSONL path, like Claude); the WS adapter routes it to the
972
+ // Codex watcher by its /.codex/ path.
973
+ if (command === 'codex') {
974
+ const launchTime = terminal.createdAt.getTime();
975
+ terminal.pollTimer = setInterval(() => {
976
+ if (terminal.status === 'exited' || terminal.sessionFile) {
977
+ if (terminal.pollTimer) {
978
+ clearInterval(terminal.pollTimer);
979
+ terminal.pollTimer = null;
980
+ }
981
+ return;
982
+ }
983
+ try {
984
+ const rollout = findCodexRolloutForCwd(cwd, launchTime);
985
+ if (rollout) {
986
+ terminal.sessionFile = rollout;
987
+ if (terminal.pollTimer) {
988
+ clearInterval(terminal.pollTimer);
989
+ terminal.pollTimer = null;
990
+ }
991
+ terminalStore.update(id, { sessionFile: rollout });
992
+ }
993
+ } catch {
994
+ // ignore filesystem errors
995
+ }
996
+ }, 1500);
997
+
998
+ setTimeout(
999
+ () => {
1000
+ if (terminal.pollTimer) {
1001
+ clearInterval(terminal.pollTimer);
1002
+ terminal.pollTimer = null;
1003
+ }
1004
+ },
1005
+ 5 * 60 * 1000
1006
+ );
1007
+ }
967
1008
  }
968
1009
 
969
1010
  /** Wire up all HolderClient callbacks (activity, CWD, output, exit, disconnect). */
package/src/lib/theme.css CHANGED
@@ -486,7 +486,7 @@
486
486
  --pill-cursor: inherit;
487
487
  }
488
488
 
489
- .pill-source-claude {
489
+ .pill-source-claude-code {
490
490
  --pill-background: var(--ds-blue-100);
491
491
  --pill-color: var(--ds-blue-900);
492
492
  --pill-font-size: 10px;
@@ -506,6 +506,26 @@
506
506
  --pill-cursor: inherit;
507
507
  }
508
508
 
509
+ .pill-source-codex {
510
+ --pill-background: var(--ds-green-100);
511
+ --pill-color: var(--ds-green-900);
512
+ --pill-font-size: 10px;
513
+ --pill-padding: 2px 8px;
514
+ --pill-hover-background: var(--ds-green-100);
515
+ --pill-hover-color: var(--ds-green-900);
516
+ --pill-cursor: inherit;
517
+ }
518
+
519
+ .pill-source-gemini {
520
+ --pill-background: var(--ds-gray-100);
521
+ --pill-color: var(--ds-gray-900);
522
+ --pill-font-size: 10px;
523
+ --pill-padding: 2px 8px;
524
+ --pill-hover-background: var(--ds-gray-100);
525
+ --pill-hover-color: var(--ds-gray-900);
526
+ --pill-cursor: inherit;
527
+ }
528
+
509
529
  /* ===== Icon Sizes ===== */
510
530
  .icon-14 {
511
531
  --icon-width: 14px;
@@ -0,0 +1,21 @@
1
+ // Codex CLI session types (hand-written: the rollout record shapes are
2
+ // provider-specific and not worth round-tripping through the YAML codegen).
3
+ // Codex stores sessions as JSONL "rollout" files under
4
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl.
5
+
6
+ import type { ConversationMessage } from './sessions';
7
+
8
+ /** Result of parsing a Codex rollout file into the canonical message model. */
9
+ export interface CodexParseResult {
10
+ messages: ConversationMessage[];
11
+ meta: CodexSessionMeta | null;
12
+ }
13
+
14
+ /** First-line `session_meta` payload of a Codex rollout file. */
15
+ export interface CodexSessionMeta {
16
+ cliVersion: string;
17
+ cwd: string;
18
+ id: string;
19
+ model: string;
20
+ startedAt: string;
21
+ }
@@ -14,12 +14,14 @@ import {
14
14
  * @type { SessionSource }
15
15
  * @description Source tool that produced the session
16
16
  */
17
- export type SessionSource = 'claude-code' | 'opencode';
17
+ export type SessionSource = 'claude-code' | 'opencode' | 'codex' | 'gemini';
18
18
 
19
19
  export function decodeSessionSource(rawInput: unknown): SessionSource | null {
20
20
  switch (rawInput) {
21
21
  case 'claude-code':
22
22
  case 'opencode':
23
+ case 'codex':
24
+ case 'gemini':
23
25
  return rawInput;
24
26
  }
25
27
  return null;
@@ -29,6 +31,8 @@ export function _decodeSessionSource(rawInput: unknown): SessionSource | undefin
29
31
  switch (rawInput) {
30
32
  case 'claude-code':
31
33
  case 'opencode':
34
+ case 'codex':
35
+ case 'gemini':
32
36
  return rawInput;
33
37
  }
34
38
  return;
@@ -4,6 +4,7 @@
4
4
  export type * from './activity';
5
5
  export type * from './apn';
6
6
  export type * from './cli';
7
+ export type * from './codex';
7
8
  export type * from './common';
8
9
  export type * from './dashboard';
9
10
  export * from './decision';