@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,918 @@
1
+ import path from "path";
2
+ import os from "os";
3
+ import fs from "fs";
4
+ import multer from "multer";
5
+ import { fileURLToPath } from "url";
6
+ import {
7
+ runAsCommand,
8
+ runAsCommandOutput,
9
+ runAsCommandOutputWithStatus,
10
+ } from "../runAs.js";
11
+ import storage from "../storage/index.js";
12
+ import {
13
+ generateId,
14
+ generateSessionName,
15
+ createMessageId,
16
+ sanitizeFilename,
17
+ getSessionTmpDir,
18
+ } from "../helpers.js";
19
+ import { debugApiWsLog } from "../middleware/debug.js";
20
+ import {
21
+ DEFAULT_GIT_AUTHOR_NAME,
22
+ DEFAULT_GIT_AUTHOR_EMAIL,
23
+ GIT_HOOKS_DIR,
24
+ } from "../config.js";
25
+ import {
26
+ getWorkspacePaths,
27
+ getWorkspaceSshPaths,
28
+ ensureWorkspaceDir,
29
+ writeWorkspaceFile,
30
+ appendWorkspaceFile,
31
+ readWorkspaceConfig,
32
+ listEnabledProviders,
33
+ pickDefaultProvider,
34
+ workspacePathExists,
35
+ listWorkspaceEntries,
36
+ getWorkspaceStat,
37
+ readWorkspaceFileBuffer,
38
+ writeWorkspaceFilePreserveMode,
39
+ appendAuditLog,
40
+ isMonoUser,
41
+ } from "./workspace.js";
42
+ import {
43
+ getSessionRuntime,
44
+ deleteSessionRuntime,
45
+ } from "../runtimeStore.js";
46
+ import {
47
+ getWorktree,
48
+ appendWorktreeMessage,
49
+ clearWorktreeMessages,
50
+ getWorktreeDiff,
51
+ updateWorktreeStatus,
52
+ updateWorktreeThreadId,
53
+ getMainWorktreeStorageId,
54
+ } from "../worktreeManager.js";
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Constants
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const __filename = fileURLToPath(import.meta.url);
61
+ const __dirname = path.dirname(__filename);
62
+
63
+ const sessionGcIntervalMs =
64
+ Number(process.env.SESSION_GC_INTERVAL_MS) || 5 * 60 * 1000;
65
+ const sessionIdleTtlMs =
66
+ Number(process.env.SESSION_IDLE_TTL_MS) || 24 * 60 * 60 * 1000;
67
+ const sessionMaxTtlMs =
68
+ Number(process.env.SESSION_MAX_TTL_MS) || 7 * 24 * 60 * 60 * 1000;
69
+ export const sessionIdPattern = /^s[0-9a-f]{24}$/;
70
+
71
+ const TREE_IGNORED_NAMES = new Set([
72
+ ".git",
73
+ "node_modules",
74
+ ".next",
75
+ "dist",
76
+ "build",
77
+ "coverage",
78
+ "out",
79
+ "worktrees",
80
+ "attachments",
81
+ ".cache",
82
+ ".turbo",
83
+ ".idea",
84
+ ".vscode",
85
+ "venv",
86
+ ".venv",
87
+ ]);
88
+ export const MAX_FILE_BYTES = 200 * 1024;
89
+ export const MAX_WRITE_BYTES = 500 * 1024;
90
+
91
+ const modelCache = new Map();
92
+ const modelCacheTtlMs = 60 * 60 * 1000;
93
+ export { modelCache, modelCacheTtlMs };
94
+
95
+ export { sessionGcIntervalMs };
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Session env / command helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ export const buildSessionEnv = (session, options = {}) => {
102
+ const tmpDir = session?.dir ? getSessionTmpDir(session.dir) : null;
103
+ const env = { ...(options.env || {}) };
104
+ if (tmpDir) {
105
+ env.TMPDIR = tmpDir;
106
+ }
107
+ return env;
108
+ };
109
+
110
+ export const runSessionCommand = (session, command, args, options = {}) =>
111
+ runAsCommand(session.workspaceId, command, args, {
112
+ ...options,
113
+ env: buildSessionEnv(session, options),
114
+ });
115
+
116
+ export const runSessionCommandOutput = (session, command, args, options = {}) =>
117
+ runAsCommandOutput(session.workspaceId, command, args, {
118
+ ...options,
119
+ env: buildSessionEnv(session, options),
120
+ });
121
+
122
+ export const runSessionCommandOutputWithStatus = (session, command, args, options = {}) =>
123
+ runAsCommandOutputWithStatus(session.workspaceId, command, args, {
124
+ ...options,
125
+ env: buildSessionEnv(session, options),
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Session CRUD
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export const touchSession = async (session) => {
133
+ if (!session) return;
134
+ const updated = { ...session, lastActivityAt: Date.now() };
135
+ await storage.saveSession(session.sessionId, updated);
136
+ return updated;
137
+ };
138
+
139
+ export const getSession = async (sessionId, workspaceId = null) => {
140
+ if (!sessionId) {
141
+ return null;
142
+ }
143
+ const session = await storage.getSession(sessionId);
144
+ if (!session) {
145
+ return null;
146
+ }
147
+ if (workspaceId && session.workspaceId !== workspaceId) {
148
+ return null;
149
+ }
150
+ return session;
151
+ };
152
+
153
+ export const getSessionFromRequest = async (req) => {
154
+ if (!req?.url) {
155
+ return null;
156
+ }
157
+ try {
158
+ const url = new URL(req.url, `http://${req.headers.host}`);
159
+ const sessionId = url.searchParams.get("session");
160
+ return getSession(sessionId, req.workspaceId);
161
+ } catch {
162
+ return null;
163
+ }
164
+ };
165
+
166
+ export const resolveDefaultDenyGitCredentialsAccess = (session) =>
167
+ typeof session?.defaultDenyGitCredentialsAccess === "boolean"
168
+ ? session.defaultDenyGitCredentialsAccess
169
+ : true;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Stop / cleanup
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export const stopClient = async (client) => {
176
+ if (!client) {
177
+ return;
178
+ }
179
+ if (typeof client.stop === "function") {
180
+ try {
181
+ await client.stop();
182
+ return;
183
+ } catch {
184
+ // fallthrough
185
+ }
186
+ }
187
+ if (client.proc && typeof client.proc.kill === "function") {
188
+ try {
189
+ client.proc.kill();
190
+ } catch {
191
+ // ignore
192
+ }
193
+ }
194
+ };
195
+
196
+ export const cleanupSession = async (sessionId, reason) => {
197
+ const session = await storage.getSession(sessionId);
198
+ if (!session) {
199
+ return;
200
+ }
201
+ const runtime = getSessionRuntime(sessionId);
202
+ if (runtime?.sockets) {
203
+ for (const socket of runtime.sockets) {
204
+ try {
205
+ socket.close();
206
+ } catch {
207
+ // ignore
208
+ }
209
+ }
210
+ }
211
+ if (runtime?.worktreeClients) {
212
+ for (const client of runtime.worktreeClients.values()) {
213
+ await stopClient(client);
214
+ }
215
+ runtime.worktreeClients.clear();
216
+ }
217
+ if (runtime?.clients) {
218
+ for (const client of Object.values(runtime.clients || {})) {
219
+ await stopClient(client);
220
+ }
221
+ }
222
+
223
+ const worktrees = await storage.listWorktrees(sessionId);
224
+ for (const worktree of worktrees) {
225
+ if (worktree?.path) {
226
+ await runAsCommand(session.workspaceId, "/bin/rm", ["-rf", worktree.path]).catch(() => {});
227
+ }
228
+ }
229
+
230
+ if (session.dir) {
231
+ await runAsCommand(session.workspaceId, "/bin/rm", ["-rf", session.dir]).catch(() => {});
232
+ }
233
+ if (session.sshKeyPath) {
234
+ await runAsCommand(session.workspaceId, "/bin/rm", ["-f", session.sshKeyPath]).catch(() => {});
235
+ }
236
+ await storage.deleteSession(sessionId, session.workspaceId);
237
+ deleteSessionRuntime(sessionId);
238
+ await appendAuditLog(session.workspaceId, "session_removed", { sessionId, reason });
239
+ };
240
+
241
+ export const runSessionGc = async () => {
242
+ const now = Date.now();
243
+ const sessions = await storage.listSessions();
244
+ for (const session of sessions) {
245
+ if (!session?.sessionId) {
246
+ continue;
247
+ }
248
+ const createdAt = session.createdAt || now;
249
+ const lastActivity = session.lastActivityAt || createdAt;
250
+ const expiredByIdle = sessionIdleTtlMs > 0 && now - lastActivity > sessionIdleTtlMs;
251
+ const expiredByMax = sessionMaxTtlMs > 0 && now - createdAt > sessionMaxTtlMs;
252
+ if (expiredByIdle || expiredByMax) {
253
+ await cleanupSession(session.sessionId, expiredByIdle ? "idle_timeout" : "max_ttl");
254
+ }
255
+ }
256
+ };
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Git helpers
260
+ // ---------------------------------------------------------------------------
261
+
262
+ const normalizeRemoteBranches = (output, remote) =>
263
+ output
264
+ .split(/\r?\n/)
265
+ .map((line) => line.trim())
266
+ .filter(Boolean)
267
+ .filter((ref) => !ref.endsWith("/HEAD"))
268
+ .map((ref) =>
269
+ ref.startsWith(`${remote}/`) ? ref.slice(remote.length + 1) : ref
270
+ );
271
+
272
+ export const getCurrentBranch = async (session) => {
273
+ const output = await runSessionCommandOutput(
274
+ session,
275
+ "git",
276
+ ["rev-parse", "--abbrev-ref", "HEAD"],
277
+ { cwd: session.repoDir }
278
+ );
279
+ const trimmed = output.trim();
280
+ return trimmed === "HEAD" ? "" : trimmed;
281
+ };
282
+
283
+ export const getLastCommit = async (session, cwd) => {
284
+ const output = await runSessionCommandOutput(
285
+ session,
286
+ "git",
287
+ ["log", "-1", "--format=%H|%s"],
288
+ { cwd }
289
+ );
290
+ const [sha, message] = output.trim().split("|");
291
+ return { sha: sha || "", message: message || "" };
292
+ };
293
+
294
+ export const getBranchInfo = async (session, remote = "origin") => {
295
+ await runSessionCommand(session, "git", ["fetch", "--prune"], {
296
+ cwd: session.repoDir,
297
+ });
298
+ const [current, branchesOutput] = await Promise.all([
299
+ getCurrentBranch(session),
300
+ runSessionCommandOutput(
301
+ session,
302
+ "git",
303
+ ["for-each-ref", "--format=%(refname:short)", `refs/remotes/${remote}`],
304
+ { cwd: session.repoDir }
305
+ ),
306
+ ]);
307
+ return {
308
+ current,
309
+ remote,
310
+ branches: normalizeRemoteBranches(branchesOutput, remote).sort(),
311
+ };
312
+ };
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Repo URL helpers (for createSession)
316
+ // ---------------------------------------------------------------------------
317
+
318
+ const resolveRepoHost = (repoUrl) => {
319
+ if (repoUrl.startsWith("ssh://")) {
320
+ try {
321
+ return new URL(repoUrl).hostname;
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+ const scpStyle = repoUrl.match(/^[^@]+@([^:]+):/);
327
+ if (scpStyle) {
328
+ return scpStyle[1];
329
+ }
330
+ return null;
331
+ };
332
+
333
+ const resolveHttpAuthInfo = (repoUrl) => {
334
+ try {
335
+ const url = new URL(repoUrl);
336
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
337
+ return null;
338
+ }
339
+ return { protocol: url.protocol.replace(":", ""), host: url.host };
340
+ } catch {
341
+ return null;
342
+ }
343
+ };
344
+
345
+ const ensureKnownHost = async (workspaceId, repoUrl, sshPaths) => {
346
+ const host = resolveRepoHost(repoUrl);
347
+ if (!host) {
348
+ return;
349
+ }
350
+ const { sshDir, knownHostsPath } = sshPaths;
351
+ await ensureWorkspaceDir(workspaceId, sshDir, 0o700);
352
+ const output = await runAsCommandOutput(workspaceId, "/usr/bin/ssh-keyscan", ["-H", host]).catch(
353
+ () => ""
354
+ );
355
+ if (output && output.trim()) {
356
+ await appendWorkspaceFile(workspaceId, knownHostsPath, output, 0o600);
357
+ }
358
+ };
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // createSession
362
+ // ---------------------------------------------------------------------------
363
+
364
+ export const createSession = async (
365
+ workspaceId,
366
+ repoUrl,
367
+ auth,
368
+ defaultInternetAccess,
369
+ defaultDenyGitCredentialsAccess,
370
+ name,
371
+ { getOrCreateClient, attachClientEvents, attachClaudeEvents, broadcastToSession }
372
+ ) => {
373
+ const workspaceConfig = await readWorkspaceConfig(workspaceId);
374
+ const enabledProviders = listEnabledProviders(workspaceConfig?.providers || {});
375
+ const defaultProvider = pickDefaultProvider(enabledProviders);
376
+ if (!defaultProvider) {
377
+ throw new Error("No providers enabled for this workspace.");
378
+ }
379
+ const resolvedInternetAccess =
380
+ typeof defaultInternetAccess === "boolean" ? defaultInternetAccess : true;
381
+ const resolvedDenyGitCredentialsAccess =
382
+ typeof defaultDenyGitCredentialsAccess === "boolean"
383
+ ? defaultDenyGitCredentialsAccess
384
+ : true;
385
+ const resolvedShareGitCredentials = !resolvedDenyGitCredentialsAccess;
386
+ const resolvedName = typeof name === "string" && name.trim()
387
+ ? name.trim()
388
+ : generateSessionName();
389
+ const workspacePaths = getWorkspacePaths(workspaceId);
390
+ const sshPaths = getWorkspaceSshPaths(workspacePaths.homeDir);
391
+ while (true) {
392
+ const sessionId = generateId("s");
393
+ const dir = path.join(workspacePaths.sessionsDir, sessionId);
394
+ let sessionRecord = null;
395
+ let sessionSshKeyPath = null;
396
+ try {
397
+ await runAsCommand(workspaceId, "/bin/mkdir", [dir]);
398
+ await runAsCommand(workspaceId, "/bin/chmod", ["2750", dir]);
399
+ const attachmentsDir = path.join(dir, "attachments");
400
+ await runAsCommand(workspaceId, "/bin/mkdir", ["-p", attachmentsDir]);
401
+ await runAsCommand(workspaceId, "/bin/chmod", ["2750", attachmentsDir]);
402
+ const tmpDir = getSessionTmpDir(dir);
403
+ await runAsCommand(workspaceId, "/bin/mkdir", ["-p", tmpDir]);
404
+ await runAsCommand(workspaceId, "/bin/chmod", ["2750", tmpDir]);
405
+ const repoDir = path.join(dir, "repository");
406
+ const gitCredsDir = path.join(dir, "git");
407
+ const needsGitCredsDir =
408
+ resolvedShareGitCredentials ||
409
+ (auth?.type === "ssh" && auth.privateKey) ||
410
+ (auth?.type === "http" && auth.username && auth.password);
411
+ if (needsGitCredsDir) {
412
+ await runAsCommand(workspaceId, "/bin/mkdir", ["-p", gitCredsDir]);
413
+ await runAsCommand(workspaceId, "/bin/chmod", ["2750", gitCredsDir]);
414
+ }
415
+ const env = { TMPDIR: tmpDir };
416
+ if (auth?.type === "ssh" && auth.privateKey) {
417
+ await ensureWorkspaceDir(workspaceId, sshPaths.sshDir, 0o700);
418
+ const keyPath = path.join(gitCredsDir, `ssh-key-${sessionId}`);
419
+ const normalizedKey = `${auth.privateKey.trimEnd()}\n`;
420
+ await writeWorkspaceFile(workspaceId, keyPath, normalizedKey, 0o600);
421
+ sessionSshKeyPath = keyPath;
422
+ await ensureKnownHost(workspaceId, repoUrl, sshPaths);
423
+ env.GIT_SSH_COMMAND = `ssh -i "${keyPath}" -o IdentitiesOnly=yes -o UserKnownHostsFile="${sshPaths.knownHostsPath}"`;
424
+ } else if (auth?.type === "http" && auth.username && auth.password) {
425
+ const authInfo = resolveHttpAuthInfo(repoUrl);
426
+ if (!authInfo) {
427
+ throw new Error("Invalid HTTP repository URL for credential auth.");
428
+ }
429
+ const credFile = path.join(gitCredsDir, "git-credentials");
430
+ const credInputPath = path.join(gitCredsDir, "git-credential-input");
431
+ env.GIT_TERMINAL_PROMPT = "0";
432
+ await writeWorkspaceFile(workspaceId, credFile, "", 0o600);
433
+ const credentialPayload = [
434
+ `protocol=${authInfo.protocol}`,
435
+ `host=${authInfo.host}`,
436
+ `username=${auth.username}`,
437
+ `password=${auth.password}`,
438
+ "",
439
+ "",
440
+ ].join("\n");
441
+ await writeWorkspaceFile(workspaceId, credInputPath, credentialPayload, 0o600);
442
+ await runAsCommand(
443
+ workspaceId,
444
+ "git",
445
+ ["-c", `credential.helper=store --file ${credFile}`, "credential", "approve"],
446
+ {
447
+ env,
448
+ input: credentialPayload,
449
+ }
450
+ );
451
+ await runAsCommand(workspaceId, "/bin/rm", ["-f", credInputPath]);
452
+ }
453
+ const cloneArgs = ["clone", repoUrl, repoDir];
454
+ const cloneEnv = { ...env };
455
+ const cloneCmd = [];
456
+ if (auth?.type === "http" && auth.username && auth.password) {
457
+ cloneCmd.push(
458
+ "-c",
459
+ `credential.helper=store --file ${path.join(
460
+ gitCredsDir,
461
+ "git-credentials"
462
+ )}`
463
+ );
464
+ }
465
+ if (auth?.type === "ssh" && sessionSshKeyPath) {
466
+ cloneCmd.push(
467
+ "-c",
468
+ `core.sshCommand="ssh -i ${sessionSshKeyPath} -o IdentitiesOnly=yes"`
469
+ );
470
+ }
471
+ cloneCmd.push(...cloneArgs);
472
+ await runAsCommand(workspaceId, "git", cloneCmd, { env: cloneEnv });
473
+ if (auth?.type === "ssh" && sessionSshKeyPath) {
474
+ await runAsCommand(
475
+ workspaceId,
476
+ "git",
477
+ [
478
+ "config",
479
+ "core.sshCommand",
480
+ `ssh -i ${sessionSshKeyPath} -o IdentitiesOnly=yes`,
481
+ ],
482
+ { cwd: repoDir }
483
+ );
484
+ }
485
+ if (DEFAULT_GIT_AUTHOR_NAME && DEFAULT_GIT_AUTHOR_EMAIL) {
486
+ await runAsCommand(
487
+ workspaceId,
488
+ "git",
489
+ ["-C", repoDir, "config", "user.name", DEFAULT_GIT_AUTHOR_NAME],
490
+ { env }
491
+ );
492
+ await runAsCommand(
493
+ workspaceId,
494
+ "git",
495
+ ["-C", repoDir, "config", "user.email", DEFAULT_GIT_AUTHOR_EMAIL],
496
+ { env }
497
+ );
498
+ }
499
+ if (auth?.type === "http" && auth.username && auth.password) {
500
+ await runAsCommand(
501
+ workspaceId,
502
+ "git",
503
+ [
504
+ "-C",
505
+ repoDir,
506
+ "config",
507
+ "--add",
508
+ "credential.helper",
509
+ `store --file ${path.join(gitCredsDir, "git-credentials")}`,
510
+ ],
511
+ { env }
512
+ );
513
+ }
514
+ await runAsCommand(
515
+ workspaceId,
516
+ "git",
517
+ ["-C", repoDir, "config", "core.hooksPath", GIT_HOOKS_DIR],
518
+ { env }
519
+ );
520
+ await runAsCommand(
521
+ workspaceId,
522
+ "git",
523
+ ["-C", repoDir, "config", "extensions.worktreeConfig", "true"],
524
+ { env }
525
+ );
526
+ await runAsCommand(
527
+ workspaceId,
528
+ "git",
529
+ ["-C", repoDir, "config", "--worktree", "vibe80.workspaceId", workspaceId],
530
+ { env }
531
+ );
532
+ await runAsCommand(
533
+ workspaceId,
534
+ "git",
535
+ ["-C", repoDir, "config", "--worktree", "vibe80.sessionId", sessionId],
536
+ { env }
537
+ );
538
+ await runAsCommand(
539
+ workspaceId,
540
+ "git",
541
+ ["-C", repoDir, "config", "--worktree", "vibe80.worktreeId", "main"],
542
+ { env }
543
+ );
544
+ const session = {
545
+ sessionId,
546
+ workspaceId,
547
+ dir,
548
+ attachmentsDir,
549
+ repoDir,
550
+ repoUrl,
551
+ name: resolvedName,
552
+ activeProvider: defaultProvider,
553
+ defaultInternetAccess: resolvedInternetAccess,
554
+ defaultDenyGitCredentialsAccess: resolvedDenyGitCredentialsAccess,
555
+ gitDir: gitCredsDir,
556
+ createdAt: Date.now(),
557
+ lastActivityAt: Date.now(),
558
+ sshKeyPath: sessionSshKeyPath,
559
+ rpcLogs: [],
560
+ threadId: null,
561
+ };
562
+ await storage.saveSession(sessionId, session);
563
+ sessionRecord = session;
564
+ await getWorktree(session, "main");
565
+ await appendAuditLog(workspaceId, "session_created", { sessionId, repoUrl });
566
+
567
+ const client = await getOrCreateClient(session, defaultProvider);
568
+ if (defaultProvider === "claude") {
569
+ attachClaudeEvents(sessionId, client, defaultProvider);
570
+ } else {
571
+ attachClientEvents(sessionId, client, defaultProvider);
572
+ }
573
+ client.start().catch((error) => {
574
+ const label = defaultProvider === "claude" ? "Claude CLI" : "Codex app-server";
575
+ console.error(`Failed to start ${label}:`, error);
576
+ broadcastToSession(sessionId, {
577
+ type: "error",
578
+ message: `${label} failed to start.`,
579
+ });
580
+ });
581
+ return { sessionId, dir };
582
+ } catch (error) {
583
+ console.error("Session creation failed:", {
584
+ repoUrl,
585
+ sessionDir: dir,
586
+ error: error?.message || error,
587
+ });
588
+ if (sessionRecord) {
589
+ await storage.deleteSession(sessionId, sessionRecord.workspaceId);
590
+ }
591
+ if (sessionSshKeyPath) {
592
+ await runAsCommand(workspaceId, "/bin/rm", ["-f", sessionSshKeyPath]).catch(() => {});
593
+ }
594
+ await runAsCommand(workspaceId, "/bin/rm", ["-rf", dir]).catch(() => {});
595
+ if (error.code !== "EEXIST") {
596
+ throw error;
597
+ }
598
+ }
599
+ }
600
+ };
601
+
602
+ // ---------------------------------------------------------------------------
603
+ // Message helpers
604
+ // ---------------------------------------------------------------------------
605
+
606
+ export const appendMainMessage = async (session, message) => {
607
+ if (!session) {
608
+ return;
609
+ }
610
+ await appendWorktreeMessage(session, "main", message);
611
+ };
612
+
613
+ export const getWorktreeMessages = async (
614
+ session,
615
+ worktreeId,
616
+ { limit = null, beforeMessageId = null } = {}
617
+ ) => {
618
+ if (!session) {
619
+ return [];
620
+ }
621
+ const resolvedId =
622
+ worktreeId === "main" ? getMainWorktreeStorageId(session.sessionId) : worktreeId;
623
+ await getWorktree(session, worktreeId);
624
+ return storage.getWorktreeMessages(session.sessionId, resolvedId, {
625
+ limit,
626
+ beforeMessageId,
627
+ });
628
+ };
629
+
630
+ export const appendRpcLog = async (sessionId, entry) => {
631
+ if (!debugApiWsLog) {
632
+ return;
633
+ }
634
+ const session = await getSession(sessionId);
635
+ if (!session) {
636
+ return;
637
+ }
638
+ const rpcLogs = Array.isArray(session.rpcLogs) ? [...session.rpcLogs, entry] : [entry];
639
+ if (rpcLogs.length > 500) {
640
+ rpcLogs.splice(0, rpcLogs.length - 500);
641
+ }
642
+ const updated = { ...session, rpcLogs, lastActivityAt: Date.now() };
643
+ await storage.saveSession(sessionId, updated);
644
+ };
645
+
646
+ // ---------------------------------------------------------------------------
647
+ // Broadcast helpers
648
+ // ---------------------------------------------------------------------------
649
+
650
+ export function broadcastToSession(sessionId, payload) {
651
+ const runtime = getSessionRuntime(sessionId);
652
+ if (!runtime) {
653
+ return;
654
+ }
655
+ const message = JSON.stringify(payload);
656
+ for (const socket of runtime.sockets) {
657
+ if (socket.readyState === socket.OPEN) {
658
+ socket.send(message);
659
+ }
660
+ }
661
+ }
662
+
663
+ const repoDiffTimers = new Map();
664
+ const repoDiffInFlight = new Set();
665
+ const repoDiffDebounceMs = 500;
666
+
667
+ export const broadcastRepoDiff = async (sessionId) => {
668
+ if (!sessionId) {
669
+ return;
670
+ }
671
+ if (repoDiffInFlight.has(sessionId)) {
672
+ if (!repoDiffTimers.has(sessionId)) {
673
+ const timer = setTimeout(() => {
674
+ repoDiffTimers.delete(sessionId);
675
+ void broadcastRepoDiff(sessionId);
676
+ }, repoDiffDebounceMs);
677
+ repoDiffTimers.set(sessionId, timer);
678
+ }
679
+ return;
680
+ }
681
+ const existingTimer = repoDiffTimers.get(sessionId);
682
+ if (existingTimer) {
683
+ clearTimeout(existingTimer);
684
+ repoDiffTimers.delete(sessionId);
685
+ }
686
+ repoDiffInFlight.add(sessionId);
687
+ const session = await getSession(sessionId);
688
+ if (!session) {
689
+ repoDiffInFlight.delete(sessionId);
690
+ return;
691
+ }
692
+ try {
693
+ const [status, diff] = await Promise.all([
694
+ runSessionCommandOutput(session, "git", ["status", "--porcelain"], {
695
+ cwd: session.repoDir,
696
+ }),
697
+ runSessionCommandOutput(session, "git", ["diff"], { cwd: session.repoDir }),
698
+ ]);
699
+ broadcastToSession(sessionId, {
700
+ type: "repo_diff",
701
+ status,
702
+ diff,
703
+ });
704
+ } catch (error) {
705
+ console.error("Failed to compute repo diff:", {
706
+ sessionId,
707
+ error: error?.message || error,
708
+ });
709
+ } finally {
710
+ repoDiffInFlight.delete(sessionId);
711
+ if (repoDiffTimers.has(sessionId)) {
712
+ const timer = setTimeout(() => {
713
+ repoDiffTimers.delete(sessionId);
714
+ void broadcastRepoDiff(sessionId);
715
+ }, repoDiffDebounceMs);
716
+ repoDiffTimers.set(sessionId, timer);
717
+ }
718
+ }
719
+ };
720
+
721
+ export const getRepoDiff = async (session) => {
722
+ if (!session?.repoDir) {
723
+ return { status: "", diff: "" };
724
+ }
725
+ try {
726
+ const [status, diff] = await Promise.all([
727
+ runSessionCommandOutput(session, "git", ["status", "--porcelain"], {
728
+ cwd: session.repoDir,
729
+ }),
730
+ runSessionCommandOutput(session, "git", ["diff"], { cwd: session.repoDir }),
731
+ ]);
732
+ return { status, diff };
733
+ } catch (error) {
734
+ console.error("Failed to load repo diff:", {
735
+ sessionId: session?.sessionId,
736
+ error: error?.message || error,
737
+ });
738
+ return { status: "", diff: "" };
739
+ }
740
+ };
741
+
742
+ export const broadcastWorktreeDiff = async (sessionId, worktreeId) => {
743
+ const session = await getSession(sessionId);
744
+ if (!session) return;
745
+
746
+ try {
747
+ const diff = await getWorktreeDiff(session, worktreeId);
748
+ broadcastToSession(sessionId, {
749
+ type: "worktree_diff",
750
+ worktreeId,
751
+ ...diff,
752
+ });
753
+ } catch (error) {
754
+ console.error("Failed to compute worktree diff:", {
755
+ sessionId,
756
+ worktreeId,
757
+ error: error?.message || error,
758
+ });
759
+ }
760
+ };
761
+
762
+ export const getProviderLabel = (session) =>
763
+ session?.activeProvider === "claude" ? "Claude CLI" : "Codex app-server";
764
+
765
+ export const isValidProvider = (p) => p === "codex" || p === "claude";
766
+
767
+ // ---------------------------------------------------------------------------
768
+ // Directory browsing
769
+ // ---------------------------------------------------------------------------
770
+
771
+ export const resolveWorktreeRoot = async (session, worktreeId) => {
772
+ if (!session) {
773
+ return { rootPath: null, worktree: null };
774
+ }
775
+ if (!worktreeId || worktreeId === "main") {
776
+ return { rootPath: session.repoDir, worktree: null };
777
+ }
778
+ const worktree = await getWorktree(session, worktreeId);
779
+ if (!worktree) {
780
+ return { rootPath: null, worktree: null };
781
+ }
782
+ return { rootPath: worktree.path, worktree };
783
+ };
784
+
785
+ export const listDirectoryEntries = async (
786
+ workspaceId,
787
+ rootPath,
788
+ relativePath = ""
789
+ ) => {
790
+ const normalized = (relativePath || "")
791
+ .trim()
792
+ .replace(/\\/g, "/")
793
+ .replace(/^\.\/+/, "")
794
+ .replace(/\/+/g, "/");
795
+ const absPath = path.resolve(rootPath, normalized || ".");
796
+ const relative = path.relative(rootPath, absPath);
797
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
798
+ throw new Error("Invalid path.");
799
+ }
800
+ const stat = await getWorkspaceStat(workspaceId, absPath);
801
+ if (!stat?.type || stat.type !== "directory") {
802
+ throw new Error("Path is not a directory.");
803
+ }
804
+ const entries = await listWorkspaceEntries(workspaceId, absPath);
805
+ const visible = entries.filter((entry) => !TREE_IGNORED_NAMES.has(entry.name));
806
+ visible.sort((a, b) => {
807
+ const aIsDir = a.type === "d";
808
+ const bIsDir = b.type === "d";
809
+ if (aIsDir && !bIsDir) return -1;
810
+ if (!aIsDir && bIsDir) return 1;
811
+ return a.name.localeCompare(b.name);
812
+ });
813
+ const nodes = visible.map((entry) => {
814
+ const entryPath = normalized ? `${normalized}/${entry.name}` : entry.name;
815
+ if (entry.type === "d") {
816
+ return {
817
+ name: entry.name,
818
+ path: entryPath,
819
+ type: "dir",
820
+ children: null,
821
+ };
822
+ }
823
+ return {
824
+ name: entry.name,
825
+ path: entryPath,
826
+ type: "file",
827
+ };
828
+ });
829
+ return { entries: nodes, path: normalized || "" };
830
+ };
831
+
832
+ export const ensureUniqueFilename = async (workspaceId, dir, filename, reserved) => {
833
+ const extension = path.extname(filename);
834
+ const base = path.basename(filename, extension);
835
+ let candidate = filename;
836
+ let counter = 1;
837
+ while (true) {
838
+ if (reserved?.has(candidate)) {
839
+ candidate = `${base}-${counter}${extension}`;
840
+ counter += 1;
841
+ continue;
842
+ }
843
+ if (reserved) {
844
+ reserved.add(candidate);
845
+ }
846
+ const exists = await workspacePathExists(workspaceId, path.join(dir, candidate));
847
+ if (exists) {
848
+ candidate = `${base}-${counter}${extension}`;
849
+ counter += 1;
850
+ continue;
851
+ }
852
+ return candidate;
853
+ }
854
+ };
855
+
856
+ // ---------------------------------------------------------------------------
857
+ // Multer upload instance
858
+ // ---------------------------------------------------------------------------
859
+
860
+ const uploadTempDir = path.join(os.tmpdir(), "vibe80_uploads");
861
+ fs.mkdirSync(uploadTempDir, { recursive: true, mode: 0o700 });
862
+
863
+ export const upload = multer({
864
+ storage: multer.diskStorage({
865
+ destination: (req, file, cb) => {
866
+ const sessionId = req.params?.sessionId || req.query.session;
867
+ getSession(sessionId, req.workspaceId)
868
+ .then((session) => {
869
+ if (!session) {
870
+ cb(new Error("Invalid session."));
871
+ return;
872
+ }
873
+ cb(null, uploadTempDir);
874
+ })
875
+ .catch((error) => cb(error));
876
+ },
877
+ filename: (req, file, cb) => {
878
+ const sessionId = req.params?.sessionId || req.query.session;
879
+ getSession(sessionId, req.workspaceId)
880
+ .then(async (session) => {
881
+ if (!session) {
882
+ cb(new Error("Invalid session."));
883
+ return;
884
+ }
885
+ try {
886
+ const safeName = sanitizeFilename(file.originalname);
887
+ const reserved =
888
+ req._reservedFilenames || (req._reservedFilenames = new Set());
889
+ const uniqueName = await ensureUniqueFilename(
890
+ session.workspaceId,
891
+ session.attachmentsDir,
892
+ safeName,
893
+ reserved
894
+ );
895
+ cb(null, uniqueName);
896
+ } catch (error) {
897
+ cb(error);
898
+ }
899
+ })
900
+ .catch((error) => cb(error));
901
+ },
902
+ }),
903
+ limits: { files: 20, fileSize: 50 * 1024 * 1024 },
904
+ });
905
+
906
+ // ---------------------------------------------------------------------------
907
+ // Re-exports needed by routes
908
+ // ---------------------------------------------------------------------------
909
+
910
+ export {
911
+ getWorktree,
912
+ clearWorktreeMessages,
913
+ getWorktreeDiff,
914
+ updateWorktreeStatus,
915
+ updateWorktreeThreadId,
916
+ readWorkspaceFileBuffer,
917
+ writeWorkspaceFilePreserveMode,
918
+ };