@vibe80/vibe80 0.1.1

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 (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +52 -0
  3. package/bin/vibe80.js +176 -0
  4. package/client/dist/assets/DiffPanel-C_IGzKI5.js +1 -0
  5. package/client/dist/assets/ExplorerPanel-BtlyAT00.js +11 -0
  6. package/client/dist/assets/LogsPanel-BW79JWzR.js +1 -0
  7. package/client/dist/assets/SettingsPanel-b9B7ygP_.js +1 -0
  8. package/client/dist/assets/TerminalPanel-C3fc1HbK.js +1 -0
  9. package/client/dist/assets/browser-e3WgtMs-.js +8 -0
  10. package/client/dist/assets/index-CgqGyssr.css +32 -0
  11. package/client/dist/assets/index-DnwKjoj7.js +706 -0
  12. package/client/dist/assets/vibe80_dark-D7OVPKcU.svg +51 -0
  13. package/client/dist/assets/vibe80_light-BJK37ybI.svg +50 -0
  14. package/client/dist/favicon.ico +0 -0
  15. package/client/dist/favicon.png +0 -0
  16. package/client/dist/favicon.svg +35 -0
  17. package/client/dist/index.html +14 -0
  18. package/client/index.html +16 -0
  19. package/client/package.json +34 -0
  20. package/client/public/favicon.ico +0 -0
  21. package/client/public/favicon.png +0 -0
  22. package/client/public/favicon.svg +35 -0
  23. package/client/public/pwa-192x192.png +0 -0
  24. package/client/public/pwa-512x512.png +0 -0
  25. package/client/src/App.jsx +3131 -0
  26. package/client/src/assets/logo_small.png +0 -0
  27. package/client/src/assets/vibe80_dark.svg +51 -0
  28. package/client/src/assets/vibe80_light.svg +50 -0
  29. package/client/src/components/Chat/ChatComposer.jsx +228 -0
  30. package/client/src/components/Chat/ChatMessages.jsx +811 -0
  31. package/client/src/components/Chat/ChatToolbar.jsx +109 -0
  32. package/client/src/components/Chat/useChatComposer.js +462 -0
  33. package/client/src/components/Diff/DiffPanel.jsx +129 -0
  34. package/client/src/components/Explorer/ExplorerPanel.jsx +449 -0
  35. package/client/src/components/Logs/LogsPanel.jsx +80 -0
  36. package/client/src/components/SessionGate/SessionGate.jsx +874 -0
  37. package/client/src/components/Settings/SettingsPanel.jsx +212 -0
  38. package/client/src/components/Terminal/TerminalPanel.jsx +39 -0
  39. package/client/src/components/Topbar/Topbar.jsx +101 -0
  40. package/client/src/components/WorktreeTabs.css +419 -0
  41. package/client/src/components/WorktreeTabs.jsx +604 -0
  42. package/client/src/hooks/useAttachments.jsx +125 -0
  43. package/client/src/hooks/useBacklog.js +254 -0
  44. package/client/src/hooks/useChatClear.js +90 -0
  45. package/client/src/hooks/useChatCollapse.js +42 -0
  46. package/client/src/hooks/useChatCommands.js +294 -0
  47. package/client/src/hooks/useChatExport.js +144 -0
  48. package/client/src/hooks/useChatMessagesState.js +69 -0
  49. package/client/src/hooks/useChatSend.js +158 -0
  50. package/client/src/hooks/useChatSocket.js +1239 -0
  51. package/client/src/hooks/useDiffNavigation.js +19 -0
  52. package/client/src/hooks/useExplorerActions.js +1184 -0
  53. package/client/src/hooks/useGitIdentity.js +114 -0
  54. package/client/src/hooks/useLayoutMode.js +31 -0
  55. package/client/src/hooks/useLocalPreferences.js +131 -0
  56. package/client/src/hooks/useMessageSync.js +30 -0
  57. package/client/src/hooks/useNotifications.js +132 -0
  58. package/client/src/hooks/usePaneNavigation.js +67 -0
  59. package/client/src/hooks/usePanelState.js +13 -0
  60. package/client/src/hooks/useProviderSelection.js +70 -0
  61. package/client/src/hooks/useRepoBranchesModels.js +218 -0
  62. package/client/src/hooks/useRepoStatus.js +350 -0
  63. package/client/src/hooks/useRpcLogActions.js +19 -0
  64. package/client/src/hooks/useRpcLogView.js +58 -0
  65. package/client/src/hooks/useSessionHandoff.js +97 -0
  66. package/client/src/hooks/useSessionLifecycle.js +287 -0
  67. package/client/src/hooks/useSessionReset.js +63 -0
  68. package/client/src/hooks/useSessionResync.js +77 -0
  69. package/client/src/hooks/useTerminalSession.js +328 -0
  70. package/client/src/hooks/useToolbarExport.js +27 -0
  71. package/client/src/hooks/useTurnInterrupt.js +43 -0
  72. package/client/src/hooks/useVibe80Forms.js +128 -0
  73. package/client/src/hooks/useWorkspaceAuth.js +932 -0
  74. package/client/src/hooks/useWorktreeCloseConfirm.js +46 -0
  75. package/client/src/hooks/useWorktrees.js +396 -0
  76. package/client/src/i18n.jsx +87 -0
  77. package/client/src/index.css +5147 -0
  78. package/client/src/locales/en.json +37 -0
  79. package/client/src/locales/fr.json +321 -0
  80. package/client/src/main.jsx +16 -0
  81. package/client/vite.config.js +62 -0
  82. package/docs/api/asyncapi.json +1511 -0
  83. package/docs/api/openapi.json +3242 -0
  84. package/git_hooks/prepare-commit-msg +35 -0
  85. package/package.json +36 -0
  86. package/server/package.json +29 -0
  87. package/server/scripts/rotate-workspace-secret.js +101 -0
  88. package/server/src/claudeClient.js +454 -0
  89. package/server/src/clientEvents.js +594 -0
  90. package/server/src/clientFactory.js +164 -0
  91. package/server/src/codexClient.js +468 -0
  92. package/server/src/config.js +27 -0
  93. package/server/src/helpers.js +138 -0
  94. package/server/src/index.js +1641 -0
  95. package/server/src/middleware/auth.js +93 -0
  96. package/server/src/middleware/debug.js +89 -0
  97. package/server/src/middleware/errorTypes.js +60 -0
  98. package/server/src/providerLogger.js +60 -0
  99. package/server/src/routes/files.js +114 -0
  100. package/server/src/routes/git.js +183 -0
  101. package/server/src/routes/health.js +13 -0
  102. package/server/src/routes/sessions.js +407 -0
  103. package/server/src/routes/workspaces.js +296 -0
  104. package/server/src/routes/worktrees.js +993 -0
  105. package/server/src/runAs.js +458 -0
  106. package/server/src/runtimeStore.js +32 -0
  107. package/server/src/services/auth.js +157 -0
  108. package/server/src/services/claudeThreadDirectory.js +33 -0
  109. package/server/src/services/session.js +918 -0
  110. package/server/src/services/workspace.js +858 -0
  111. package/server/src/storage/index.js +17 -0
  112. package/server/src/storage/redis.js +412 -0
  113. package/server/src/storage/sqlite.js +649 -0
  114. package/server/src/worktreeManager.js +717 -0
  115. package/server/tests/README.md +13 -0
  116. package/server/tests/factories/workspaceFactory.js +13 -0
  117. package/server/tests/fixtures/workspaceCredentials.json +4 -0
  118. package/server/tests/integration/routes/workspaces-routes.test.js +626 -0
  119. package/server/tests/setup/env.js +9 -0
  120. package/server/tests/unit/helpers.test.js +95 -0
  121. package/server/tests/unit/services/auth.test.js +181 -0
  122. package/server/tests/unit/services/workspace.test.js +115 -0
  123. package/server/vitest.config.js +23 -0
@@ -0,0 +1,717 @@
1
+ import path from "path";
2
+ import crypto from "crypto";
3
+ import { runAsCommand, runAsCommandOutput } from "./runAs.js";
4
+ import storage from "./storage/index.js";
5
+ import { getSessionRuntime } from "./runtimeStore.js";
6
+ import { createWorktreeClient } from "./clientFactory.js";
7
+ import { createMessageId, toIsoDateTime } from "./helpers.js";
8
+ import { copyClaudeThreadDirectory } from "./services/claudeThreadDirectory.js";
9
+
10
+ const MAIN_WORKTREE_SENTINEL = "main";
11
+ const MAIN_WORKTREE_PREFIX = "main-";
12
+
13
+ export const getMainWorktreeStorageId = (sessionId) =>
14
+ `${MAIN_WORKTREE_PREFIX}${sessionId}`;
15
+
16
+ const resolveWorktreeStorageId = (session, worktreeId) => {
17
+ if (!worktreeId) return worktreeId;
18
+ if (worktreeId === MAIN_WORKTREE_SENTINEL) {
19
+ return getMainWorktreeStorageId(session.sessionId);
20
+ }
21
+ return worktreeId;
22
+ };
23
+
24
+ const isMainWorktreeStorageId = (session, worktreeId) =>
25
+ worktreeId === getMainWorktreeStorageId(session.sessionId);
26
+
27
+ // Palette de couleurs pour distinguer les worktrees
28
+ const WORKTREE_COLORS = [
29
+ "#3b82f6", // blue
30
+ "#10b981", // emerald
31
+ "#f59e0b", // amber
32
+ "#ef4444", // red
33
+ "#8b5cf6", // violet
34
+ "#ec4899", // pink
35
+ "#06b6d4", // cyan
36
+ "#84cc16", // lime
37
+ ];
38
+
39
+ let colorIndex = 0;
40
+ const getNextColor = () => {
41
+ const color = WORKTREE_COLORS[colorIndex % WORKTREE_COLORS.length];
42
+ colorIndex += 1;
43
+ return color;
44
+ };
45
+
46
+ const buildSessionEnv = (session, options = {}) => {
47
+ const env = { ...(options.env || {}) };
48
+ if (session?.dir) {
49
+ env.TMPDIR = path.join(session.dir, "tmp");
50
+ }
51
+ return env;
52
+ };
53
+
54
+ const runSessionCommand = (session, command, args, options = {}) =>
55
+ runAsCommand(session.workspaceId, command, args, {
56
+ ...options,
57
+ env: buildSessionEnv(session, options),
58
+ });
59
+
60
+ const runSessionCommandOutput = (session, command, args, options = {}) =>
61
+ runAsCommandOutput(session.workspaceId, command, args, {
62
+ ...options,
63
+ env: buildSessionEnv(session, options),
64
+ });
65
+
66
+ const resolveSessionGitDir = (session) =>
67
+ session?.gitDir || path.join(session.dir, "git");
68
+
69
+ const ensureSessionGitDir = async (session) => {
70
+ const gitDir = resolveSessionGitDir(session);
71
+ await runAsCommand(session.workspaceId, "/bin/mkdir", ["-p", gitDir]);
72
+ await runAsCommand(session.workspaceId, "/bin/chmod", ["2750", gitDir]);
73
+ return gitDir;
74
+ };
75
+
76
+ const applyVibe80WorktreeMetadata = async (session, worktreePath, worktreeId) => {
77
+ await runSessionCommand(
78
+ session,
79
+ "git",
80
+ ["-C", worktreePath, "config", "--worktree", "vibe80.workspaceId", session.workspaceId]
81
+ );
82
+ await runSessionCommand(
83
+ session,
84
+ "git",
85
+ ["-C", worktreePath, "config", "--worktree", "vibe80.sessionId", session.sessionId]
86
+ );
87
+ await runSessionCommand(
88
+ session,
89
+ "git",
90
+ ["-C", worktreePath, "config", "--worktree", "vibe80.worktreeId", worktreeId]
91
+ );
92
+ };
93
+
94
+ const resolveStartingRef = (startingBranch, remote = "origin") => {
95
+ if (!startingBranch || typeof startingBranch !== "string") {
96
+ return null;
97
+ }
98
+ const trimmed = startingBranch.trim();
99
+ if (!trimmed) {
100
+ return null;
101
+ }
102
+ if (trimmed.startsWith("refs/")) {
103
+ return trimmed;
104
+ }
105
+ if (trimmed.startsWith(`${remote}/`)) {
106
+ return trimmed;
107
+ }
108
+ return `${remote}/${trimmed}`;
109
+ };
110
+
111
+ const normalizeBranchName = (value, remote = "origin") => {
112
+ if (!value || typeof value !== "string") {
113
+ return "";
114
+ }
115
+ let name = value.trim();
116
+ if (!name) {
117
+ return "";
118
+ }
119
+ if (name.startsWith("refs/heads/")) {
120
+ name = name.slice("refs/heads/".length);
121
+ } else if (name.startsWith(`refs/remotes/${remote}/`)) {
122
+ name = name.slice(`refs/remotes/${remote}/`.length);
123
+ } else if (name.startsWith(`${remote}/`)) {
124
+ name = name.slice(`${remote}/`.length);
125
+ }
126
+ return name.trim();
127
+ };
128
+
129
+ const resolveCurrentBranchName = async (session) => {
130
+ const output = await runSessionCommandOutput(
131
+ session,
132
+ "git",
133
+ ["rev-parse", "--abbrev-ref", "HEAD"],
134
+ { cwd: session.repoDir }
135
+ );
136
+ return output.trim();
137
+ };
138
+
139
+ const serializeWorktree = (worktree) => {
140
+ if (!worktree) return null;
141
+ const { client, ...persisted } = worktree;
142
+ return persisted;
143
+ };
144
+
145
+ const resolveSessionDenyGitCredentialsAccess = (session) =>
146
+ typeof session?.defaultDenyGitCredentialsAccess === "boolean"
147
+ ? session.defaultDenyGitCredentialsAccess
148
+ : true;
149
+
150
+ const touchSession = async (session) => {
151
+ const updated = { ...session, lastActivityAt: Date.now() };
152
+ await storage.saveSession(session.sessionId, updated);
153
+ return updated;
154
+ };
155
+
156
+ const loadWorktree = async (worktreeId) => {
157
+ if (!worktreeId) return null;
158
+ return storage.getWorktree(worktreeId);
159
+ };
160
+
161
+ const ensureMainWorktree = async (session) => {
162
+ const worktreeId = getMainWorktreeStorageId(session.sessionId);
163
+ const existing = await loadWorktree(worktreeId);
164
+ if (existing) return existing;
165
+ const branchName = await resolveCurrentBranchName(session);
166
+ const worktree = {
167
+ id: worktreeId,
168
+ sessionId: session.sessionId,
169
+ name: branchName || "main",
170
+ branchName: branchName || "main",
171
+ threadId: session.threadId || null,
172
+ path: session.repoDir,
173
+ provider: session.activeProvider || "codex",
174
+ model: null,
175
+ reasoningEffort: null,
176
+ internetAccess: Boolean(session.defaultInternetAccess),
177
+ denyGitCredentialsAccess: resolveSessionDenyGitCredentialsAccess(session),
178
+ startingBranch: branchName || null,
179
+ workspaceId: session.workspaceId,
180
+ status: "ready",
181
+ parentWorktreeId: null,
182
+ createdAt: new Date().toISOString(),
183
+ lastActivityAt: new Date().toISOString(),
184
+ color: getNextColor(),
185
+ };
186
+ await storage.saveWorktree(session.sessionId, worktreeId, serializeWorktree(worktree));
187
+ return worktree;
188
+ };
189
+
190
+ /**
191
+ * Génère un nom de worktree à partir du premier message ou un nom par défaut
192
+ */
193
+ const generateWorktreeName = (text, index) => {
194
+ if (text) {
195
+ const cleaned = text
196
+ .slice(0, 30)
197
+ .toLowerCase()
198
+ .replace(/[^a-z0-9]+/g, "-")
199
+ .replace(/^-+|-+$/g, "");
200
+ if (cleaned.length > 2) {
201
+ return cleaned;
202
+ }
203
+ }
204
+ return `branch-${index}`;
205
+ };
206
+
207
+ /**
208
+ * Crée un nouveau worktree pour une session
209
+ */
210
+ export async function createWorktree(session, options) {
211
+ const {
212
+ context,
213
+ provider,
214
+ sourceWorktree,
215
+ name,
216
+ parentWorktreeId,
217
+ startingBranch,
218
+ model,
219
+ reasoningEffort,
220
+ internetAccess,
221
+ denyGitCredentialsAccess,
222
+ } = options;
223
+ const creationContext = context === "fork" ? "fork" : "new";
224
+ let resolvedProvider = provider;
225
+ let resolvedModel = model || null;
226
+ let resolvedReasoningEffort = reasoningEffort || null;
227
+ let resolvedSourceWorktreeId = null;
228
+ let forkSourceThreadId = null;
229
+ let sourceWorktreeRecord = null;
230
+
231
+ if (creationContext === "fork") {
232
+ sourceWorktreeRecord = await getWorktree(session, sourceWorktree);
233
+ if (!sourceWorktreeRecord) {
234
+ throw new Error("Source worktree not found.");
235
+ }
236
+ resolvedProvider = sourceWorktreeRecord.provider;
237
+ resolvedModel = null;
238
+ resolvedReasoningEffort = null;
239
+ resolvedSourceWorktreeId = sourceWorktree === "main" ? "main" : sourceWorktreeRecord.id;
240
+ forkSourceThreadId =
241
+ sourceWorktreeRecord.threadId ||
242
+ (resolvedSourceWorktreeId === "main" ? session.threadId || null : null);
243
+ if (!forkSourceThreadId) {
244
+ throw new Error("Source worktree has no threadId to fork from.");
245
+ }
246
+ }
247
+
248
+ if (resolvedProvider !== "codex" && resolvedProvider !== "claude") {
249
+ throw new Error("Invalid provider for worktree creation.");
250
+ }
251
+ const resolvedDenyGitCredentialsAccess =
252
+ typeof denyGitCredentialsAccess === "boolean"
253
+ ? denyGitCredentialsAccess
254
+ : typeof sourceWorktreeRecord?.denyGitCredentialsAccess === "boolean"
255
+ ? sourceWorktreeRecord.denyGitCredentialsAccess
256
+ : resolveSessionDenyGitCredentialsAccess(session);
257
+ const resolvedInternetAccess =
258
+ typeof internetAccess === "boolean"
259
+ ? internetAccess
260
+ : typeof sourceWorktreeRecord?.internetAccess === "boolean"
261
+ ? sourceWorktreeRecord.internetAccess
262
+ : true;
263
+ const shareGitCredentials = !resolvedDenyGitCredentialsAccess;
264
+
265
+ const worktreesDir = path.join(session.dir, "worktrees");
266
+ await runAsCommand(session.workspaceId, "/bin/mkdir", ["-p", worktreesDir]);
267
+ await runAsCommand(session.workspaceId, "/bin/chmod", ["2750", worktreesDir]);
268
+
269
+ const worktreeId = crypto.randomBytes(8).toString("hex");
270
+ const existingWorktrees = await listStoredWorktrees(session);
271
+ const worktreeIndex = existingWorktrees.length + 1;
272
+ const requestedBranchName = normalizeBranchName(name);
273
+ const worktreePath = path.join(worktreesDir, worktreeId);
274
+
275
+ let startCommit = "HEAD";
276
+ let sourceBranchName = "";
277
+ if (parentWorktreeId) {
278
+ const parent = await loadWorktree(parentWorktreeId);
279
+ if (parent) {
280
+ startCommit = await runSessionCommandOutput(
281
+ session,
282
+ "git",
283
+ ["rev-parse", "HEAD"],
284
+ { cwd: parent.path }
285
+ );
286
+ startCommit = startCommit.trim();
287
+ sourceBranchName = parent.branchName || "";
288
+ }
289
+ } else if (startingBranch) {
290
+ startCommit = resolveStartingRef(startingBranch) || startingBranch;
291
+ sourceBranchName = normalizeBranchName(startingBranch);
292
+ } else {
293
+ sourceBranchName = await resolveCurrentBranchName(session);
294
+ }
295
+
296
+ const baseName =
297
+ requestedBranchName ||
298
+ sourceBranchName ||
299
+ generateWorktreeName(null, worktreeIndex);
300
+ const defaultDisplayName = `wt-${worktreeId.slice(0, 6)}-${baseName}`;
301
+ const branchName = requestedBranchName
302
+ ? requestedBranchName
303
+ : `wt-${worktreeId.slice(0, 6)}-${baseName}`;
304
+
305
+ const checkRemoteBranchExists = async (branch) => {
306
+ const remoteRef = resolveStartingRef(branch);
307
+ if (!remoteRef) return false;
308
+ const remoteVerifyRef = remoteRef.startsWith("refs/")
309
+ ? remoteRef
310
+ : `refs/remotes/${remoteRef}`;
311
+ try {
312
+ await runSessionCommand(session, "git", ["show-ref", "--verify", remoteVerifyRef], {
313
+ cwd: session.repoDir,
314
+ });
315
+ return true;
316
+ } catch {
317
+ return false;
318
+ }
319
+ };
320
+
321
+ const remoteBranchExists = requestedBranchName
322
+ ? await checkRemoteBranchExists(requestedBranchName)
323
+ : false;
324
+
325
+ if (requestedBranchName) {
326
+ if (remoteBranchExists) {
327
+ await runSessionCommand(
328
+ session,
329
+ "git",
330
+ ["branch", branchName, resolveStartingRef(requestedBranchName)],
331
+ { cwd: session.repoDir }
332
+ );
333
+ } else {
334
+ if (!parentWorktreeId && !startingBranch) {
335
+ throw new Error("Branche source requise pour creer une nouvelle branche.");
336
+ }
337
+ await runSessionCommand(session, "git", ["branch", branchName, startCommit], {
338
+ cwd: session.repoDir,
339
+ });
340
+ }
341
+ } else {
342
+ await runSessionCommand(session, "git", ["branch", branchName, startCommit], {
343
+ cwd: session.repoDir,
344
+ });
345
+ }
346
+
347
+ await runSessionCommand(
348
+ session,
349
+ "git",
350
+ ["config", `branch.${branchName}.remote`, "origin"],
351
+ { cwd: session.repoDir }
352
+ );
353
+ await runSessionCommand(
354
+ session,
355
+ "git",
356
+ ["config", `branch.${branchName}.merge`, `refs/heads/${branchName}`],
357
+ { cwd: session.repoDir }
358
+ );
359
+
360
+ await runSessionCommand(
361
+ session,
362
+ "git",
363
+ ["worktree", "add", worktreePath, branchName],
364
+ { cwd: session.repoDir }
365
+ );
366
+ await runAsCommand(session.workspaceId, "/bin/chmod", ["2750", worktreePath]);
367
+ await applyVibe80WorktreeMetadata(session, worktreePath, worktreeId);
368
+ if (creationContext === "fork" && resolvedProvider === "claude" && sourceWorktreeRecord?.path) {
369
+ await copyClaudeThreadDirectory(session.workspaceId, sourceWorktreeRecord.path, worktreePath);
370
+ }
371
+
372
+ if (shareGitCredentials) {
373
+ await ensureSessionGitDir(session);
374
+ }
375
+
376
+ const worktree = {
377
+ id: worktreeId,
378
+ sessionId: session.sessionId,
379
+ name: name && String(name).trim() ? baseName : defaultDisplayName,
380
+ branchName,
381
+ threadId: null,
382
+ path: worktreePath,
383
+ provider: resolvedProvider,
384
+ model: resolvedModel,
385
+ reasoningEffort: resolvedReasoningEffort,
386
+ context: creationContext,
387
+ sourceWorktreeId: resolvedSourceWorktreeId,
388
+ forkSourceThreadId: creationContext === "fork" ? forkSourceThreadId : null,
389
+ internetAccess: Boolean(resolvedInternetAccess),
390
+ denyGitCredentialsAccess: resolvedDenyGitCredentialsAccess,
391
+ startingBranch: startingBranch || null,
392
+ workspaceId: session.workspaceId,
393
+ status: "creating",
394
+ parentWorktreeId: parentWorktreeId || null,
395
+ createdAt: new Date().toISOString(),
396
+ lastActivityAt: new Date().toISOString(),
397
+ color: getNextColor(),
398
+ };
399
+
400
+ await storage.saveWorktree(session.sessionId, worktreeId, serializeWorktree(worktree));
401
+ await touchSession(session);
402
+
403
+ try {
404
+ const client = createWorktreeClient(
405
+ worktree,
406
+ session.attachmentsDir,
407
+ session.repoDir,
408
+ worktree.internetAccess,
409
+ worktree.threadId,
410
+ resolveSessionGitDir(session),
411
+ {
412
+ threadStartMode: creationContext === "fork" ? "fork" : "new",
413
+ sourceThreadId: creationContext === "fork" ? forkSourceThreadId : null,
414
+ }
415
+ );
416
+ const runtime = getSessionRuntime(session.sessionId);
417
+ if (runtime) {
418
+ runtime.worktreeClients.set(worktreeId, client);
419
+ }
420
+ worktree.client = client;
421
+ worktree.status = "ready";
422
+ } catch (error) {
423
+ worktree.status = "error";
424
+ await storage.saveWorktree(session.sessionId, worktreeId, serializeWorktree(worktree));
425
+ throw error;
426
+ }
427
+
428
+ return worktree;
429
+ }
430
+
431
+ /**
432
+ * Supprime un worktree
433
+ */
434
+ export async function removeWorktree(session, worktreeId, deleteBranch = true) {
435
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
436
+ if (isMainWorktreeStorageId(session, resolvedId)) {
437
+ throw new Error("Main worktree cannot be removed");
438
+ }
439
+ const worktree = await loadWorktree(resolvedId);
440
+ if (!worktree) {
441
+ throw new Error("Worktree not found");
442
+ }
443
+
444
+ const runtime = getSessionRuntime(session.sessionId);
445
+ const client = runtime?.worktreeClients?.get(resolvedId);
446
+ if (client) {
447
+ try {
448
+ if (typeof client.stop === "function") {
449
+ await client.stop();
450
+ }
451
+ } catch (error) {
452
+ console.error("Error stopping worktree client:", error);
453
+ }
454
+ runtime.worktreeClients.delete(resolvedId);
455
+ }
456
+
457
+ await runSessionCommand(
458
+ session,
459
+ "git",
460
+ ["worktree", "remove", "--force", worktree.path],
461
+ { cwd: session.repoDir }
462
+ );
463
+
464
+ if (deleteBranch) {
465
+ try {
466
+ await runSessionCommand(session, "git", ["branch", "-D", worktree.branchName], {
467
+ cwd: session.repoDir,
468
+ });
469
+ } catch {
470
+ console.warn("Could not delete branch:", worktree.branchName);
471
+ }
472
+ }
473
+
474
+ await storage.deleteWorktree(session.sessionId, resolvedId);
475
+ await touchSession(session);
476
+ }
477
+
478
+ export async function getWorktreeDiff(session, worktreeId) {
479
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
480
+ const worktree = await loadWorktree(resolvedId);
481
+ if (!worktree) {
482
+ throw new Error("Worktree not found");
483
+ }
484
+ const [status, diff] = await Promise.all([
485
+ runSessionCommandOutput(session, "git", ["status", "--porcelain"], {
486
+ cwd: worktree.path,
487
+ }),
488
+ runSessionCommandOutput(session, "git", ["diff"], { cwd: worktree.path }),
489
+ ]);
490
+ return { status, diff };
491
+ }
492
+
493
+ export async function getWorktreeCommits(session, worktreeId, limit = 20) {
494
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
495
+ const worktree = await loadWorktree(resolvedId);
496
+ if (!worktree) {
497
+ throw new Error("Worktree not found");
498
+ }
499
+
500
+ const output = await runSessionCommandOutput(
501
+ session,
502
+ "git",
503
+ ["log", `--max-count=${limit}`, "--format=%H|%s|%cI"],
504
+ { cwd: worktree.path }
505
+ );
506
+
507
+ return output
508
+ .trim()
509
+ .split("\n")
510
+ .filter(Boolean)
511
+ .map((line) => {
512
+ const [sha, message, date] = line.split("|");
513
+ return { sha, message, date: toIsoDateTime(date) };
514
+ });
515
+ }
516
+
517
+ export async function mergeWorktree(session, sourceWorktreeId, targetWorktreeId) {
518
+ const source = await loadWorktree(resolveWorktreeStorageId(session, sourceWorktreeId));
519
+ if (!source) {
520
+ throw new Error("Source worktree not found");
521
+ }
522
+ const target = await loadWorktree(resolveWorktreeStorageId(session, targetWorktreeId));
523
+ if (!target) {
524
+ throw new Error("Target worktree not found");
525
+ }
526
+
527
+ try {
528
+ await runSessionCommand(session, "git", ["merge", source.branchName, "--no-edit"], {
529
+ cwd: target.path,
530
+ });
531
+ return { success: true };
532
+ } catch (error) {
533
+ const status = await runSessionCommandOutput(
534
+ session,
535
+ "git",
536
+ ["status", "--porcelain"],
537
+ { cwd: target.path }
538
+ );
539
+ const conflicts = status
540
+ .split("\n")
541
+ .filter((line) => line.startsWith("UU") || line.startsWith("AA"))
542
+ .map((line) => line.slice(3).trim());
543
+ if (conflicts.length > 0) {
544
+ return { success: false, conflicts };
545
+ }
546
+ throw error;
547
+ }
548
+ }
549
+
550
+ export async function abortMerge(session, worktreeId) {
551
+ const worktree = await loadWorktree(resolveWorktreeStorageId(session, worktreeId));
552
+ if (!worktree) {
553
+ throw new Error("Worktree not found");
554
+ }
555
+ await runSessionCommand(session, "git", ["merge", "--abort"], { cwd: worktree.path });
556
+ }
557
+
558
+ export async function cherryPickCommit(session, commitSha, targetWorktreeId) {
559
+ const target = await loadWorktree(resolveWorktreeStorageId(session, targetWorktreeId));
560
+ if (!target) {
561
+ throw new Error("Target worktree not found");
562
+ }
563
+ try {
564
+ await runSessionCommand(session, "git", ["cherry-pick", commitSha], {
565
+ cwd: target.path,
566
+ });
567
+ return { success: true };
568
+ } catch (error) {
569
+ const status = await runSessionCommandOutput(
570
+ session,
571
+ "git",
572
+ ["status", "--porcelain"],
573
+ { cwd: target.path }
574
+ );
575
+ const conflicts = status
576
+ .split("\n")
577
+ .filter((line) => line.startsWith("UU") || line.startsWith("AA"))
578
+ .map((line) => line.slice(3).trim());
579
+ if (conflicts.length > 0) {
580
+ return { success: false, conflicts };
581
+ }
582
+ throw error;
583
+ }
584
+ }
585
+
586
+ export async function listStoredWorktrees(session) {
587
+ const worktrees = await storage.listWorktrees(session.sessionId);
588
+ return worktrees.filter((wt) => !isMainWorktreeStorageId(session, wt?.id));
589
+ }
590
+
591
+ export async function listWorktrees(session) {
592
+ const worktrees = await listStoredWorktrees(session);
593
+ return worktrees.map((wt) => ({
594
+ id: wt.id,
595
+ name: wt.name,
596
+ branchName: wt.branchName,
597
+ provider: wt.provider,
598
+ model: wt.model || null,
599
+ reasoningEffort: wt.reasoningEffort || null,
600
+ context: wt.context || "new",
601
+ sourceWorktreeId: wt.sourceWorktreeId || null,
602
+ status: wt.status,
603
+ parentWorktreeId: wt.parentWorktreeId,
604
+ internetAccess: Boolean(wt.internetAccess),
605
+ denyGitCredentialsAccess:
606
+ typeof wt.denyGitCredentialsAccess === "boolean"
607
+ ? wt.denyGitCredentialsAccess
608
+ : true,
609
+ createdAt: toIsoDateTime(wt.createdAt),
610
+ lastActivityAt: toIsoDateTime(wt.lastActivityAt),
611
+ color: wt.color,
612
+ }));
613
+ }
614
+
615
+ export async function getWorktree(session, worktreeId) {
616
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
617
+ if (isMainWorktreeStorageId(session, resolvedId)) {
618
+ const mainWorktree = await ensureMainWorktree(session);
619
+ return mainWorktree;
620
+ }
621
+ const worktree = await loadWorktree(resolvedId);
622
+ if (!worktree) return null;
623
+ const runtime = getSessionRuntime(session.sessionId);
624
+ if (runtime?.worktreeClients?.has(resolvedId)) {
625
+ worktree.client = runtime.worktreeClients.get(resolvedId);
626
+ }
627
+ return worktree;
628
+ }
629
+
630
+ export async function updateWorktreeStatus(session, worktreeId, status) {
631
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
632
+ let worktree = await loadWorktree(resolvedId);
633
+ if (!worktree && isMainWorktreeStorageId(session, resolvedId)) {
634
+ worktree = await ensureMainWorktree(session);
635
+ }
636
+ if (!worktree) return;
637
+ const updated = {
638
+ ...worktree,
639
+ status,
640
+ lastActivityAt: new Date().toISOString(),
641
+ };
642
+ await storage.saveWorktree(session.sessionId, resolvedId, serializeWorktree(updated));
643
+ }
644
+
645
+ export async function updateWorktreeThreadId(session, worktreeId, threadId) {
646
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
647
+ const worktree = await loadWorktree(resolvedId);
648
+ if (!worktree || !threadId) return;
649
+ const updated = {
650
+ ...worktree,
651
+ threadId,
652
+ lastActivityAt: new Date().toISOString(),
653
+ };
654
+ await storage.saveWorktree(session.sessionId, resolvedId, serializeWorktree(updated));
655
+ }
656
+
657
+ export async function updateWorktreeModel(
658
+ session,
659
+ worktreeId,
660
+ model = null,
661
+ reasoningEffort = null
662
+ ) {
663
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
664
+ let worktree = await loadWorktree(resolvedId);
665
+ if (!worktree && isMainWorktreeStorageId(session, resolvedId)) {
666
+ worktree = await ensureMainWorktree(session);
667
+ }
668
+ if (!worktree) return;
669
+ const updated = {
670
+ ...worktree,
671
+ model: model || null,
672
+ reasoningEffort: reasoningEffort || null,
673
+ lastActivityAt: new Date().toISOString(),
674
+ };
675
+ await storage.saveWorktree(session.sessionId, resolvedId, serializeWorktree(updated));
676
+ }
677
+
678
+ export async function appendWorktreeMessage(session, worktreeId, message) {
679
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
680
+ let worktree = await loadWorktree(resolvedId);
681
+ if (!worktree && isMainWorktreeStorageId(session, resolvedId)) {
682
+ worktree = await ensureMainWorktree(session);
683
+ }
684
+ if (!worktree) return;
685
+ const messageId = message?.id || createMessageId();
686
+ const payload = message?.id ? message : { ...message, id: messageId };
687
+ await storage.appendWorktreeMessage(session.sessionId, resolvedId, payload);
688
+ const updated = {
689
+ ...worktree,
690
+ lastActivityAt: new Date().toISOString(),
691
+ };
692
+ await storage.saveWorktree(session.sessionId, resolvedId, serializeWorktree(updated));
693
+ }
694
+
695
+ export async function clearWorktreeMessages(session, worktreeId) {
696
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
697
+ let worktree = await loadWorktree(resolvedId);
698
+ if (!worktree && isMainWorktreeStorageId(session, resolvedId)) {
699
+ worktree = await ensureMainWorktree(session);
700
+ }
701
+ if (!worktree) return;
702
+ await storage.clearWorktreeMessages(session.sessionId, resolvedId);
703
+ const updated = {
704
+ ...worktree,
705
+ lastActivityAt: new Date().toISOString(),
706
+ };
707
+ await storage.saveWorktree(session.sessionId, resolvedId, serializeWorktree(updated));
708
+ }
709
+
710
+ export async function renameWorktree(session, worktreeId, newName) {
711
+ const resolvedId = resolveWorktreeStorageId(session, worktreeId);
712
+ if (isMainWorktreeStorageId(session, resolvedId)) return;
713
+ const worktree = await loadWorktree(resolvedId);
714
+ if (!worktree || !newName) return;
715
+ const updated = { ...worktree, name: newName };
716
+ await storage.saveWorktree(session.sessionId, resolvedId, serializeWorktree(updated));
717
+ }