remodex-cli 1.0.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 (99) hide show
  1. package/LICENSE +12 -0
  2. package/README.md +105 -0
  3. package/dist/archive-store.d.ts +28 -0
  4. package/dist/archive-store.js +68 -0
  5. package/dist/archive-store.js.map +1 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +88 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/codex-process.d.ts +186 -0
  10. package/dist/codex-process.js +2111 -0
  11. package/dist/codex-process.js.map +1 -0
  12. package/dist/debug-trace-store.d.ts +15 -0
  13. package/dist/debug-trace-store.js +78 -0
  14. package/dist/debug-trace-store.js.map +1 -0
  15. package/dist/doctor.d.ts +58 -0
  16. package/dist/doctor.js +670 -0
  17. package/dist/doctor.js.map +1 -0
  18. package/dist/firebase-auth.d.ts +35 -0
  19. package/dist/firebase-auth.js +132 -0
  20. package/dist/firebase-auth.js.map +1 -0
  21. package/dist/gallery-store.d.ts +67 -0
  22. package/dist/gallery-store.js +333 -0
  23. package/dist/gallery-store.js.map +1 -0
  24. package/dist/git-assist.d.ts +7 -0
  25. package/dist/git-assist.js +51 -0
  26. package/dist/git-assist.js.map +1 -0
  27. package/dist/git-operations.d.ts +63 -0
  28. package/dist/git-operations.js +292 -0
  29. package/dist/git-operations.js.map +1 -0
  30. package/dist/image-store.d.ts +23 -0
  31. package/dist/image-store.js +142 -0
  32. package/dist/image-store.js.map +1 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +198 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/mdns.d.ts +7 -0
  37. package/dist/mdns.js +49 -0
  38. package/dist/mdns.js.map +1 -0
  39. package/dist/parser.d.ts +620 -0
  40. package/dist/parser.js +423 -0
  41. package/dist/parser.js.map +1 -0
  42. package/dist/path-utils.d.ts +4 -0
  43. package/dist/path-utils.js +34 -0
  44. package/dist/path-utils.js.map +1 -0
  45. package/dist/project-history.d.ts +10 -0
  46. package/dist/project-history.js +73 -0
  47. package/dist/project-history.js.map +1 -0
  48. package/dist/prompt-history-backup.d.ts +15 -0
  49. package/dist/prompt-history-backup.js +46 -0
  50. package/dist/prompt-history-backup.js.map +1 -0
  51. package/dist/proxy.d.ts +15 -0
  52. package/dist/proxy.js +95 -0
  53. package/dist/proxy.js.map +1 -0
  54. package/dist/push-i18n.d.ts +7 -0
  55. package/dist/push-i18n.js +75 -0
  56. package/dist/push-i18n.js.map +1 -0
  57. package/dist/push-relay.d.ts +29 -0
  58. package/dist/push-relay.js +70 -0
  59. package/dist/push-relay.js.map +1 -0
  60. package/dist/recording-store.d.ts +51 -0
  61. package/dist/recording-store.js +158 -0
  62. package/dist/recording-store.js.map +1 -0
  63. package/dist/screenshot.d.ts +28 -0
  64. package/dist/screenshot.js +98 -0
  65. package/dist/screenshot.js.map +1 -0
  66. package/dist/sdk-process.d.ts +180 -0
  67. package/dist/sdk-process.js +960 -0
  68. package/dist/sdk-process.js.map +1 -0
  69. package/dist/session.d.ts +144 -0
  70. package/dist/session.js +687 -0
  71. package/dist/session.js.map +1 -0
  72. package/dist/sessions-index.d.ts +130 -0
  73. package/dist/sessions-index.js +1817 -0
  74. package/dist/sessions-index.js.map +1 -0
  75. package/dist/setup-launchd.d.ts +9 -0
  76. package/dist/setup-launchd.js +115 -0
  77. package/dist/setup-launchd.js.map +1 -0
  78. package/dist/setup-systemd.d.ts +9 -0
  79. package/dist/setup-systemd.js +122 -0
  80. package/dist/setup-systemd.js.map +1 -0
  81. package/dist/startup-info.d.ts +9 -0
  82. package/dist/startup-info.js +116 -0
  83. package/dist/startup-info.js.map +1 -0
  84. package/dist/usage.d.ts +69 -0
  85. package/dist/usage.js +545 -0
  86. package/dist/usage.js.map +1 -0
  87. package/dist/version.d.ts +13 -0
  88. package/dist/version.js +43 -0
  89. package/dist/version.js.map +1 -0
  90. package/dist/websocket.d.ts +132 -0
  91. package/dist/websocket.js +3551 -0
  92. package/dist/websocket.js.map +1 -0
  93. package/dist/worktree-store.d.ts +26 -0
  94. package/dist/worktree-store.js +61 -0
  95. package/dist/worktree-store.js.map +1 -0
  96. package/dist/worktree.d.ts +47 -0
  97. package/dist/worktree.js +330 -0
  98. package/dist/worktree.js.map +1 -0
  99. package/package.json +62 -0
@@ -0,0 +1,687 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { pathToSlug, renameClaudeSession, renameCodexSession, } from "./sessions-index.js";
7
+ import { SdkProcess, } from "./sdk-process.js";
8
+ import { CodexProcess } from "./codex-process.js";
9
+ import { createWorktree } from "./worktree.js";
10
+ const MAX_HISTORY_PER_SESSION = 100;
11
+ function mergeCodexSettings(current, msg) {
12
+ const model = sanitizeCodexModel(msg.model);
13
+ const next = {
14
+ ...(current ?? {}),
15
+ ...(msg.approvalPolicy !== undefined
16
+ ? { approvalPolicy: msg.approvalPolicy }
17
+ : {}),
18
+ ...(msg.sandboxMode !== undefined ? { sandboxMode: msg.sandboxMode } : {}),
19
+ ...(model !== undefined ? { model } : {}),
20
+ ...(msg.modelReasoningEffort !== undefined
21
+ ? { modelReasoningEffort: msg.modelReasoningEffort }
22
+ : {}),
23
+ ...(msg.networkAccessEnabled !== undefined
24
+ ? { networkAccessEnabled: msg.networkAccessEnabled }
25
+ : {}),
26
+ ...(msg.webSearchMode !== undefined
27
+ ? { webSearchMode: msg.webSearchMode }
28
+ : {}),
29
+ };
30
+ return Object.values(next).some((value) => value !== undefined)
31
+ ? next
32
+ : current;
33
+ }
34
+ function sanitizeCodexModel(model) {
35
+ if (typeof model !== "string")
36
+ return undefined;
37
+ const normalized = model.trim();
38
+ if (!normalized || normalized === "codex")
39
+ return undefined;
40
+ return normalized;
41
+ }
42
+ export class SessionManager {
43
+ sessions = new Map();
44
+ onMessage;
45
+ imageStore;
46
+ galleryStore;
47
+ onGalleryImage;
48
+ worktreeStore;
49
+ /** Cache slash commands per project path for early loading on subsequent sessions. */
50
+ commandCache = new Map();
51
+ constructor(onMessage, imageStore, galleryStore, onGalleryImage, worktreeStore) {
52
+ this.onMessage = onMessage;
53
+ this.imageStore = imageStore ?? null;
54
+ this.galleryStore = galleryStore ?? null;
55
+ this.onGalleryImage = onGalleryImage ?? null;
56
+ this.worktreeStore = worktreeStore ?? null;
57
+ }
58
+ create(projectPath, options, pastMessages, worktreeOpts, provider, codexOptions) {
59
+ const id = randomUUID().slice(0, 8);
60
+ const effectiveProvider = provider ?? "claude";
61
+ const proc = effectiveProvider === "codex" ? new CodexProcess() : new SdkProcess();
62
+ // Handle worktree: reuse existing or create new
63
+ let wtPath;
64
+ let wtBranch;
65
+ if (worktreeOpts?.existingWorktreePath) {
66
+ // Reuse an existing worktree (resume case)
67
+ wtPath = worktreeOpts.existingWorktreePath;
68
+ wtBranch = worktreeOpts.worktreeBranch;
69
+ console.log(`[session] Reusing existing worktree at ${wtPath}`);
70
+ }
71
+ else if (worktreeOpts?.useWorktree) {
72
+ // Create a new worktree
73
+ try {
74
+ const wt = createWorktree(projectPath, id, worktreeOpts.worktreeBranch);
75
+ wtPath = wt.worktreePath;
76
+ wtBranch = wt.branch;
77
+ console.log(`[session] Created worktree at ${wtPath} (branch: ${wtBranch})`);
78
+ }
79
+ catch (err) {
80
+ console.error(`[session] Failed to create worktree:`, err);
81
+ // Fall through to use original projectPath
82
+ }
83
+ }
84
+ // Use worktree path as cwd if available
85
+ const effectiveCwd = wtPath ?? projectPath;
86
+ let gitBranch = "";
87
+ try {
88
+ gitBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
89
+ cwd: effectiveCwd,
90
+ encoding: "utf-8",
91
+ }).trim();
92
+ }
93
+ catch {
94
+ /* not a git repo */
95
+ }
96
+ const session = {
97
+ id,
98
+ process: proc,
99
+ provider: effectiveProvider,
100
+ history: [],
101
+ pastMessages: pastMessages && pastMessages.length > 0 ? pastMessages : undefined,
102
+ projectPath,
103
+ status: "starting",
104
+ createdAt: new Date(),
105
+ lastActivityAt: new Date(),
106
+ gitBranch,
107
+ worktreePath: wtPath,
108
+ worktreeBranch: wtBranch,
109
+ // Pre-populate claudeSessionId for resumed sessions so that get_history
110
+ // can return it immediately (before the SDK sends a system/result event).
111
+ claudeSessionId: options?.sessionId,
112
+ };
113
+ // Cache tool_use id → name for enriching tool_result messages
114
+ const toolUseNames = new Map();
115
+ proc.on("message", async (msg) => {
116
+ try {
117
+ session.lastActivityAt = new Date();
118
+ if (effectiveProvider === "claude") {
119
+ // Capture Claude session_id from result events
120
+ if (msg.type === "result" && "sessionId" in msg && msg.sessionId) {
121
+ session.claudeSessionId = msg.sessionId;
122
+ this.saveWorktreeMapping(session);
123
+ }
124
+ if (msg.type === "system" && "sessionId" in msg && msg.sessionId) {
125
+ session.claudeSessionId = msg.sessionId;
126
+ this.saveWorktreeMapping(session);
127
+ }
128
+ // Cache slash commands and skills from system messages.
129
+ if (msg.type === "system" &&
130
+ (msg.subtype === "init" || msg.subtype === "supported_commands") &&
131
+ msg.slashCommands) {
132
+ this.commandCache.set(projectPath, {
133
+ slashCommands: msg.slashCommands,
134
+ skills: msg.skills ?? this.commandCache.get(projectPath)?.skills ?? [],
135
+ skillMetadata: msg.skillMetadata ??
136
+ this.commandCache.get(projectPath)?.skillMetadata,
137
+ });
138
+ }
139
+ // Cache tool_use names from assistant messages
140
+ if (msg.type === "assistant" && Array.isArray(msg.message.content)) {
141
+ for (const content of msg.message.content) {
142
+ if (content.type === "tool_use") {
143
+ const toolUse = content;
144
+ toolUseNames.set(toolUse.id, toolUse.name);
145
+ }
146
+ }
147
+ }
148
+ // Enrich tool_result with toolName
149
+ if (msg.type === "tool_result") {
150
+ const cachedName = toolUseNames.get(msg.toolUseId);
151
+ if (cachedName) {
152
+ msg = { ...msg, toolName: cachedName };
153
+ }
154
+ }
155
+ }
156
+ else {
157
+ // Codex: capture thread_id for session tracking and worktree restore.
158
+ if (msg.type === "system" && "sessionId" in msg && msg.sessionId) {
159
+ session.claudeSessionId = msg.sessionId;
160
+ this.saveWorktreeMapping(session);
161
+ }
162
+ if (msg.type === "system") {
163
+ session.codexSettings = mergeCodexSettings(session.codexSettings, msg);
164
+ }
165
+ const messageModel = sanitizeCodexModel(msg.type === "assistant" ? msg.message.model : undefined);
166
+ if (msg.type === "assistant" && messageModel) {
167
+ session.codexSettings = {
168
+ ...(session.codexSettings ?? {}),
169
+ model: messageModel,
170
+ };
171
+ }
172
+ }
173
+ // Extract images from tool_result content for both Claude and Codex.
174
+ if (msg.type === "tool_result" && this.imageStore) {
175
+ const paths = this.imageStore.extractImagePaths(msg.content);
176
+ if (paths.length > 0) {
177
+ const images = await this.imageStore.registerImages(paths, session.projectPath);
178
+ if (images.length > 0) {
179
+ msg = { ...msg, images };
180
+ }
181
+ // Also register in GalleryStore (disk-persistent)
182
+ if (this.galleryStore) {
183
+ for (const p of paths) {
184
+ const meta = await this.galleryStore.addImage(p, session.projectPath, session.id);
185
+ if (meta && this.onGalleryImage) {
186
+ this.onGalleryImage(meta);
187
+ }
188
+ }
189
+ }
190
+ }
191
+ // Extract base64 images from content blocks (e.g., MCP screenshots)
192
+ if (msg.rawContentBlocks) {
193
+ const imageBlocks = msg.rawContentBlocks.filter((c) => c.type === "image" &&
194
+ c.source?.type === "base64");
195
+ if (imageBlocks.length > 0) {
196
+ const existingImages = msg.images ?? [];
197
+ const newImages = [];
198
+ for (const block of imageBlocks) {
199
+ const source = block.source;
200
+ if (typeof source?.data !== "string" ||
201
+ typeof source?.media_type !== "string")
202
+ continue;
203
+ const b64Data = source.data;
204
+ const mimeType = source.media_type;
205
+ const ref = this.imageStore.registerFromBase64(b64Data, mimeType);
206
+ if (ref) {
207
+ newImages.push(ref);
208
+ // Also persist to GalleryStore
209
+ if (this.galleryStore) {
210
+ const meta = await this.galleryStore.addImageFromBase64(b64Data, mimeType, session.projectPath, session.id);
211
+ if (meta && this.onGalleryImage) {
212
+ this.onGalleryImage(meta);
213
+ }
214
+ }
215
+ }
216
+ }
217
+ if (newImages.length > 0) {
218
+ msg = { ...msg, images: [...existingImages, ...newImages] };
219
+ }
220
+ }
221
+ // Strip transient rawContentBlocks before sending to client
222
+ const { rawContentBlocks: _, ...cleanMsg } = msg;
223
+ msg = cleanMsg;
224
+ }
225
+ }
226
+ // Don't add streaming deltas to history
227
+ if (msg.type !== "stream_delta" && msg.type !== "thinking_delta") {
228
+ // When SDK echoes back a user_input with UUID, merge into the
229
+ // UUID-less placeholder that websocket.ts pushed earlier.
230
+ // This avoids duplicate entries while preserving the UUID needed
231
+ // for rewind candidate matching.
232
+ let merged = false;
233
+ if (msg.type === "user_input" &&
234
+ "userMessageUuid" in msg &&
235
+ msg.userMessageUuid) {
236
+ for (let i = session.history.length - 1; i >= 0; i--) {
237
+ const m = session.history[i];
238
+ if (m.type === "user_input" &&
239
+ !("userMessageUuid" in m && m.userMessageUuid)) {
240
+ // Preserve the original text from the user input and only
241
+ // take the UUID from the SDK echo. The SDK may return a
242
+ // transformed/translated version of the user's message, so
243
+ // we must not overwrite the original text.
244
+ session.history[i] = {
245
+ ...m,
246
+ userMessageUuid: msg.userMessageUuid,
247
+ };
248
+ merged = true;
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ if (!merged) {
254
+ session.history.push(msg);
255
+ }
256
+ if (session.history.length > MAX_HISTORY_PER_SESSION) {
257
+ // Protect user_input and system messages from eviction so they
258
+ // remain available when the client requests history after
259
+ // reconnecting. System messages carry slash commands, permission
260
+ // modes, and other metadata needed to restore client state.
261
+ const idx = session.history.findIndex((m) => m.type !== "user_input" && m.type !== "system");
262
+ if (idx >= 0) {
263
+ session.history.splice(idx, 1);
264
+ }
265
+ else {
266
+ session.history.shift();
267
+ }
268
+ }
269
+ }
270
+ this.onMessage(id, msg);
271
+ // After a result (turn complete), backfill UUIDs from disk.
272
+ // The SDK does not echo user messages via the stream, so
273
+ // in-memory user_input entries lack UUIDs. The disk
274
+ // conversation file always has them.
275
+ if (msg.type === "result") {
276
+ this.backfillUserUuidsFromDisk(session);
277
+ }
278
+ }
279
+ catch (err) {
280
+ console.error(`[session] Error processing message for session ${id}:`, err);
281
+ }
282
+ });
283
+ proc.on("status", (status) => {
284
+ session.status = status;
285
+ });
286
+ proc.on("exit", () => {
287
+ session.status = "idle";
288
+ // Add status message to history so it stays in sync with session.status
289
+ session.history.push({ type: "status", status: "idle" });
290
+ });
291
+ // Re-persist customTitle after CLI finishes writing sessions-index.json.
292
+ // session_end fires after the query iterator completes (CLI has shut down
293
+ // and flushed its files), so writing the name here prevents the CLI from
294
+ // overwriting our customTitle.
295
+ if (proc instanceof SdkProcess) {
296
+ proc.on("session_end", async () => {
297
+ if (!session.name)
298
+ return;
299
+ try {
300
+ if (session.provider === "claude" && session.claudeSessionId) {
301
+ await renameClaudeSession(session.worktreePath ?? session.projectPath, session.claudeSessionId, session.name);
302
+ }
303
+ else if (session.provider === "codex" && session.claudeSessionId) {
304
+ await renameCodexSession(session.claudeSessionId, session.name);
305
+ }
306
+ }
307
+ catch (err) {
308
+ console.warn(`[session] Failed to re-persist session name on session end:`, err);
309
+ }
310
+ });
311
+ }
312
+ // Store Claude sandbox state for resume
313
+ if (effectiveProvider === "claude" && options?.sandboxEnabled != null) {
314
+ session.sandboxEnabled = options.sandboxEnabled;
315
+ }
316
+ if (effectiveProvider === "codex" && codexOptions) {
317
+ session.codexSettings = {
318
+ approvalPolicy: codexOptions.approvalPolicy,
319
+ sandboxMode: codexOptions.sandboxMode,
320
+ model: codexOptions.model,
321
+ modelReasoningEffort: codexOptions.modelReasoningEffort,
322
+ networkAccessEnabled: codexOptions.networkAccessEnabled,
323
+ webSearchMode: codexOptions.webSearchMode,
324
+ };
325
+ // Resume starts know the thread id up front.
326
+ if (codexOptions.threadId) {
327
+ session.claudeSessionId = codexOptions.threadId;
328
+ this.saveWorktreeMapping(session);
329
+ }
330
+ }
331
+ if (effectiveProvider === "codex") {
332
+ proc.start(effectiveCwd, codexOptions);
333
+ }
334
+ else {
335
+ proc.start(effectiveCwd, options);
336
+ }
337
+ // Add session to Map only after proc.start() succeeds.
338
+ // If start() throws, no zombie session is left behind.
339
+ this.sessions.set(id, session);
340
+ console.log(`[session] Created ${effectiveProvider} session ${id} for ${effectiveCwd}${wtPath ? ` (worktree of ${projectPath})` : ""}`);
341
+ return id;
342
+ }
343
+ get(id) {
344
+ return this.sessions.get(id);
345
+ }
346
+ list() {
347
+ return Array.from(this.sessions.values()).map((s) => {
348
+ const processWithPending = s.process;
349
+ const pendingPermission = s.status === "waiting_approval"
350
+ ? processWithPending.getPendingPermission?.()
351
+ : undefined;
352
+ const executionMode = s.process instanceof SdkProcess
353
+ ? s.process.permissionMode === "bypassPermissions"
354
+ ? "fullAccess"
355
+ : s.process.permissionMode === "acceptEdits"
356
+ ? "acceptEdits"
357
+ : "default"
358
+ : s.process instanceof CodexProcess
359
+ ? s.process.approvalPolicy === "never"
360
+ ? "fullAccess"
361
+ : "default"
362
+ : undefined;
363
+ const planMode = s.process instanceof SdkProcess
364
+ ? s.process.permissionMode === "plan"
365
+ : s.process instanceof CodexProcess
366
+ ? s.process.collaborationMode === "plan"
367
+ : undefined;
368
+ return {
369
+ id: s.id,
370
+ provider: s.provider,
371
+ projectPath: s.projectPath,
372
+ claudeSessionId: s.claudeSessionId,
373
+ name: s.name,
374
+ status: s.status,
375
+ createdAt: s.createdAt.toISOString(),
376
+ lastActivityAt: s.lastActivityAt.toISOString(),
377
+ gitBranch: s.gitBranch,
378
+ lastMessage: this.extractLastMessage(s),
379
+ worktreePath: s.worktreePath,
380
+ worktreeBranch: s.worktreeBranch,
381
+ permissionMode: s.process instanceof SdkProcess
382
+ ? s.process.permissionMode
383
+ : s.process instanceof CodexProcess
384
+ ? s.process.collaborationMode === "plan"
385
+ ? "plan"
386
+ : s.process.approvalPolicy === "never"
387
+ ? "bypassPermissions"
388
+ : "acceptEdits"
389
+ : undefined,
390
+ executionMode,
391
+ planMode,
392
+ model: s.process instanceof SdkProcess ? s.process.model : undefined,
393
+ codexSettings: s.codexSettings,
394
+ agentNickname: s.process instanceof CodexProcess
395
+ ? (s.process.agentNickname ?? undefined)
396
+ : undefined,
397
+ agentRole: s.process instanceof CodexProcess
398
+ ? (s.process.agentRole ?? undefined)
399
+ : undefined,
400
+ sandboxEnabled: s.sandboxEnabled,
401
+ pendingPermission,
402
+ };
403
+ });
404
+ }
405
+ extractLastMessage(s) {
406
+ // Search in-memory history (newest first) for assistant text
407
+ for (let i = s.history.length - 1; i >= 0; i--) {
408
+ const msg = s.history[i];
409
+ if (msg.type === "assistant") {
410
+ const textBlock = msg.message.content.find((c) => c.type === "text");
411
+ if (textBlock && "text" in textBlock && textBlock.text) {
412
+ return textBlock.text.replace(/\s+/g, " ").trim().slice(0, 100);
413
+ }
414
+ }
415
+ }
416
+ // Fallback to pastMessages (raw Claude CLI format)
417
+ if (s.pastMessages) {
418
+ for (let i = s.pastMessages.length - 1; i >= 0; i--) {
419
+ const msg = s.pastMessages[i];
420
+ if (msg.role === "assistant") {
421
+ // Handle string content (defensive — normally array)
422
+ if (typeof msg.content === "string") {
423
+ return msg.content.replace(/\s+/g, " ").trim().slice(0, 100);
424
+ }
425
+ const content = msg.content;
426
+ const textBlock = content?.find((c) => c.type === "text");
427
+ if (textBlock?.text)
428
+ return textBlock.text
429
+ .replace(/\s+/g, " ")
430
+ .trim()
431
+ .slice(0, 100);
432
+ }
433
+ }
434
+ }
435
+ return "";
436
+ }
437
+ getCachedCommands(projectPath) {
438
+ return this.commandCache.get(projectPath);
439
+ }
440
+ /** Get worktree store for external use (e.g., resume_session in websocket.ts). */
441
+ getWorktreeStore() {
442
+ return this.worktreeStore;
443
+ }
444
+ /** Save worktree mapping when a provider session ID is available. */
445
+ saveWorktreeMapping(session) {
446
+ if (this.worktreeStore &&
447
+ session.claudeSessionId &&
448
+ session.worktreePath &&
449
+ session.worktreeBranch) {
450
+ this.worktreeStore.set(session.claudeSessionId, {
451
+ worktreePath: session.worktreePath,
452
+ worktreeBranch: session.worktreeBranch,
453
+ projectPath: session.projectPath,
454
+ });
455
+ }
456
+ }
457
+ /**
458
+ * Rewind files to their state at the specified user message.
459
+ * Delegates to the session's SdkProcess.rewindFiles().
460
+ */
461
+ async rewindFiles(id, targetUuid, dryRun) {
462
+ const session = this.sessions.get(id);
463
+ if (!session) {
464
+ return { canRewind: false, error: "Session not found" };
465
+ }
466
+ if (session.provider === "codex") {
467
+ return {
468
+ canRewind: false,
469
+ error: "Rewind is not supported for Codex sessions",
470
+ };
471
+ }
472
+ return session.process.rewindFiles(targetUuid, dryRun);
473
+ }
474
+ /**
475
+ * Rewind the conversation to a specific point.
476
+ * Stops the current process and restarts with resumeSessionAt.
477
+ *
478
+ * `targetUuid` is a **user message UUID**. The SDK's `resumeSessionAt`
479
+ * expects an **assistant message UUID**, so we look up the assistant
480
+ * message that follows the target user message.
481
+ */
482
+ rewindConversation(id, targetUuid, onReady) {
483
+ const session = this.sessions.get(id);
484
+ if (!session) {
485
+ throw new Error(`Session ${id} not found`);
486
+ }
487
+ if (session.provider === "codex") {
488
+ throw new Error("Rewind is not supported for Codex sessions");
489
+ }
490
+ const claudeSessionId = session.claudeSessionId;
491
+ if (!claudeSessionId) {
492
+ throw new Error("Session has no Claude session ID");
493
+ }
494
+ // resumeSessionAt expects assistant message UUID (per SDK docs).
495
+ // Convert user UUID → following assistant UUID.
496
+ const assistantUuid = this.findAssistantUuidAfterUser(session, targetUuid);
497
+ if (!assistantUuid) {
498
+ throw new Error("Cannot find assistant message after target user message");
499
+ }
500
+ const projectPath = session.projectPath;
501
+ const permissionMode = session.process.permissionMode;
502
+ const worktreePath = session.worktreePath;
503
+ const worktreeBranch = session.worktreeBranch;
504
+ // Stop and destroy the current session
505
+ this.destroy(id);
506
+ // Create a new session with resumeSessionAt (assistant UUID)
507
+ const newId = this.create(projectPath, {
508
+ sessionId: claudeSessionId,
509
+ permissionMode,
510
+ resumeSessionAt: assistantUuid,
511
+ }, undefined, worktreePath
512
+ ? { existingWorktreePath: worktreePath, worktreeBranch }
513
+ : undefined);
514
+ onReady(newId);
515
+ }
516
+ /**
517
+ * Find the assistant message UUID that follows a given user message UUID.
518
+ *
519
+ * Searches in-memory history first, then pastMessages (disk history).
520
+ */
521
+ findAssistantUuidAfterUser(session, userUuid) {
522
+ // 1. Search in-memory history
523
+ let foundUser = false;
524
+ for (const msg of session.history) {
525
+ if (!foundUser) {
526
+ // user_input or tool_result with matching userMessageUuid
527
+ if ((msg.type === "user_input" || msg.type === "tool_result") &&
528
+ "userMessageUuid" in msg &&
529
+ msg.userMessageUuid === userUuid) {
530
+ foundUser = true;
531
+ }
532
+ continue;
533
+ }
534
+ // Found user message — look for next assistant
535
+ if (msg.type === "assistant" && "messageUuid" in msg && msg.messageUuid) {
536
+ return msg.messageUuid;
537
+ }
538
+ }
539
+ // 2. Search pastMessages (disk history with uuid field)
540
+ if (session.pastMessages) {
541
+ foundUser = false;
542
+ for (const raw of session.pastMessages) {
543
+ const pm = raw;
544
+ if (!foundUser) {
545
+ if (pm.role === "user" && pm.uuid === userUuid) {
546
+ foundUser = true;
547
+ }
548
+ continue;
549
+ }
550
+ if (pm.role === "assistant" && pm.uuid) {
551
+ return pm.uuid;
552
+ }
553
+ }
554
+ }
555
+ return null;
556
+ }
557
+ /**
558
+ * Read the Claude CLI conversation history file from disk and backfill
559
+ * `userMessageUuid` into in-memory history entries that are missing it.
560
+ *
561
+ * The SDK does not echo user messages via the stream, so in-memory
562
+ * `user_input` entries (pushed by websocket.ts) lack UUIDs. The disk
563
+ * file, however, always contains UUIDs. We match by text content.
564
+ *
565
+ * Also re-broadcasts the updated `user_input` message so the Flutter
566
+ * client can update its UserChatEntry.messageUuid values.
567
+ */
568
+ backfillUserUuidsFromDisk(session) {
569
+ if (!session.claudeSessionId || !session.projectPath)
570
+ return;
571
+ const historyPath = this.findHistoryJsonlPath(session);
572
+ if (!historyPath)
573
+ return;
574
+ let lines;
575
+ try {
576
+ const raw = readFileSync(historyPath, "utf-8").trim();
577
+ if (!raw)
578
+ return;
579
+ lines = raw.split("\n");
580
+ }
581
+ catch {
582
+ // File may not exist yet (e.g., very new session)
583
+ return;
584
+ }
585
+ // Collect user message text→uuid queue from disk.
586
+ // Use an array per text key so duplicate messages ("yes", "ok", etc.)
587
+ // are matched in order rather than collapsed to one UUID.
588
+ const diskUuids = new Map();
589
+ for (const line of lines) {
590
+ try {
591
+ const entry = JSON.parse(line);
592
+ if (entry.type !== "user" && entry.role !== "user")
593
+ continue;
594
+ if (!entry.uuid)
595
+ continue;
596
+ // Extract text from content array
597
+ const content = entry.message?.content;
598
+ if (!Array.isArray(content))
599
+ continue;
600
+ const texts = content
601
+ .filter((c) => c.type === "text")
602
+ .map((c) => c.text);
603
+ if (texts.length > 0) {
604
+ const key = texts.join("\n");
605
+ const arr = diskUuids.get(key) ?? [];
606
+ arr.push(entry.uuid);
607
+ diskUuids.set(key, arr);
608
+ }
609
+ }
610
+ catch {
611
+ // skip malformed lines
612
+ }
613
+ }
614
+ // Backfill UUIDs into in-memory history
615
+ for (const msg of session.history) {
616
+ if (msg.type === "user_input" &&
617
+ !("userMessageUuid" in msg &&
618
+ msg.userMessageUuid)) {
619
+ const text = msg.text;
620
+ const queue = text ? diskUuids.get(text) : undefined;
621
+ if (queue && queue.length > 0) {
622
+ msg.userMessageUuid = queue.shift();
623
+ // Re-broadcast so Flutter can update UserChatEntry.messageUuid
624
+ this.onMessage(session.id, msg);
625
+ }
626
+ }
627
+ }
628
+ }
629
+ findHistoryJsonlPath(session) {
630
+ if (!session.claudeSessionId)
631
+ return null;
632
+ const projectsDir = join(homedir(), ".claude", "projects");
633
+ const fileName = `${session.claudeSessionId}.jsonl`;
634
+ const slugCandidates = new Set([pathToSlug(session.projectPath)]);
635
+ // Worktree sessions are persisted under the worktree slug, not projectPath.
636
+ if (session.worktreePath) {
637
+ slugCandidates.add(pathToSlug(session.worktreePath));
638
+ }
639
+ for (const slug of slugCandidates) {
640
+ const candidate = join(projectsDir, slug, fileName);
641
+ if (existsSync(candidate))
642
+ return candidate;
643
+ }
644
+ // Fallback: scan all project dirs in case metadata paths drift.
645
+ try {
646
+ const entries = readdirSync(projectsDir, { withFileTypes: true });
647
+ for (const entry of entries) {
648
+ if (!entry.isDirectory() || entry.name.startsWith("."))
649
+ continue;
650
+ const candidate = join(projectsDir, entry.name, fileName);
651
+ if (existsSync(candidate))
652
+ return candidate;
653
+ }
654
+ }
655
+ catch {
656
+ return null;
657
+ }
658
+ return null;
659
+ }
660
+ /**
661
+ * Rename a running session (in-memory only).
662
+ * Persistent storage is handled by the caller (websocket.ts).
663
+ */
664
+ renameSession(id, name) {
665
+ const session = this.sessions.get(id);
666
+ if (!session)
667
+ return false;
668
+ session.name = name ?? undefined;
669
+ return true;
670
+ }
671
+ destroy(id) {
672
+ const session = this.sessions.get(id);
673
+ if (!session)
674
+ return false;
675
+ session.process.stop();
676
+ session.process.removeAllListeners();
677
+ this.sessions.delete(id);
678
+ console.log(`[session] Destroyed session ${id}`);
679
+ return true;
680
+ }
681
+ destroyAll() {
682
+ for (const [id] of this.sessions) {
683
+ this.destroy(id);
684
+ }
685
+ }
686
+ }
687
+ //# sourceMappingURL=session.js.map