@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,310 @@
1
+ /**
2
+ * Qwen Code session reader.
3
+ *
4
+ * Qwen Code (a Gemini-CLI fork) writes a HYBRID format: a Claude-style JSONL
5
+ * envelope (uuid/parentUuid/type per line) carrying a Gemini-style message body
6
+ * (`message: { role, parts: [{text}|{thought}|{functionCall}|{functionResponse}] }`).
7
+ * Stored at ~/.qwen/projects/<encoded-cwd>/chats/<id>.jsonl. So we parse the
8
+ * envelope ourselves and map the Gemini-style parts (NOT the Claude parser,
9
+ * which expects message.content).
10
+ */
11
+
12
+ import type { ConversationMessage, MessagePart, ProjectGroup, SessionInfo } from '$lib/types';
13
+
14
+ import * as crypto from 'crypto';
15
+ import * as fs from 'fs';
16
+ import { homedir } from 'os';
17
+ import * as path from 'path';
18
+
19
+ const QWEN_PROJECTS = path.join(homedir(), '.qwen', 'projects');
20
+ const PREFIX_BYTES = 64 * 1024;
21
+ /** Cap conversation reads at 16 MB; oversized files are tail-read (matches the Codex reader). */
22
+ const MAX_QWEN_FILE_BYTES = 16 * 1024 * 1024;
23
+ const SYSTEM_TAG_PREFIXES = [
24
+ '<command-name>',
25
+ '<local-command',
26
+ '<system-reminder>',
27
+ '<task-notification>',
28
+ ];
29
+
30
+ /** Find Qwen sessions whose file changed within `thresholdMs` — i.e. currently or recently active. */
31
+ export function detectActiveQwenSessions(
32
+ thresholdMs: number
33
+ ): { cwd: string; id: string; startedAt: number }[] {
34
+ const cutoff = Date.now() - thresholdMs;
35
+ const out: { cwd: string; id: string; startedAt: number }[] = [];
36
+ for (const filePath of collectQwenFiles()) {
37
+ try {
38
+ const stat = fs.statSync(filePath);
39
+ if (stat.mtimeMs < cutoff) {
40
+ continue;
41
+ }
42
+ const meta = readMeta(readPrefix(filePath));
43
+ if (meta) {
44
+ out.push({ cwd: meta.cwd, id: meta.id, startedAt: stat.birthtimeMs });
45
+ }
46
+ } catch {
47
+ // skip
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Return a page of a Qwen session's conversation. With `offset` 0 the most recent
55
+ * `limit` messages are returned, backed up to a user-message boundary so turns
56
+ * aren't clipped; otherwise the `offset`..`offset + limit` slice is returned.
57
+ */
58
+ export function getQwenConversation(
59
+ sessionId: string,
60
+ offset = 0,
61
+ limit = 200
62
+ ): ConversationMessage[] {
63
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) {
64
+ return [];
65
+ }
66
+ const filePath = collectQwenFiles().find((p) => path.basename(p) === `${sessionId}.jsonl`);
67
+ if (!filePath) {
68
+ return [];
69
+ }
70
+ try {
71
+ const messages: ConversationMessage[] = [];
72
+ for (const line of readQwenTextBounded(filePath).split('\n')) {
73
+ const trimmed = line.trim();
74
+ if (!trimmed) {
75
+ continue;
76
+ }
77
+ try {
78
+ const msg = qwenLineToMessage(JSON.parse(trimmed) as Record<string, unknown>);
79
+ if (msg) {
80
+ messages.push(msg);
81
+ }
82
+ } catch {
83
+ // skip malformed line
84
+ }
85
+ }
86
+ if (offset === 0 && messages.length > limit) {
87
+ let startIdx = messages.length - limit;
88
+ while (startIdx > 0 && messages[startIdx].role !== 'user') {
89
+ startIdx--;
90
+ }
91
+ return messages.slice(startIdx);
92
+ }
93
+ return messages.slice(offset, offset + limit);
94
+ } catch (error) {
95
+ console.error('[qwen] Failed to read conversation:', error);
96
+ return [];
97
+ }
98
+ }
99
+
100
+ /** List all Qwen sessions grouped by working directory, most-recently-modified first. */
101
+ export function listQwenProjects(): ProjectGroup[] {
102
+ const byCwd = new Map<string, SessionInfo[]>();
103
+ for (const filePath of collectQwenFiles()) {
104
+ let stat: fs.Stats;
105
+ try {
106
+ stat = fs.statSync(filePath);
107
+ } catch {
108
+ continue;
109
+ }
110
+ const meta = readMeta(readPrefix(filePath));
111
+ if (!meta) {
112
+ continue;
113
+ }
114
+ const session: SessionInfo = {
115
+ created: meta.started || stat.birthtime.toISOString(),
116
+ gitBranch: meta.gitBranch,
117
+ id: meta.id,
118
+ messageCount: 0,
119
+ modified: stat.mtime.toISOString(),
120
+ projectPath: meta.cwd,
121
+ source: 'qwen' as const,
122
+ summary: '',
123
+ title: meta.title || 'Untitled Session',
124
+ };
125
+ const bucket = byCwd.get(meta.cwd);
126
+ if (bucket) {
127
+ bucket.push(session);
128
+ } else {
129
+ byCwd.set(meta.cwd, [session]);
130
+ }
131
+ }
132
+
133
+ const projects: ProjectGroup[] = [];
134
+ for (const [cwd, sessions] of byCwd) {
135
+ sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
136
+ const segments = cwd.split('/').filter(Boolean);
137
+ projects.push({
138
+ fullPath: cwd,
139
+ id: shortHash(cwd),
140
+ lastModified: sessions[0]?.modified ?? '',
141
+ name: segments.slice(-2).join('/'),
142
+ sessionCount: sessions.length,
143
+ sessions,
144
+ });
145
+ }
146
+ return projects.sort(
147
+ (a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
148
+ );
149
+ }
150
+
151
+ /** All chats/*.jsonl files under ~/.qwen/projects/<encoded-cwd>/chats/. */
152
+ function collectQwenFiles(): string[] {
153
+ const out: string[] = [];
154
+ let projectDirs: fs.Dirent[];
155
+ try {
156
+ projectDirs = fs.readdirSync(QWEN_PROJECTS, { withFileTypes: true });
157
+ } catch {
158
+ return out;
159
+ }
160
+ for (const dir of projectDirs) {
161
+ if (!dir.isDirectory()) {
162
+ continue;
163
+ }
164
+ const chatsDir = path.join(QWEN_PROJECTS, dir.name, 'chats');
165
+ try {
166
+ for (const f of fs.readdirSync(chatsDir)) {
167
+ if (f.endsWith('.jsonl')) {
168
+ out.push(path.join(chatsDir, f));
169
+ }
170
+ }
171
+ } catch {
172
+ // no chats dir
173
+ }
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /** Map a Qwen JSONL line (Claude envelope + Gemini message.parts) to a ConversationMessage. */
179
+ function qwenLineToMessage(entry: Record<string, unknown>): ConversationMessage | null {
180
+ const type = entry.type;
181
+ if (type !== 'user' && type !== 'assistant') {
182
+ return null; // skip system/control records
183
+ }
184
+ const message = (entry.message ?? {}) as { content?: unknown; parts?: unknown };
185
+ const rawParts = Array.isArray(message.parts) ? message.parts : [];
186
+ const parts: MessagePart[] = [];
187
+ for (const raw of rawParts) {
188
+ if (typeof raw !== 'object' || raw === null) {
189
+ continue;
190
+ }
191
+ const p = raw as Record<string, unknown>;
192
+ if (p.functionCall && typeof p.functionCall === 'object') {
193
+ const fc = p.functionCall as Record<string, unknown>;
194
+ const toolName = typeof fc.name === 'string' ? fc.name : 'tool';
195
+ parts.push({
196
+ id: typeof fc.id === 'string' ? fc.id : toolName,
197
+ input: (fc.args as Record<string, unknown>) ?? {},
198
+ toolName,
199
+ type: 'tool_use',
200
+ });
201
+ } else if (p.thought === true && typeof p.text === 'string') {
202
+ parts.push({ content: p.text, type: 'thinking' });
203
+ } else if (typeof p.text === 'string') {
204
+ parts.push({ content: p.text, type: 'text' });
205
+ }
206
+ }
207
+ // Fallback for any Claude-style string content.
208
+ if (parts.length === 0 && typeof message.content === 'string' && message.content) {
209
+ parts.push({ content: message.content, type: 'text' });
210
+ }
211
+ if (parts.length === 0) {
212
+ return null;
213
+ }
214
+ return {
215
+ id: typeof entry.uuid === 'string' ? entry.uuid : `qwen-${type}`,
216
+ parts,
217
+ role: type === 'user' ? 'user' : 'assistant',
218
+ timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : '',
219
+ };
220
+ }
221
+
222
+ /** Extract {cwd, sessionId, gitBranch, started} + first real user prompt from a Qwen session prefix. */
223
+ function readMeta(
224
+ prefix: string
225
+ ): null | { cwd: string; gitBranch: string; id: string; started: string; title: string } {
226
+ let cwd = '';
227
+ let id = '';
228
+ let gitBranch = '';
229
+ let started = '';
230
+ let title = '';
231
+ for (const line of prefix.split('\n')) {
232
+ const trimmed = line.trim();
233
+ if (!trimmed) {
234
+ continue;
235
+ }
236
+ let entry: Record<string, unknown>;
237
+ try {
238
+ entry = JSON.parse(trimmed) as Record<string, unknown>;
239
+ } catch {
240
+ continue;
241
+ }
242
+ if (!cwd && typeof entry.cwd === 'string') {
243
+ cwd = entry.cwd;
244
+ }
245
+ if (!id && typeof entry.sessionId === 'string') {
246
+ id = entry.sessionId;
247
+ }
248
+ if (!gitBranch && typeof entry.gitBranch === 'string') {
249
+ gitBranch = entry.gitBranch;
250
+ }
251
+ if (!started && typeof entry.timestamp === 'string') {
252
+ started = entry.timestamp;
253
+ }
254
+ if (!title && entry.type === 'user') {
255
+ const msg = entry.message as undefined | { content?: unknown; parts?: unknown };
256
+ let text = typeof msg?.content === 'string' ? msg.content : '';
257
+ if (!text && Array.isArray(msg?.parts)) {
258
+ text = msg.parts
259
+ .map((p) =>
260
+ p && typeof p === 'object' && typeof (p as { text?: unknown }).text === 'string'
261
+ ? (p as { text: string }).text
262
+ : ''
263
+ )
264
+ .join(' ')
265
+ .trim();
266
+ }
267
+ if (text && !SYSTEM_TAG_PREFIXES.some((p) => text.startsWith(p))) {
268
+ title = text.split('\n')[0]?.slice(0, 80) ?? '';
269
+ }
270
+ }
271
+ }
272
+ return id && cwd ? { cwd, gitBranch, id, started, title } : null;
273
+ }
274
+
275
+ /** Read the head of a file (complete lines only). */
276
+ function readPrefix(filePath: string): string {
277
+ const fd = fs.openSync(filePath, 'r');
278
+ try {
279
+ const buf = Buffer.alloc(PREFIX_BYTES);
280
+ const n = fs.readSync(fd, buf, 0, PREFIX_BYTES, 0);
281
+ const text = buf.toString('utf-8', 0, n);
282
+ const lastNl = text.lastIndexOf('\n');
283
+ return lastNl === -1 ? text : text.slice(0, lastNl);
284
+ } finally {
285
+ fs.closeSync(fd);
286
+ }
287
+ }
288
+
289
+ /** Read a Qwen session file, bounded to the tail for oversized files to cap memory. */
290
+ function readQwenTextBounded(filePath: string): string {
291
+ const size = fs.statSync(filePath).size;
292
+ if (size <= MAX_QWEN_FILE_BYTES) {
293
+ return fs.readFileSync(filePath, 'utf-8');
294
+ }
295
+ // Oversized: read only the trailing window (most recent messages), dropping the
296
+ // first (likely partial) line so JSON.parse never sees a fragment.
297
+ const fd = fs.openSync(filePath, 'r');
298
+ try {
299
+ const buf = Buffer.alloc(MAX_QWEN_FILE_BYTES);
300
+ const n = fs.readSync(fd, buf, 0, MAX_QWEN_FILE_BYTES, size - MAX_QWEN_FILE_BYTES);
301
+ const tail = buf.toString('utf-8', 0, n);
302
+ return tail.slice(tail.indexOf('\n') + 1);
303
+ } finally {
304
+ fs.closeSync(fd);
305
+ }
306
+ }
307
+
308
+ function shortHash(input: string): string {
309
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
310
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Provider registry — the single place that knows every AI-agent provider.
3
+ *
4
+ * Inspired by wesm/agentsview's AgentDef registry: instead of branching on
5
+ * `command === 'claude'` across ~14 call sites, the session API, the connect
6
+ * route, and the terminal allowlist all derive from this list. Adding a
7
+ * provider is one entry here (+ its reader) rather than edits everywhere.
8
+ *
9
+ * Detection (process-detector) and live watching (server.ts adapter) stay
10
+ * provider-specific because their mechanisms differ too much to unify cheaply.
11
+ */
12
+
13
+ import type { ConversationMessage, ProjectGroup, ProviderDef } from '$lib/types';
14
+
15
+ import { getCodexConversation, listCodexProjects } from './codex-reader';
16
+ import { getGeminiConversation, listGeminiProjects } from './gemini-reader';
17
+ import { getSessionConversation, listProjectsWithSessions } from './jsonl-reader';
18
+ import { getOpenCodeConversation, listOpenCodeProjects } from './opencode-reader';
19
+ import { getQwenConversation, listQwenProjects } from './qwen-reader';
20
+
21
+ /**
22
+ * Every AI-agent provider, in merge order. `claude-code` MUST stay first: its
23
+ * projects seed the merge map so the canonical (decoded) project path/name wins
24
+ * over other providers' guesses.
25
+ */
26
+ export const PROVIDERS: ProviderDef[] = [
27
+ {
28
+ command: 'claude',
29
+ getConversation: (id, offset, limit) => getSessionConversation(id, offset, limit),
30
+ isAI: true,
31
+ label: 'Claude Code',
32
+ listProjects: listProjectsWithSessions,
33
+ resumeArgs: (id) => ['--resume', id],
34
+ source: 'claude-code',
35
+ },
36
+ {
37
+ command: 'opencode',
38
+ getConversation: getOpenCodeConversation,
39
+ isAI: true,
40
+ label: 'OpenCode',
41
+ listProjects: listOpenCodeProjects,
42
+ nameSuffix: ' (OpenCode)',
43
+ resumeArgs: (id) => ['--session', id],
44
+ source: 'opencode',
45
+ },
46
+ {
47
+ command: 'codex',
48
+ getConversation: getCodexConversation,
49
+ isAI: true,
50
+ label: 'Codex',
51
+ listProjects: listCodexProjects,
52
+ resumeArgs: (id) => ['resume', id],
53
+ source: 'codex',
54
+ },
55
+ {
56
+ command: 'gemini',
57
+ getConversation: getGeminiConversation,
58
+ isAI: true,
59
+ label: 'Gemini',
60
+ listProjects: listGeminiProjects,
61
+ resumeArgs: () => [], // Gemini CLI has no session-resume flag
62
+ source: 'gemini',
63
+ },
64
+ {
65
+ command: 'qwen',
66
+ getConversation: getQwenConversation,
67
+ isAI: true,
68
+ label: 'Qwen',
69
+ listProjects: listQwenProjects,
70
+ resumeArgs: () => [],
71
+ source: 'qwen',
72
+ },
73
+ ];
74
+
75
+ /** AI-agent binary names (for AI_COMMANDS-style checks). */
76
+ export const AI_COMMANDS: string[] = PROVIDERS.filter((p) => p.isAI).map((p) => p.command);
77
+
78
+ /** All provider binary names (for the terminal allowlist + connect validation). */
79
+ export const PROVIDER_COMMANDS: string[] = PROVIDERS.map((p) => p.command);
80
+
81
+ /** Resolve a session's conversation across providers (Claude first, with its project dir). */
82
+ export function getProviderConversation(
83
+ sessionId: string,
84
+ offset: number,
85
+ limit: number,
86
+ claudeProjectDir?: string
87
+ ): ConversationMessage[] {
88
+ const claude = getSessionConversation(sessionId, offset, limit, claudeProjectDir);
89
+ if (claude.length > 0) {
90
+ return claude;
91
+ }
92
+ for (const provider of PROVIDERS) {
93
+ if (provider.source === 'claude-code') {
94
+ continue;
95
+ }
96
+ const messages = provider.getConversation(sessionId, offset, limit);
97
+ if (messages.length > 0) {
98
+ return messages;
99
+ }
100
+ }
101
+ return [];
102
+ }
103
+
104
+ /** Merge every provider's projects, deduplicating by absolute path. */
105
+ export function listAllProviderProjects(): ProjectGroup[] {
106
+ const byPath = new Map<string, ProjectGroup>();
107
+ for (const provider of PROVIDERS) {
108
+ let groups: ProjectGroup[];
109
+ try {
110
+ groups = provider.listProjects();
111
+ } catch {
112
+ continue; // a broken provider must not take down the whole listing
113
+ }
114
+ for (const group of groups) {
115
+ const name = provider.nameSuffix ? group.name.replace(provider.nameSuffix, '') : group.name;
116
+ const existing = byPath.get(group.fullPath);
117
+ if (existing) {
118
+ existing.sessions.push(...group.sessions);
119
+ existing.sessions.sort(
120
+ (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
121
+ );
122
+ existing.sessionCount = existing.sessions.length;
123
+ existing.lastModified = existing.sessions[0]?.modified || existing.lastModified;
124
+ } else {
125
+ byPath.set(group.fullPath, { ...group, name });
126
+ }
127
+ }
128
+ }
129
+ return [...byPath.values()].sort(
130
+ (a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
131
+ );
132
+ }
133
+
134
+ /** Resume args for a launched command (e.g. `codex resume <id>`). */
135
+ export function resumeArgsForCommand(command: string, sessionId: string): string[] {
136
+ return PROVIDERS.find((p) => p.command === command)?.resumeArgs(sessionId) ?? [];
137
+ }
@@ -0,0 +1,182 @@
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
+ import { readBoundedRolloutText } from '../sessions/codex-reader';
19
+
20
+ /** Flush the open run after this many ms without a write. */
21
+ const IDLE_FLUSH_MS = 1500;
22
+
23
+ class CodexWatcher {
24
+ private watched = new Map<string, WatchState>();
25
+
26
+ /** One-shot full-history read of a rollout file (bypasses the live watch). */
27
+ getHistory(filePath: string): ConversationMessage[] {
28
+ if (!fs.existsSync(filePath)) {
29
+ return [];
30
+ }
31
+ try {
32
+ // Bounded read — rollout files can be hundreds of MB; this is called on
33
+ // every WS session connection (mirrors getCodexConversation).
34
+ return parseCodexRollout(readBoundedRolloutText(filePath)).messages;
35
+ } catch (error) {
36
+ console.error(`[codex-watcher] Failed to read history for ${filePath}:`, error);
37
+ return [];
38
+ }
39
+ }
40
+
41
+ /** Detach one `callback` (closing the watcher only when none remain), or stop entirely if no callback is given. */
42
+ stop(filePath: string, callback?: (messages: ConversationMessage[]) => void): void {
43
+ const state = this.watched.get(filePath);
44
+ if (!state) {
45
+ return;
46
+ }
47
+ if (callback) {
48
+ state.callbacks.delete(callback);
49
+ if (state.callbacks.size > 0) {
50
+ return;
51
+ }
52
+ }
53
+ if (state.idleTimer) {
54
+ clearTimeout(state.idleTimer);
55
+ }
56
+ void state.watcher.close();
57
+ this.watched.delete(filePath);
58
+ console.log(`[codex-watcher] Stopped watching: ${filePath}`);
59
+ }
60
+
61
+ /** Stop watching every file. */
62
+ stopAll(): void {
63
+ for (const [filePath] of this.watched) {
64
+ this.stop(filePath);
65
+ }
66
+ }
67
+
68
+ /** Watch a rollout file for appended messages; returns an unsubscribe function. */
69
+ subscribe(filePath: string, callback: (messages: ConversationMessage[]) => void): () => void {
70
+ const existing = this.watched.get(filePath);
71
+ if (existing) {
72
+ existing.callbacks.add(callback);
73
+ return () => {
74
+ this.stop(filePath, callback);
75
+ };
76
+ }
77
+
78
+ const watcher = chokidarWatch(filePath, {
79
+ awaitWriteFinish: { pollInterval: 100, stabilityThreshold: 200 },
80
+ ignoreInitial: true,
81
+ });
82
+
83
+ const state: WatchState = {
84
+ callbacks: new Set([callback]),
85
+ idleTimer: null,
86
+ lineBuffer: '',
87
+ offset: fs.existsSync(filePath) ? fs.statSync(filePath).size : 0,
88
+ parser: new CodexStreamParser(),
89
+ watcher,
90
+ };
91
+
92
+ watcher.on('change', () => {
93
+ this.readNew(filePath);
94
+ });
95
+ watcher.on('add', () => {
96
+ this.readNew(filePath);
97
+ });
98
+ watcher.on('error', (err) => {
99
+ console.error(`[codex-watcher] watch error ${filePath}:`, err);
100
+ });
101
+
102
+ this.watched.set(filePath, state);
103
+ console.log(`[codex-watcher] Watching: ${filePath} (offset ${state.offset})`);
104
+ return () => {
105
+ this.stop(filePath, callback);
106
+ };
107
+ }
108
+
109
+ private emit(state: WatchState, messages: ConversationMessage[]): void {
110
+ if (messages.length === 0) {
111
+ return;
112
+ }
113
+ for (const cb of state.callbacks) {
114
+ try {
115
+ cb(messages);
116
+ } catch (err) {
117
+ console.error('[codex-watcher] callback error:', err);
118
+ }
119
+ }
120
+ }
121
+
122
+ private readNew(filePath: string): void {
123
+ const state = this.watched.get(filePath);
124
+ if (!state) {
125
+ return;
126
+ }
127
+
128
+ let stat: fs.Stats;
129
+ try {
130
+ stat = fs.statSync(filePath);
131
+ } catch {
132
+ return;
133
+ }
134
+ if (stat.size < state.offset) {
135
+ // Truncated/rotated — reset.
136
+ state.offset = 0;
137
+ state.lineBuffer = '';
138
+ state.parser = new CodexStreamParser();
139
+ }
140
+ if (stat.size <= state.offset) {
141
+ return;
142
+ }
143
+
144
+ const fd = fs.openSync(filePath, 'r');
145
+ try {
146
+ const buf = Buffer.alloc(stat.size - state.offset);
147
+ fs.readSync(fd, buf, 0, buf.length, state.offset);
148
+ state.offset = stat.size;
149
+
150
+ const combined = state.lineBuffer + buf.toString('utf-8');
151
+ const segments = combined.split('\n');
152
+ state.lineBuffer = combined.endsWith('\n') ? '' : (segments.pop() ?? '');
153
+
154
+ const emitted: ConversationMessage[] = [];
155
+ for (const line of segments) {
156
+ if (line.trim()) {
157
+ emitted.push(...state.parser.pushLine(line));
158
+ }
159
+ }
160
+ this.emit(state, emitted);
161
+ } finally {
162
+ fs.closeSync(fd);
163
+ }
164
+
165
+ // Reset the idle timer; flush the final open run once writes stop.
166
+ if (state.idleTimer) {
167
+ clearTimeout(state.idleTimer);
168
+ }
169
+ state.idleTimer = setTimeout(() => {
170
+ const current = this.watched.get(filePath);
171
+ if (current) {
172
+ this.emit(current, current.parser.flushOpen());
173
+ }
174
+ }, IDLE_FLUSH_MS);
175
+ }
176
+ }
177
+
178
+ // Single shared instance across module loaders (same pattern as the other watchers).
179
+ const CW_GLOBAL_KEY = '__shooter_codex_watcher';
180
+ export const codexWatcher: CodexWatcher =
181
+ ((globalThis as Record<string, unknown>)[CW_GLOBAL_KEY] as CodexWatcher) || new CodexWatcher();
182
+ (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). */