@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,993 @@
1
+ import path from "path";
2
+ import { Router } from "express";
3
+ import {
4
+ createWorktreeClient,
5
+ } from "../clientFactory.js";
6
+ import {
7
+ getSession,
8
+ touchSession,
9
+ runSessionCommand,
10
+ runSessionCommandOutput,
11
+ resolveWorktreeRoot,
12
+ listDirectoryEntries,
13
+ broadcastToSession,
14
+ isValidProvider,
15
+ resolveDefaultDenyGitCredentialsAccess,
16
+ MAX_FILE_BYTES,
17
+ MAX_WRITE_BYTES,
18
+ readWorkspaceFileBuffer,
19
+ writeWorkspaceFilePreserveMode,
20
+ getWorktree,
21
+ getWorktreeMessages,
22
+ getWorktreeDiff,
23
+ } from "../services/session.js";
24
+ import { getSessionTmpDir, createMessageId, toIsoDateTime } from "../helpers.js";
25
+ import { getSessionRuntime } from "../runtimeStore.js";
26
+ import {
27
+ listWorktrees,
28
+ createWorktree,
29
+ removeWorktree,
30
+ renameWorktree,
31
+ mergeWorktree,
32
+ abortMerge,
33
+ cherryPickCommit,
34
+ getWorktreeCommits,
35
+ updateWorktreeStatus,
36
+ appendWorktreeMessage,
37
+ } from "../worktreeManager.js";
38
+
39
+ export default function worktreeRoutes(deps) {
40
+ const {
41
+ getActiveClient,
42
+ getOrCreateClient,
43
+ attachClientEvents,
44
+ attachClaudeEvents,
45
+ attachClientEventsForWorktree,
46
+ attachClaudeEventsForWorktree,
47
+ } = deps;
48
+
49
+ const router = Router();
50
+ const DEFAULT_WAKEUP_TIMEOUT_MS = 15000;
51
+ const resolveRelativePath = (rootPath, requestedPath) => {
52
+ if (!requestedPath || typeof requestedPath !== "string") {
53
+ return null;
54
+ }
55
+ const absPath = path.resolve(rootPath, requestedPath);
56
+ const relative = path.relative(rootPath, absPath);
57
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
58
+ return null;
59
+ }
60
+ return { absPath, relative };
61
+ };
62
+
63
+ const waitUntilClientReady = (client, timeoutMs) =>
64
+ new Promise((resolve, reject) => {
65
+ if (client?.ready) {
66
+ resolve();
67
+ return;
68
+ }
69
+ const timeout = setTimeout(() => {
70
+ cleanup();
71
+ reject(new Error("Wakeup timeout."));
72
+ }, timeoutMs);
73
+ const cleanup = () => {
74
+ clearTimeout(timeout);
75
+ client?.off?.("ready", onReady);
76
+ client?.off?.("exit", onExit);
77
+ };
78
+ const onReady = () => {
79
+ cleanup();
80
+ resolve();
81
+ };
82
+ const onExit = (payload) => {
83
+ cleanup();
84
+ reject(
85
+ new Error(
86
+ `Client exited before ready (code=${payload?.code ?? "unknown"}, signal=${payload?.signal ?? "unknown"}).`
87
+ )
88
+ );
89
+ };
90
+ client.on("ready", onReady);
91
+ client.on("exit", onExit);
92
+ });
93
+
94
+ const ensureReadyWorktreeClient = async (session, worktree, timeoutMs = DEFAULT_WAKEUP_TIMEOUT_MS) => {
95
+ const sessionId = session.sessionId;
96
+ const runtime = getSessionRuntime(sessionId);
97
+ const worktreeId = worktree?.id === `main-${sessionId}` ? "main" : worktree?.id;
98
+ const isMain = worktreeId === "main";
99
+ const provider = isMain ? session.activeProvider : worktree.provider;
100
+ let client = null;
101
+
102
+ if (isMain) {
103
+ client = await getOrCreateClient(session, provider);
104
+ const procExited = client?.proc && client.proc.exitCode != null;
105
+ if (procExited && runtime?.clients?.[provider]) {
106
+ delete runtime.clients[provider];
107
+ client = await getOrCreateClient(session, provider);
108
+ }
109
+ if (!client.listenerCount("ready")) {
110
+ if (provider === "claude") {
111
+ attachClaudeEvents(sessionId, client);
112
+ } else {
113
+ attachClientEvents(sessionId, client, provider);
114
+ }
115
+ }
116
+ } else {
117
+ client = runtime?.worktreeClients?.get(worktreeId) || null;
118
+ const procExited = client?.proc && client.proc.exitCode != null;
119
+ if (procExited && runtime?.worktreeClients) {
120
+ runtime.worktreeClients.delete(worktreeId);
121
+ client = null;
122
+ }
123
+ if (!client) {
124
+ client = createWorktreeClient(
125
+ worktree,
126
+ session.attachmentsDir,
127
+ session.repoDir,
128
+ worktree.internetAccess,
129
+ worktree.threadId,
130
+ session.gitDir || path.join(session.dir, "git")
131
+ );
132
+ runtime?.worktreeClients?.set(worktreeId, client);
133
+ }
134
+ worktree.client = client;
135
+ if (!client.listenerCount("ready")) {
136
+ if (provider === "claude") {
137
+ attachClaudeEventsForWorktree(sessionId, worktree);
138
+ } else {
139
+ attachClientEventsForWorktree(sessionId, worktree);
140
+ }
141
+ }
142
+ }
143
+
144
+ if (!client.ready && !client.proc) {
145
+ await client.start();
146
+ }
147
+ if (!client.ready) {
148
+ await waitUntilClientReady(client, timeoutMs);
149
+ }
150
+ if (typeof client.markActive === "function") {
151
+ client.markActive();
152
+ }
153
+ return client;
154
+ };
155
+
156
+ router.get("/sessions/:sessionId/worktrees", async (req, res) => {
157
+ const sessionId = req.params.sessionId;
158
+ const session = await getSession(sessionId, req.workspaceId);
159
+ if (!session) {
160
+ res.status(400).json({ error: "Invalid session." });
161
+ return;
162
+ }
163
+ await touchSession(session);
164
+ try {
165
+ const worktrees = await listWorktrees(session);
166
+ res.json({ worktrees });
167
+ } catch (error) {
168
+ console.error("Failed to list worktrees:", {
169
+ sessionId,
170
+ error: error?.message || error,
171
+ });
172
+ res.status(500).json({ error: "Failed to list worktrees." });
173
+ }
174
+ });
175
+
176
+ router.post("/sessions/:sessionId/worktrees", async (req, res) => {
177
+ const sessionId = req.params.sessionId;
178
+ const session = await getSession(sessionId, req.workspaceId);
179
+ if (!session) {
180
+ res.status(400).json({ error: "Invalid session." });
181
+ return;
182
+ }
183
+ await touchSession(session);
184
+
185
+ const context = req.body?.context === "fork" ? "fork" : "new";
186
+ const provider = req.body?.provider;
187
+ const sourceWorktree = req.body?.sourceWorktree;
188
+
189
+ if (context === "new") {
190
+ if (!isValidProvider(provider)) {
191
+ res.status(400).json({ error: "Invalid provider. Must be 'codex' or 'claude'." });
192
+ return;
193
+ }
194
+ if (
195
+ Array.isArray(session.providers) &&
196
+ session.providers.length &&
197
+ !session.providers.includes(provider)
198
+ ) {
199
+ res.status(400).json({ error: "Provider not enabled for this session." });
200
+ return;
201
+ }
202
+ } else {
203
+ if (!sourceWorktree || typeof sourceWorktree !== "string") {
204
+ res.status(400).json({ error: "sourceWorktree is required when context=fork." });
205
+ return;
206
+ }
207
+ if (provider != null || req.body?.model != null || req.body?.reasoningEffort != null) {
208
+ res.status(400).json({
209
+ error: "provider, model and reasoningEffort must not be provided when context=fork.",
210
+ });
211
+ return;
212
+ }
213
+ const source = await getWorktree(session, sourceWorktree);
214
+ if (!source) {
215
+ res.status(404).json({ error: "Source worktree not found." });
216
+ return;
217
+ }
218
+ const sourceThreadId =
219
+ source.threadId || (sourceWorktree === "main" ? session.threadId || null : null);
220
+ if (!sourceThreadId) {
221
+ res.status(409).json({ error: "Source worktree has no threadId to fork from." });
222
+ return;
223
+ }
224
+ if (
225
+ Array.isArray(session.providers) &&
226
+ session.providers.length &&
227
+ !session.providers.includes(source.provider)
228
+ ) {
229
+ res.status(400).json({ error: "Source provider not enabled for this session." });
230
+ return;
231
+ }
232
+ }
233
+
234
+ try {
235
+ const internetAccess =
236
+ typeof req.body?.internetAccess === "boolean"
237
+ ? req.body.internetAccess
238
+ : typeof session.defaultInternetAccess === "boolean"
239
+ ? session.defaultInternetAccess
240
+ : true;
241
+ const denyGitCredentialsAccess =
242
+ typeof req.body?.denyGitCredentialsAccess === "boolean"
243
+ ? req.body.denyGitCredentialsAccess
244
+ : resolveDefaultDenyGitCredentialsAccess(session);
245
+ const worktree = await createWorktree(session, {
246
+ context,
247
+ provider: context === "new" ? provider : null,
248
+ sourceWorktree: context === "fork" ? sourceWorktree : null,
249
+ name: req.body?.name || null,
250
+ parentWorktreeId: req.body?.parentWorktreeId || null,
251
+ startingBranch: req.body?.startingBranch || null,
252
+ model: context === "new" ? req.body?.model || null : null,
253
+ reasoningEffort: context === "new" ? req.body?.reasoningEffort || null : null,
254
+ internetAccess,
255
+ denyGitCredentialsAccess,
256
+ });
257
+
258
+ if (worktree.client) {
259
+ if (worktree.provider === "claude") {
260
+ attachClaudeEventsForWorktree(sessionId, worktree);
261
+ } else {
262
+ attachClientEventsForWorktree(sessionId, worktree);
263
+ }
264
+ worktree.client.start().catch((error) => {
265
+ console.error("Failed to start worktree client:", error);
266
+ void updateWorktreeStatus(session, worktree.id, "error");
267
+ broadcastToSession(sessionId, {
268
+ type: "worktree_status",
269
+ worktreeId: worktree.id,
270
+ status: "error",
271
+ error: error.message,
272
+ });
273
+ });
274
+ }
275
+
276
+ res.status(201).location(
277
+ `/api/sessions/${sessionId}/worktrees/${worktree.id}`
278
+ ).json({
279
+ worktreeId: worktree.id,
280
+ name: worktree.name,
281
+ branchName: worktree.branchName,
282
+ provider: worktree.provider,
283
+ context: worktree.context || "new",
284
+ sourceWorktreeId: worktree.sourceWorktreeId || null,
285
+ model: worktree.model || null,
286
+ reasoningEffort: worktree.reasoningEffort || null,
287
+ internetAccess: Boolean(worktree.internetAccess),
288
+ denyGitCredentialsAccess:
289
+ typeof worktree.denyGitCredentialsAccess === "boolean"
290
+ ? worktree.denyGitCredentialsAccess
291
+ : true,
292
+ status: worktree.status,
293
+ color: worktree.color,
294
+ });
295
+ } catch (error) {
296
+ console.error("Failed to create worktree:", {
297
+ sessionId,
298
+ error: error?.message || error,
299
+ });
300
+ res.status(500).json({ error: "Failed to create worktree." });
301
+ }
302
+ });
303
+
304
+ router.get("/sessions/:sessionId/worktrees/:worktreeId", async (req, res) => {
305
+ const sessionId = req.params.sessionId;
306
+ const session = await getSession(sessionId, req.workspaceId);
307
+ if (!session) {
308
+ res.status(400).json({ error: "Invalid session." });
309
+ return;
310
+ }
311
+ await touchSession(session);
312
+
313
+ const worktree = await getWorktree(session, req.params.worktreeId);
314
+ if (!worktree) {
315
+ res.status(404).json({ error: "Worktree not found." });
316
+ return;
317
+ }
318
+
319
+ try {
320
+ res.json({
321
+ id: worktree.id,
322
+ name: worktree.name,
323
+ branchName: worktree.branchName,
324
+ provider: worktree.provider,
325
+ context: worktree.context || "new",
326
+ sourceWorktreeId: worktree.sourceWorktreeId || null,
327
+ model: worktree.model || null,
328
+ reasoningEffort: worktree.reasoningEffort || null,
329
+ internetAccess: Boolean(worktree.internetAccess),
330
+ denyGitCredentialsAccess:
331
+ typeof worktree.denyGitCredentialsAccess === "boolean"
332
+ ? worktree.denyGitCredentialsAccess
333
+ : true,
334
+ status: worktree.status,
335
+ color: worktree.color,
336
+ createdAt: toIsoDateTime(worktree.createdAt),
337
+ });
338
+ } catch (error) {
339
+ console.error("Failed to get worktree:", {
340
+ sessionId,
341
+ worktreeId: req.params.worktreeId,
342
+ error: error?.message || error,
343
+ });
344
+ res.status(500).json({ error: "Failed to get worktree." });
345
+ }
346
+ });
347
+
348
+ router.get("/sessions/:sessionId/worktrees/:worktreeId/messages", async (req, res) => {
349
+ const sessionId = req.params.sessionId;
350
+ const session = await getSession(sessionId, req.workspaceId);
351
+ if (!session) {
352
+ res.status(400).json({ error: "Invalid session." });
353
+ return;
354
+ }
355
+ await touchSession(session);
356
+
357
+ const worktreeId = req.params.worktreeId;
358
+ const worktree = await getWorktree(session, worktreeId);
359
+ if (!worktree) {
360
+ res.status(404).json({ error: "Worktree not found." });
361
+ return;
362
+ }
363
+ try {
364
+ const limitValue = Number.parseInt(req.query?.limit, 10);
365
+ const limit =
366
+ Number.isFinite(limitValue) && limitValue > 0 ? limitValue : 50;
367
+ const beforeMessageId =
368
+ typeof req.query?.beforeMessageId === "string"
369
+ ? req.query.beforeMessageId
370
+ : null;
371
+ const messages = await getWorktreeMessages(session, worktreeId, {
372
+ limit: limit + 1,
373
+ beforeMessageId,
374
+ });
375
+ const hasMore = messages.length > limit;
376
+ const trimmed = hasMore ? messages.slice(1) : messages;
377
+
378
+ res.json({
379
+ worktreeId,
380
+ messages: trimmed,
381
+ hasMore,
382
+ });
383
+ } catch (error) {
384
+ console.error("Failed to get worktree messages:", {
385
+ sessionId,
386
+ worktreeId,
387
+ error: error?.message || error,
388
+ });
389
+ res.status(500).json({ error: "Failed to get worktree messages." });
390
+ }
391
+ });
392
+
393
+ router.post("/sessions/:sessionId/worktrees/:worktreeId/messages", async (req, res) => {
394
+ const sessionId = req.params.sessionId;
395
+ const session = await getSession(sessionId, req.workspaceId);
396
+ if (!session) {
397
+ res.status(400).json({ error: "Invalid session." });
398
+ return;
399
+ }
400
+ await touchSession(session);
401
+
402
+ const worktreeId = req.params.worktreeId;
403
+ const worktree = await getWorktree(session, worktreeId);
404
+ if (!worktree) {
405
+ res.status(404).json({ error: "Worktree not found." });
406
+ return;
407
+ }
408
+ if (worktree.status === "stopped") {
409
+ res.status(409).json({
410
+ error: "Worktree is stopped. Wake it up before sending a message.",
411
+ });
412
+ return;
413
+ }
414
+
415
+ const role = req.body?.role;
416
+ if (role !== "user") {
417
+ res.status(400).json({ error: "Only role=user is supported." });
418
+ return;
419
+ }
420
+
421
+ const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
422
+ if (!text) {
423
+ res.status(400).json({ error: "Message text is required." });
424
+ return;
425
+ }
426
+
427
+ const attachments = Array.isArray(req.body?.attachments)
428
+ ? req.body.attachments
429
+ : [];
430
+
431
+ const isMainWorktree = worktreeId === "main";
432
+ const runtime = getSessionRuntime(sessionId);
433
+ const client = isMainWorktree
434
+ ? getActiveClient(session)
435
+ : runtime?.worktreeClients?.get(worktreeId);
436
+ if (!client?.ready) {
437
+ const label = isMainWorktree
438
+ ? worktree.provider === "claude"
439
+ ? "Claude CLI"
440
+ : "Codex app-server"
441
+ : (worktree.provider === "claude" ? "Claude CLI" : "Codex app-server");
442
+ res.status(409).json({ error: `${label} not ready for worktree.` });
443
+ return;
444
+ }
445
+
446
+ try {
447
+ if (worktree.provider === "claude") {
448
+ await updateWorktreeStatus(session, worktreeId, "processing");
449
+ broadcastToSession(sessionId, {
450
+ type: "worktree_status",
451
+ worktreeId,
452
+ status: "processing",
453
+ });
454
+ }
455
+ const result = await client.sendTurn(text);
456
+
457
+ if (worktree.provider === "claude") {
458
+ const turnId = result?.turn?.id;
459
+ const onDone = async (status) => {
460
+ const newStatus = status === "success" ? "ready" : "error";
461
+ await updateWorktreeStatus(session, worktreeId, newStatus);
462
+ broadcastToSession(sessionId, {
463
+ type: "worktree_status",
464
+ worktreeId,
465
+ status: newStatus,
466
+ });
467
+ };
468
+ const onceCompleted = ({ turnId: completedId, status }) => {
469
+ if (!turnId || !completedId || turnId === completedId) {
470
+ client.off("turn_error", onceError);
471
+ onDone(status || "success").catch(() => null);
472
+ }
473
+ };
474
+ const onceError = ({ turnId: errorId }) => {
475
+ if (!turnId || !errorId || turnId === errorId) {
476
+ client.off("turn_completed", onceCompleted);
477
+ onDone("error").catch(() => null);
478
+ }
479
+ };
480
+ client.once("turn_completed", onceCompleted);
481
+ client.once("turn_error", onceError);
482
+ }
483
+
484
+ const messageId = createMessageId();
485
+ await appendWorktreeMessage(session, worktreeId, {
486
+ id: messageId,
487
+ role: "user",
488
+ text,
489
+ attachments,
490
+ provider: worktree.provider,
491
+ });
492
+ const turnPayload = {
493
+ type: "turn_started",
494
+ turnId: result.turn.id,
495
+ threadId: client.threadId,
496
+ provider: worktree.provider,
497
+ status: "processing",
498
+ worktreeId,
499
+ };
500
+ broadcastToSession(sessionId, turnPayload);
501
+ res.json({
502
+ messageId,
503
+ turnId: result.turn.id,
504
+ threadId: client.threadId,
505
+ provider: worktree.provider,
506
+ worktreeId,
507
+ });
508
+ } catch (error) {
509
+ res.status(500).json({ error: error.message || "Failed to send message." });
510
+ }
511
+ });
512
+
513
+ const handleWorktreeWakeup = async (req, res) => {
514
+ const sessionId = req.params.sessionId;
515
+ const session = await getSession(sessionId, req.workspaceId);
516
+ if (!session) {
517
+ res.status(400).json({ error: "Invalid session." });
518
+ return;
519
+ }
520
+ await touchSession(session);
521
+
522
+ const worktreeId = req.params.worktreeId;
523
+ const worktree = await getWorktree(session, worktreeId);
524
+ if (!worktree) {
525
+ res.status(404).json({ error: "Worktree not found." });
526
+ return;
527
+ }
528
+
529
+ const requestedTimeout = Number.parseInt(req.body?.timeoutMs, 10);
530
+ const timeoutMs = Number.isFinite(requestedTimeout)
531
+ ? Math.min(Math.max(requestedTimeout, 1000), 60000)
532
+ : DEFAULT_WAKEUP_TIMEOUT_MS;
533
+
534
+ try {
535
+ const client = await ensureReadyWorktreeClient(session, worktree, timeoutMs);
536
+ const effectiveWorktreeId = worktreeId || "main";
537
+ const provider = effectiveWorktreeId === "main"
538
+ ? session.activeProvider
539
+ : worktree.provider;
540
+ res.json({
541
+ worktreeId: effectiveWorktreeId,
542
+ provider,
543
+ status: "ready",
544
+ threadId: client?.threadId || null,
545
+ });
546
+ } catch (error) {
547
+ const message = error?.message || "Failed to wake provider.";
548
+ if (/timeout/i.test(message)) {
549
+ res.status(504).json({ error: message });
550
+ return;
551
+ }
552
+ if (/not ready/i.test(message) || /exited before ready/i.test(message)) {
553
+ res.status(409).json({ error: message });
554
+ return;
555
+ }
556
+ const providerLabel = (worktreeId === "main" ? session.activeProvider : worktree.provider) === "claude"
557
+ ? "Claude CLI"
558
+ : "Codex app-server";
559
+ res.status(500).json({ error: `${providerLabel} wakeup failed: ${message}` });
560
+ }
561
+ };
562
+
563
+ router.post("/sessions/:sessionId/worktrees/:worktreeId/wakeup", handleWorktreeWakeup);
564
+
565
+ router.get("/sessions/:sessionId/worktrees/:worktreeId/browse", async (req, res) => {
566
+ const sessionId = req.params.sessionId;
567
+ const session = await getSession(sessionId, req.workspaceId);
568
+ if (!session) {
569
+ res.status(400).json({ error: "Invalid session." });
570
+ return;
571
+ }
572
+ await touchSession(session);
573
+ const { rootPath } = await resolveWorktreeRoot(session, req.params.worktreeId);
574
+ if (!rootPath) {
575
+ res.status(404).json({ error: "Worktree not found." });
576
+ return;
577
+ }
578
+ try {
579
+ const payload = await listDirectoryEntries(
580
+ session.workspaceId,
581
+ rootPath,
582
+ typeof req.query?.path === "string" ? req.query.path : ""
583
+ );
584
+ res.json(payload);
585
+ } catch (error) {
586
+ console.error("Failed to browse worktree:", {
587
+ sessionId,
588
+ worktreeId: req.params.worktreeId,
589
+ error: error?.message || error,
590
+ });
591
+ res.status(500).json({ error: "Failed to browse worktree." });
592
+ }
593
+ });
594
+
595
+ router.get("/sessions/:sessionId/worktrees/:worktreeId/file", async (req, res) => {
596
+ const sessionId = req.params.sessionId;
597
+ const session = await getSession(sessionId, req.workspaceId);
598
+ if (!session) {
599
+ res.status(400).json({ error: "Invalid session." });
600
+ return;
601
+ }
602
+ await touchSession(session);
603
+ const { rootPath } = await resolveWorktreeRoot(session, req.params.worktreeId);
604
+ if (!rootPath) {
605
+ res.status(404).json({ error: "Worktree not found." });
606
+ return;
607
+ }
608
+ const requestedPath = req.query?.path;
609
+ if (!requestedPath || typeof requestedPath !== "string") {
610
+ res.status(400).json({ error: "Path is required." });
611
+ return;
612
+ }
613
+ const resolved = resolveRelativePath(rootPath, requestedPath);
614
+ if (!resolved) {
615
+ res.status(400).json({ error: "Invalid path." });
616
+ return;
617
+ }
618
+ const { absPath } = resolved;
619
+ try {
620
+ const { buffer, truncated } = await readWorkspaceFileBuffer(
621
+ session.workspaceId,
622
+ absPath,
623
+ MAX_FILE_BYTES,
624
+ { env: { TMPDIR: getSessionTmpDir(session.dir) } }
625
+ );
626
+ const binary = buffer.includes(0);
627
+ const content = binary ? "" : buffer.toString("utf8");
628
+ res.json({ path: requestedPath, content, truncated, binary });
629
+ } catch (error) {
630
+ console.error("Failed to read file:", {
631
+ sessionId,
632
+ worktreeId: req.params.worktreeId,
633
+ path: requestedPath,
634
+ error: error?.message || error,
635
+ });
636
+ res.status(500).json({ error: "Failed to read file." });
637
+ }
638
+ });
639
+
640
+ router.post("/sessions/:sessionId/worktrees/:worktreeId/file", async (req, res) => {
641
+ const sessionId = req.params.sessionId;
642
+ const session = await getSession(sessionId, req.workspaceId);
643
+ if (!session) {
644
+ res.status(400).json({ error: "Invalid session." });
645
+ return;
646
+ }
647
+ await touchSession(session);
648
+ const { rootPath } = await resolveWorktreeRoot(session, req.params.worktreeId);
649
+ if (!rootPath) {
650
+ res.status(404).json({ error: "Worktree not found." });
651
+ return;
652
+ }
653
+ const requestedPath = req.body?.path;
654
+ const content = req.body?.content;
655
+ if (!requestedPath || typeof requestedPath !== "string") {
656
+ res.status(400).json({ error: "Path is required." });
657
+ return;
658
+ }
659
+ if (typeof content !== "string") {
660
+ res.status(400).json({ error: "Content must be a string." });
661
+ return;
662
+ }
663
+ const bytes = Buffer.byteLength(content, "utf8");
664
+ if (bytes > MAX_WRITE_BYTES) {
665
+ res.status(400).json({ error: "File too large to write." });
666
+ return;
667
+ }
668
+ const resolved = resolveRelativePath(rootPath, requestedPath);
669
+ if (!resolved) {
670
+ res.status(400).json({ error: "Invalid path." });
671
+ return;
672
+ }
673
+ const { absPath } = resolved;
674
+ try {
675
+ let updated = false;
676
+ try {
677
+ await writeWorkspaceFilePreserveMode(session.workspaceId, absPath, content);
678
+ updated = true;
679
+ } catch {
680
+ updated = false;
681
+ }
682
+ if (!updated) {
683
+ await runSessionCommand(session, "/bin/mkdir", ["-p", path.dirname(absPath)], {
684
+ cwd: rootPath,
685
+ });
686
+ await runSessionCommand(session, "/usr/bin/tee", [absPath], { input: content });
687
+ }
688
+ res.json({ ok: true, path: requestedPath });
689
+ } catch (error) {
690
+ console.error("Failed to write file:", {
691
+ sessionId,
692
+ worktreeId: req.params.worktreeId,
693
+ path: requestedPath,
694
+ error: error?.message || error,
695
+ });
696
+ res.status(500).json({ error: "Failed to write file." });
697
+ }
698
+ });
699
+
700
+ router.post("/sessions/:sessionId/worktrees/:worktreeId/folder", async (req, res) => {
701
+ const sessionId = req.params.sessionId;
702
+ const session = await getSession(sessionId, req.workspaceId);
703
+ if (!session) {
704
+ res.status(400).json({ error: "Invalid session." });
705
+ return;
706
+ }
707
+ await touchSession(session);
708
+ const { rootPath } = await resolveWorktreeRoot(session, req.params.worktreeId);
709
+ if (!rootPath) {
710
+ res.status(404).json({ error: "Worktree not found." });
711
+ return;
712
+ }
713
+ const requestedPath = req.body?.path;
714
+ if (!requestedPath || typeof requestedPath !== "string") {
715
+ res.status(400).json({ error: "Path is required." });
716
+ return;
717
+ }
718
+ const resolved = resolveRelativePath(rootPath, requestedPath);
719
+ if (!resolved) {
720
+ res.status(400).json({ error: "Invalid path." });
721
+ return;
722
+ }
723
+ try {
724
+ await runSessionCommand(session, "/bin/mkdir", ["-p", resolved.absPath], {
725
+ cwd: rootPath,
726
+ });
727
+ res.json({ ok: true, path: requestedPath });
728
+ } catch (error) {
729
+ console.error("Failed to create folder:", {
730
+ sessionId,
731
+ worktreeId: req.params.worktreeId,
732
+ path: requestedPath,
733
+ error: error?.message || error,
734
+ });
735
+ res.status(500).json({ error: "Failed to create folder." });
736
+ }
737
+ });
738
+
739
+ router.post("/sessions/:sessionId/worktrees/:worktreeId/file/rename", async (req, res) => {
740
+ const sessionId = req.params.sessionId;
741
+ const session = await getSession(sessionId, req.workspaceId);
742
+ if (!session) {
743
+ res.status(400).json({ error: "Invalid session." });
744
+ return;
745
+ }
746
+ await touchSession(session);
747
+ const { rootPath } = await resolveWorktreeRoot(session, req.params.worktreeId);
748
+ if (!rootPath) {
749
+ res.status(404).json({ error: "Worktree not found." });
750
+ return;
751
+ }
752
+ const fromPath = req.body?.fromPath;
753
+ const toPath = req.body?.toPath;
754
+ if (!fromPath || typeof fromPath !== "string") {
755
+ res.status(400).json({ error: "fromPath is required." });
756
+ return;
757
+ }
758
+ if (!toPath || typeof toPath !== "string") {
759
+ res.status(400).json({ error: "toPath is required." });
760
+ return;
761
+ }
762
+ const fromResolved = resolveRelativePath(rootPath, fromPath);
763
+ const toResolved = resolveRelativePath(rootPath, toPath);
764
+ if (!fromResolved || !toResolved) {
765
+ res.status(400).json({ error: "Invalid path." });
766
+ return;
767
+ }
768
+ if (fromResolved.relative === toResolved.relative) {
769
+ res.json({ ok: true, fromPath, toPath });
770
+ return;
771
+ }
772
+ try {
773
+ await runSessionCommand(
774
+ session,
775
+ "/bin/mkdir",
776
+ ["-p", path.dirname(toResolved.absPath)],
777
+ { cwd: rootPath }
778
+ );
779
+ await runSessionCommand(
780
+ session,
781
+ "/bin/mv",
782
+ ["-f", fromResolved.absPath, toResolved.absPath],
783
+ { cwd: rootPath }
784
+ );
785
+ res.json({ ok: true, fromPath, toPath });
786
+ } catch (error) {
787
+ console.error("Failed to rename path:", {
788
+ sessionId,
789
+ worktreeId: req.params.worktreeId,
790
+ fromPath,
791
+ toPath,
792
+ error: error?.message || error,
793
+ });
794
+ res.status(500).json({ error: "Failed to rename path." });
795
+ }
796
+ });
797
+
798
+ router.post("/sessions/:sessionId/worktrees/:worktreeId/file/delete", async (req, res) => {
799
+ const sessionId = req.params.sessionId;
800
+ const session = await getSession(sessionId, req.workspaceId);
801
+ if (!session) {
802
+ res.status(400).json({ error: "Invalid session." });
803
+ return;
804
+ }
805
+ await touchSession(session);
806
+ const { rootPath } = await resolveWorktreeRoot(session, req.params.worktreeId);
807
+ if (!rootPath) {
808
+ res.status(404).json({ error: "Worktree not found." });
809
+ return;
810
+ }
811
+ const requestedPath = req.body?.path;
812
+ if (!requestedPath || typeof requestedPath !== "string") {
813
+ res.status(400).json({ error: "Path is required." });
814
+ return;
815
+ }
816
+ const resolved = resolveRelativePath(rootPath, requestedPath);
817
+ if (!resolved) {
818
+ res.status(400).json({ error: "Invalid path." });
819
+ return;
820
+ }
821
+ try {
822
+ await runSessionCommand(
823
+ session,
824
+ "/bin/rm",
825
+ ["-rf", resolved.absPath],
826
+ { cwd: rootPath }
827
+ );
828
+ res.json({ ok: true, path: requestedPath });
829
+ } catch (error) {
830
+ console.error("Failed to delete path:", {
831
+ sessionId,
832
+ worktreeId: req.params.worktreeId,
833
+ path: requestedPath,
834
+ error: error?.message || error,
835
+ });
836
+ res.status(500).json({ error: "Failed to delete path." });
837
+ }
838
+ });
839
+
840
+ router.get("/sessions/:sessionId/worktrees/:worktreeId/status", async (req, res) => {
841
+ const sessionId = req.params.sessionId;
842
+ const session = await getSession(sessionId, req.workspaceId);
843
+ if (!session) {
844
+ res.status(400).json({ error: "Invalid session." });
845
+ return;
846
+ }
847
+ await touchSession(session);
848
+ const { rootPath } = await resolveWorktreeRoot(session, req.params.worktreeId);
849
+ if (!rootPath) {
850
+ res.status(404).json({ error: "Worktree not found." });
851
+ return;
852
+ }
853
+ try {
854
+ const output = await runSessionCommandOutput(session, "git", ["status", "--porcelain"], {
855
+ cwd: rootPath,
856
+ });
857
+ const entries = output
858
+ .split(/\r?\n/)
859
+ .map((line) => line.trimEnd())
860
+ .filter(Boolean)
861
+ .map((line) => {
862
+ const isUntracked = line.startsWith("??");
863
+ let rawPath = line.slice(3);
864
+ if (rawPath.includes(" -> ")) {
865
+ rawPath = rawPath.split(" -> ").pop();
866
+ }
867
+ if (rawPath.startsWith("\"") && rawPath.endsWith("\"")) {
868
+ rawPath = rawPath
869
+ .slice(1, -1)
870
+ .replace(/\\"/g, "\"")
871
+ .replace(/\\\\/g, "\\");
872
+ }
873
+ return {
874
+ path: rawPath,
875
+ type: isUntracked ? "untracked" : "modified",
876
+ };
877
+ });
878
+ res.json({ entries });
879
+ } catch (error) {
880
+ console.error("Failed to read worktree status:", {
881
+ sessionId,
882
+ worktreeId: req.params.worktreeId,
883
+ error: error?.message || error,
884
+ });
885
+ res.status(500).json({ error: "Failed to read worktree status." });
886
+ }
887
+ });
888
+
889
+ router.delete("/sessions/:sessionId/worktrees/:worktreeId", async (req, res) => {
890
+ const sessionId = req.params.sessionId;
891
+ const session = await getSession(sessionId, req.workspaceId);
892
+ if (!session) {
893
+ res.status(400).json({ error: "Invalid session." });
894
+ return;
895
+ }
896
+ await touchSession(session);
897
+
898
+ try {
899
+ await removeWorktree(session, req.params.worktreeId);
900
+ broadcastToSession(sessionId, {
901
+ type: "worktree_removed",
902
+ worktreeId: req.params.worktreeId,
903
+ });
904
+ res.json({ ok: true });
905
+ } catch (error) {
906
+ console.error("Failed to remove worktree:", {
907
+ sessionId,
908
+ worktreeId: req.params.worktreeId,
909
+ error: error?.message || error,
910
+ });
911
+ res.status(500).json({ error: "Failed to remove worktree." });
912
+ }
913
+ });
914
+
915
+ router.patch("/sessions/:sessionId/worktrees/:worktreeId", async (req, res) => {
916
+ const sessionId = req.params.sessionId;
917
+ const session = await getSession(sessionId, req.workspaceId);
918
+ if (!session) {
919
+ res.status(400).json({ error: "Invalid session." });
920
+ return;
921
+ }
922
+ await touchSession(session);
923
+
924
+ const worktree = await getWorktree(session, req.params.worktreeId);
925
+ if (!worktree) {
926
+ res.status(404).json({ error: "Worktree not found." });
927
+ return;
928
+ }
929
+
930
+ if (req.body?.name) {
931
+ await renameWorktree(session, req.params.worktreeId, req.body.name);
932
+ broadcastToSession(sessionId, {
933
+ type: "worktree_renamed",
934
+ worktreeId: req.params.worktreeId,
935
+ name: req.body.name,
936
+ });
937
+ }
938
+
939
+ res.json({
940
+ id: worktree.id,
941
+ name: worktree.name,
942
+ branchName: worktree.branchName,
943
+ status: worktree.status,
944
+ });
945
+ });
946
+
947
+ router.get("/sessions/:sessionId/worktrees/:worktreeId/diff", async (req, res) => {
948
+ const sessionId = req.params.sessionId;
949
+ const session = await getSession(sessionId, req.workspaceId);
950
+ if (!session) {
951
+ res.status(400).json({ error: "Invalid session." });
952
+ return;
953
+ }
954
+ await touchSession(session);
955
+
956
+ try {
957
+ const diff = await getWorktreeDiff(session, req.params.worktreeId);
958
+ res.json(diff);
959
+ } catch (error) {
960
+ console.error("Failed to get worktree diff:", {
961
+ sessionId,
962
+ worktreeId: req.params.worktreeId,
963
+ error: error?.message || error,
964
+ });
965
+ res.status(500).json({ error: "Failed to get worktree diff." });
966
+ }
967
+ });
968
+
969
+ router.get("/sessions/:sessionId/worktrees/:worktreeId/commits", async (req, res) => {
970
+ const sessionId = req.params.sessionId;
971
+ const session = await getSession(sessionId, req.workspaceId);
972
+ if (!session) {
973
+ res.status(400).json({ error: "Invalid session." });
974
+ return;
975
+ }
976
+ await touchSession(session);
977
+
978
+ try {
979
+ const limit = parseInt(req.query.limit, 10) || 20;
980
+ const commits = await getWorktreeCommits(session, req.params.worktreeId, limit);
981
+ res.json({ commits });
982
+ } catch (error) {
983
+ console.error("Failed to get worktree commits:", {
984
+ sessionId,
985
+ worktreeId: req.params.worktreeId,
986
+ error: error?.message || error,
987
+ });
988
+ res.status(500).json({ error: "Failed to get worktree commits." });
989
+ }
990
+ });
991
+
992
+ return router;
993
+ }