@juspay/shooter 1.17.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 (156) hide show
  1. package/build/client/_app/immutable/assets/{0.B0O0vCnX.css → 0.NV8k8wxG.css} +1 -1
  2. package/build/client/_app/immutable/assets/0.NV8k8wxG.css.br +0 -0
  3. package/build/client/_app/immutable/assets/{0.B0O0vCnX.css.gz → 0.NV8k8wxG.css.gz} +0 -0
  4. package/build/client/_app/immutable/chunks/{BctvtE4d.js → 8lO1IL7u.js} +1 -1
  5. package/build/client/_app/immutable/chunks/8lO1IL7u.js.br +0 -0
  6. package/build/client/_app/immutable/chunks/{BctvtE4d.js.gz → 8lO1IL7u.js.gz} +0 -0
  7. package/build/client/_app/immutable/chunks/B9WQy_3X.js +1 -0
  8. package/build/client/_app/immutable/chunks/B9WQy_3X.js.br +0 -0
  9. package/build/client/_app/immutable/chunks/B9WQy_3X.js.gz +0 -0
  10. package/build/client/_app/immutable/chunks/BdtLzPpO.js +1 -0
  11. package/build/client/_app/immutable/chunks/BdtLzPpO.js.br +0 -0
  12. package/build/client/_app/immutable/chunks/BdtLzPpO.js.gz +0 -0
  13. package/build/client/_app/immutable/chunks/{CjfxuHdN.js → DJvX78LW.js} +1 -1
  14. package/build/client/_app/immutable/chunks/DJvX78LW.js.br +0 -0
  15. package/build/client/_app/immutable/chunks/DJvX78LW.js.gz +0 -0
  16. package/build/client/_app/immutable/chunks/nWG9RHyB.js +3 -0
  17. package/build/client/_app/immutable/chunks/nWG9RHyB.js.br +0 -0
  18. package/build/client/_app/immutable/chunks/nWG9RHyB.js.gz +0 -0
  19. package/build/client/_app/immutable/entry/{app.CNaTe-zm.js → app.f46Ko1hu.js} +2 -2
  20. package/build/client/_app/immutable/entry/app.f46Ko1hu.js.br +0 -0
  21. package/build/client/_app/immutable/entry/app.f46Ko1hu.js.gz +0 -0
  22. package/build/client/_app/immutable/entry/start.BVDjNnXt.js +1 -0
  23. package/build/client/_app/immutable/entry/start.BVDjNnXt.js.br +2 -0
  24. package/build/client/_app/immutable/entry/start.BVDjNnXt.js.gz +0 -0
  25. package/build/client/_app/immutable/nodes/{0.C3ELOf4c.js → 0.D_9EwVmq.js} +1 -1
  26. package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.br +0 -0
  27. package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.gz +0 -0
  28. package/build/client/_app/immutable/nodes/{1.Fqso94b3.js → 1.C4eFlqSB.js} +1 -1
  29. package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.br +0 -0
  30. package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.gz +0 -0
  31. package/build/client/_app/immutable/nodes/{2.BusCVJWk.js → 2.CdC092Za.js} +1 -1
  32. package/build/client/_app/immutable/nodes/2.CdC092Za.js.br +0 -0
  33. package/build/client/_app/immutable/nodes/2.CdC092Za.js.gz +0 -0
  34. package/build/client/_app/immutable/nodes/{3.DUlpocIc.js → 3.Dhf4ZWW0.js} +1 -1
  35. package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.br +0 -0
  36. package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.gz +0 -0
  37. package/build/client/_app/immutable/nodes/{6.CG4eKRH0.js → 6.B3SEB_li.js} +1 -1
  38. package/build/client/_app/immutable/nodes/6.B3SEB_li.js.br +0 -0
  39. package/build/client/_app/immutable/nodes/6.B3SEB_li.js.gz +0 -0
  40. package/build/client/_app/immutable/nodes/{7.DHilxD1o.js → 7.DV8cJ1lX.js} +1 -1
  41. package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.br +0 -0
  42. package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.gz +0 -0
  43. package/build/client/_app/immutable/nodes/{8.BjKgvSie.js → 8.Bs362gyb.js} +2 -2
  44. package/build/client/_app/immutable/nodes/8.Bs362gyb.js.br +0 -0
  45. package/build/client/_app/immutable/nodes/8.Bs362gyb.js.gz +0 -0
  46. package/build/client/_app/immutable/nodes/{9.BRT6HOXB.js → 9.Cf7_3uqT.js} +1 -1
  47. package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.br +0 -0
  48. package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.gz +0 -0
  49. package/build/client/_app/version.json +1 -1
  50. package/build/client/_app/version.json.br +0 -0
  51. package/build/client/_app/version.json.gz +0 -0
  52. package/build/server/chunks/{0-BWFSL107.js → 0-Cd7jY0a7.js} +3 -3
  53. package/build/server/chunks/{0-BWFSL107.js.map → 0-Cd7jY0a7.js.map} +1 -1
  54. package/build/server/chunks/{1-Bw5KlAjL.js → 1-C4BOGoJY.js} +2 -2
  55. package/build/server/chunks/{1-Bw5KlAjL.js.map → 1-C4BOGoJY.js.map} +1 -1
  56. package/build/server/chunks/{2-CQ3yYSVK.js → 2-Ba0mNwJ6.js} +2 -2
  57. package/build/server/chunks/{2-CQ3yYSVK.js.map → 2-Ba0mNwJ6.js.map} +1 -1
  58. package/build/server/chunks/{3-DZ4H9hPs.js → 3-Pg8t1uJU.js} +2 -2
  59. package/build/server/chunks/{3-DZ4H9hPs.js.map → 3-Pg8t1uJU.js.map} +1 -1
  60. package/build/server/chunks/{6-BZ0enR6b.js → 6-D8xbnTSo.js} +2 -2
  61. package/build/server/chunks/{6-BZ0enR6b.js.map → 6-D8xbnTSo.js.map} +1 -1
  62. package/build/server/chunks/{7-Lg8imTZn.js → 7-CkVK06S0.js} +2 -2
  63. package/build/server/chunks/{7-Lg8imTZn.js.map → 7-CkVK06S0.js.map} +1 -1
  64. package/build/server/chunks/{8-DKs4yOL7.js → 8-C8qVhrds.js} +2 -2
  65. package/build/server/chunks/{8-DKs4yOL7.js.map → 8-C8qVhrds.js.map} +1 -1
  66. package/build/server/chunks/{9-UNmpUWDY.js → 9-fL5zqN0T.js} +2 -2
  67. package/build/server/chunks/{9-UNmpUWDY.js.map → 9-fL5zqN0T.js.map} +1 -1
  68. package/build/server/chunks/{_server.ts-B1z0q6qZ.js → _server.ts-BA_uWcPw.js} +4 -5
  69. package/build/server/chunks/_server.ts-BA_uWcPw.js.map +1 -0
  70. package/build/server/chunks/{_server.ts-5wx4ZppI.js → _server.ts-Bu3s5hfv.js} +3 -3
  71. package/build/server/chunks/{_server.ts-5wx4ZppI.js.map → _server.ts-Bu3s5hfv.js.map} +1 -1
  72. package/build/server/chunks/{_server.ts-CKXVBbwb.js → _server.ts-CwAjt91u.js} +8 -8
  73. package/build/server/chunks/_server.ts-CwAjt91u.js.map +1 -0
  74. package/build/server/chunks/{_server.ts-CgHc1Zpx.js → _server.ts-DZP2lhaY.js} +3 -3
  75. package/build/server/chunks/{_server.ts-CgHc1Zpx.js.map → _server.ts-DZP2lhaY.js.map} +1 -1
  76. package/build/server/chunks/{_server.ts-BMMTS86y.js → _server.ts-DZgfQKiH.js} +3 -4
  77. package/build/server/chunks/{_server.ts-BMMTS86y.js.map → _server.ts-DZgfQKiH.js.map} +1 -1
  78. package/build/server/chunks/{_server.ts-Bt7EAfjo.js → _server.ts-MbnroWEF.js} +25 -48
  79. package/build/server/chunks/_server.ts-MbnroWEF.js.map +1 -0
  80. package/build/server/chunks/{pty-manager-RmhVe2Ez.js → pty-manager-DmNSCKAr.js} +99 -2
  81. package/build/server/chunks/pty-manager-DmNSCKAr.js.map +1 -0
  82. package/build/server/chunks/qwen-reader-DGfUbKaJ.js +2112 -0
  83. package/build/server/chunks/qwen-reader-DGfUbKaJ.js.map +1 -0
  84. package/build/server/chunks/{registry-DzJj2E6I.js → registry-Kcw2UCMv.js} +55 -23
  85. package/build/server/chunks/registry-Kcw2UCMv.js.map +1 -0
  86. package/build/server/index.js +1 -1
  87. package/build/server/index.js.map +1 -1
  88. package/build/server/manifest.js +15 -15
  89. package/build/server/manifest.js.map +1 -1
  90. package/package.json +2 -2
  91. package/scripts/e2e-all-features.sh +165 -0
  92. package/scripts/e2e-cross-terminal.sh +168 -0
  93. package/server.ts +12 -0
  94. package/src/lib/modules/client/common/provider.ts +0 -2
  95. package/src/lib/modules/client/terminal/ChatView.svelte +9 -2
  96. package/src/lib/modules/client/terminal/LaunchSheet.svelte +3 -0
  97. package/src/lib/modules/server/sessions/amp-reader.ts +439 -0
  98. package/src/lib/modules/server/sessions/copilot-reader.ts +542 -0
  99. package/src/lib/modules/server/sessions/cursor-reader.ts +634 -0
  100. package/src/lib/modules/server/sessions/gemini-reader.ts +48 -25
  101. package/src/lib/modules/server/sessions/opencode-reader.ts +13 -12
  102. package/src/lib/modules/server/sessions/process-detector.ts +37 -60
  103. package/src/lib/modules/server/sessions/provider-paths.ts +173 -0
  104. package/src/lib/modules/server/sessions/qwen-reader.ts +41 -15
  105. package/src/lib/modules/server/sessions/registry.ts +55 -14
  106. package/src/lib/modules/server/terminal/generic-session-watcher.ts +163 -0
  107. package/src/lib/modules/server/terminal/pty-manager.ts +51 -0
  108. package/src/lib/modules/server/ws/session-handler.ts +11 -1
  109. package/src/lib/theme.css +1 -2
  110. package/src/lib/types/generated/Sessions.ts +1 -4
  111. package/src/lib/types/server.ts +23 -6
  112. package/src/lib/types/sessions.ts +1 -10
  113. package/src/routes/api/sessions/connect/+server.ts +7 -3
  114. package/build/client/_app/immutable/assets/0.B0O0vCnX.css.br +0 -0
  115. package/build/client/_app/immutable/chunks/BctvtE4d.js.br +0 -0
  116. package/build/client/_app/immutable/chunks/BxFShcQO.js +0 -1
  117. package/build/client/_app/immutable/chunks/BxFShcQO.js.br +0 -0
  118. package/build/client/_app/immutable/chunks/BxFShcQO.js.gz +0 -0
  119. package/build/client/_app/immutable/chunks/ByzqAuXw.js +0 -3
  120. package/build/client/_app/immutable/chunks/ByzqAuXw.js.br +0 -0
  121. package/build/client/_app/immutable/chunks/ByzqAuXw.js.gz +0 -0
  122. package/build/client/_app/immutable/chunks/CjfxuHdN.js.br +0 -0
  123. package/build/client/_app/immutable/chunks/CjfxuHdN.js.gz +0 -0
  124. package/build/client/_app/immutable/chunks/Pw0jDB7M.js +0 -1
  125. package/build/client/_app/immutable/chunks/Pw0jDB7M.js.br +0 -0
  126. package/build/client/_app/immutable/chunks/Pw0jDB7M.js.gz +0 -0
  127. package/build/client/_app/immutable/entry/app.CNaTe-zm.js.br +0 -0
  128. package/build/client/_app/immutable/entry/app.CNaTe-zm.js.gz +0 -0
  129. package/build/client/_app/immutable/entry/start.hxYnjcDu.js +0 -1
  130. package/build/client/_app/immutable/entry/start.hxYnjcDu.js.br +0 -0
  131. package/build/client/_app/immutable/entry/start.hxYnjcDu.js.gz +0 -0
  132. package/build/client/_app/immutable/nodes/0.C3ELOf4c.js.br +0 -0
  133. package/build/client/_app/immutable/nodes/0.C3ELOf4c.js.gz +0 -0
  134. package/build/client/_app/immutable/nodes/1.Fqso94b3.js.br +0 -0
  135. package/build/client/_app/immutable/nodes/1.Fqso94b3.js.gz +0 -0
  136. package/build/client/_app/immutable/nodes/2.BusCVJWk.js.br +0 -0
  137. package/build/client/_app/immutable/nodes/2.BusCVJWk.js.gz +0 -0
  138. package/build/client/_app/immutable/nodes/3.DUlpocIc.js.br +0 -0
  139. package/build/client/_app/immutable/nodes/3.DUlpocIc.js.gz +0 -0
  140. package/build/client/_app/immutable/nodes/6.CG4eKRH0.js.br +0 -0
  141. package/build/client/_app/immutable/nodes/6.CG4eKRH0.js.gz +0 -0
  142. package/build/client/_app/immutable/nodes/7.DHilxD1o.js.br +0 -0
  143. package/build/client/_app/immutable/nodes/7.DHilxD1o.js.gz +0 -0
  144. package/build/client/_app/immutable/nodes/8.BjKgvSie.js.br +0 -0
  145. package/build/client/_app/immutable/nodes/8.BjKgvSie.js.gz +0 -0
  146. package/build/client/_app/immutable/nodes/9.BRT6HOXB.js.br +0 -0
  147. package/build/client/_app/immutable/nodes/9.BRT6HOXB.js.gz +0 -0
  148. package/build/server/chunks/_server.ts-B1z0q6qZ.js.map +0 -1
  149. package/build/server/chunks/_server.ts-Bt7EAfjo.js.map +0 -1
  150. package/build/server/chunks/_server.ts-CKXVBbwb.js.map +0 -1
  151. package/build/server/chunks/opencode-db-path-BwaPufWf.js +0 -411
  152. package/build/server/chunks/opencode-db-path-BwaPufWf.js.map +0 -1
  153. package/build/server/chunks/pty-manager-RmhVe2Ez.js.map +0 -1
  154. package/build/server/chunks/qwen-reader-2fTFuC_D.js +0 -622
  155. package/build/server/chunks/qwen-reader-2fTFuC_D.js.map +0 -1
  156. package/build/server/chunks/registry-DzJj2E6I.js.map +0 -1
@@ -209,6 +209,32 @@ export function listGeminiProjects(): ProjectGroup[] {
209
209
  );
210
210
  }
211
211
 
212
+ /**
213
+ * Parse the full conversation from a single chats/session-*.json file.
214
+ * Returns all messages with no pagination — suitable for bulk processing.
215
+ */
216
+ export function parseGeminiSessionFile(filePath: string): ConversationMessage[] {
217
+ try {
218
+ return conversationFromChatFile(filePath, 0, Number.MAX_SAFE_INTEGER);
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Resolve the absolute path of the chats/session-*.json file backing the
226
+ * given sessionId, or null if no chat file exists (e.g. logs.json-only session).
227
+ */
228
+ export function resolveGeminiSessionFile(sessionId: string): null | string {
229
+ for (const hashDir of collectProjectHashDirs()) {
230
+ const chatFile = findChatFileForSession(hashDir, sessionId);
231
+ if (chatFile !== null) {
232
+ return chatFile;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
212
238
  // ---------------------------------------------------------------------------
213
239
  // Session building helpers
214
240
  // ---------------------------------------------------------------------------
@@ -390,36 +416,33 @@ function extractTextFromContent(
390
416
  }
391
417
 
392
418
  /**
393
- * Find the chats/session-*.json file for a specific sessionId.
394
- * The filename embeds the first 8 chars of the UUID; we fall back to reading
395
- * every file in the directory if necessary.
419
+ * Find the chats/session-*.json file for a specific sessionId. The filename
420
+ * embeds the first 8 chars of the UUID, so prefix matches are tried first, but
421
+ * the exact sessionId field is always verified before returning — a short/empty
422
+ * id or a prefix collision must never mis-resolve to the wrong session.
396
423
  */
397
424
  function findChatFileForSession(hashDir: string, sessionId: string): null | string {
398
- const shortId = sessionId.slice(0, 8);
425
+ if (!sessionId) {
426
+ return null;
427
+ }
399
428
  const chatsDir = path.join(hashDir, 'chats');
429
+ let names: string[];
400
430
  try {
401
- const entries = fs.readdirSync(chatsDir, { withFileTypes: true });
402
- for (const entry of entries) {
403
- if (!entry.isFile() || !entry.name.startsWith('session-') || !entry.name.endsWith('.json')) {
404
- continue;
405
- }
406
- // Fast path: filename contains shortId.
407
- if (entry.name.includes(shortId)) {
408
- return path.join(chatsDir, entry.name);
409
- }
410
- }
411
- // Slow path: read each file and check the sessionId field.
412
- for (const entry of entries) {
413
- if (!entry.isFile() || !entry.name.startsWith('session-') || !entry.name.endsWith('.json')) {
414
- continue;
415
- }
416
- const record = readChatFile(path.join(chatsDir, entry.name));
417
- if (record?.sessionId === sessionId) {
418
- return path.join(chatsDir, entry.name);
419
- }
420
- }
431
+ names = fs
432
+ .readdirSync(chatsDir, { withFileTypes: true })
433
+ .filter((e) => e.isFile() && e.name.startsWith('session-') && e.name.endsWith('.json'))
434
+ .map((e) => e.name);
421
435
  } catch {
422
- // chats dir missing or unreadable
436
+ return null; // chats dir missing or unreadable
437
+ }
438
+ const shortId = sessionId.slice(0, 8);
439
+ // Order prefix-matching filenames first (cheap), then verify the exact id.
440
+ names.sort((a, b) => Number(b.includes(shortId)) - Number(a.includes(shortId)));
441
+ for (const name of names) {
442
+ const full = path.join(chatsDir, name);
443
+ if (readChatFile(full)?.sessionId === sessionId) {
444
+ return full;
445
+ }
423
446
  }
424
447
  return null;
425
448
  }
@@ -2,16 +2,6 @@ import Database from 'better-sqlite3';
2
2
  import * as crypto from 'crypto';
3
3
  import * as fs from 'fs';
4
4
 
5
- function shortHash(input: string): string {
6
- return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
7
- }
8
-
9
- import type { ConversationMessage, MessagePart, ProjectGroup, SessionInfo } from '$lib/types';
10
-
11
- import { resolveOpenCodeDbPath } from './opencode-db-path';
12
-
13
- const OPENCODE_DB_PATH = resolveOpenCodeDbPath();
14
-
15
5
  export function getOpenCodeConversation(
16
6
  sessionId: string,
17
7
  offset = 0,
@@ -127,6 +117,10 @@ export function getOpenCodeConversation(
127
117
  }
128
118
  }
129
119
 
120
+ import type { ConversationMessage, MessagePart, ProjectGroup, SessionInfo } from '$lib/types';
121
+
122
+ import { resolveOpenCodeDbPath } from './opencode-db-path';
123
+
130
124
  export function listOpenCodeProjects(): ProjectGroup[] {
131
125
  const db = getDb();
132
126
  if (!db) {
@@ -251,12 +245,19 @@ function convertOpenCodePart(data: Record<string, unknown>): MessagePart | null
251
245
  }
252
246
 
253
247
  function getDb(): Database.Database | null {
254
- if (!fs.existsSync(OPENCODE_DB_PATH)) {
248
+ // Resolve per call (not at module load) so a DB created after server start —
249
+ // or an XDG_DATA_HOME change — is still picked up.
250
+ const dbPath = resolveOpenCodeDbPath();
251
+ if (!fs.existsSync(dbPath)) {
255
252
  return null;
256
253
  }
257
254
  try {
258
- return new Database(OPENCODE_DB_PATH, { readonly: true });
255
+ return new Database(dbPath, { readonly: true });
259
256
  } catch {
260
257
  return null;
261
258
  }
262
259
  }
260
+
261
+ function shortHash(input: string): string {
262
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
263
+ }
@@ -1,6 +1,9 @@
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';
4
7
  import { detectActiveGeminiSessions } from '$lib/modules/server/sessions/gemini-reader';
5
8
  import { resolveOpenCodeDbPath } from '$lib/modules/server/sessions/opencode-db-path';
6
9
  import { detectActiveQwenSessions } from '$lib/modules/server/sessions/qwen-reader';
@@ -48,14 +51,22 @@ const CLAUDE_SESSIONS_DIR = join(homedir(), '.claude', 'sessions');
48
51
  // OpenCode sessions updated within this window are considered "live"
49
52
  const OPENCODE_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
50
53
 
51
- // Codex rollout files written within this window are considered "live"
52
- const CODEX_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
53
-
54
- // Gemini session files written within this window are considered "live"
55
- const GEMINI_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
56
-
57
- // Qwen session files written within this window are considered "live"
58
- const QWEN_ACTIVE_THRESHOLD_MS = 3 * 60_000; // 3 minutes
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
+ ];
59
70
 
60
71
  /**
61
72
  * Scan ~/.claude/sessions/*.json to find running Claude Code processes,
@@ -149,59 +160,25 @@ export function detectRunningAISessions(): DetectedProcess[] {
149
160
  }
150
161
  }
151
162
 
152
- // --- Codex sessions ---
153
- // Codex has no PID file; a rollout file written in the last few minutes
154
- // indicates an active session. cwd/id come from its session_meta line.
155
- try {
156
- for (const s of detectActiveCodexSessions(CODEX_ACTIVE_THRESHOLD_MS)) {
157
- results.push({
158
- command: 'codex',
159
- cwd: s.cwd,
160
- kind: 'interactive',
161
- pid: 0, // Codex doesn't expose a per-session PID
162
- projectPath: cwdToProjectPath(s.cwd),
163
- sessionId: s.id,
164
- startedAt: s.startedAt,
165
- });
166
- }
167
- } catch {
168
- // ~/.codex/sessions missing or unreadable — skip silently
169
- }
170
-
171
- // --- Gemini sessions ---
172
- // Gemini has no PID file; a logs.json / chat file written in the last few
173
- // minutes indicates an active session. cwd is reverse-mapped where possible.
174
- try {
175
- for (const s of detectActiveGeminiSessions(GEMINI_ACTIVE_THRESHOLD_MS)) {
176
- results.push({
177
- command: 'gemini',
178
- cwd: s.cwd,
179
- kind: 'interactive',
180
- pid: 0, // Gemini doesn't expose a per-session PID
181
- projectPath: cwdToProjectPath(s.cwd),
182
- sessionId: s.id,
183
- startedAt: s.startedAt,
184
- });
185
- }
186
- } catch {
187
- // ~/.gemini/tmp missing or unreadable — skip silently
188
- }
189
-
190
- // --- Qwen sessions ---
191
- try {
192
- for (const s of detectActiveQwenSessions(QWEN_ACTIVE_THRESHOLD_MS)) {
193
- results.push({
194
- command: 'qwen',
195
- cwd: s.cwd,
196
- kind: 'interactive',
197
- pid: 0,
198
- projectPath: cwdToProjectPath(s.cwd),
199
- sessionId: s.id,
200
- startedAt: s.startedAt,
201
- });
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
202
181
  }
203
- } catch {
204
- // ~/.qwen/projects missing or unreadable — skip silently
205
182
  }
206
183
 
207
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
+ }
@@ -68,21 +68,7 @@ export function getQwenConversation(
68
68
  return [];
69
69
  }
70
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
- }
71
+ const messages = readQwenMessages(filePath);
86
72
  if (offset === 0 && messages.length > limit) {
87
73
  let startIdx = messages.length - limit;
88
74
  while (startIdx > 0 && messages[startIdx].role !== 'user') {
@@ -148,6 +134,23 @@ export function listQwenProjects(): ProjectGroup[] {
148
134
  );
149
135
  }
150
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
+
151
154
  /** All chats/*.jsonl files under ~/.qwen/projects/<encoded-cwd>/chats/. */
152
155
  function collectQwenFiles(): string[] {
153
156
  const out: string[] = [];
@@ -286,6 +289,29 @@ function readPrefix(filePath: string): string {
286
289
  }
287
290
  }
288
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
+
289
315
  /** Read a Qwen session file, bounded to the tail for oversized files to cap memory. */
290
316
  function readQwenTextBounded(filePath: string): string {
291
317
  const size = fs.statSync(filePath).size;
@@ -12,7 +12,10 @@
12
12
 
13
13
  import type { ConversationMessage, ProjectGroup, ProviderDef } from '$lib/types';
14
14
 
15
+ import { getAmpConversation, listAmpProjects } from './amp-reader';
15
16
  import { getCodexConversation, listCodexProjects } from './codex-reader';
17
+ import { getCopilotConversation, listCopilotProjects } from './copilot-reader';
18
+ import { getCursorConversation, listCursorProjects } from './cursor-reader';
16
19
  import { getGeminiConversation, listGeminiProjects } from './gemini-reader';
17
20
  import { getSessionConversation, listProjectsWithSessions } from './jsonl-reader';
18
21
  import { getOpenCodeConversation, listOpenCodeProjects } from './opencode-reader';
@@ -70,6 +73,33 @@ export const PROVIDERS: ProviderDef[] = [
70
73
  resumeArgs: () => [],
71
74
  source: 'qwen',
72
75
  },
76
+ {
77
+ command: 'cursor-agent',
78
+ getConversation: getCursorConversation,
79
+ isAI: true,
80
+ label: 'Cursor',
81
+ listProjects: listCursorProjects,
82
+ resumeArgs: () => [],
83
+ source: 'cursor',
84
+ },
85
+ {
86
+ command: 'copilot',
87
+ getConversation: getCopilotConversation,
88
+ isAI: true,
89
+ label: 'Copilot',
90
+ listProjects: listCopilotProjects,
91
+ resumeArgs: () => [],
92
+ source: 'copilot',
93
+ },
94
+ {
95
+ command: 'amp',
96
+ getConversation: getAmpConversation,
97
+ isAI: true,
98
+ label: 'Amp',
99
+ listProjects: listAmpProjects,
100
+ resumeArgs: () => [],
101
+ source: 'amp',
102
+ },
73
103
  ];
74
104
 
75
105
  /** AI-agent binary names (for AI_COMMANDS-style checks). */
@@ -93,9 +123,13 @@ export function getProviderConversation(
93
123
  if (provider.source === 'claude-code') {
94
124
  continue;
95
125
  }
96
- const messages = provider.getConversation(sessionId, offset, limit);
97
- if (messages.length > 0) {
98
- return messages;
126
+ try {
127
+ const messages = provider.getConversation(sessionId, offset, limit);
128
+ if (messages.length > 0) {
129
+ return messages;
130
+ }
131
+ } catch {
132
+ // a failing provider reader must not break resolution — try the next
99
133
  }
100
134
  }
101
135
  return [];
@@ -112,17 +146,24 @@ export function listAllProviderProjects(): ProjectGroup[] {
112
146
  continue; // a broken provider must not take down the whole listing
113
147
  }
114
148
  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 });
149
+ try {
150
+ const name =
151
+ provider.nameSuffix && typeof group.name === 'string'
152
+ ? group.name.replace(provider.nameSuffix, '')
153
+ : group.name;
154
+ const existing = byPath.get(group.fullPath);
155
+ if (existing) {
156
+ existing.sessions.push(...group.sessions);
157
+ existing.sessions.sort(
158
+ (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
159
+ );
160
+ existing.sessionCount = existing.sessions.length;
161
+ existing.lastModified = existing.sessions[0]?.modified || existing.lastModified;
162
+ } else {
163
+ byPath.set(group.fullPath, { ...group, name });
164
+ }
165
+ } catch {
166
+ // skip a malformed group rather than dropping the rest of the provider
126
167
  }
127
168
  }
128
169
  }