@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
@@ -170,7 +170,14 @@
170
170
  }
171
171
 
172
172
  function formatTime(ts: string): string {
173
- return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
173
+ if (!ts) {
174
+ return '';
175
+ }
176
+ const date = new Date(ts);
177
+ if (Number.isNaN(date.getTime())) {
178
+ return '';
179
+ }
180
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
174
181
  }
175
182
 
176
183
  function getToolDescriptionFromPart(part: ToolUsePart): string {
@@ -366,7 +373,7 @@
366
373
  role="button"
367
374
  tabindex="0"
368
375
  >
369
- 💭 Thinking... {isThinkExpanded ? '▼' : '▶'}
376
+ 💭 Thinking... {isThinkExpanded ? '' : ''}
370
377
  </div>
371
378
  <Accordion expand={isThinkExpanded}>
372
379
  {#if isThinkExpanded}
@@ -11,6 +11,10 @@
11
11
  { args: [], command: 'claude', label: 'Claude Code' },
12
12
  { args: [], command: 'codex', label: 'Codex' },
13
13
  { args: [], command: 'gemini', label: 'Gemini' },
14
+ { args: [], command: 'qwen', label: 'Qwen' },
15
+ { args: [], command: 'cursor-agent', label: 'Cursor' },
16
+ { args: [], command: 'copilot', label: 'Copilot' },
17
+ { args: [], command: 'amp', label: 'Amp' },
14
18
  { args: [], command: 'opencode', label: 'OpenCode' },
15
19
  { args: [], command: 'zsh', label: 'Shell / zsh' },
16
20
  { args: [], command: 'bash', label: 'Bash' },
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Amp session reader — listing + conversation retrieval.
3
+ *
4
+ * Amp stores one JSON document per thread at:
5
+ * ~/.local/share/amp/threads/T-<id>.json
6
+ *
7
+ * Document shape:
8
+ * { id, title, created, messages: [{role, content}],
9
+ * meta: { traces: [{endTime}] },
10
+ * env: { initial: { trees: [{displayName}] } } }
11
+ *
12
+ * `content` is either a plain string or an array of Anthropic-style blocks
13
+ * (text / thinking / tool_use / tool_result), so we handle both paths.
14
+ * The file is a single JSON document — skip files larger than 64 MB.
15
+ */
16
+
17
+ import type { ConversationMessage, MessagePart, ProjectGroup, SessionInfo } from '$lib/types';
18
+
19
+ import * as crypto from 'crypto';
20
+ import * as fs from 'fs';
21
+ import { homedir } from 'os';
22
+ import * as path from 'path';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const AMP_THREADS = path.join(homedir(), '.local', 'share', 'amp', 'threads');
29
+
30
+ /** Single JSON document — skip (do not truncate) if larger than this. */
31
+ const MAX_FILE_BYTES = 64 * 1024 * 1024;
32
+
33
+ /** Pattern for valid thread file names: T-<id>.json */
34
+ const THREAD_FILE_RE = /^T-(.+)\.json$/;
35
+
36
+ /** Only allow these chars in a sessionId used to build a path. */
37
+ const SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/;
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Public API
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Return sessions whose thread file mtime is within `thresholdMs` of now —
45
+ * i.e. sessions that are currently (or very recently) active.
46
+ */
47
+ export function detectActiveAmpSessions(
48
+ thresholdMs: number
49
+ ): { cwd: string; id: string; startedAt: number }[] {
50
+ const cutoff = Date.now() - thresholdMs;
51
+ const out: { cwd: string; id: string; startedAt: number }[] = [];
52
+ for (const filePath of collectThreadFiles()) {
53
+ try {
54
+ const stat = fs.statSync(filePath);
55
+ if (stat.mtimeMs < cutoff) {
56
+ continue;
57
+ }
58
+ const doc = readThreadDoc(filePath);
59
+ if (!doc) {
60
+ continue;
61
+ }
62
+ const id = threadId(filePath, doc);
63
+ const cwd = projectCwd(doc);
64
+ if (id && cwd) {
65
+ out.push({ cwd, id, startedAt: stat.birthtimeMs });
66
+ }
67
+ } catch {
68
+ // skip unreadable files
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Return the conversation messages for a given Amp thread ID.
76
+ * Mirrors the pagination pattern from codex-reader / qwen-reader.
77
+ */
78
+ export function getAmpConversation(
79
+ sessionId: string,
80
+ offset = 0,
81
+ limit = 200
82
+ ): ConversationMessage[] {
83
+ if (!SESSION_ID_RE.test(sessionId)) {
84
+ return [];
85
+ }
86
+ const filePath = path.join(AMP_THREADS, `T-${sessionId}.json`);
87
+ if (!fs.existsSync(filePath)) {
88
+ return [];
89
+ }
90
+ try {
91
+ const doc = readThreadDoc(filePath);
92
+ if (!doc) {
93
+ return [];
94
+ }
95
+ const rawMessages = Array.isArray(doc.messages) ? (doc.messages as unknown[]) : [];
96
+ const messages: ConversationMessage[] = [];
97
+ let idx = 0;
98
+ for (const raw of rawMessages) {
99
+ const msg = ampMessageToConversationMessage(raw, idx);
100
+ if (msg) {
101
+ messages.push(msg);
102
+ idx++;
103
+ }
104
+ }
105
+ if (offset === 0 && messages.length > limit) {
106
+ let startIdx = messages.length - limit;
107
+ while (startIdx > 0 && messages[startIdx]?.role !== 'user') {
108
+ startIdx--;
109
+ }
110
+ return messages.slice(startIdx);
111
+ }
112
+ return messages.slice(offset, offset + limit);
113
+ } catch (err) {
114
+ console.error('[amp] Failed to read conversation:', err);
115
+ return [];
116
+ }
117
+ }
118
+
119
+ /**
120
+ * List all Amp threads grouped by project (env.initial.trees[0].displayName),
121
+ * sorted by most-recently-modified first.
122
+ */
123
+ export function listAmpProjects(): ProjectGroup[] {
124
+ const byCwd = new Map<string, SessionInfo[]>();
125
+ for (const filePath of collectThreadFiles()) {
126
+ let stat: fs.Stats;
127
+ try {
128
+ stat = fs.statSync(filePath);
129
+ } catch {
130
+ continue;
131
+ }
132
+ const doc = readThreadDoc(filePath);
133
+ if (!doc) {
134
+ continue;
135
+ }
136
+ const id = threadId(filePath, doc);
137
+ if (!id) {
138
+ continue;
139
+ }
140
+ const cwd = projectCwd(doc) || id;
141
+ const modified = lastModified(doc, stat);
142
+ const created =
143
+ typeof doc.created === 'string' && doc.created ? doc.created : stat.birthtime.toISOString();
144
+ const rawMessages = Array.isArray(doc.messages) ? (doc.messages as unknown[]) : [];
145
+ const session: SessionInfo = {
146
+ created,
147
+ gitBranch: '',
148
+ id,
149
+ messageCount: rawMessages.length,
150
+ modified,
151
+ projectPath: cwd,
152
+ source: 'amp' as const,
153
+ summary: '',
154
+ title: cleanTitle(typeof doc.title === 'string' ? doc.title : ''),
155
+ };
156
+ const bucket = byCwd.get(cwd);
157
+ if (bucket) {
158
+ bucket.push(session);
159
+ } else {
160
+ byCwd.set(cwd, [session]);
161
+ }
162
+ }
163
+
164
+ const projects: ProjectGroup[] = [];
165
+ for (const [cwd, sessions] of byCwd) {
166
+ sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
167
+ const segments = cwd.split('/').filter(Boolean);
168
+ projects.push({
169
+ fullPath: cwd,
170
+ id: shortHash(cwd),
171
+ lastModified: sessions[0]?.modified ?? '',
172
+ name: segments.slice(-2).join('/') || cwd,
173
+ sessionCount: sessions.length,
174
+ sessions,
175
+ });
176
+ }
177
+ return projects.sort(
178
+ (a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
179
+ );
180
+ }
181
+
182
+ /**
183
+ * Parse the Amp thread JSON file at `filePath` and return the full list of
184
+ * ConversationMessage — no pagination or tail-limit applied.
185
+ * Returns `[]` on any error (missing file, malformed JSON, oversized file).
186
+ */
187
+ export function parseAmpSessionFile(filePath: string): ConversationMessage[] {
188
+ try {
189
+ const doc = readThreadDoc(filePath);
190
+ if (!doc) {
191
+ return [];
192
+ }
193
+ const rawMessages = Array.isArray(doc.messages) ? (doc.messages as unknown[]) : [];
194
+ const messages: ConversationMessage[] = [];
195
+ let idx = 0;
196
+ for (const raw of rawMessages) {
197
+ const msg = ampMessageToConversationMessage(raw, idx);
198
+ if (msg) {
199
+ messages.push(msg);
200
+ idx++;
201
+ }
202
+ }
203
+ return messages;
204
+ } catch {
205
+ return [];
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Return the absolute path of the Amp thread file for `sessionId`, or `null`
211
+ * if the session ID is invalid or the file does not exist.
212
+ */
213
+ export function resolveAmpSessionFile(sessionId: string): null | string {
214
+ if (!SESSION_ID_RE.test(sessionId)) {
215
+ return null;
216
+ }
217
+ const filePath = path.join(AMP_THREADS, `T-${sessionId}.json`);
218
+ return fs.existsSync(filePath) ? filePath : null;
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Block → MessagePart mapping (Anthropic-style blocks)
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /** Map one Anthropic-style content block to a MessagePart, or null to skip. */
226
+ function ampBlockToPart(block: Record<string, unknown>): MessagePart | null {
227
+ switch (block.type) {
228
+ case 'text':
229
+ return { content: typeof block.text === 'string' ? block.text : '', type: 'text' };
230
+ case 'thinking':
231
+ return {
232
+ content: typeof block.thinking === 'string' ? block.thinking : '',
233
+ type: 'thinking',
234
+ };
235
+ case 'tool_result': {
236
+ const output = extractToolResultText(block.content);
237
+ return {
238
+ isError: typeof block.is_error === 'boolean' ? block.is_error : false,
239
+ output: output.slice(0, 2000),
240
+ toolUseId: typeof block.tool_use_id === 'string' ? block.tool_use_id : '',
241
+ type: 'tool_result',
242
+ };
243
+ }
244
+ case 'tool_use':
245
+ return {
246
+ id: typeof block.id === 'string' ? block.id : '',
247
+ input: isRecord(block.input) ? block.input : {},
248
+ toolName: typeof block.name === 'string' ? block.name : 'tool',
249
+ type: 'tool_use',
250
+ };
251
+ default:
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /** Map one raw Amp message object to a ConversationMessage, or null to skip. */
257
+ function ampMessageToConversationMessage(raw: unknown, index: number): ConversationMessage | null {
258
+ if (!isRecord(raw)) {
259
+ return null;
260
+ }
261
+ const role = raw.role;
262
+ if (role !== 'user' && role !== 'assistant') {
263
+ return null;
264
+ }
265
+ const parts = contentToParts(raw.content);
266
+ if (parts.length === 0) {
267
+ return null;
268
+ }
269
+ const msgRole: 'assistant' | 'user' = role === 'user' ? 'user' : 'assistant';
270
+ return {
271
+ id: `amp-${msgRole}-${index}`,
272
+ parts,
273
+ role: msgRole,
274
+ timestamp: '',
275
+ };
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // File I/O helpers
280
+ // ---------------------------------------------------------------------------
281
+
282
+ function cleanTitle(raw: string): string {
283
+ const first = raw.replace(/\s+/g, ' ').trim().split('\n')[0]?.trim() ?? '';
284
+ if (!first) {
285
+ return 'Untitled Session';
286
+ }
287
+ return first.length > 80 ? `${first.slice(0, 77)}...` : first;
288
+ }
289
+
290
+ /** Collect all T-*.json files under the Amp threads directory, sorted by mtime desc. */
291
+ function collectThreadFiles(): string[] {
292
+ let entries: fs.Dirent[];
293
+ try {
294
+ entries = fs.readdirSync(AMP_THREADS, { withFileTypes: true });
295
+ } catch {
296
+ return [];
297
+ }
298
+ const files: { mtime: number; path: string }[] = [];
299
+ for (const entry of entries) {
300
+ if (!entry.isFile() || !THREAD_FILE_RE.test(entry.name)) {
301
+ continue;
302
+ }
303
+ const full = path.join(AMP_THREADS, entry.name);
304
+ try {
305
+ const stat = fs.statSync(full);
306
+ files.push({ mtime: stat.mtimeMs, path: full });
307
+ } catch {
308
+ // skip
309
+ }
310
+ }
311
+ files.sort((a, b) => b.mtime - a.mtime);
312
+ return files.map((f) => f.path);
313
+ }
314
+
315
+ /** Map a `content` field (string or block array) to MessagePart[]. */
316
+ function contentToParts(content: unknown): MessagePart[] {
317
+ if (typeof content === 'string' && content) {
318
+ return [{ content, type: 'text' }];
319
+ }
320
+ if (!Array.isArray(content)) {
321
+ return [];
322
+ }
323
+ const parts: MessagePart[] = [];
324
+ for (const item of content as unknown[]) {
325
+ if (!isRecord(item)) {
326
+ continue;
327
+ }
328
+ const part = ampBlockToPart(item);
329
+ if (part) {
330
+ parts.push(part);
331
+ }
332
+ }
333
+ return parts;
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Metadata helpers
338
+ // ---------------------------------------------------------------------------
339
+
340
+ /** Extract plain text from a tool_result content field (string or text-block array). */
341
+ function extractToolResultText(content: unknown): string {
342
+ if (typeof content === 'string') {
343
+ return content;
344
+ }
345
+ if (!Array.isArray(content)) {
346
+ return '';
347
+ }
348
+ return (content as unknown[])
349
+ .filter((c): c is Record<string, unknown> => isRecord(c) && c.type === 'text')
350
+ .map((c) => (typeof c.text === 'string' ? c.text : ''))
351
+ .join('\n');
352
+ }
353
+
354
+ /** Narrow an unknown value to a plain object with string keys. */
355
+ function isRecord(value: unknown): value is Record<string, unknown> {
356
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
357
+ }
358
+
359
+ /**
360
+ * Derive the modified timestamp: last trace endTime or file mtime.
361
+ * Amp records execution traces in meta.traces[].endTime (ISO string or ms number).
362
+ */
363
+ function lastModified(doc: Record<string, unknown>, stat: fs.Stats): string {
364
+ if (isRecord(doc.meta)) {
365
+ const traces = doc.meta.traces;
366
+ if (Array.isArray(traces) && traces.length > 0) {
367
+ const last: unknown = traces[traces.length - 1];
368
+ if (isRecord(last)) {
369
+ const et = last.endTime;
370
+ if (typeof et === 'string' && et) {
371
+ return et;
372
+ }
373
+ if (typeof et === 'number' && et > 0) {
374
+ return new Date(et).toISOString();
375
+ }
376
+ }
377
+ }
378
+ }
379
+ return stat.mtime.toISOString();
380
+ }
381
+
382
+ /**
383
+ * Extract the project cwd/name from env.initial.trees[0].displayName.
384
+ * May be an absolute path or just a display name — surface it as-is.
385
+ */
386
+ function projectCwd(doc: Record<string, unknown>): string {
387
+ if (isRecord(doc.env) && isRecord(doc.env.initial)) {
388
+ const trees = doc.env.initial.trees;
389
+ if (Array.isArray(trees) && trees.length > 0 && isRecord(trees[0])) {
390
+ const dn = trees[0].displayName;
391
+ if (typeof dn === 'string' && dn) {
392
+ return dn;
393
+ }
394
+ }
395
+ }
396
+ return '';
397
+ }
398
+
399
+ /**
400
+ * Read and parse a thread JSON file.
401
+ * Returns null if the file is too large or malformed.
402
+ */
403
+ function readThreadDoc(filePath: string): null | Record<string, unknown> {
404
+ try {
405
+ if (fs.statSync(filePath).size > MAX_FILE_BYTES) {
406
+ return null;
407
+ }
408
+ const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
409
+ return isRecord(parsed) ? parsed : null;
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ function shortHash(input: string): string {
416
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
417
+ }
418
+
419
+ /**
420
+ * Derive the canonical session id from the filename stem (the part after
421
+ * "T-" in "T-<id>.json"). This must agree with what getAmpConversation
422
+ * expects: it reconstructs the file path as `T-${sessionId}.json`, so the
423
+ * id returned here must NOT include the leading "T-".
424
+ *
425
+ * The doc's `.id` field is accepted only as a last-resort fallback when the
426
+ * filename doesn't match the expected pattern — using doc.id first was the
427
+ * original bug (doc.id may contain or omit the "T-" prefix differently from
428
+ * the filename, causing a mismatch that produced zero messages on retrieval).
429
+ */
430
+ function threadId(filePath: string, doc: Record<string, unknown>): string {
431
+ const m = THREAD_FILE_RE.exec(path.basename(filePath));
432
+ if (m?.[1]) {
433
+ return m[1];
434
+ }
435
+ if (typeof doc.id === 'string' && doc.id) {
436
+ return doc.id;
437
+ }
438
+ return '';
439
+ }
@@ -58,6 +58,15 @@ export function detectActiveCodexSessions(
58
58
  return out;
59
59
  }
60
60
 
61
+ /** Locate a rollout file by its session UUID (embedded in the filename and session_meta.id). */
62
+ export function findCodexRolloutById(sessionId: string): null | string {
63
+ if (!/^[0-9a-f-]+$/i.test(sessionId)) {
64
+ return null;
65
+ }
66
+ const suffix = `-${sessionId}.jsonl`;
67
+ return collectRolloutFiles().find((p) => path.basename(p).endsWith(suffix)) ?? null;
68
+ }
69
+
61
70
  /**
62
71
  * Find the rollout file for a Codex session launched in `cwd` after `sinceMs`
63
72
  * (used by pty-manager to link a freshly-launched `codex` terminal to its file).
@@ -92,13 +101,13 @@ export function getCodexConversation(
92
101
  offset = 0,
93
102
  limit = 200
94
103
  ): ConversationMessage[] {
95
- const filePath = findRolloutPath(sessionId);
104
+ const filePath = findCodexRolloutById(sessionId);
96
105
  if (!filePath || !fs.existsSync(filePath)) {
97
106
  return [];
98
107
  }
99
108
 
100
109
  try {
101
- const { messages } = parseCodexRollout(readRolloutText(filePath));
110
+ const { messages } = parseCodexRollout(readBoundedRolloutText(filePath));
102
111
 
103
112
  // Match the Claude reader: with no explicit offset, return the most recent
104
113
  // `limit` messages, backing up to a user-message boundary so turns aren't clipped.
@@ -135,7 +144,8 @@ export function listCodexProjects(): ProjectGroup[] {
135
144
  continue;
136
145
  }
137
146
 
138
- const firstLine = prefix.slice(0, Math.max(0, prefix.indexOf('\n')) || prefix.length);
147
+ const nlIdx = prefix.indexOf('\n');
148
+ const firstLine = nlIdx === -1 ? prefix : prefix.slice(0, nlIdx);
139
149
  const meta = parseCodexMeta(firstLine);
140
150
  if (!meta) {
141
151
  continue;
@@ -180,6 +190,27 @@ export function listCodexProjects(): ProjectGroup[] {
180
190
  );
181
191
  }
182
192
 
193
+ /** Read a rollout file's text, bounded to the tail for oversized files (Codex files can be 100s of MB). */
194
+ export function readBoundedRolloutText(filePath: string): string {
195
+ const stat = fs.statSync(filePath);
196
+ if (stat.size <= MAX_FULL_READ_BYTES) {
197
+ return fs.readFileSync(filePath, 'utf-8');
198
+ }
199
+ // Oversized: keep session_meta (first line) + the tail (most recent messages).
200
+ const head = readPrefix(filePath).split('\n')[0] ?? '';
201
+ const fd = fs.openSync(filePath, 'r');
202
+ try {
203
+ const start = stat.size - MAX_FULL_READ_BYTES;
204
+ const buf = Buffer.alloc(MAX_FULL_READ_BYTES);
205
+ const bytesRead = fs.readSync(fd, buf, 0, MAX_FULL_READ_BYTES, start);
206
+ const tail = buf.toString('utf-8', 0, bytesRead);
207
+ // Drop the first (likely partial) line of the tail.
208
+ return `${head}\n${tail.slice(tail.indexOf('\n') + 1)}`;
209
+ } finally {
210
+ fs.closeSync(fd);
211
+ }
212
+ }
213
+
183
214
  function cleanTitle(prompt: string): string {
184
215
  const firstLine = prompt.replace(/\s+/g, ' ').trim().split('\n')[0]?.trim() ?? '';
185
216
  if (!firstLine) {
@@ -218,15 +249,6 @@ function collectRolloutFiles(): string[] {
218
249
  return out;
219
250
  }
220
251
 
221
- /** Locate a rollout file by its session UUID (embedded in the filename and session_meta.id). */
222
- function findRolloutPath(sessionId: string): null | string {
223
- if (!/^[0-9a-f-]+$/i.test(sessionId)) {
224
- return null;
225
- }
226
- const suffix = `-${sessionId}.jsonl`;
227
- return collectRolloutFiles().find((p) => path.basename(p).endsWith(suffix)) ?? null;
228
- }
229
-
230
252
  /** Pull the first genuine user prompt (skipping Codex's auto-injected wrappers) for a title. */
231
253
  function firstUserPrompt(prefixText: string): string {
232
254
  for (const line of prefixText.split('\n')) {
@@ -267,27 +289,6 @@ function readPrefix(filePath: string): string {
267
289
  }
268
290
  }
269
291
 
270
- /** Read a rollout file's text, bounded to the tail for oversized files. */
271
- function readRolloutText(filePath: string): string {
272
- const stat = fs.statSync(filePath);
273
- if (stat.size <= MAX_FULL_READ_BYTES) {
274
- return fs.readFileSync(filePath, 'utf-8');
275
- }
276
- // Oversized: keep session_meta (first line) + the tail (most recent messages).
277
- const head = readPrefix(filePath).split('\n')[0] ?? '';
278
- const fd = fs.openSync(filePath, 'r');
279
- try {
280
- const start = stat.size - MAX_FULL_READ_BYTES;
281
- const buf = Buffer.alloc(MAX_FULL_READ_BYTES);
282
- const bytesRead = fs.readSync(fd, buf, 0, MAX_FULL_READ_BYTES, start);
283
- const tail = buf.toString('utf-8', 0, bytesRead);
284
- // Drop the first (likely partial) line of the tail.
285
- return `${head}\n${tail.slice(tail.indexOf('\n') + 1)}`;
286
- } finally {
287
- fs.closeSync(fd);
288
- }
289
- }
290
-
291
292
  function shortHash(input: string): string {
292
293
  return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
293
294
  }