@juspay/shooter 1.16.0 → 1.18.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 (175) 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.DEfoFaGR.css → 0.NV8k8wxG.css} +1 -1
  4. package/build/client/_app/immutable/assets/0.NV8k8wxG.css.br +0 -0
  5. package/build/client/_app/immutable/assets/0.NV8k8wxG.css.gz +0 -0
  6. package/build/client/_app/immutable/chunks/8lO1IL7u.js +1 -0
  7. package/build/client/_app/immutable/chunks/8lO1IL7u.js.br +0 -0
  8. package/build/client/_app/immutable/chunks/8lO1IL7u.js.gz +0 -0
  9. package/build/client/_app/immutable/chunks/B9WQy_3X.js +1 -0
  10. package/build/client/_app/immutable/chunks/B9WQy_3X.js.br +0 -0
  11. package/build/client/_app/immutable/chunks/B9WQy_3X.js.gz +0 -0
  12. package/build/client/_app/immutable/chunks/BdtLzPpO.js +1 -0
  13. package/build/client/_app/immutable/chunks/BdtLzPpO.js.br +0 -0
  14. package/build/client/_app/immutable/chunks/BdtLzPpO.js.gz +0 -0
  15. package/build/client/_app/immutable/chunks/{DlS3abGJ.js → DJvX78LW.js} +1 -1
  16. package/build/client/_app/immutable/chunks/DJvX78LW.js.br +0 -0
  17. package/build/client/_app/immutable/chunks/DJvX78LW.js.gz +0 -0
  18. package/build/client/_app/immutable/chunks/nWG9RHyB.js +3 -0
  19. package/build/client/_app/immutable/chunks/nWG9RHyB.js.br +0 -0
  20. package/build/client/_app/immutable/chunks/nWG9RHyB.js.gz +0 -0
  21. package/build/client/_app/immutable/entry/{app.CSJG7N9H.js → app.f46Ko1hu.js} +2 -2
  22. package/build/client/_app/immutable/entry/app.f46Ko1hu.js.br +0 -0
  23. package/build/client/_app/immutable/entry/app.f46Ko1hu.js.gz +0 -0
  24. package/build/client/_app/immutable/entry/start.BVDjNnXt.js +1 -0
  25. package/build/client/_app/immutable/entry/start.BVDjNnXt.js.br +2 -0
  26. package/build/client/_app/immutable/entry/start.BVDjNnXt.js.gz +0 -0
  27. package/build/client/_app/immutable/nodes/{0.qOL7xtFn.js → 0.D_9EwVmq.js} +1 -1
  28. package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.br +0 -0
  29. package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.gz +0 -0
  30. package/build/client/_app/immutable/nodes/{1.Di708Ago.js → 1.C4eFlqSB.js} +1 -1
  31. package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.br +0 -0
  32. package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.gz +0 -0
  33. package/build/client/_app/immutable/nodes/{2.DSM1znqa.js → 2.CdC092Za.js} +1 -1
  34. package/build/client/_app/immutable/nodes/2.CdC092Za.js.br +0 -0
  35. package/build/client/_app/immutable/nodes/2.CdC092Za.js.gz +0 -0
  36. package/build/client/_app/immutable/nodes/{3.BPa5fh75.js → 3.Dhf4ZWW0.js} +1 -1
  37. package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.br +0 -0
  38. package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.gz +0 -0
  39. package/build/client/_app/immutable/nodes/6.B3SEB_li.js +1 -0
  40. package/build/client/_app/immutable/nodes/6.B3SEB_li.js.br +0 -0
  41. package/build/client/_app/immutable/nodes/6.B3SEB_li.js.gz +0 -0
  42. package/build/client/_app/immutable/nodes/{7.B7UJd8GQ.js → 7.DV8cJ1lX.js} +3 -3
  43. package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.br +0 -0
  44. package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.gz +0 -0
  45. package/build/client/_app/immutable/nodes/8.Bs362gyb.js +2 -0
  46. package/build/client/_app/immutable/nodes/8.Bs362gyb.js.br +0 -0
  47. package/build/client/_app/immutable/nodes/8.Bs362gyb.js.gz +0 -0
  48. package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js +2 -0
  49. package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.br +0 -0
  50. package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.gz +0 -0
  51. package/build/client/_app/version.json +1 -1
  52. package/build/client/_app/version.json.br +0 -0
  53. package/build/client/_app/version.json.gz +0 -0
  54. package/build/server/chunks/{0-D8uPamNd.js → 0-Cd7jY0a7.js} +3 -3
  55. package/build/server/chunks/{0-D8uPamNd.js.map → 0-Cd7jY0a7.js.map} +1 -1
  56. package/build/server/chunks/{1-DhtioHbs.js → 1-C4BOGoJY.js} +2 -2
  57. package/build/server/chunks/{1-DhtioHbs.js.map → 1-C4BOGoJY.js.map} +1 -1
  58. package/build/server/chunks/{2-Cgh7ZFgE.js → 2-Ba0mNwJ6.js} +2 -2
  59. package/build/server/chunks/{2-Cgh7ZFgE.js.map → 2-Ba0mNwJ6.js.map} +1 -1
  60. package/build/server/chunks/{3-I6hnjssH.js → 3-Pg8t1uJU.js} +2 -2
  61. package/build/server/chunks/{3-I6hnjssH.js.map → 3-Pg8t1uJU.js.map} +1 -1
  62. package/build/server/chunks/{6-bPDbH1_W.js → 6-D8xbnTSo.js} +2 -2
  63. package/build/server/chunks/6-D8xbnTSo.js.map +1 -0
  64. package/build/server/chunks/{7-CpBrOkxQ.js → 7-CkVK06S0.js} +2 -2
  65. package/build/server/chunks/7-CkVK06S0.js.map +1 -0
  66. package/build/server/chunks/{8-BRGAVfze.js → 8-C8qVhrds.js} +2 -2
  67. package/build/server/chunks/8-C8qVhrds.js.map +1 -0
  68. package/build/server/chunks/{9-C6xuAb_Y.js → 9-fL5zqN0T.js} +2 -2
  69. package/build/server/chunks/9-fL5zqN0T.js.map +1 -0
  70. package/build/server/chunks/{_server.ts-BrRZXr-8.js → _server.ts-BA_uWcPw.js} +9 -9
  71. package/build/server/chunks/_server.ts-BA_uWcPw.js.map +1 -0
  72. package/build/server/chunks/{_server.ts-C6xbNz6d.js → _server.ts-Bu3s5hfv.js} +3 -3
  73. package/build/server/chunks/{_server.ts-C6xbNz6d.js.map → _server.ts-Bu3s5hfv.js.map} +1 -1
  74. package/build/server/chunks/{_server.ts-Cq9_scaV.js → _server.ts-CwAjt91u.js} +18 -18
  75. package/build/server/chunks/_server.ts-CwAjt91u.js.map +1 -0
  76. package/build/server/chunks/{_server.ts-CFX-S_8q.js → _server.ts-DZ5naqSL.js} +2 -2
  77. package/build/server/chunks/{_server.ts-CFX-S_8q.js.map → _server.ts-DZ5naqSL.js.map} +1 -1
  78. package/build/server/chunks/{_server.ts-Dekgb6Hx.js → _server.ts-DZP2lhaY.js} +3 -3
  79. package/build/server/chunks/{_server.ts-Dekgb6Hx.js.map → _server.ts-DZP2lhaY.js.map} +1 -1
  80. package/build/server/chunks/_server.ts-DZgfQKiH.js +81 -0
  81. package/build/server/chunks/_server.ts-DZgfQKiH.js.map +1 -0
  82. package/build/server/chunks/{_server.ts-CjK0g9dO.js → _server.ts-MbnroWEF.js} +25 -16
  83. package/build/server/chunks/_server.ts-MbnroWEF.js.map +1 -0
  84. package/build/server/chunks/{pty-manager-aFpChJah.js → pty-manager-DmNSCKAr.js} +99 -2
  85. package/build/server/chunks/pty-manager-DmNSCKAr.js.map +1 -0
  86. package/build/server/chunks/qwen-reader-DGfUbKaJ.js +2112 -0
  87. package/build/server/chunks/qwen-reader-DGfUbKaJ.js.map +1 -0
  88. package/build/server/chunks/{_server.ts-D--_NXt2.js → registry-Kcw2UCMv.js} +132 -106
  89. package/build/server/chunks/registry-Kcw2UCMv.js.map +1 -0
  90. package/build/server/index.js +1 -1
  91. package/build/server/index.js.map +1 -1
  92. package/build/server/manifest.js +16 -16
  93. package/build/server/manifest.js.map +1 -1
  94. package/package.json +2 -2
  95. package/scripts/e2e-all-features.sh +165 -0
  96. package/scripts/e2e-cross-terminal.sh +168 -0
  97. package/server.ts +12 -0
  98. package/src/lib/modules/client/common/index.ts +1 -1
  99. package/src/lib/modules/client/common/provider.ts +11 -0
  100. package/src/lib/modules/client/terminal/ChatView.svelte +9 -2
  101. package/src/lib/modules/client/terminal/LaunchSheet.svelte +4 -0
  102. package/src/lib/modules/server/sessions/amp-reader.ts +439 -0
  103. package/src/lib/modules/server/sessions/codex-reader.ts +34 -33
  104. package/src/lib/modules/server/sessions/copilot-reader.ts +542 -0
  105. package/src/lib/modules/server/sessions/cursor-reader.ts +634 -0
  106. package/src/lib/modules/server/sessions/gemini-reader.ts +594 -0
  107. package/src/lib/modules/server/sessions/opencode-db-path.ts +19 -10
  108. package/src/lib/modules/server/sessions/opencode-reader.ts +13 -12
  109. package/src/lib/modules/server/sessions/process-detector.ts +39 -18
  110. package/src/lib/modules/server/sessions/provider-paths.ts +173 -0
  111. package/src/lib/modules/server/sessions/qwen-reader.ts +336 -0
  112. package/src/lib/modules/server/sessions/registry.ts +178 -0
  113. package/src/lib/modules/server/terminal/codex-watcher.ts +4 -1
  114. package/src/lib/modules/server/terminal/generic-session-watcher.ts +163 -0
  115. package/src/lib/modules/server/terminal/pty-manager.ts +51 -0
  116. package/src/lib/modules/server/ws/session-handler.ts +34 -20
  117. package/src/lib/theme.css +32 -0
  118. package/src/lib/types/gemini.ts +100 -0
  119. package/src/lib/types/generated/Sessions.ts +17 -1
  120. package/src/lib/types/index.ts +1 -0
  121. package/src/lib/types/server.ts +23 -6
  122. package/src/lib/types/sessions.ts +14 -2
  123. package/src/routes/api/sessions/+server.ts +5 -52
  124. package/src/routes/api/sessions/connect/+server.ts +18 -11
  125. package/src/routes/api/terminals/+server.ts +7 -5
  126. package/src/routes/terminals/+page.svelte +7 -2
  127. package/src/routes/terminals/[id]/+page.svelte +1 -2
  128. package/build/client/_app/immutable/assets/0.DEfoFaGR.css.br +0 -0
  129. package/build/client/_app/immutable/assets/0.DEfoFaGR.css.gz +0 -0
  130. package/build/client/_app/immutable/chunks/Bkqjn62J.js +0 -1
  131. package/build/client/_app/immutable/chunks/Bkqjn62J.js.br +0 -1
  132. package/build/client/_app/immutable/chunks/Bkqjn62J.js.gz +0 -0
  133. package/build/client/_app/immutable/chunks/DOHhmtDH.js +0 -3
  134. package/build/client/_app/immutable/chunks/DOHhmtDH.js.br +0 -0
  135. package/build/client/_app/immutable/chunks/DOHhmtDH.js.gz +0 -0
  136. package/build/client/_app/immutable/chunks/DlS3abGJ.js.br +0 -0
  137. package/build/client/_app/immutable/chunks/DlS3abGJ.js.gz +0 -0
  138. package/build/client/_app/immutable/chunks/Pw0jDB7M.js +0 -1
  139. package/build/client/_app/immutable/chunks/Pw0jDB7M.js.br +0 -0
  140. package/build/client/_app/immutable/chunks/Pw0jDB7M.js.gz +0 -0
  141. package/build/client/_app/immutable/entry/app.CSJG7N9H.js.br +0 -0
  142. package/build/client/_app/immutable/entry/app.CSJG7N9H.js.gz +0 -0
  143. package/build/client/_app/immutable/entry/start.CTt1901T.js +0 -1
  144. package/build/client/_app/immutable/entry/start.CTt1901T.js.br +0 -2
  145. package/build/client/_app/immutable/entry/start.CTt1901T.js.gz +0 -0
  146. package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.br +0 -0
  147. package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.gz +0 -0
  148. package/build/client/_app/immutable/nodes/1.Di708Ago.js.br +0 -0
  149. package/build/client/_app/immutable/nodes/1.Di708Ago.js.gz +0 -0
  150. package/build/client/_app/immutable/nodes/2.DSM1znqa.js.br +0 -0
  151. package/build/client/_app/immutable/nodes/2.DSM1znqa.js.gz +0 -0
  152. package/build/client/_app/immutable/nodes/3.BPa5fh75.js.br +0 -0
  153. package/build/client/_app/immutable/nodes/3.BPa5fh75.js.gz +0 -0
  154. package/build/client/_app/immutable/nodes/6.B1LwwEF-.js +0 -1
  155. package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.br +0 -0
  156. package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.gz +0 -0
  157. package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.br +0 -0
  158. package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.gz +0 -0
  159. package/build/client/_app/immutable/nodes/8.CG0mrgBU.js +0 -2
  160. package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.br +0 -0
  161. package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.gz +0 -0
  162. package/build/client/_app/immutable/nodes/9.KwzWaMHj.js +0 -2
  163. package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.br +0 -0
  164. package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.gz +0 -0
  165. package/build/server/chunks/6-bPDbH1_W.js.map +0 -1
  166. package/build/server/chunks/7-CpBrOkxQ.js.map +0 -1
  167. package/build/server/chunks/8-BRGAVfze.js.map +0 -1
  168. package/build/server/chunks/9-C6xuAb_Y.js.map +0 -1
  169. package/build/server/chunks/_server.ts-BrRZXr-8.js.map +0 -1
  170. package/build/server/chunks/_server.ts-CjK0g9dO.js.map +0 -1
  171. package/build/server/chunks/_server.ts-Cq9_scaV.js.map +0 -1
  172. package/build/server/chunks/_server.ts-D--_NXt2.js.map +0 -1
  173. package/build/server/chunks/opencode-db-path-CRgzBK5U.js +0 -402
  174. package/build/server/chunks/opencode-db-path-CRgzBK5U.js.map +0 -1
  175. package/build/server/chunks/pty-manager-aFpChJah.js.map +0 -1
@@ -1,7 +1,12 @@
1
1
  import type { ClaudeSessionFile, DetectedProcess } from '$lib/types';
2
2
 
3
+ import { detectActiveAmpSessions } from '$lib/modules/server/sessions/amp-reader';
3
4
  import { detectActiveCodexSessions } from '$lib/modules/server/sessions/codex-reader';
5
+ import { detectActiveCopilotSessions } from '$lib/modules/server/sessions/copilot-reader';
6
+ import { detectActiveCursorSessions } from '$lib/modules/server/sessions/cursor-reader';
7
+ import { detectActiveGeminiSessions } from '$lib/modules/server/sessions/gemini-reader';
4
8
  import { resolveOpenCodeDbPath } from '$lib/modules/server/sessions/opencode-db-path';
9
+ import { detectActiveQwenSessions } from '$lib/modules/server/sessions/qwen-reader';
5
10
  import Database from 'better-sqlite3';
6
11
  import { execSync } from 'child_process';
7
12
  import { existsSync, readdirSync, readFileSync } from 'fs';
@@ -46,8 +51,22 @@ const CLAUDE_SESSIONS_DIR = join(homedir(), '.claude', 'sessions');
46
51
  // OpenCode sessions updated within this window are considered "live"
47
52
  const OPENCODE_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
48
53
 
49
- // Codex rollout files written within this window are considered "live"
50
- const CODEX_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
54
+ // File-based providers: a session file written within this window = "live".
55
+ const FILE_PROVIDER_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
56
+
57
+ // File-based providers (no PID file) share one detection shape:
58
+ // detectActive<P>Sessions(thresholdMs) -> { cwd, id, startedAt }[].
59
+ const FILE_PROVIDER_DETECTORS: {
60
+ command: DetectedProcess['command'];
61
+ detect: (thresholdMs: number) => { cwd: string; id: string; startedAt: number }[];
62
+ }[] = [
63
+ { command: 'codex', detect: detectActiveCodexSessions },
64
+ { command: 'gemini', detect: detectActiveGeminiSessions },
65
+ { command: 'qwen', detect: detectActiveQwenSessions },
66
+ { command: 'cursor-agent', detect: detectActiveCursorSessions },
67
+ { command: 'copilot', detect: detectActiveCopilotSessions },
68
+ { command: 'amp', detect: detectActiveAmpSessions },
69
+ ];
51
70
 
52
71
  /**
53
72
  * Scan ~/.claude/sessions/*.json to find running Claude Code processes,
@@ -141,23 +160,25 @@ export function detectRunningAISessions(): DetectedProcess[] {
141
160
  }
142
161
  }
143
162
 
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
- });
163
+ // --- File-based providers (Codex/Gemini/Qwen/Cursor/Copilot/Amp) ---
164
+ // None expose a PID; a recently-written session file means "live". cwd/id come
165
+ // from each provider's reader. One loop instead of a block per provider.
166
+ for (const { command, detect } of FILE_PROVIDER_DETECTORS) {
167
+ try {
168
+ for (const s of detect(FILE_PROVIDER_ACTIVE_THRESHOLD_MS)) {
169
+ results.push({
170
+ command,
171
+ cwd: s.cwd,
172
+ kind: 'interactive',
173
+ pid: 0,
174
+ projectPath: cwdToProjectPath(s.cwd),
175
+ sessionId: s.id,
176
+ startedAt: s.startedAt,
177
+ });
178
+ }
179
+ } catch {
180
+ // provider session dir missing/unreadable — skip silently
158
181
  }
159
- } catch {
160
- // ~/.codex/sessions missing or unreadable — skip silently
161
182
  }
162
183
 
163
184
  // Sort by startedAt descending (most recent first)
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Path-based dispatch for the five read-only providers (cursor, copilot,
3
+ * qwen, gemini, amp). These have no incremental byte watcher of their own;
4
+ * the generic-session-watcher re-reads the whole file on change and parses it
5
+ * through here. Keeping all path↔provider knowledge in one module means the
6
+ * watcher, the WS session handler, and the SoS coordinator share one source of
7
+ * truth and never branch on provider directories themselves.
8
+ */
9
+
10
+ import type { ConversationMessage, SessionSource } from '$lib/types';
11
+
12
+ import { detectActiveAmpSessions, parseAmpSessionFile, resolveAmpSessionFile } from './amp-reader';
13
+ import {
14
+ detectActiveCopilotSessions,
15
+ parseCopilotSessionFile,
16
+ resolveCopilotSessionFile,
17
+ } from './copilot-reader';
18
+ import {
19
+ detectActiveCursorSessions,
20
+ parseCursorSessionFile,
21
+ resolveCursorSessionFile,
22
+ } from './cursor-reader';
23
+ import {
24
+ detectActiveGeminiSessions,
25
+ parseGeminiSessionFile,
26
+ resolveGeminiSessionFile,
27
+ } from './gemini-reader';
28
+ import {
29
+ detectActiveQwenSessions,
30
+ parseQwenSessionFile,
31
+ resolveQwenSessionFile,
32
+ } from './qwen-reader';
33
+
34
+ /** True when the path belongs to one of the five read-only providers. */
35
+ export function isReadOnlyProviderPath(filePath: string): boolean {
36
+ return readOnlyProviderForPath(filePath) !== null;
37
+ }
38
+
39
+ /**
40
+ * Parse a single read-only-provider session file (by path) into the full
41
+ * conversation. Returns [] for unknown paths or on any read/parse error.
42
+ */
43
+ export function parseReadOnlyProviderFile(filePath: string): ConversationMessage[] {
44
+ switch (readOnlyProviderForPath(filePath)) {
45
+ case 'amp':
46
+ return parseAmpSessionFile(filePath);
47
+ case 'copilot':
48
+ return parseCopilotSessionFile(filePath);
49
+ case 'cursor':
50
+ return parseCursorSessionFile(filePath);
51
+ case 'gemini':
52
+ return parseGeminiSessionFile(filePath);
53
+ case 'qwen':
54
+ return parseQwenSessionFile(filePath);
55
+ default:
56
+ return [];
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Identify which read-only provider backs a given session-file path, or null
62
+ * for claude/codex/opencode (which have their own watchers) and unknown paths.
63
+ * Detection is by the provider's root directory, which is unique per provider.
64
+ */
65
+ export function readOnlyProviderForPath(filePath: string): null | SessionSource {
66
+ if (filePath.includes('/.cursor/')) {
67
+ return 'cursor';
68
+ }
69
+ if (filePath.includes('/.copilot/')) {
70
+ return 'copilot';
71
+ }
72
+ if (filePath.includes('/.qwen/')) {
73
+ return 'qwen';
74
+ }
75
+ if (filePath.includes('/.gemini/')) {
76
+ return 'gemini';
77
+ }
78
+ if (filePath.includes('/amp/threads/')) {
79
+ return 'amp';
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /** Map a launchable CLI command to its read-only provider source, or null. */
85
+ export function readOnlySourceForCommand(command: string): null | SessionSource {
86
+ switch (command) {
87
+ case 'amp':
88
+ return 'amp';
89
+ case 'copilot':
90
+ return 'copilot';
91
+ case 'cursor-agent':
92
+ return 'cursor';
93
+ case 'gemini':
94
+ return 'gemini';
95
+ case 'qwen':
96
+ return 'qwen';
97
+ default:
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Resolve a read-only provider's session ID to its backing file path, or null
104
+ * if the provider has no single-file backing for that session (e.g. a Gemini
105
+ * session present only in logs.json) or the ID is not found.
106
+ */
107
+ export function resolveReadOnlyProviderFile(
108
+ source: SessionSource,
109
+ sessionId: string
110
+ ): null | string {
111
+ switch (source) {
112
+ case 'amp':
113
+ return resolveAmpSessionFile(sessionId);
114
+ case 'copilot':
115
+ return resolveCopilotSessionFile(sessionId);
116
+ case 'cursor':
117
+ return resolveCursorSessionFile(sessionId);
118
+ case 'gemini':
119
+ return resolveGeminiSessionFile(sessionId);
120
+ case 'qwen':
121
+ return resolveQwenSessionFile(sessionId);
122
+ default:
123
+ return null;
124
+ }
125
+ }
126
+
127
+ const READ_ONLY_DETECTORS: Partial<
128
+ Record<SessionSource, (thresholdMs: number) => { cwd: string; id: string; startedAt: number }[]>
129
+ > = {
130
+ amp: detectActiveAmpSessions,
131
+ copilot: detectActiveCopilotSessions,
132
+ cursor: detectActiveCursorSessions,
133
+ gemini: detectActiveGeminiSessions,
134
+ qwen: detectActiveQwenSessions,
135
+ };
136
+
137
+ /**
138
+ * After Shooter launches a read-only-provider CLI, find the session file it
139
+ * just created so the terminal can be live-tailed. Picks the most recent
140
+ * session started at/after launch, preferring an exact cwd match (cwd decoding
141
+ * is heuristic for some providers, so a same-provider, started-after-launch
142
+ * session is the fallback). Returns null until the CLI has written a session.
143
+ */
144
+ export function discoverReadOnlyProviderSessionFile(
145
+ source: SessionSource,
146
+ cwd: string,
147
+ launchTimeMs: number,
148
+ nowMs: number
149
+ ): null | string {
150
+ const detect = READ_ONLY_DETECTORS[source];
151
+ if (!detect) {
152
+ return null;
153
+ }
154
+ // Detector scan window: reach back to ~60s before launch so the just-created
155
+ // session is still inside the mtime window however long ago we launched.
156
+ const thresholdMs = Math.max(nowMs - launchTimeMs + 60_000, 60_000);
157
+ let active: { cwd: string; id: string; startedAt: number }[];
158
+ try {
159
+ active = detect(thresholdMs);
160
+ } catch {
161
+ return null;
162
+ }
163
+ // Start-time tolerance (distinct from the scan window above): keep only
164
+ // sessions that started at/after launch, with 2s of clock-skew slack.
165
+ const afterLaunch = active
166
+ .filter((s) => s.startedAt >= launchTimeMs - 2000)
167
+ .sort((a, b) => b.startedAt - a.startedAt);
168
+ const match = afterLaunch.find((s) => s.cwd === cwd) ?? afterLaunch[0];
169
+ if (!match) {
170
+ return null;
171
+ }
172
+ return resolveReadOnlyProviderFile(source, match.id);
173
+ }
@@ -0,0 +1,336 @@
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 = readQwenMessages(filePath);
72
+ if (offset === 0 && messages.length > limit) {
73
+ let startIdx = messages.length - limit;
74
+ while (startIdx > 0 && messages[startIdx].role !== 'user') {
75
+ startIdx--;
76
+ }
77
+ return messages.slice(startIdx);
78
+ }
79
+ return messages.slice(offset, offset + limit);
80
+ } catch (error) {
81
+ console.error('[qwen] Failed to read conversation:', error);
82
+ return [];
83
+ }
84
+ }
85
+
86
+ /** List all Qwen sessions grouped by working directory, most-recently-modified first. */
87
+ export function listQwenProjects(): ProjectGroup[] {
88
+ const byCwd = new Map<string, SessionInfo[]>();
89
+ for (const filePath of collectQwenFiles()) {
90
+ let stat: fs.Stats;
91
+ try {
92
+ stat = fs.statSync(filePath);
93
+ } catch {
94
+ continue;
95
+ }
96
+ const meta = readMeta(readPrefix(filePath));
97
+ if (!meta) {
98
+ continue;
99
+ }
100
+ const session: SessionInfo = {
101
+ created: meta.started || stat.birthtime.toISOString(),
102
+ gitBranch: meta.gitBranch,
103
+ id: meta.id,
104
+ messageCount: 0,
105
+ modified: stat.mtime.toISOString(),
106
+ projectPath: meta.cwd,
107
+ source: 'qwen' as const,
108
+ summary: '',
109
+ title: meta.title || 'Untitled Session',
110
+ };
111
+ const bucket = byCwd.get(meta.cwd);
112
+ if (bucket) {
113
+ bucket.push(session);
114
+ } else {
115
+ byCwd.set(meta.cwd, [session]);
116
+ }
117
+ }
118
+
119
+ const projects: ProjectGroup[] = [];
120
+ for (const [cwd, sessions] of byCwd) {
121
+ sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
122
+ const segments = cwd.split('/').filter(Boolean);
123
+ projects.push({
124
+ fullPath: cwd,
125
+ id: shortHash(cwd),
126
+ lastModified: sessions[0]?.modified ?? '',
127
+ name: segments.slice(-2).join('/'),
128
+ sessionCount: sessions.length,
129
+ sessions,
130
+ });
131
+ }
132
+ return projects.sort(
133
+ (a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
134
+ );
135
+ }
136
+
137
+ /** Parse a Qwen session file into messages (used by the generic read-only watcher); [] on error. */
138
+ export function parseQwenSessionFile(filePath: string): ConversationMessage[] {
139
+ try {
140
+ return readQwenMessages(filePath);
141
+ } catch {
142
+ return [];
143
+ }
144
+ }
145
+
146
+ /** Resolve a Qwen session id to its `chats/*.jsonl` path, or null if not found. */
147
+ export function resolveQwenSessionFile(sessionId: string): null | string {
148
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) {
149
+ return null;
150
+ }
151
+ return collectQwenFiles().find((p) => path.basename(p) === `${sessionId}.jsonl`) ?? null;
152
+ }
153
+
154
+ /** All chats/*.jsonl files under ~/.qwen/projects/<encoded-cwd>/chats/. */
155
+ function collectQwenFiles(): string[] {
156
+ const out: string[] = [];
157
+ let projectDirs: fs.Dirent[];
158
+ try {
159
+ projectDirs = fs.readdirSync(QWEN_PROJECTS, { withFileTypes: true });
160
+ } catch {
161
+ return out;
162
+ }
163
+ for (const dir of projectDirs) {
164
+ if (!dir.isDirectory()) {
165
+ continue;
166
+ }
167
+ const chatsDir = path.join(QWEN_PROJECTS, dir.name, 'chats');
168
+ try {
169
+ for (const f of fs.readdirSync(chatsDir)) {
170
+ if (f.endsWith('.jsonl')) {
171
+ out.push(path.join(chatsDir, f));
172
+ }
173
+ }
174
+ } catch {
175
+ // no chats dir
176
+ }
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** Map a Qwen JSONL line (Claude envelope + Gemini message.parts) to a ConversationMessage. */
182
+ function qwenLineToMessage(entry: Record<string, unknown>): ConversationMessage | null {
183
+ const type = entry.type;
184
+ if (type !== 'user' && type !== 'assistant') {
185
+ return null; // skip system/control records
186
+ }
187
+ const message = (entry.message ?? {}) as { content?: unknown; parts?: unknown };
188
+ const rawParts = Array.isArray(message.parts) ? message.parts : [];
189
+ const parts: MessagePart[] = [];
190
+ for (const raw of rawParts) {
191
+ if (typeof raw !== 'object' || raw === null) {
192
+ continue;
193
+ }
194
+ const p = raw as Record<string, unknown>;
195
+ if (p.functionCall && typeof p.functionCall === 'object') {
196
+ const fc = p.functionCall as Record<string, unknown>;
197
+ const toolName = typeof fc.name === 'string' ? fc.name : 'tool';
198
+ parts.push({
199
+ id: typeof fc.id === 'string' ? fc.id : toolName,
200
+ input: (fc.args as Record<string, unknown>) ?? {},
201
+ toolName,
202
+ type: 'tool_use',
203
+ });
204
+ } else if (p.thought === true && typeof p.text === 'string') {
205
+ parts.push({ content: p.text, type: 'thinking' });
206
+ } else if (typeof p.text === 'string') {
207
+ parts.push({ content: p.text, type: 'text' });
208
+ }
209
+ }
210
+ // Fallback for any Claude-style string content.
211
+ if (parts.length === 0 && typeof message.content === 'string' && message.content) {
212
+ parts.push({ content: message.content, type: 'text' });
213
+ }
214
+ if (parts.length === 0) {
215
+ return null;
216
+ }
217
+ return {
218
+ id: typeof entry.uuid === 'string' ? entry.uuid : `qwen-${type}`,
219
+ parts,
220
+ role: type === 'user' ? 'user' : 'assistant',
221
+ timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : '',
222
+ };
223
+ }
224
+
225
+ /** Extract {cwd, sessionId, gitBranch, started} + first real user prompt from a Qwen session prefix. */
226
+ function readMeta(
227
+ prefix: string
228
+ ): null | { cwd: string; gitBranch: string; id: string; started: string; title: string } {
229
+ let cwd = '';
230
+ let id = '';
231
+ let gitBranch = '';
232
+ let started = '';
233
+ let title = '';
234
+ for (const line of prefix.split('\n')) {
235
+ const trimmed = line.trim();
236
+ if (!trimmed) {
237
+ continue;
238
+ }
239
+ let entry: Record<string, unknown>;
240
+ try {
241
+ entry = JSON.parse(trimmed) as Record<string, unknown>;
242
+ } catch {
243
+ continue;
244
+ }
245
+ if (!cwd && typeof entry.cwd === 'string') {
246
+ cwd = entry.cwd;
247
+ }
248
+ if (!id && typeof entry.sessionId === 'string') {
249
+ id = entry.sessionId;
250
+ }
251
+ if (!gitBranch && typeof entry.gitBranch === 'string') {
252
+ gitBranch = entry.gitBranch;
253
+ }
254
+ if (!started && typeof entry.timestamp === 'string') {
255
+ started = entry.timestamp;
256
+ }
257
+ if (!title && entry.type === 'user') {
258
+ const msg = entry.message as undefined | { content?: unknown; parts?: unknown };
259
+ let text = typeof msg?.content === 'string' ? msg.content : '';
260
+ if (!text && Array.isArray(msg?.parts)) {
261
+ text = msg.parts
262
+ .map((p) =>
263
+ p && typeof p === 'object' && typeof (p as { text?: unknown }).text === 'string'
264
+ ? (p as { text: string }).text
265
+ : ''
266
+ )
267
+ .join(' ')
268
+ .trim();
269
+ }
270
+ if (text && !SYSTEM_TAG_PREFIXES.some((p) => text.startsWith(p))) {
271
+ title = text.split('\n')[0]?.slice(0, 80) ?? '';
272
+ }
273
+ }
274
+ }
275
+ return id && cwd ? { cwd, gitBranch, id, started, title } : null;
276
+ }
277
+
278
+ /** Read the head of a file (complete lines only). */
279
+ function readPrefix(filePath: string): string {
280
+ const fd = fs.openSync(filePath, 'r');
281
+ try {
282
+ const buf = Buffer.alloc(PREFIX_BYTES);
283
+ const n = fs.readSync(fd, buf, 0, PREFIX_BYTES, 0);
284
+ const text = buf.toString('utf-8', 0, n);
285
+ const lastNl = text.lastIndexOf('\n');
286
+ return lastNl === -1 ? text : text.slice(0, lastNl);
287
+ } finally {
288
+ fs.closeSync(fd);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Read all ConversationMessages from a Qwen JSONL file without pagination.
294
+ * Oversized files are tail-bounded (see readQwenTextBounded) to cap memory.
295
+ */
296
+ function readQwenMessages(filePath: string): ConversationMessage[] {
297
+ const messages: ConversationMessage[] = [];
298
+ for (const line of readQwenTextBounded(filePath).split('\n')) {
299
+ const trimmed = line.trim();
300
+ if (!trimmed) {
301
+ continue;
302
+ }
303
+ try {
304
+ const msg = qwenLineToMessage(JSON.parse(trimmed) as Record<string, unknown>);
305
+ if (msg) {
306
+ messages.push(msg);
307
+ }
308
+ } catch {
309
+ // skip malformed line
310
+ }
311
+ }
312
+ return messages;
313
+ }
314
+
315
+ /** Read a Qwen session file, bounded to the tail for oversized files to cap memory. */
316
+ function readQwenTextBounded(filePath: string): string {
317
+ const size = fs.statSync(filePath).size;
318
+ if (size <= MAX_QWEN_FILE_BYTES) {
319
+ return fs.readFileSync(filePath, 'utf-8');
320
+ }
321
+ // Oversized: read only the trailing window (most recent messages), dropping the
322
+ // first (likely partial) line so JSON.parse never sees a fragment.
323
+ const fd = fs.openSync(filePath, 'r');
324
+ try {
325
+ const buf = Buffer.alloc(MAX_QWEN_FILE_BYTES);
326
+ const n = fs.readSync(fd, buf, 0, MAX_QWEN_FILE_BYTES, size - MAX_QWEN_FILE_BYTES);
327
+ const tail = buf.toString('utf-8', 0, n);
328
+ return tail.slice(tail.indexOf('\n') + 1);
329
+ } finally {
330
+ fs.closeSync(fd);
331
+ }
332
+ }
333
+
334
+ function shortHash(input: string): string {
335
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
336
+ }