@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,1641 @@
1
+ import express from "express";
2
+ import http from "http";
3
+ import path from "path";
4
+ import fs from "fs";
5
+ import { fileURLToPath } from "url";
6
+ import { WebSocketServer } from "ws";
7
+ import * as pty from "node-pty";
8
+ import rateLimit from "express-rate-limit";
9
+ import storage from "./storage/index.js";
10
+ import {
11
+ getSessionRuntime,
12
+ listSessionRuntimeEntries,
13
+ } from "./runtimeStore.js";
14
+ import {
15
+ buildSandboxArgs,
16
+ runAsCommand,
17
+ } from "./runAs.js";
18
+ import {
19
+ getOrCreateClient,
20
+ getActiveClient,
21
+ isValidProvider,
22
+ createWorktreeClient,
23
+ } from "./clientFactory.js";
24
+ import {
25
+ listStoredWorktrees,
26
+ getWorktree,
27
+ getMainWorktreeStorageId,
28
+ updateWorktreeStatus,
29
+ updateWorktreeModel,
30
+ appendWorktreeMessage,
31
+ } from "./worktreeManager.js";
32
+ import {
33
+ attachCodexEvents,
34
+ attachClaudeEvents as attachClaudeEventsImpl,
35
+ } from "./clientEvents.js";
36
+ import { createMessageId, getSessionTmpDir } from "./helpers.js";
37
+ import { verifyWorkspaceToken } from "./middleware/auth.js";
38
+ import { authMiddleware } from "./middleware/auth.js";
39
+ import { errorTypesMiddleware } from "./middleware/errorTypes.js";
40
+ import { attachWebSocketDebug, debugMiddleware, debugApiWsLog } from "./middleware/debug.js";
41
+ import {
42
+ cleanupHandoffTokens,
43
+ createMonoAuthToken,
44
+ cleanupMonoAuthTokens,
45
+ } from "./services/auth.js";
46
+ import {
47
+ ensureDefaultMonoWorkspace,
48
+ isMonoUser,
49
+ getWorkspacePaths,
50
+ getWorkspaceSshPaths,
51
+ ensureWorkspaceUserExists,
52
+ } from "./services/workspace.js";
53
+ import {
54
+ getSession,
55
+ touchSession,
56
+ getSessionFromRequest,
57
+ createSession,
58
+ cleanupSession,
59
+ runSessionGc,
60
+ broadcastToSession,
61
+ broadcastRepoDiff,
62
+ broadcastWorktreeDiff,
63
+ appendMainMessage,
64
+ getWorktreeMessages,
65
+ appendRpcLog,
66
+ getProviderLabel,
67
+ resolveDefaultDenyGitCredentialsAccess,
68
+ sessionGcIntervalMs,
69
+ updateWorktreeThreadId,
70
+ runSessionCommandOutputWithStatus,
71
+ } from "./services/session.js";
72
+ import healthRoutes from "./routes/health.js";
73
+ import workspaceRoutes from "./routes/workspaces.js";
74
+ import sessionRoutes from "./routes/sessions.js";
75
+ import gitRoutes from "./routes/git.js";
76
+ import worktreeRoutes from "./routes/worktrees.js";
77
+ import fileRoutes from "./routes/files.js";
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // App + server setup
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const __filename = fileURLToPath(import.meta.url);
84
+ const __dirname = path.dirname(__filename);
85
+ const app = express();
86
+ const server = http.createServer(app);
87
+ const wss = new WebSocketServer({ noServer: true });
88
+ const trustProxySetting = process.env.TRUST_PROXY;
89
+ if (trustProxySetting !== undefined) {
90
+ const normalized = trustProxySetting.trim().toLowerCase();
91
+ if (normalized === "true") {
92
+ app.set("trust proxy", true);
93
+ } else if (normalized === "false") {
94
+ app.set("trust proxy", false);
95
+ } else {
96
+ const numeric = Number(trustProxySetting);
97
+ if (!Number.isNaN(numeric)) {
98
+ app.set("trust proxy", numeric);
99
+ } else {
100
+ app.set("trust proxy", trustProxySetting);
101
+ }
102
+ }
103
+ }
104
+ const terminalEnabled = !/^(0|false|no|off)$/i.test(
105
+ process.env.TERMINAL_ENABLED || ""
106
+ );
107
+ const allowRunSlashCommand = !/^(0|false|no|off)$/i.test(
108
+ process.env.ALLOW_RUN_SLASH_COMMAND || ""
109
+ );
110
+ const allowGitSlashCommand = !/^(0|false|no|off)$/i.test(
111
+ process.env.ALLOW_GIT_SLASH_COMMAND || ""
112
+ );
113
+ const codexIdleTtlSeconds = Number.parseInt(
114
+ process.env.CODEX_IDLE_TTL_SECONDS || "300",
115
+ 10
116
+ );
117
+ const codexIdleGcIntervalSeconds = Number.parseInt(
118
+ process.env.CODEX_IDLE_GC_INTERVAL_SECONDS || "60",
119
+ 10
120
+ );
121
+ const worktreeStatusIntervalMs = 10 * 1000;
122
+ const terminalWss = terminalEnabled ? new WebSocketServer({ noServer: true }) : null;
123
+
124
+ const wsErrorPayload = ({
125
+ message,
126
+ errorCode = null,
127
+ recoverable = null,
128
+ details = null,
129
+ }) => {
130
+ const payload = { type: "error", message };
131
+ if (errorCode) payload.error_code = errorCode;
132
+ if (recoverable != null) payload.recoverable = recoverable;
133
+ if (details) payload.details = details;
134
+ return payload;
135
+ };
136
+
137
+ const resolveWorkspaceTokenErrorCode = (error) => {
138
+ const name = error?.name || "";
139
+ const message = String(error?.message || "").toLowerCase();
140
+ if (name === "TokenExpiredError" || message.includes("jwt expired")) {
141
+ return "WORKSPACE_TOKEN_EXPIRED";
142
+ }
143
+ return "WORKSPACE_TOKEN_INVALID";
144
+ };
145
+
146
+ const deploymentMode = process.env.DEPLOYMENT_MODE || "mono_user";
147
+ if (deploymentMode !== "mono_user" && deploymentMode !== "multi_user") {
148
+ console.error(`Invalid DEPLOYMENT_MODE: ${deploymentMode}. Use mono_user or multi_user.`);
149
+ process.exit(1);
150
+ }
151
+
152
+ const runAsHelperPath = process.env.VIBE80_RUN_AS_HELPER || "/usr/local/bin/vibe80-run-as";
153
+ const sudoPath = process.env.VIBE80_SUDO_PATH || "sudo";
154
+
155
+ await storage.init();
156
+ await ensureDefaultMonoWorkspace();
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Middleware pipeline
160
+ // ---------------------------------------------------------------------------
161
+
162
+ const apiLimiter = rateLimit({
163
+ windowMs: 60 * 1000,
164
+ max: 100,
165
+ standardHeaders: true,
166
+ legacyHeaders: false,
167
+ });
168
+
169
+ const authLimiter = rateLimit({
170
+ windowMs: 60 * 1000,
171
+ max: 10,
172
+ standardHeaders: true,
173
+ legacyHeaders: false,
174
+ });
175
+
176
+ const createLimiter = rateLimit({
177
+ windowMs: 60 * 1000,
178
+ max: 20,
179
+ standardHeaders: true,
180
+ legacyHeaders: false,
181
+ });
182
+
183
+ app.use(express.json({ limit: "10mb" }));
184
+ app.use(errorTypesMiddleware);
185
+ app.use(debugMiddleware);
186
+ app.use("/api/v1", apiLimiter);
187
+ app.post("/api/v1/workspaces/login", authLimiter);
188
+ app.post("/api/v1/workspaces/refresh", authLimiter);
189
+ app.post("/api/v1/workspaces", createLimiter);
190
+ app.post("/api/v1/sessions", createLimiter);
191
+ app.use("/api/v1", authMiddleware);
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Route mounting
195
+ // ---------------------------------------------------------------------------
196
+
197
+ const routeDeps = {
198
+ getOrCreateClient,
199
+ getActiveClient,
200
+ attachClientEvents,
201
+ attachClaudeEvents,
202
+ attachClientEventsForWorktree,
203
+ attachClaudeEventsForWorktree,
204
+ deploymentMode,
205
+ debugApiWsLog,
206
+ };
207
+
208
+ app.use("/api/v1", healthRoutes({
209
+ getSession,
210
+ touchSession,
211
+ getActiveClient,
212
+ deploymentMode,
213
+ debugApiWsLog,
214
+ }));
215
+ app.use("/api/v1", workspaceRoutes());
216
+ app.use("/api/v1", sessionRoutes(routeDeps));
217
+ app.use("/api/v1", gitRoutes(routeDeps));
218
+ app.use("/api/v1", worktreeRoutes(routeDeps));
219
+ app.use("/api/v1", fileRoutes());
220
+
221
+ // Attachment error handler
222
+ app.use((err, req, res, next) => {
223
+ if (req.path.startsWith("/api/attachments")) {
224
+ res.status(400).json({ error: err.message || "Attachment error." });
225
+ return;
226
+ }
227
+ next(err);
228
+ });
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Event attachment helpers (delegates to clientEvents.js)
232
+ // ---------------------------------------------------------------------------
233
+
234
+ function unifiedAppendMessage(session, worktreeId, message) {
235
+ return appendWorktreeMessage(session, worktreeId || "main", message);
236
+ }
237
+
238
+ function unifiedBroadcastDiff(sessionId, worktreeId) {
239
+ if (!worktreeId || worktreeId === "main") {
240
+ return broadcastRepoDiff(sessionId);
241
+ }
242
+ return broadcastWorktreeDiff(sessionId, worktreeId);
243
+ }
244
+
245
+ function buildEventDeps() {
246
+ return {
247
+ getSession,
248
+ broadcastToSession,
249
+ appendMessage: unifiedAppendMessage,
250
+ broadcastDiff: unifiedBroadcastDiff,
251
+ updateWorktreeStatus,
252
+ updateWorktreeThreadId,
253
+ appendRpcLog,
254
+ getProviderLabel,
255
+ storage,
256
+ debugApiWsLog,
257
+ };
258
+ }
259
+
260
+ function attachClientEvents(sessionId, client, provider) {
261
+ attachCodexEvents(
262
+ { sessionId, worktreeId: null, provider, client },
263
+ buildEventDeps()
264
+ );
265
+ }
266
+
267
+ function attachClientEventsForWorktree(sessionId, worktree) {
268
+ attachCodexEvents(
269
+ {
270
+ sessionId,
271
+ worktreeId: worktree.id,
272
+ provider: worktree.provider,
273
+ client: worktree.client,
274
+ },
275
+ buildEventDeps()
276
+ );
277
+ }
278
+
279
+ function attachClaudeEvents(sessionId, client, provider) {
280
+ attachClaudeEventsImpl(
281
+ { sessionId, worktreeId: null, provider, client },
282
+ buildEventDeps()
283
+ );
284
+ }
285
+
286
+ function attachClaudeEventsForWorktree(sessionId, worktree) {
287
+ attachClaudeEventsImpl(
288
+ {
289
+ sessionId,
290
+ worktreeId: worktree.id,
291
+ provider: worktree.provider,
292
+ client: worktree.client,
293
+ },
294
+ buildEventDeps()
295
+ );
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Ensure worktree clients on reconnect
300
+ // ---------------------------------------------------------------------------
301
+
302
+ const ensureClaudeWorktreeClients = async (session) => {
303
+ const runtime = getSessionRuntime(session.sessionId);
304
+ if (!runtime) return;
305
+ const worktrees = await listStoredWorktrees(session);
306
+ const claudeWorktrees = worktrees.filter((wt) => wt?.provider === "claude");
307
+ if (!claudeWorktrees.length) return;
308
+ await Promise.all(
309
+ claudeWorktrees.map(async (worktree) => {
310
+ let client = runtime.worktreeClients.get(worktree.id);
311
+ if (client?.ready) return;
312
+ if (!client) {
313
+ client = createWorktreeClient(
314
+ worktree,
315
+ session.attachmentsDir,
316
+ session.repoDir,
317
+ worktree.internetAccess,
318
+ worktree.threadId,
319
+ session.gitDir || path.join(session.dir, "git")
320
+ );
321
+ runtime.worktreeClients.set(worktree.id, client);
322
+ }
323
+ worktree.client = client;
324
+ if (!client.listenerCount("ready")) {
325
+ attachClaudeEventsForWorktree(session.sessionId, worktree);
326
+ }
327
+ if (!client.ready) {
328
+ try {
329
+ await client.start();
330
+ } catch (error) {
331
+ console.error("Failed to start Claude worktree client:", error);
332
+ void updateWorktreeStatus(session, worktree.id, "error");
333
+ broadcastToSession(session.sessionId, {
334
+ type: "worktree_status",
335
+ worktreeId: worktree.id,
336
+ status: "error",
337
+ error: error?.message || "Claude CLI failed to start.",
338
+ });
339
+ }
340
+ }
341
+ })
342
+ );
343
+ };
344
+
345
+ const ensureCodexWorktreeClients = async (session) => {
346
+ const runtime = getSessionRuntime(session.sessionId);
347
+ if (!runtime) return;
348
+ const worktrees = await listStoredWorktrees(session);
349
+ const codexWorktrees = worktrees.filter((wt) => wt?.provider === "codex");
350
+ if (!codexWorktrees.length) return;
351
+ await Promise.all(
352
+ codexWorktrees.map(async (worktree) => {
353
+ let client = runtime.worktreeClients.get(worktree.id);
354
+ const procExited = client?.proc && client.proc.exitCode != null;
355
+ if (procExited) {
356
+ runtime.worktreeClients.delete(worktree.id);
357
+ client = null;
358
+ }
359
+ if (!client) {
360
+ client = createWorktreeClient(
361
+ worktree,
362
+ session.attachmentsDir,
363
+ session.repoDir,
364
+ worktree.internetAccess,
365
+ worktree.threadId,
366
+ session.gitDir || path.join(session.dir, "git")
367
+ );
368
+ runtime.worktreeClients.set(worktree.id, client);
369
+ }
370
+ worktree.client = client;
371
+ if (!client.listenerCount("ready")) {
372
+ attachClientEventsForWorktree(session.sessionId, worktree);
373
+ }
374
+ if (!client.ready && !client.proc) {
375
+ try {
376
+ await client.start();
377
+ } catch (error) {
378
+ console.error("Failed to start Codex worktree client:", error);
379
+ void updateWorktreeStatus(session, worktree.id, "error");
380
+ broadcastToSession(session.sessionId, {
381
+ type: "worktree_status",
382
+ worktreeId: worktree.id,
383
+ status: "error",
384
+ error: error?.message || "Codex app-server failed to start.",
385
+ });
386
+ }
387
+ }
388
+ })
389
+ );
390
+ };
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Chat WebSocket
394
+ // ---------------------------------------------------------------------------
395
+
396
+ wss.on("connection", (socket, req) => {
397
+ void (async () => {
398
+ attachWebSocketDebug(socket, req, "chat");
399
+ const url = new URL(req.url, `http://${req.headers.host}`);
400
+ const sessionId = url.searchParams.get("session");
401
+ if (!sessionId) {
402
+ socket.send(JSON.stringify({ type: "error", message: "Missing session id." }));
403
+ socket.close();
404
+ return;
405
+ }
406
+
407
+ let workspaceId = null;
408
+ let runtime = null;
409
+ let authenticated = false;
410
+ let authTimeout = null;
411
+
412
+ const clearAuthTimeout = () => {
413
+ if (authTimeout) {
414
+ clearTimeout(authTimeout);
415
+ authTimeout = null;
416
+ }
417
+ };
418
+
419
+ const handleChatMessage = async (data) => {
420
+ const session = await getSession(sessionId, workspaceId);
421
+ if (!session) {
422
+ socket.send(JSON.stringify({ type: "error", message: "Unknown session." }));
423
+ return;
424
+ }
425
+ await touchSession(session);
426
+ let payload;
427
+ try {
428
+ payload = JSON.parse(data.toString());
429
+ } catch (error) {
430
+ socket.send(JSON.stringify({ type: "error", message: "Invalid JSON message." }));
431
+ return;
432
+ }
433
+
434
+ if (payload.type === "ping") {
435
+ socket.send(JSON.stringify({ type: "pong" }));
436
+ return;
437
+ }
438
+
439
+ if (payload.type === "action_request") {
440
+ const requestType = typeof payload.request === "string" ? payload.request : "";
441
+ const arg = typeof payload.arg === "string" ? payload.arg.trim() : "";
442
+ const worktreeId = payload.worktreeId || "main";
443
+ if (!requestType || !arg) {
444
+ socket.send(JSON.stringify({ type: "error", message: "Invalid action request." }));
445
+ return;
446
+ }
447
+ if (requestType !== "run" && requestType !== "git") {
448
+ socket.send(JSON.stringify({ type: "error", message: "Unsupported action request." }));
449
+ return;
450
+ }
451
+ if (requestType === "run" && !allowRunSlashCommand) {
452
+ socket.send(JSON.stringify({ type: "error", message: "Run command disabled." }));
453
+ return;
454
+ }
455
+ if (requestType === "git" && !allowGitSlashCommand) {
456
+ socket.send(JSON.stringify({ type: "error", message: "Git command disabled." }));
457
+ return;
458
+ }
459
+ const worktree =
460
+ worktreeId !== "main" ? await getWorktree(session, worktreeId) : null;
461
+ if (worktreeId !== "main" && !worktree) {
462
+ socket.send(JSON.stringify({ type: "error", message: "Worktree not found." }));
463
+ return;
464
+ }
465
+ try {
466
+ const requestMessageId = createMessageId();
467
+ const resultMessageId = createMessageId();
468
+ const actionText = `/${requestType} ${arg}`.trim();
469
+ const actionMessage = {
470
+ id: requestMessageId,
471
+ role: "user",
472
+ type: "action_request",
473
+ text: actionText,
474
+ action: {
475
+ request: requestType,
476
+ arg,
477
+ },
478
+ };
479
+ await appendWorktreeMessage(session, worktreeId, actionMessage);
480
+ const requestPayload = {
481
+ type: "action_request",
482
+ id: requestMessageId,
483
+ request: requestType,
484
+ arg,
485
+ text: actionText,
486
+ worktreeId,
487
+ };
488
+ broadcastToSession(session.sessionId, requestPayload);
489
+
490
+ const cwd = worktree?.path || session.repoDir;
491
+ const denyGitCreds = typeof worktree?.denyGitCredentialsAccess === "boolean"
492
+ ? worktree.denyGitCredentialsAccess
493
+ : resolveDefaultDenyGitCredentialsAccess(session);
494
+ const allowGitCreds = requestType === "git" ? true : !denyGitCreds;
495
+ const gitDir = session.gitDir || path.join(session.dir, "git");
496
+ const sshDir = getWorkspaceSshPaths(getWorkspacePaths(session.workspaceId).homeDir).sshDir;
497
+ const extraAllowRw = [
498
+ session.repoDir,
499
+ worktree?.path,
500
+ ...(allowGitCreds ? [gitDir, sshDir] : []),
501
+ ].filter(Boolean);
502
+ const { output, code } = await runSessionCommandOutputWithStatus(
503
+ session,
504
+ "/bin/bash",
505
+ ["-lc", requestType === "run" ? arg: "git " + arg],
506
+ {
507
+ cwd,
508
+ sandbox: true,
509
+ repoDir: cwd,
510
+ workspaceId: session.workspaceId,
511
+ tmpDir: getSessionTmpDir(session.dir),
512
+ attachmentsDir: session.attachmentsDir,
513
+ netMode: requestType === "git" ? "tcp:22,53,443" : "none",
514
+ extraAllowRw,
515
+ }
516
+ );
517
+ const trimmedOutput = (output || "").trim();
518
+ const resultText = `\`\`\`\n${trimmedOutput}${trimmedOutput ? "\n" : ""}\`\`\``;
519
+ const status = code === 0 ? "success" : "error";
520
+ const resultMessage = {
521
+ id: resultMessageId,
522
+ role: "assistant",
523
+ type: "action_result",
524
+ text: resultText,
525
+ action: {
526
+ request: requestType,
527
+ arg,
528
+ status,
529
+ output: output || "",
530
+ requestMessageId,
531
+ },
532
+ };
533
+ await appendWorktreeMessage(session, worktreeId, resultMessage);
534
+ const resultPayload = {
535
+ type: "action_result",
536
+ id: resultMessageId,
537
+ request: requestType,
538
+ arg,
539
+ status,
540
+ output: output || "",
541
+ text: resultText,
542
+ requestMessageId,
543
+ worktreeId,
544
+ };
545
+ broadcastToSession(session.sessionId, resultPayload);
546
+ } catch (error) {
547
+ socket.send(
548
+ JSON.stringify({
549
+ type: "error",
550
+ message: error.message || "Failed to execute action.",
551
+ })
552
+ );
553
+ }
554
+ return;
555
+ }
556
+
557
+ if (payload.type === "wake_up") {
558
+ const worktreeId = payload.worktreeId || "main";
559
+ if (worktreeId === "main") {
560
+ if (session.activeProvider !== "codex") {
561
+ return;
562
+ }
563
+ try {
564
+ let client = await getOrCreateClient(session, "codex");
565
+ if (!client.listenerCount("ready")) {
566
+ attachClientEvents(sessionId, client, "codex");
567
+ }
568
+ const procExited = client?.proc && client.proc.exitCode != null;
569
+ if (procExited && runtime?.clients?.codex) {
570
+ delete runtime.clients.codex;
571
+ client = await getOrCreateClient(session, "codex");
572
+ if (!client.listenerCount("ready")) {
573
+ attachClientEvents(sessionId, client, "codex");
574
+ }
575
+ }
576
+ if (!client.ready && !client.proc) {
577
+ await client.start();
578
+ }
579
+ if (typeof client.markActive === "function") {
580
+ client.markActive();
581
+ }
582
+ } catch (error) {
583
+ socket.send(
584
+ JSON.stringify({
585
+ type: "error",
586
+ message: error.message || "Failed to wake Codex client.",
587
+ })
588
+ );
589
+ }
590
+ return;
591
+ }
592
+
593
+ const worktree = await getWorktree(session, worktreeId);
594
+ if (!worktree) {
595
+ socket.send(JSON.stringify({ type: "error", message: "Worktree not found." }));
596
+ return;
597
+ }
598
+ if (worktree.provider !== "codex") {
599
+ return;
600
+ }
601
+ try {
602
+ let client = runtime?.worktreeClients?.get(worktreeId) || null;
603
+ const procExited = client?.proc && client.proc.exitCode != null;
604
+ if (procExited && runtime?.worktreeClients) {
605
+ runtime.worktreeClients.delete(worktreeId);
606
+ client = null;
607
+ }
608
+ if (!client) {
609
+ client = createWorktreeClient(
610
+ worktree,
611
+ session.attachmentsDir,
612
+ session.repoDir,
613
+ worktree.internetAccess,
614
+ worktree.threadId,
615
+ session.gitDir || path.join(session.dir, "git")
616
+ );
617
+ runtime?.worktreeClients?.set(worktreeId, client);
618
+ }
619
+ worktree.client = client;
620
+ if (!client.listenerCount("ready")) {
621
+ attachClientEventsForWorktree(sessionId, worktree);
622
+ }
623
+ if (!client.ready && !client.proc) {
624
+ await client.start();
625
+ }
626
+ if (typeof client.markActive === "function") {
627
+ client.markActive();
628
+ }
629
+ } catch (error) {
630
+ socket.send(
631
+ JSON.stringify({
632
+ type: "error",
633
+ message: error.message || "Failed to wake Codex worktree.",
634
+ })
635
+ );
636
+ }
637
+ return;
638
+ }
639
+
640
+ // ============== Worktree WebSocket Handlers ==============
641
+
642
+ if (payload.type === "worktree_send_message") {
643
+ const worktreeId = payload.worktreeId;
644
+ if (!worktreeId) {
645
+ socket.send(JSON.stringify({ type: "error", message: "worktreeId is required." }));
646
+ return;
647
+ }
648
+ const worktree = await getWorktree(session, worktreeId);
649
+ if (!worktree) {
650
+ socket.send(JSON.stringify({ type: "error", message: "Worktree not found." }));
651
+ return;
652
+ }
653
+ if (worktree.status === "stopped") {
654
+ socket.send(
655
+ JSON.stringify({
656
+ type: "error",
657
+ message: "Worktree is stopped. Wake it up before sending a message.",
658
+ })
659
+ );
660
+ return;
661
+ }
662
+ const isMainWorktree = worktreeId === "main";
663
+ const client = isMainWorktree
664
+ ? getActiveClient(session)
665
+ : runtime.worktreeClients.get(worktreeId);
666
+ if (!client?.ready) {
667
+ const label = isMainWorktree
668
+ ? getProviderLabel(session)
669
+ : (worktree.provider === "claude" ? "Claude CLI" : "Codex app-server");
670
+ socket.send(
671
+ JSON.stringify({
672
+ type: "error",
673
+ message: `${label} not ready for worktree.`,
674
+ })
675
+ );
676
+ return;
677
+ }
678
+ try {
679
+ if (worktree.provider === "claude") {
680
+ await updateWorktreeStatus(session, worktreeId, "processing");
681
+ broadcastToSession(sessionId, {
682
+ type: "worktree_status",
683
+ worktreeId,
684
+ status: "processing",
685
+ });
686
+ }
687
+ const result = await client.sendTurn(payload.text);
688
+ await appendWorktreeMessage(session, worktreeId, {
689
+ id: createMessageId(),
690
+ role: "user",
691
+ text: payload.displayText || payload.text,
692
+ attachments: Array.isArray(payload.attachments) ? payload.attachments : [],
693
+ provider: worktree.provider,
694
+ });
695
+
696
+ if (worktree.provider === "claude") {
697
+ const turnId = result?.turn?.id;
698
+ const onDone = async (status) => {
699
+ const newStatus = status === "success" ? "ready" : "error";
700
+ await updateWorktreeStatus(session, worktreeId, newStatus);
701
+ broadcastToSession(sessionId, {
702
+ type: "worktree_status",
703
+ worktreeId,
704
+ status: newStatus,
705
+ });
706
+ };
707
+ const onceCompleted = ({ turnId: completedId, status }) => {
708
+ if (!turnId || !completedId || turnId === completedId) {
709
+ client.off("turn_error", onceError);
710
+ onDone(status || "success").catch(() => null);
711
+ }
712
+ };
713
+ const onceError = ({ turnId: errorId }) => {
714
+ if (!turnId || !errorId || turnId === errorId) {
715
+ client.off("turn_completed", onceCompleted);
716
+ onDone("error").catch(() => null);
717
+ }
718
+ };
719
+ client.once("turn_completed", onceCompleted);
720
+ client.once("turn_error", onceError);
721
+ }
722
+
723
+ const turnPayload = {
724
+ type: "turn_started",
725
+ turnId: result.turn.id,
726
+ threadId: client.threadId,
727
+ provider: worktree.provider,
728
+ status: "processing",
729
+ worktreeId,
730
+ };
731
+ socket.send(JSON.stringify(turnPayload));
732
+ } catch (error) {
733
+ socket.send(
734
+ JSON.stringify({
735
+ type: "error",
736
+ message: error.message || "Failed to send worktree message.",
737
+ })
738
+ );
739
+ }
740
+ return;
741
+ }
742
+
743
+ if (payload.type === "worktree_messages_sync") {
744
+ const worktreeId = payload.worktreeId;
745
+ if (!worktreeId) {
746
+ socket.send(JSON.stringify({ type: "error", message: "worktreeId is required." }));
747
+ return;
748
+ }
749
+ const worktree = await getWorktree(session, worktreeId);
750
+ if (!worktree) {
751
+ socket.send(JSON.stringify({ type: "error", message: "Worktree not found." }));
752
+ return;
753
+ }
754
+ const requestedLimit = Number.parseInt(payload?.limit, 10);
755
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
756
+ ? requestedLimit
757
+ : 50;
758
+ const messages = await getWorktreeMessages(session, worktreeId, {
759
+ limit,
760
+ beforeMessageId: payload.lastSeenMessageId || null,
761
+ });
762
+ const status = worktree.status || "idle";
763
+
764
+ socket.send(
765
+ JSON.stringify({
766
+ type: "worktree_messages_sync",
767
+ worktreeId,
768
+ messages,
769
+ status,
770
+ })
771
+ );
772
+ return;
773
+ }
774
+
775
+ // ============== End Worktree WebSocket Handlers ==============
776
+
777
+ if (payload.type === "turn_interrupt") {
778
+ const worktreeId = payload.worktreeId;
779
+ const client = worktreeId
780
+ ? runtime?.worktreeClients?.get(worktreeId)
781
+ : getActiveClient(session);
782
+ if (!client?.ready) {
783
+ socket.send(
784
+ JSON.stringify({
785
+ type: "error",
786
+ message: `${getProviderLabel(session)} not ready yet.`,
787
+ })
788
+ );
789
+ return;
790
+ }
791
+ try {
792
+ await client.interruptTurn(payload.turnId);
793
+ socket.send(JSON.stringify({ type: "turn_interrupt_sent" }));
794
+ } catch (error) {
795
+ socket.send(
796
+ JSON.stringify({
797
+ type: "error",
798
+ message: error.message || "Failed to interrupt turn.",
799
+ })
800
+ );
801
+ }
802
+ }
803
+
804
+ if (payload.type === "model_list") {
805
+ const worktreeId =
806
+ typeof payload.worktreeId === "string" && payload.worktreeId.trim()
807
+ ? payload.worktreeId.trim()
808
+ : "main";
809
+ const isMainWorktree = worktreeId === "main";
810
+ const worktree = isMainWorktree ? null : await getWorktree(session, worktreeId);
811
+ if (!isMainWorktree && !worktree) {
812
+ socket.send(JSON.stringify({ type: "error", message: "Worktree not found." }));
813
+ return;
814
+ }
815
+ const client = isMainWorktree
816
+ ? getActiveClient(session)
817
+ : runtime?.worktreeClients?.get(worktreeId);
818
+ if (!client?.ready) {
819
+ const providerLabel = isMainWorktree
820
+ ? getProviderLabel(session)
821
+ : (worktree.provider === "claude" ? "Claude CLI" : "Codex app-server");
822
+ socket.send(
823
+ JSON.stringify({
824
+ type: "error",
825
+ message: `${providerLabel} not ready yet.`,
826
+ })
827
+ );
828
+ return;
829
+ }
830
+ try {
831
+ let cursor = null;
832
+ const models = [];
833
+ do {
834
+ const result = await client.listModels(cursor, 200);
835
+ if (Array.isArray(result?.data)) {
836
+ models.push(...result.data);
837
+ }
838
+ cursor = result?.nextCursor ?? null;
839
+ } while (cursor);
840
+ socket.send(
841
+ JSON.stringify({
842
+ type: "model_list",
843
+ worktreeId,
844
+ models,
845
+ provider: isMainWorktree ? session.activeProvider : worktree.provider,
846
+ })
847
+ );
848
+ } catch (error) {
849
+ socket.send(
850
+ JSON.stringify({
851
+ type: "error",
852
+ message: error.message || "Failed to list models.",
853
+ })
854
+ );
855
+ }
856
+ }
857
+
858
+ if (payload.type === "model_set") {
859
+ const worktreeId =
860
+ typeof payload.worktreeId === "string" && payload.worktreeId.trim()
861
+ ? payload.worktreeId.trim()
862
+ : "main";
863
+ const isMainWorktree = worktreeId === "main";
864
+ const worktree = isMainWorktree ? null : await getWorktree(session, worktreeId);
865
+ if (!isMainWorktree && !worktree) {
866
+ socket.send(JSON.stringify({ type: "error", message: "Worktree not found." }));
867
+ return;
868
+ }
869
+ const client = isMainWorktree
870
+ ? getActiveClient(session)
871
+ : runtime?.worktreeClients?.get(worktreeId);
872
+ if (!client?.ready) {
873
+ const providerLabel = isMainWorktree
874
+ ? getProviderLabel(session)
875
+ : (worktree.provider === "claude" ? "Claude CLI" : "Codex app-server");
876
+ socket.send(
877
+ JSON.stringify({
878
+ type: "error",
879
+ message: `${providerLabel} not ready yet.`,
880
+ })
881
+ );
882
+ return;
883
+ }
884
+ try {
885
+ const model = payload.model || null;
886
+ const reasoningEffort = payload.reasoningEffort ?? null;
887
+ await client.setDefaultModel(
888
+ model,
889
+ reasoningEffort
890
+ );
891
+ await updateWorktreeModel(session, worktreeId, model, reasoningEffort);
892
+ socket.send(
893
+ JSON.stringify({
894
+ type: "model_set",
895
+ worktreeId,
896
+ model,
897
+ reasoningEffort,
898
+ provider: isMainWorktree ? session.activeProvider : worktree.provider,
899
+ })
900
+ );
901
+ } catch (error) {
902
+ socket.send(
903
+ JSON.stringify({
904
+ type: "error",
905
+ message: error.message || "Failed to set model.",
906
+ })
907
+ );
908
+ }
909
+ }
910
+
911
+ if (payload.type === "account_login_start") {
912
+ try {
913
+ const requestedProvider = isValidProvider(payload.provider)
914
+ ? payload.provider
915
+ : session.activeProvider || "codex";
916
+ if (
917
+ Array.isArray(session.providers) &&
918
+ session.providers.length &&
919
+ !session.providers.includes(requestedProvider)
920
+ ) {
921
+ socket.send(
922
+ JSON.stringify({
923
+ type: "account_login_error",
924
+ message: "Provider not enabled for this session.",
925
+ })
926
+ );
927
+ return;
928
+ }
929
+ const client = await getOrCreateClient(session, requestedProvider);
930
+ if (!client.ready) {
931
+ await client.start();
932
+ }
933
+ const result = await client.startAccountLogin(payload.params);
934
+ socket.send(
935
+ JSON.stringify({
936
+ type: "account_login_started",
937
+ result,
938
+ provider: requestedProvider,
939
+ })
940
+ );
941
+ } catch (error) {
942
+ socket.send(
943
+ JSON.stringify({
944
+ type: "account_login_error",
945
+ message: error.message || "Failed to start account login.",
946
+ })
947
+ );
948
+ }
949
+ }
950
+
951
+ if (payload.type === "switch_provider") {
952
+ const newProvider = payload.provider;
953
+ if (!isValidProvider(newProvider)) {
954
+ socket.send(
955
+ JSON.stringify({
956
+ type: "error",
957
+ message: "Invalid provider. Must be 'codex' or 'claude'.",
958
+ })
959
+ );
960
+ return;
961
+ }
962
+ if (
963
+ Array.isArray(session.providers) &&
964
+ session.providers.length &&
965
+ !session.providers.includes(newProvider)
966
+ ) {
967
+ socket.send(
968
+ JSON.stringify({
969
+ type: "error",
970
+ message: "Provider not enabled for this session.",
971
+ })
972
+ );
973
+ return;
974
+ }
975
+
976
+ if (session.activeProvider === newProvider) {
977
+ socket.send(
978
+ JSON.stringify({
979
+ type: "provider_switched",
980
+ provider: newProvider,
981
+ })
982
+ );
983
+ return;
984
+ }
985
+
986
+ try {
987
+ const newClient = await getOrCreateClient(session, newProvider);
988
+ if (!newClient.listenerCount("ready")) {
989
+ if (newProvider === "claude") {
990
+ attachClaudeEvents(sessionId, newClient, newProvider);
991
+ } else {
992
+ attachClientEvents(sessionId, newClient, newProvider);
993
+ }
994
+ }
995
+ if (!newClient.ready) {
996
+ await newClient.start();
997
+ }
998
+ session.activeProvider = newProvider;
999
+ await storage.saveSession(sessionId, {
1000
+ ...session,
1001
+ activeProvider: newProvider,
1002
+ lastActivityAt: Date.now(),
1003
+ });
1004
+
1005
+ let models = [];
1006
+ try {
1007
+ let cursor = null;
1008
+ do {
1009
+ const result = await newClient.listModels(cursor, 200);
1010
+ if (Array.isArray(result?.data)) {
1011
+ models.push(...result.data);
1012
+ }
1013
+ cursor = result?.nextCursor ?? null;
1014
+ } while (cursor);
1015
+ } catch {
1016
+ // Models fetch failed, continue without models
1017
+ }
1018
+
1019
+ broadcastToSession(sessionId, {
1020
+ type: "provider_switched",
1021
+ provider: newProvider,
1022
+ models,
1023
+ threadId: newClient.threadId || null,
1024
+ });
1025
+ } catch (error) {
1026
+ socket.send(
1027
+ JSON.stringify({
1028
+ type: "error",
1029
+ message: error.message || "Failed to switch provider.",
1030
+ })
1031
+ );
1032
+ }
1033
+ }
1034
+ };
1035
+
1036
+ socket.on("close", () => {
1037
+ if (runtime) {
1038
+ runtime.sockets.delete(socket);
1039
+ }
1040
+ clearAuthTimeout();
1041
+ });
1042
+
1043
+ authTimeout = setTimeout(() => {
1044
+ if (!authenticated) {
1045
+ socket.send(
1046
+ JSON.stringify(
1047
+ wsErrorPayload({
1048
+ message: "Auth timeout.",
1049
+ errorCode: "WORKSPACE_AUTH_REQUIRED",
1050
+ recoverable: true,
1051
+ })
1052
+ )
1053
+ );
1054
+ socket.close();
1055
+ }
1056
+ }, 5000);
1057
+
1058
+ socket.on("message", async function handleAuth(data) {
1059
+ let payload;
1060
+ try {
1061
+ payload = JSON.parse(data.toString());
1062
+ } catch {
1063
+ socket.send(JSON.stringify({ type: "error", message: "Invalid JSON message." }));
1064
+ return;
1065
+ }
1066
+ if (payload?.type !== "auth") {
1067
+ socket.send(
1068
+ JSON.stringify(
1069
+ wsErrorPayload({
1070
+ message: "Auth required.",
1071
+ errorCode: "WORKSPACE_AUTH_REQUIRED",
1072
+ recoverable: true,
1073
+ })
1074
+ )
1075
+ );
1076
+ return;
1077
+ }
1078
+ if (authenticated) {
1079
+ return;
1080
+ }
1081
+ const token = typeof payload?.token === "string" ? payload.token : "";
1082
+ if (!token) {
1083
+ socket.send(
1084
+ JSON.stringify(
1085
+ wsErrorPayload({
1086
+ message: "Missing workspace token.",
1087
+ errorCode: "WORKSPACE_TOKEN_MISSING",
1088
+ recoverable: false,
1089
+ })
1090
+ )
1091
+ );
1092
+ socket.close();
1093
+ return;
1094
+ }
1095
+ try {
1096
+ workspaceId = verifyWorkspaceToken(token);
1097
+ } catch (error) {
1098
+ socket.send(
1099
+ JSON.stringify(
1100
+ wsErrorPayload({
1101
+ message: "Invalid workspace token.",
1102
+ errorCode: resolveWorkspaceTokenErrorCode(error),
1103
+ recoverable: true,
1104
+ })
1105
+ )
1106
+ );
1107
+ socket.close();
1108
+ return;
1109
+ }
1110
+ const session = await getSession(sessionId, workspaceId);
1111
+ if (!session) {
1112
+ socket.send(JSON.stringify({ type: "error", message: "Unknown session." }));
1113
+ socket.close();
1114
+ return;
1115
+ }
1116
+ await ensureWorkspaceUserExists(session.workspaceId);
1117
+ runtime = getSessionRuntime(sessionId);
1118
+ if (!runtime) {
1119
+ socket.send(JSON.stringify({ type: "error", message: "Unknown session." }));
1120
+ socket.close();
1121
+ return;
1122
+ }
1123
+ runtime.sockets.add(socket);
1124
+
1125
+ await ensureClaudeWorktreeClients(session);
1126
+ await ensureCodexWorktreeClients(session);
1127
+
1128
+ if (session.activeProvider === "codex") {
1129
+ const existingClient = getActiveClient(session);
1130
+ const procExited = existingClient?.proc && existingClient.proc.exitCode != null;
1131
+ if (procExited && runtime.clients?.codex) {
1132
+ delete runtime.clients.codex;
1133
+ }
1134
+ const client = await getOrCreateClient(session, "codex");
1135
+ if (!client.listenerCount("ready")) {
1136
+ attachClientEvents(sessionId, client, "codex");
1137
+ }
1138
+ if (!client.ready && !client.proc) {
1139
+ client.start().catch((error) => {
1140
+ console.error("Failed to restart Codex app-server:", error);
1141
+ broadcastToSession(sessionId, {
1142
+ type: "error",
1143
+ message: "Codex app-server failed to start.",
1144
+ });
1145
+ });
1146
+ }
1147
+ }
1148
+
1149
+ authenticated = true;
1150
+ clearAuthTimeout();
1151
+ socket.off("message", handleAuth);
1152
+ socket.on("message", handleChatMessage);
1153
+
1154
+ socket.send(JSON.stringify({ type: "auth_ok" }));
1155
+
1156
+ const activeClient = getActiveClient(session);
1157
+ if (activeClient?.ready && activeClient?.threadId) {
1158
+ socket.send(
1159
+ JSON.stringify({
1160
+ type: "ready",
1161
+ threadId: activeClient.threadId,
1162
+ provider: session.activeProvider,
1163
+ })
1164
+ );
1165
+ } else {
1166
+ socket.send(
1167
+ JSON.stringify({
1168
+ type: "status",
1169
+ message: `Starting ${getProviderLabel(session)}...`,
1170
+ provider: session.activeProvider,
1171
+ })
1172
+ );
1173
+ }
1174
+ });
1175
+ })();
1176
+ });
1177
+
1178
+ // ---------------------------------------------------------------------------
1179
+ // Terminal WebSocket
1180
+ // ---------------------------------------------------------------------------
1181
+
1182
+ if (terminalWss) {
1183
+ terminalWss.on("connection", (socket, req) => {
1184
+ void (async () => {
1185
+ attachWebSocketDebug(socket, req, "terminal");
1186
+ const shell = "/bin/bash";
1187
+ let term = null;
1188
+ let closed = false;
1189
+ let authenticated = false;
1190
+ let workspaceId = null;
1191
+ let session = null;
1192
+ let worktree = null;
1193
+ let authTimeout = null;
1194
+
1195
+ const clearAuthTimeout = () => {
1196
+ if (authTimeout) {
1197
+ clearTimeout(authTimeout);
1198
+ authTimeout = null;
1199
+ }
1200
+ };
1201
+
1202
+ const startTerminal = (cols = 80, rows = 24) => {
1203
+ if (term) {
1204
+ return;
1205
+ }
1206
+ const env = {
1207
+ ...process.env,
1208
+ TMPDIR: getSessionTmpDir(session.dir),
1209
+ };
1210
+ const cwd = worktree?.path || session.repoDir;
1211
+ const denyGitCreds = typeof worktree?.denyGitCredentialsAccess === "boolean"
1212
+ ? worktree.denyGitCredentialsAccess
1213
+ : resolveDefaultDenyGitCredentialsAccess(session);
1214
+ const allowGitCreds = !denyGitCreds;
1215
+ const gitDir = session.gitDir || path.join(session.dir, "git");
1216
+ const sshDir = getWorkspaceSshPaths(getWorkspacePaths(session.workspaceId).homeDir).sshDir;
1217
+ if (isMonoUser) {
1218
+ term = pty.spawn(shell, [], {
1219
+ name: "xterm-256color",
1220
+ cols,
1221
+ rows,
1222
+ env: {
1223
+ ...env,
1224
+ TERM: "xterm-256color",
1225
+ },
1226
+ cwd,
1227
+ });
1228
+ } else {
1229
+ const termArgs = [
1230
+ "-n",
1231
+ runAsHelperPath,
1232
+ "--workspace-id",
1233
+ session.workspaceId,
1234
+ "--cwd",
1235
+ cwd,
1236
+ "--env",
1237
+ "TERM=xterm-256color",
1238
+ "--env",
1239
+ `TMPDIR=${getSessionTmpDir(session.dir)}`,
1240
+ ...buildSandboxArgs({
1241
+ cwd,
1242
+ repoDir: cwd,
1243
+ workspaceId: session.workspaceId,
1244
+ tmpDir: getSessionTmpDir(session.dir),
1245
+ internetAccess: session.defaultInternetAccess,
1246
+ netMode: "none",
1247
+ extraAllowRw: allowGitCreds ? [gitDir, sshDir] : [],
1248
+ }),
1249
+ "--",
1250
+ shell,
1251
+ ];
1252
+ term = pty.spawn(sudoPath, termArgs, {
1253
+ name: "xterm-256color",
1254
+ cols,
1255
+ rows,
1256
+ env,
1257
+ });
1258
+ }
1259
+
1260
+ term.onData((data) => {
1261
+ if (socket.readyState === socket.OPEN) {
1262
+ socket.send(JSON.stringify({ type: "output", data }));
1263
+ }
1264
+ });
1265
+
1266
+ term.onExit(({ exitCode }) => {
1267
+ if (closed) {
1268
+ return;
1269
+ }
1270
+ if (socket.readyState === socket.OPEN) {
1271
+ socket.send(JSON.stringify({ type: "exit", code: exitCode }));
1272
+ }
1273
+ socket.close();
1274
+ });
1275
+ };
1276
+
1277
+ const handleTerminalMessage = async (raw) => {
1278
+ let message = null;
1279
+ try {
1280
+ message = JSON.parse(raw.toString());
1281
+ } catch {
1282
+ return;
1283
+ }
1284
+ if (!message?.type) {
1285
+ return;
1286
+ }
1287
+ if (!authenticated) {
1288
+ if (message.type !== "auth") {
1289
+ socket.send(
1290
+ JSON.stringify(
1291
+ wsErrorPayload({
1292
+ message: "Auth required.",
1293
+ errorCode: "WORKSPACE_AUTH_REQUIRED",
1294
+ recoverable: true,
1295
+ })
1296
+ )
1297
+ );
1298
+ return;
1299
+ }
1300
+ const token = typeof message?.token === "string" ? message.token : "";
1301
+ if (!token) {
1302
+ socket.send(
1303
+ JSON.stringify(
1304
+ wsErrorPayload({
1305
+ message: "Missing workspace token.",
1306
+ errorCode: "WORKSPACE_TOKEN_MISSING",
1307
+ recoverable: false,
1308
+ })
1309
+ )
1310
+ );
1311
+ socket.close();
1312
+ return;
1313
+ }
1314
+ try {
1315
+ workspaceId = verifyWorkspaceToken(token);
1316
+ } catch (error) {
1317
+ socket.send(
1318
+ JSON.stringify(
1319
+ wsErrorPayload({
1320
+ message: "Invalid workspace token.",
1321
+ errorCode: resolveWorkspaceTokenErrorCode(error),
1322
+ recoverable: true,
1323
+ })
1324
+ )
1325
+ );
1326
+ socket.close();
1327
+ return;
1328
+ }
1329
+ req.workspaceId = workspaceId;
1330
+ session = await getSessionFromRequest(req);
1331
+ if (!session) {
1332
+ socket.send(JSON.stringify({ type: "error", message: "Unknown session." }));
1333
+ socket.close();
1334
+ return;
1335
+ }
1336
+ await touchSession(session);
1337
+ try {
1338
+ const url = new URL(req.url, `http://${req.headers.host}`);
1339
+ const worktreeId = url.searchParams.get("worktreeId");
1340
+ if (worktreeId && worktreeId !== "main") {
1341
+ worktree = await getWorktree(session, worktreeId);
1342
+ }
1343
+ } catch {
1344
+ // Ignore invalid URL parsing; fall back to main repo.
1345
+ }
1346
+ authenticated = true;
1347
+ clearAuthTimeout();
1348
+ socket.send(JSON.stringify({ type: "auth_ok" }));
1349
+ return;
1350
+ }
1351
+ if (message.type === "init") {
1352
+ startTerminal(message.cols, message.rows);
1353
+ return;
1354
+ }
1355
+ if (!term) {
1356
+ startTerminal();
1357
+ }
1358
+ if (message.type === "resize") {
1359
+ if (
1360
+ Number.isFinite(message.cols) &&
1361
+ Number.isFinite(message.rows) &&
1362
+ term
1363
+ ) {
1364
+ term.resize(message.cols, message.rows);
1365
+ }
1366
+ return;
1367
+ }
1368
+ if (message.type === "input" && typeof message.data === "string" && term) {
1369
+ term.write(message.data);
1370
+ }
1371
+ };
1372
+
1373
+ authTimeout = setTimeout(() => {
1374
+ if (!authenticated) {
1375
+ socket.send(
1376
+ JSON.stringify(
1377
+ wsErrorPayload({
1378
+ message: "Auth timeout.",
1379
+ errorCode: "WORKSPACE_AUTH_REQUIRED",
1380
+ recoverable: true,
1381
+ })
1382
+ )
1383
+ );
1384
+ socket.close();
1385
+ }
1386
+ }, 5000);
1387
+
1388
+ socket.on("message", handleTerminalMessage);
1389
+
1390
+ socket.on("close", () => {
1391
+ closed = true;
1392
+ clearAuthTimeout();
1393
+ if (term) {
1394
+ term.kill();
1395
+ term = null;
1396
+ }
1397
+ });
1398
+ })();
1399
+ });
1400
+ }
1401
+
1402
+ // ---------------------------------------------------------------------------
1403
+ // Static files + SPA fallback
1404
+ // ---------------------------------------------------------------------------
1405
+
1406
+ const distPath = path.resolve(__dirname, "../../client/dist");
1407
+ if (fs.existsSync(distPath)) {
1408
+ app.use(express.static(distPath));
1409
+ app.get("*", (req, res) => {
1410
+ res.sendFile(path.join(distPath, "index.html"));
1411
+ });
1412
+ }
1413
+
1414
+ // ---------------------------------------------------------------------------
1415
+ // Server start + timers
1416
+ // ---------------------------------------------------------------------------
1417
+
1418
+ let shuttingDown = false;
1419
+ const stopAllRuntimeClients = async () => {
1420
+ const stopPromises = [];
1421
+ for (const [, runtime] of listSessionRuntimeEntries()) {
1422
+ if (runtime?.clients) {
1423
+ for (const client of Object.values(runtime.clients)) {
1424
+ if (client && typeof client.stop === "function") {
1425
+ stopPromises.push(client.stop({ reason: "server_shutdown" }));
1426
+ }
1427
+ }
1428
+ }
1429
+ if (runtime?.worktreeClients instanceof Map) {
1430
+ for (const client of runtime.worktreeClients.values()) {
1431
+ if (client && typeof client.stop === "function") {
1432
+ stopPromises.push(client.stop({ reason: "server_shutdown" }));
1433
+ }
1434
+ }
1435
+ }
1436
+ }
1437
+ await Promise.allSettled(stopPromises);
1438
+ };
1439
+
1440
+ const gracefulShutdown = async (signal) => {
1441
+ if (shuttingDown) {
1442
+ return;
1443
+ }
1444
+ shuttingDown = true;
1445
+ console.log(`${signal} received. Stopping spawned clients...`);
1446
+ const hardExitTimer = setTimeout(() => {
1447
+ console.error("Forced exit after shutdown timeout.");
1448
+ process.exit(1);
1449
+ }, 15000);
1450
+ hardExitTimer.unref?.();
1451
+ try {
1452
+ await stopAllRuntimeClients();
1453
+ } catch (error) {
1454
+ console.error("Failed to stop spawned clients:", error?.message || error);
1455
+ }
1456
+ server.close(() => {
1457
+ process.exit(0);
1458
+ });
1459
+ // If there are no open handles, the close callback may not fire reliably in some edge cases.
1460
+ setTimeout(() => process.exit(0), 1000).unref?.();
1461
+ };
1462
+
1463
+ process.on("SIGTERM", () => {
1464
+ void gracefulShutdown("SIGTERM");
1465
+ });
1466
+ process.on("SIGINT", () => {
1467
+ void gracefulShutdown("SIGINT");
1468
+ });
1469
+
1470
+ const port = process.env.PORT || 5179;
1471
+ server.listen(port, async () => {
1472
+ console.log(`Server listening on http://localhost:${port}`);
1473
+ if (deploymentMode === "mono_user") {
1474
+ const monoAuthRecord = createMonoAuthToken("default");
1475
+ const monoAuthOrigin = process.env.MONO_AUTH_ORIGIN || `http://127.0.0.1:${port}`;
1476
+ const monoAuthUrl = `${monoAuthOrigin.replace(/\/+$/, "")}/#mono_auth=${encodeURIComponent(
1477
+ monoAuthRecord.token
1478
+ )}`;
1479
+ const monoAuthUrlFile = process.env.VIBE80_MONO_AUTH_URL_FILE;
1480
+ if (monoAuthUrlFile) {
1481
+ try {
1482
+ fs.writeFileSync(monoAuthUrlFile, `${monoAuthUrl}\n`, { mode: 0o600 });
1483
+ } catch (error) {
1484
+ console.error("Failed to write mono auth URL file:", error?.message || error);
1485
+ }
1486
+ }
1487
+ console.log(`Mono auth URL: ${monoAuthUrl}`);
1488
+ }
1489
+ });
1490
+
1491
+ setInterval(() => {
1492
+ runSessionGc().catch((error) => {
1493
+ console.error("Session GC failed:", error?.message || error);
1494
+ });
1495
+ }, sessionGcIntervalMs);
1496
+ setInterval(() => {
1497
+ cleanupHandoffTokens();
1498
+ cleanupMonoAuthTokens();
1499
+ if (typeof storage.cleanupWorkspaceRefreshTokens === "function") {
1500
+ storage.cleanupWorkspaceRefreshTokens().catch((error) => {
1501
+ console.error("Refresh token cleanup failed:", error?.message || error);
1502
+ });
1503
+ }
1504
+ }, 30 * 1000);
1505
+ if (Number.isFinite(codexIdleGcIntervalSeconds) && codexIdleGcIntervalSeconds > 0) {
1506
+ setInterval(() => {
1507
+ if (!Number.isFinite(codexIdleTtlSeconds) || codexIdleTtlSeconds <= 0) {
1508
+ return;
1509
+ }
1510
+ const ttlMs = codexIdleTtlSeconds * 1000;
1511
+ const now = Date.now();
1512
+ for (const [sessionId, runtime] of listSessionRuntimeEntries()) {
1513
+ const candidates = [];
1514
+ if (runtime?.clients?.codex) {
1515
+ candidates.push(["main", runtime.clients.codex]);
1516
+ }
1517
+ if (runtime?.worktreeClients instanceof Map) {
1518
+ runtime.worktreeClients.forEach((client, worktreeId) => {
1519
+ candidates.push([worktreeId, client]);
1520
+ });
1521
+ }
1522
+ candidates.forEach(([worktreeId, client]) => {
1523
+ if (!client || client?.constructor?.name !== "CodexAppServerClient") {
1524
+ return;
1525
+ }
1526
+ if (typeof client.getStatus !== "function") {
1527
+ return;
1528
+ }
1529
+ if (client.getStatus() !== "idle") {
1530
+ return;
1531
+ }
1532
+ const lastIdleAt = client.lastIdleAt;
1533
+ if (!Number.isFinite(lastIdleAt)) {
1534
+ return;
1535
+ }
1536
+ if (now - lastIdleAt < ttlMs) {
1537
+ return;
1538
+ }
1539
+ client.stop({ force: false, reason: "gc_idle" }).catch(() => null);
1540
+ if (worktreeId === "main") {
1541
+ return;
1542
+ }
1543
+ void (async () => {
1544
+ const session = await getSession(sessionId);
1545
+ if (!session) {
1546
+ return;
1547
+ }
1548
+ await updateWorktreeStatus(session, worktreeId, "stopped");
1549
+ broadcastToSession(sessionId, {
1550
+ type: "worktree_status",
1551
+ worktreeId,
1552
+ status: "stopped",
1553
+ error: null,
1554
+ });
1555
+ })();
1556
+ });
1557
+ }
1558
+ }, codexIdleGcIntervalSeconds * 1000);
1559
+ }
1560
+
1561
+ setInterval(() => {
1562
+ void (async () => {
1563
+ for (const [sessionId, runtime] of listSessionRuntimeEntries()) {
1564
+ if (!runtime?.sockets || runtime.sockets.size === 0) {
1565
+ continue;
1566
+ }
1567
+ const session = await getSession(sessionId);
1568
+ if (!session) {
1569
+ continue;
1570
+ }
1571
+ const mainStorageId = getMainWorktreeStorageId(session.sessionId);
1572
+ let worktrees = await listStoredWorktrees(session);
1573
+ if (!worktrees.some((wt) => wt?.id === mainStorageId)) {
1574
+ const mainWorktree = await getWorktree(session, "main");
1575
+ if (mainWorktree) {
1576
+ worktrees = [...worktrees, mainWorktree];
1577
+ }
1578
+ }
1579
+ worktrees.forEach((worktree) => {
1580
+ const worktreeId =
1581
+ worktree?.id === mainStorageId ? "main" : worktree?.id;
1582
+ if (!worktreeId) {
1583
+ return;
1584
+ }
1585
+ let status = worktree?.status;
1586
+ if (worktree?.provider === "codex") {
1587
+ const runtimeClient = worktreeId === "main"
1588
+ ? runtime?.clients?.codex
1589
+ : runtime?.worktreeClients?.get?.(worktreeId);
1590
+ if (runtimeClient?.getStatus) {
1591
+ const runtimeStatus = runtimeClient.getStatus();
1592
+ if (runtimeStatus === "busy") {
1593
+ status = "processing";
1594
+ } else if (
1595
+ runtimeStatus === "starting" ||
1596
+ runtimeStatus === "restarting"
1597
+ ) {
1598
+ status = "processing";
1599
+ } else if (runtimeStatus === "stopping") {
1600
+ status = "stopped";
1601
+ } else if (runtimeStatus === "idle") {
1602
+ status = "ready";
1603
+ }
1604
+ }
1605
+ }
1606
+ if (!status) {
1607
+ return;
1608
+ }
1609
+ broadcastToSession(sessionId, {
1610
+ type: "worktree_status",
1611
+ worktreeId,
1612
+ status,
1613
+ error: worktree?.error || null,
1614
+ });
1615
+ });
1616
+ }
1617
+ })().catch((error) => {
1618
+ console.error("Worktree status heartbeat failed:", error?.message || error);
1619
+ });
1620
+ }, worktreeStatusIntervalMs);
1621
+
1622
+ server.on("upgrade", (req, socket, head) => {
1623
+ const url = new URL(req.url, `http://${req.headers.host}`);
1624
+ if (url.pathname === "/ws") {
1625
+ wss.handleUpgrade(req, socket, head, (ws) => {
1626
+ wss.emit("connection", ws, req);
1627
+ });
1628
+ return;
1629
+ }
1630
+ if (url.pathname === "/terminal-ws") {
1631
+ if (!terminalEnabled || !terminalWss) {
1632
+ socket.destroy();
1633
+ return;
1634
+ }
1635
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
1636
+ terminalWss.emit("connection", ws, req);
1637
+ });
1638
+ return;
1639
+ }
1640
+ socket.destroy();
1641
+ });