@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.
- package/.claude/hooks/codex-hooks.example.json +75 -0
- package/.claude/hooks/notifier.cjs +158 -8
- package/build/client/_app/immutable/assets/{0.DEfoFaGR.css → 0.NV8k8wxG.css} +1 -1
- package/build/client/_app/immutable/assets/0.NV8k8wxG.css.br +0 -0
- package/build/client/_app/immutable/assets/0.NV8k8wxG.css.gz +0 -0
- package/build/client/_app/immutable/chunks/8lO1IL7u.js +1 -0
- package/build/client/_app/immutable/chunks/8lO1IL7u.js.br +0 -0
- package/build/client/_app/immutable/chunks/8lO1IL7u.js.gz +0 -0
- package/build/client/_app/immutable/chunks/B9WQy_3X.js +1 -0
- package/build/client/_app/immutable/chunks/B9WQy_3X.js.br +0 -0
- package/build/client/_app/immutable/chunks/B9WQy_3X.js.gz +0 -0
- package/build/client/_app/immutable/chunks/BdtLzPpO.js +1 -0
- package/build/client/_app/immutable/chunks/BdtLzPpO.js.br +0 -0
- package/build/client/_app/immutable/chunks/BdtLzPpO.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{DlS3abGJ.js → DJvX78LW.js} +1 -1
- package/build/client/_app/immutable/chunks/DJvX78LW.js.br +0 -0
- package/build/client/_app/immutable/chunks/DJvX78LW.js.gz +0 -0
- package/build/client/_app/immutable/chunks/nWG9RHyB.js +3 -0
- package/build/client/_app/immutable/chunks/nWG9RHyB.js.br +0 -0
- package/build/client/_app/immutable/chunks/nWG9RHyB.js.gz +0 -0
- package/build/client/_app/immutable/entry/{app.CSJG7N9H.js → app.f46Ko1hu.js} +2 -2
- package/build/client/_app/immutable/entry/app.f46Ko1hu.js.br +0 -0
- package/build/client/_app/immutable/entry/app.f46Ko1hu.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.BVDjNnXt.js +1 -0
- package/build/client/_app/immutable/entry/start.BVDjNnXt.js.br +2 -0
- package/build/client/_app/immutable/entry/start.BVDjNnXt.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{0.qOL7xtFn.js → 0.D_9EwVmq.js} +1 -1
- package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{1.Di708Ago.js → 1.C4eFlqSB.js} +1 -1
- package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{2.DSM1znqa.js → 2.CdC092Za.js} +1 -1
- package/build/client/_app/immutable/nodes/2.CdC092Za.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.CdC092Za.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{3.BPa5fh75.js → 3.Dhf4ZWW0.js} +1 -1
- package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.B3SEB_li.js +1 -0
- package/build/client/_app/immutable/nodes/6.B3SEB_li.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.B3SEB_li.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{7.B7UJd8GQ.js → 7.DV8cJ1lX.js} +3 -3
- package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.Bs362gyb.js +2 -0
- package/build/client/_app/immutable/nodes/8.Bs362gyb.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.Bs362gyb.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js +2 -0
- package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.gz +0 -0
- package/build/client/_app/version.json +1 -1
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/server/chunks/{0-D8uPamNd.js → 0-Cd7jY0a7.js} +3 -3
- package/build/server/chunks/{0-D8uPamNd.js.map → 0-Cd7jY0a7.js.map} +1 -1
- package/build/server/chunks/{1-DhtioHbs.js → 1-C4BOGoJY.js} +2 -2
- package/build/server/chunks/{1-DhtioHbs.js.map → 1-C4BOGoJY.js.map} +1 -1
- package/build/server/chunks/{2-Cgh7ZFgE.js → 2-Ba0mNwJ6.js} +2 -2
- package/build/server/chunks/{2-Cgh7ZFgE.js.map → 2-Ba0mNwJ6.js.map} +1 -1
- package/build/server/chunks/{3-I6hnjssH.js → 3-Pg8t1uJU.js} +2 -2
- package/build/server/chunks/{3-I6hnjssH.js.map → 3-Pg8t1uJU.js.map} +1 -1
- package/build/server/chunks/{6-bPDbH1_W.js → 6-D8xbnTSo.js} +2 -2
- package/build/server/chunks/6-D8xbnTSo.js.map +1 -0
- package/build/server/chunks/{7-CpBrOkxQ.js → 7-CkVK06S0.js} +2 -2
- package/build/server/chunks/7-CkVK06S0.js.map +1 -0
- package/build/server/chunks/{8-BRGAVfze.js → 8-C8qVhrds.js} +2 -2
- package/build/server/chunks/8-C8qVhrds.js.map +1 -0
- package/build/server/chunks/{9-C6xuAb_Y.js → 9-fL5zqN0T.js} +2 -2
- package/build/server/chunks/9-fL5zqN0T.js.map +1 -0
- package/build/server/chunks/{_server.ts-BrRZXr-8.js → _server.ts-BA_uWcPw.js} +9 -9
- package/build/server/chunks/_server.ts-BA_uWcPw.js.map +1 -0
- package/build/server/chunks/{_server.ts-C6xbNz6d.js → _server.ts-Bu3s5hfv.js} +3 -3
- package/build/server/chunks/{_server.ts-C6xbNz6d.js.map → _server.ts-Bu3s5hfv.js.map} +1 -1
- package/build/server/chunks/{_server.ts-Cq9_scaV.js → _server.ts-CwAjt91u.js} +18 -18
- package/build/server/chunks/_server.ts-CwAjt91u.js.map +1 -0
- package/build/server/chunks/{_server.ts-CFX-S_8q.js → _server.ts-DZ5naqSL.js} +2 -2
- package/build/server/chunks/{_server.ts-CFX-S_8q.js.map → _server.ts-DZ5naqSL.js.map} +1 -1
- package/build/server/chunks/{_server.ts-Dekgb6Hx.js → _server.ts-DZP2lhaY.js} +3 -3
- package/build/server/chunks/{_server.ts-Dekgb6Hx.js.map → _server.ts-DZP2lhaY.js.map} +1 -1
- package/build/server/chunks/_server.ts-DZgfQKiH.js +81 -0
- package/build/server/chunks/_server.ts-DZgfQKiH.js.map +1 -0
- package/build/server/chunks/{_server.ts-CjK0g9dO.js → _server.ts-MbnroWEF.js} +25 -16
- package/build/server/chunks/_server.ts-MbnroWEF.js.map +1 -0
- package/build/server/chunks/{pty-manager-aFpChJah.js → pty-manager-DmNSCKAr.js} +99 -2
- package/build/server/chunks/pty-manager-DmNSCKAr.js.map +1 -0
- package/build/server/chunks/qwen-reader-DGfUbKaJ.js +2112 -0
- package/build/server/chunks/qwen-reader-DGfUbKaJ.js.map +1 -0
- package/build/server/chunks/{_server.ts-D--_NXt2.js → registry-Kcw2UCMv.js} +132 -106
- package/build/server/chunks/registry-Kcw2UCMv.js.map +1 -0
- package/build/server/index.js +1 -1
- package/build/server/index.js.map +1 -1
- package/build/server/manifest.js +16 -16
- package/build/server/manifest.js.map +1 -1
- package/package.json +2 -2
- package/scripts/e2e-all-features.sh +165 -0
- package/scripts/e2e-cross-terminal.sh +168 -0
- package/server.ts +12 -0
- package/src/lib/modules/client/common/index.ts +1 -1
- package/src/lib/modules/client/common/provider.ts +11 -0
- package/src/lib/modules/client/terminal/ChatView.svelte +9 -2
- package/src/lib/modules/client/terminal/LaunchSheet.svelte +4 -0
- package/src/lib/modules/server/sessions/amp-reader.ts +439 -0
- package/src/lib/modules/server/sessions/codex-reader.ts +34 -33
- package/src/lib/modules/server/sessions/copilot-reader.ts +542 -0
- package/src/lib/modules/server/sessions/cursor-reader.ts +634 -0
- package/src/lib/modules/server/sessions/gemini-reader.ts +594 -0
- package/src/lib/modules/server/sessions/opencode-db-path.ts +19 -10
- package/src/lib/modules/server/sessions/opencode-reader.ts +13 -12
- package/src/lib/modules/server/sessions/process-detector.ts +39 -18
- package/src/lib/modules/server/sessions/provider-paths.ts +173 -0
- package/src/lib/modules/server/sessions/qwen-reader.ts +336 -0
- package/src/lib/modules/server/sessions/registry.ts +178 -0
- package/src/lib/modules/server/terminal/codex-watcher.ts +4 -1
- package/src/lib/modules/server/terminal/generic-session-watcher.ts +163 -0
- package/src/lib/modules/server/terminal/pty-manager.ts +51 -0
- package/src/lib/modules/server/ws/session-handler.ts +34 -20
- package/src/lib/theme.css +32 -0
- package/src/lib/types/gemini.ts +100 -0
- package/src/lib/types/generated/Sessions.ts +17 -1
- package/src/lib/types/index.ts +1 -0
- package/src/lib/types/server.ts +23 -6
- package/src/lib/types/sessions.ts +14 -2
- package/src/routes/api/sessions/+server.ts +5 -52
- package/src/routes/api/sessions/connect/+server.ts +18 -11
- package/src/routes/api/terminals/+server.ts +7 -5
- package/src/routes/terminals/+page.svelte +7 -2
- package/src/routes/terminals/[id]/+page.svelte +1 -2
- package/build/client/_app/immutable/assets/0.DEfoFaGR.css.br +0 -0
- package/build/client/_app/immutable/assets/0.DEfoFaGR.css.gz +0 -0
- package/build/client/_app/immutable/chunks/Bkqjn62J.js +0 -1
- package/build/client/_app/immutable/chunks/Bkqjn62J.js.br +0 -1
- package/build/client/_app/immutable/chunks/Bkqjn62J.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DOHhmtDH.js +0 -3
- package/build/client/_app/immutable/chunks/DOHhmtDH.js.br +0 -0
- package/build/client/_app/immutable/chunks/DOHhmtDH.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DlS3abGJ.js.br +0 -0
- package/build/client/_app/immutable/chunks/DlS3abGJ.js.gz +0 -0
- package/build/client/_app/immutable/chunks/Pw0jDB7M.js +0 -1
- package/build/client/_app/immutable/chunks/Pw0jDB7M.js.br +0 -0
- package/build/client/_app/immutable/chunks/Pw0jDB7M.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.CSJG7N9H.js.br +0 -0
- package/build/client/_app/immutable/entry/app.CSJG7N9H.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.CTt1901T.js +0 -1
- package/build/client/_app/immutable/entry/start.CTt1901T.js.br +0 -2
- package/build/client/_app/immutable/entry/start.CTt1901T.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.Di708Ago.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.Di708Ago.js.gz +0 -0
- package/build/client/_app/immutable/nodes/2.DSM1znqa.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.DSM1znqa.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.BPa5fh75.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.BPa5fh75.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.B1LwwEF-.js +0 -1
- package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.gz +0 -0
- package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.CG0mrgBU.js +0 -2
- package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.KwzWaMHj.js +0 -2
- package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.gz +0 -0
- package/build/server/chunks/6-bPDbH1_W.js.map +0 -1
- package/build/server/chunks/7-CpBrOkxQ.js.map +0 -1
- package/build/server/chunks/8-BRGAVfze.js.map +0 -1
- package/build/server/chunks/9-C6xuAb_Y.js.map +0 -1
- package/build/server/chunks/_server.ts-BrRZXr-8.js.map +0 -1
- package/build/server/chunks/_server.ts-CjK0g9dO.js.map +0 -1
- package/build/server/chunks/_server.ts-Cq9_scaV.js.map +0 -1
- package/build/server/chunks/_server.ts-D--_NXt2.js.map +0 -1
- package/build/server/chunks/opencode-db-path-CRgzBK5U.js +0 -402
- package/build/server/chunks/opencode-db-path-CRgzBK5U.js.map +0 -1
- 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
|
-
//
|
|
50
|
-
const
|
|
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
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
}
|