@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,410 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import type { ContentBlock } from "./session-types.js";
5
+
6
+ const DEFAULT_HISTORY_PAGE_LIMIT = 40;
7
+ const MAX_HISTORY_PAGE_LIMIT = 200;
8
+
9
+ interface ClaudeSessionHistoryMessage {
10
+ id: string;
11
+ role: "user" | "assistant";
12
+ content: string;
13
+ contentBlocks?: ContentBlock[];
14
+ timestamp: number;
15
+ model?: string;
16
+ stopReason?: string | null;
17
+ }
18
+
19
+ export interface ClaudeSessionHistoryPage {
20
+ sourceFile: string;
21
+ messages: ClaudeSessionHistoryMessage[];
22
+ nextCursor: number;
23
+ hasMore: boolean;
24
+ totalMessages: number;
25
+ }
26
+
27
+ export interface ClaudeSessionHistoryPageOptions {
28
+ sessionId: string;
29
+ cursor?: number;
30
+ limit?: number;
31
+ projectsRoot?: string;
32
+ }
33
+
34
+ interface TimelineMessage extends ClaudeSessionHistoryMessage {
35
+ order: number;
36
+ }
37
+
38
+ interface ParsedHistoryCacheEntry {
39
+ sourceFile: string;
40
+ mtimeMs: number;
41
+ messages: ClaudeSessionHistoryMessage[];
42
+ }
43
+
44
+ const parsedHistoryCache = new Map<string, ParsedHistoryCacheEntry>();
45
+
46
+ function getProjectsRoot(projectsRoot?: string): string {
47
+ return projectsRoot
48
+ || process.env.CLAUDE_PROJECTS_DIR
49
+ || join(homedir(), ".claude", "projects");
50
+ }
51
+
52
+ function getHistoryCacheKey(sessionId: string, projectsRoot: string): string {
53
+ return `${projectsRoot}::${sessionId}`;
54
+ }
55
+
56
+ function resolveSessionSourceFile(
57
+ sessionId: string,
58
+ projectsRoot: string,
59
+ ): { sourceFile: string; mtimeMs: number } | null {
60
+ if (!sessionId || !existsSync(projectsRoot)) return null;
61
+
62
+ let projectDirs: string[] = [];
63
+ try {
64
+ projectDirs = readdirSync(projectsRoot);
65
+ } catch {
66
+ return null;
67
+ }
68
+
69
+ let newest: { sourceFile: string; mtimeMs: number } | null = null;
70
+ for (const projectDir of projectDirs) {
71
+ const projectPath = join(projectsRoot, projectDir);
72
+ let projectStats: ReturnType<typeof statSync>;
73
+ try {
74
+ projectStats = statSync(projectPath);
75
+ } catch {
76
+ continue;
77
+ }
78
+ if (!projectStats.isDirectory()) continue;
79
+
80
+ const candidate = join(projectPath, `${sessionId}.jsonl`);
81
+ let candidateStats: ReturnType<typeof statSync>;
82
+ try {
83
+ candidateStats = statSync(candidate);
84
+ } catch {
85
+ continue;
86
+ }
87
+ if (!candidateStats.isFile()) continue;
88
+ if (!newest || candidateStats.mtimeMs > newest.mtimeMs) {
89
+ newest = { sourceFile: candidate, mtimeMs: candidateStats.mtimeMs };
90
+ }
91
+ }
92
+
93
+ return newest;
94
+ }
95
+
96
+ function parseTimestamp(raw: unknown, fallback: number): number {
97
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
98
+ if (typeof raw === "string") {
99
+ const parsed = Date.parse(raw);
100
+ if (Number.isFinite(parsed)) return parsed;
101
+ }
102
+ return fallback;
103
+ }
104
+
105
+ function extractUserContent(content: unknown): string {
106
+ if (typeof content === "string") return content.trim();
107
+ if (!Array.isArray(content)) return "";
108
+
109
+ const parts: string[] = [];
110
+ for (const block of content) {
111
+ if (!block || typeof block !== "object") continue;
112
+ const typed = block as { type?: unknown; text?: unknown };
113
+ if (typed.type === "text" && typeof typed.text === "string" && typed.text.trim()) {
114
+ parts.push(typed.text.trim());
115
+ }
116
+ }
117
+ return parts.join("\n").trim();
118
+ }
119
+
120
+ function isCommandNoiseUserContent(content: string): boolean {
121
+ const trimmed = content.trim();
122
+ if (!trimmed) return true;
123
+ return trimmed.startsWith("<command-name>")
124
+ || trimmed.startsWith("<command-message>")
125
+ || trimmed.startsWith("<command-args>")
126
+ || trimmed.startsWith("<local-command-caveat>")
127
+ || trimmed.startsWith("<local-command-stdout>")
128
+ || trimmed.startsWith("<local-command-stderr>");
129
+ }
130
+
131
+ function toContentBlocks(raw: unknown): ContentBlock[] {
132
+ if (!Array.isArray(raw)) return [];
133
+ const out: ContentBlock[] = [];
134
+
135
+ for (const item of raw) {
136
+ if (!item || typeof item !== "object") continue;
137
+ const block = item as Record<string, unknown>;
138
+ if (block.type === "text" && typeof block.text === "string") {
139
+ out.push({ type: "text", text: block.text });
140
+ continue;
141
+ }
142
+ if (block.type === "thinking" && typeof block.thinking === "string") {
143
+ out.push({
144
+ type: "thinking",
145
+ thinking: block.thinking,
146
+ budget_tokens: typeof block.budget_tokens === "number" ? block.budget_tokens : undefined,
147
+ });
148
+ continue;
149
+ }
150
+ if (
151
+ block.type === "tool_use"
152
+ && typeof block.id === "string"
153
+ && typeof block.name === "string"
154
+ && block.input
155
+ && typeof block.input === "object"
156
+ ) {
157
+ out.push({
158
+ type: "tool_use",
159
+ id: block.id,
160
+ name: block.name,
161
+ input: block.input as Record<string, unknown>,
162
+ });
163
+ continue;
164
+ }
165
+ if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
166
+ let content: string | ContentBlock[] = "";
167
+ if (typeof block.content === "string") {
168
+ content = block.content;
169
+ } else if (Array.isArray(block.content)) {
170
+ content = toContentBlocks(block.content);
171
+ }
172
+ out.push({
173
+ type: "tool_result",
174
+ tool_use_id: block.tool_use_id,
175
+ content,
176
+ is_error: block.is_error === true,
177
+ });
178
+ }
179
+ }
180
+
181
+ return out;
182
+ }
183
+
184
+ function extractAssistantContent(blocks: ContentBlock[]): string {
185
+ return blocks
186
+ .map((block) => {
187
+ if (block.type === "text") return block.text;
188
+ if (block.type === "thinking") return block.thinking;
189
+ return "";
190
+ })
191
+ .filter(Boolean)
192
+ .join("\n")
193
+ .trim();
194
+ }
195
+
196
+ function mergeContentBlocks(
197
+ previous?: ContentBlock[],
198
+ next?: ContentBlock[],
199
+ ): ContentBlock[] | undefined {
200
+ const prev = previous || [];
201
+ const nxt = next || [];
202
+ if (prev.length === 0 && nxt.length === 0) return undefined;
203
+
204
+ const merged: ContentBlock[] = [];
205
+ const seen = new Set<string>();
206
+
207
+ const pushUnique = (block: ContentBlock) => {
208
+ const key = JSON.stringify(block);
209
+ if (seen.has(key)) return;
210
+ seen.add(key);
211
+ merged.push(block);
212
+ };
213
+
214
+ for (const block of prev) pushUnique(block);
215
+ for (const block of nxt) pushUnique(block);
216
+ return merged;
217
+ }
218
+
219
+ function buildMessageId(
220
+ sessionId: string,
221
+ role: "user" | "assistant",
222
+ baseId: string,
223
+ ): string {
224
+ return `resume-${sessionId}-${role}-${baseId}`;
225
+ }
226
+
227
+ function parseHistoryFile(
228
+ sessionId: string,
229
+ sourceFile: string,
230
+ ): ClaudeSessionHistoryMessage[] {
231
+ let fileContent: string;
232
+ try {
233
+ fileContent = readFileSync(sourceFile, "utf-8");
234
+ } catch {
235
+ return [];
236
+ }
237
+
238
+ const timeline: TimelineMessage[] = [];
239
+ const assistantById = new Map<string, TimelineMessage>();
240
+ let lineOrder = 0;
241
+
242
+ for (const line of fileContent.split("\n")) {
243
+ const trimmed = line.trim();
244
+ if (!trimmed.startsWith("{")) continue;
245
+
246
+ let parsed: Record<string, unknown>;
247
+ try {
248
+ parsed = JSON.parse(trimmed) as Record<string, unknown>;
249
+ } catch {
250
+ continue;
251
+ }
252
+
253
+ if (parsed.isSidechain === true) continue;
254
+ if (typeof parsed.sessionId === "string" && parsed.sessionId !== sessionId) continue;
255
+
256
+ const fallbackTs = Date.now() + lineOrder;
257
+ const timestamp = parseTimestamp(parsed.timestamp, fallbackTs);
258
+ const message = parsed.message as Record<string, unknown> | undefined;
259
+ const role = typeof message?.role === "string" ? message.role : null;
260
+
261
+ if (parsed.type === "user" && role === "user") {
262
+ if (parsed.isMeta === true) {
263
+ lineOrder++;
264
+ continue;
265
+ }
266
+ const userContent = extractUserContent(message?.content);
267
+ if (!userContent || isCommandNoiseUserContent(userContent)) {
268
+ lineOrder++;
269
+ continue;
270
+ }
271
+ const rawId =
272
+ (typeof parsed.uuid === "string" && parsed.uuid)
273
+ || (typeof parsed.parentUuid === "string" && parsed.parentUuid)
274
+ || String(lineOrder);
275
+ timeline.push({
276
+ id: buildMessageId(sessionId, "user", rawId),
277
+ role: "user",
278
+ content: userContent,
279
+ timestamp,
280
+ order: lineOrder,
281
+ });
282
+ lineOrder++;
283
+ continue;
284
+ }
285
+
286
+ if (parsed.type === "assistant" && role === "assistant") {
287
+ const rawAssistantId =
288
+ (typeof message?.id === "string" && message.id)
289
+ || (typeof parsed.uuid === "string" && parsed.uuid)
290
+ || String(lineOrder);
291
+ const assistantId = buildMessageId(sessionId, "assistant", rawAssistantId);
292
+
293
+ const incomingBlocks = toContentBlocks(message?.content);
294
+ const existing = assistantById.get(assistantId);
295
+ const mergedBlocks = mergeContentBlocks(existing?.contentBlocks, incomingBlocks);
296
+ const nextContent = mergedBlocks ? extractAssistantContent(mergedBlocks) : "";
297
+
298
+ if (existing) {
299
+ existing.contentBlocks = mergedBlocks;
300
+ existing.content = nextContent || existing.content;
301
+ existing.model =
302
+ (typeof message?.model === "string" ? message.model : undefined)
303
+ || existing.model;
304
+ existing.stopReason =
305
+ (typeof message?.stop_reason === "string" || message?.stop_reason === null)
306
+ ? (message.stop_reason as string | null)
307
+ : existing.stopReason;
308
+ } else {
309
+ const created: TimelineMessage = {
310
+ id: assistantId,
311
+ role: "assistant",
312
+ content: nextContent,
313
+ contentBlocks: mergedBlocks,
314
+ timestamp,
315
+ model: typeof message?.model === "string" ? message.model : undefined,
316
+ stopReason:
317
+ (typeof message?.stop_reason === "string" || message?.stop_reason === null)
318
+ ? (message.stop_reason as string | null)
319
+ : null,
320
+ order: lineOrder,
321
+ };
322
+ assistantById.set(assistantId, created);
323
+ timeline.push(created);
324
+ }
325
+ lineOrder++;
326
+ continue;
327
+ }
328
+
329
+ lineOrder++;
330
+ }
331
+
332
+ return timeline
333
+ .filter((entry) => {
334
+ if (entry.role === "assistant") {
335
+ return (entry.content && entry.content.trim().length > 0)
336
+ || (entry.contentBlocks && entry.contentBlocks.length > 0);
337
+ }
338
+ return entry.content.trim().length > 0;
339
+ })
340
+ .sort((a, b) => {
341
+ if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
342
+ return a.order - b.order;
343
+ })
344
+ .map(({ order, ...entry }) => entry);
345
+ }
346
+
347
+ function getParsedHistory(
348
+ sessionId: string,
349
+ projectsRoot: string,
350
+ ): ParsedHistoryCacheEntry | null {
351
+ const resolved = resolveSessionSourceFile(sessionId, projectsRoot);
352
+ if (!resolved) return null;
353
+
354
+ const cacheKey = getHistoryCacheKey(sessionId, projectsRoot);
355
+ const cached = parsedHistoryCache.get(cacheKey);
356
+ if (
357
+ cached
358
+ && cached.sourceFile === resolved.sourceFile
359
+ && cached.mtimeMs === resolved.mtimeMs
360
+ ) {
361
+ return cached;
362
+ }
363
+
364
+ const parsed: ParsedHistoryCacheEntry = {
365
+ sourceFile: resolved.sourceFile,
366
+ mtimeMs: resolved.mtimeMs,
367
+ messages: parseHistoryFile(sessionId, resolved.sourceFile),
368
+ };
369
+ parsedHistoryCache.set(cacheKey, parsed);
370
+ return parsed;
371
+ }
372
+
373
+ export function getClaudeSessionHistoryPage(
374
+ options: ClaudeSessionHistoryPageOptions,
375
+ ): ClaudeSessionHistoryPage | null {
376
+ const sessionId = options.sessionId.trim();
377
+ if (!sessionId) return null;
378
+
379
+ const projectsRoot = getProjectsRoot(options.projectsRoot);
380
+ const parsed = getParsedHistory(sessionId, projectsRoot);
381
+ if (!parsed) return null;
382
+
383
+ const totalMessages = parsed.messages.length;
384
+ const limit = Math.max(
385
+ 1,
386
+ Math.min(
387
+ MAX_HISTORY_PAGE_LIMIT,
388
+ Number.isFinite(options.limit) ? Math.floor(options.limit as number) : DEFAULT_HISTORY_PAGE_LIMIT,
389
+ ),
390
+ );
391
+ const cursorInput = Number.isFinite(options.cursor) ? Math.floor(options.cursor as number) : 0;
392
+ const cursor = Math.max(0, Math.min(totalMessages, cursorInput));
393
+
394
+ const endExclusive = Math.max(0, totalMessages - cursor);
395
+ const start = Math.max(0, endExclusive - limit);
396
+ const messages = parsed.messages.slice(start, endExclusive);
397
+ const nextCursor = cursor + messages.length;
398
+
399
+ return {
400
+ sourceFile: parsed.sourceFile,
401
+ messages,
402
+ nextCursor,
403
+ hasMore: start > 0,
404
+ totalMessages,
405
+ };
406
+ }
407
+
408
+ export function clearClaudeSessionHistoryCacheForTests(): void {
409
+ parsedHistoryCache.clear();
410
+ }