@openchamber/web 1.0.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.
- package/README.md +34 -0
- package/bin/cli.js +561 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +18 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/MonacoDiffViewer-J2AIDXvs.js +1 -0
- package/dist/assets/ToolOutputDialog-B0y5ge-3.js +5 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-iDfKTtMQ.css +1 -0
- package/dist/assets/index-kNntYPVa.js +2 -0
- package/dist/assets/main-BEJ2XliY.css +1 -0
- package/dist/assets/main-Ba339xde.js +59 -0
- package/dist/assets/vendor--B3aGWKBE.css +32 -0
- package/dist/assets/vendor-.pnpm-B1ce5n1Z.js +3192 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/index.html +197 -0
- package/dist/logo-dark.svg +4 -0
- package/dist/logo-light.svg +4 -0
- package/dist/site.webmanifest +36 -0
- package/dist/vite.svg +1 -0
- package/package.json +92 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +18 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/logo-dark.svg +4 -0
- package/public/logo-light.svg +4 -0
- package/public/site.webmanifest +36 -0
- package/public/vite.svg +1 -0
- package/server/index.d.ts +28 -0
- package/server/index.js +3038 -0
- package/server/lib/git-identity-storage.js +108 -0
- package/server/lib/git-service.js +899 -0
- package/server/lib/opencode-config.js +471 -0
- package/server/lib/opencode-config.js.d.ts +12 -0
- package/server/lib/ui-auth.js +266 -0
package/server/index.js
ADDED
|
@@ -0,0 +1,3038 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createProxyMiddleware } from 'http-proxy-middleware';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { spawn, spawnSync } from 'child_process';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import http from 'http';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import { createUiAuth } from './lib/ui-auth.js';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PORT = 3000;
|
|
15
|
+
const DEFAULT_OPENCODE_PORT = 0;
|
|
16
|
+
const HEALTH_CHECK_INTERVAL = 30000;
|
|
17
|
+
const SHUTDOWN_TIMEOUT = 10000;
|
|
18
|
+
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
|
|
19
|
+
const MODELS_METADATA_CACHE_TTL = 5 * 60 * 1000;
|
|
20
|
+
const CLIENT_RELOAD_DELAY_MS = 800;
|
|
21
|
+
const OPEN_CODE_READY_GRACE_MS = 12000;
|
|
22
|
+
const LONG_REQUEST_TIMEOUT_MS = 4 * 60 * 1000;
|
|
23
|
+
const fsPromises = fs.promises;
|
|
24
|
+
const DEFAULT_FILE_SEARCH_LIMIT = 60;
|
|
25
|
+
const MAX_FILE_SEARCH_LIMIT = 400;
|
|
26
|
+
const FILE_SEARCH_MAX_CONCURRENCY = 5;
|
|
27
|
+
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
|
|
28
|
+
'node_modules',
|
|
29
|
+
'.git',
|
|
30
|
+
'dist',
|
|
31
|
+
'build',
|
|
32
|
+
'.next',
|
|
33
|
+
'.turbo',
|
|
34
|
+
'.cache',
|
|
35
|
+
'coverage',
|
|
36
|
+
'tmp',
|
|
37
|
+
'logs'
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const normalizeRelativeSearchPath = (rootPath, targetPath) => {
|
|
41
|
+
const relative = path.relative(rootPath, targetPath) || path.basename(targetPath);
|
|
42
|
+
return relative.split(path.sep).join('/') || targetPath;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const shouldSkipSearchDirectory = (name) => {
|
|
46
|
+
if (!name) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (name.startsWith('.')) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const listDirectoryEntries = async (dirPath) => {
|
|
56
|
+
try {
|
|
57
|
+
return await fsPromises.readdir(dirPath, { withFileTypes: true });
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const searchFilesystemFiles = async (rootPath, options) => {
|
|
64
|
+
const { limit, query } = options;
|
|
65
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
66
|
+
const matchAll = normalizedQuery.length === 0;
|
|
67
|
+
const queue = [rootPath];
|
|
68
|
+
const visited = new Set([rootPath]);
|
|
69
|
+
const results = [];
|
|
70
|
+
|
|
71
|
+
while (queue.length > 0 && results.length < limit) {
|
|
72
|
+
const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY);
|
|
73
|
+
const dirLists = await Promise.all(batch.map((dir) => listDirectoryEntries(dir)));
|
|
74
|
+
|
|
75
|
+
for (let index = 0; index < batch.length; index += 1) {
|
|
76
|
+
const currentDir = batch[index];
|
|
77
|
+
const dirents = dirLists[index];
|
|
78
|
+
|
|
79
|
+
for (const dirent of dirents) {
|
|
80
|
+
const entryName = dirent.name;
|
|
81
|
+
if (!entryName || entryName.startsWith('.')) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const entryPath = path.join(currentDir, entryName);
|
|
86
|
+
|
|
87
|
+
if (dirent.isDirectory()) {
|
|
88
|
+
if (shouldSkipSearchDirectory(entryName)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!visited.has(entryPath)) {
|
|
92
|
+
visited.add(entryPath);
|
|
93
|
+
queue.push(entryPath);
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!dirent.isFile()) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const relativePath = normalizeRelativeSearchPath(rootPath, entryPath);
|
|
103
|
+
if (!matchAll) {
|
|
104
|
+
const lowercaseName = entryName.toLowerCase();
|
|
105
|
+
const lowercasePath = relativePath.toLowerCase();
|
|
106
|
+
if (!lowercaseName.includes(normalizedQuery) && !lowercasePath.includes(normalizedQuery)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
results.push({
|
|
112
|
+
name: entryName,
|
|
113
|
+
path: entryPath,
|
|
114
|
+
relativePath,
|
|
115
|
+
extension: entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (results.length >= limit) {
|
|
119
|
+
queue.length = 0;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (results.length >= limit) {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return results;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const createTimeoutSignal = (timeoutMs) => {
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
136
|
+
return {
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
cleanup: () => clearTimeout(timer),
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const stripJsonMarkdownWrapper = (value) => {
|
|
143
|
+
if (typeof value !== 'string') {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
let trimmed = value.trim();
|
|
147
|
+
if (!trimmed) {
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
if (trimmed.startsWith('```')) {
|
|
151
|
+
trimmed = trimmed.replace(/^```(?:json)?\s*/i, '');
|
|
152
|
+
const closingFenceIndex = trimmed.lastIndexOf('```');
|
|
153
|
+
if (closingFenceIndex !== -1) {
|
|
154
|
+
trimmed = trimmed.slice(0, closingFenceIndex);
|
|
155
|
+
}
|
|
156
|
+
trimmed = trimmed.trim();
|
|
157
|
+
}
|
|
158
|
+
if (trimmed.endsWith('```')) {
|
|
159
|
+
trimmed = trimmed.slice(0, -3).trim();
|
|
160
|
+
}
|
|
161
|
+
return trimmed;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
|
165
|
+
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
|
166
|
+
: path.join(os.homedir(), '.config', 'openchamber');
|
|
167
|
+
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
|
168
|
+
|
|
169
|
+
const readSettingsFromDisk = async () => {
|
|
170
|
+
try {
|
|
171
|
+
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
|
|
172
|
+
const parsed = JSON.parse(raw);
|
|
173
|
+
if (parsed && typeof parsed === 'object') {
|
|
174
|
+
return parsed;
|
|
175
|
+
}
|
|
176
|
+
return {};
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
console.warn('Failed to read settings file:', error);
|
|
182
|
+
return {};
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const writeSettingsToDisk = async (settings) => {
|
|
187
|
+
try {
|
|
188
|
+
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
|
|
189
|
+
await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.warn('Failed to write settings file:', error);
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const sanitizeTypographySizesPartial = (input) => {
|
|
197
|
+
if (!input || typeof input !== 'object') {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
const candidate = input;
|
|
201
|
+
const result = {};
|
|
202
|
+
let populated = false;
|
|
203
|
+
|
|
204
|
+
const assign = (key) => {
|
|
205
|
+
if (typeof candidate[key] === 'string' && candidate[key].length > 0) {
|
|
206
|
+
result[key] = candidate[key];
|
|
207
|
+
populated = true;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
assign('markdown');
|
|
212
|
+
assign('code');
|
|
213
|
+
assign('uiHeader');
|
|
214
|
+
assign('uiLabel');
|
|
215
|
+
assign('meta');
|
|
216
|
+
assign('micro');
|
|
217
|
+
|
|
218
|
+
return populated ? result : undefined;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const normalizeStringArray = (input) => {
|
|
222
|
+
if (!Array.isArray(input)) {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
return Array.from(
|
|
226
|
+
new Set(
|
|
227
|
+
input.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const sanitizeSettingsUpdate = (payload) => {
|
|
233
|
+
if (!payload || typeof payload !== 'object') {
|
|
234
|
+
return {};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const candidate = payload;
|
|
238
|
+
const result = {};
|
|
239
|
+
|
|
240
|
+
if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) {
|
|
241
|
+
result.themeId = candidate.themeId;
|
|
242
|
+
}
|
|
243
|
+
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
|
|
244
|
+
result.themeVariant = candidate.themeVariant;
|
|
245
|
+
}
|
|
246
|
+
if (typeof candidate.useSystemTheme === 'boolean') {
|
|
247
|
+
result.useSystemTheme = candidate.useSystemTheme;
|
|
248
|
+
}
|
|
249
|
+
if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) {
|
|
250
|
+
result.lightThemeId = candidate.lightThemeId;
|
|
251
|
+
}
|
|
252
|
+
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
|
|
253
|
+
result.darkThemeId = candidate.darkThemeId;
|
|
254
|
+
}
|
|
255
|
+
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
|
|
256
|
+
result.lastDirectory = candidate.lastDirectory;
|
|
257
|
+
}
|
|
258
|
+
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
|
259
|
+
result.homeDirectory = candidate.homeDirectory;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (Array.isArray(candidate.approvedDirectories)) {
|
|
263
|
+
result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories);
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(candidate.securityScopedBookmarks)) {
|
|
266
|
+
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
|
|
267
|
+
}
|
|
268
|
+
if (Array.isArray(candidate.pinnedDirectories)) {
|
|
269
|
+
result.pinnedDirectories = normalizeStringArray(candidate.pinnedDirectories);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
|
|
273
|
+
result.uiFont = candidate.uiFont;
|
|
274
|
+
}
|
|
275
|
+
if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) {
|
|
276
|
+
result.monoFont = candidate.monoFont;
|
|
277
|
+
}
|
|
278
|
+
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
|
|
279
|
+
result.markdownDisplayMode = candidate.markdownDisplayMode;
|
|
280
|
+
}
|
|
281
|
+
if (typeof candidate.showReasoningTraces === 'boolean') {
|
|
282
|
+
result.showReasoningTraces = candidate.showReasoningTraces;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
|
|
286
|
+
if (typography) {
|
|
287
|
+
result.typographySizes = typography;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return result;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const mergePersistedSettings = (current, changes) => {
|
|
294
|
+
const baseApproved = Array.isArray(changes.approvedDirectories)
|
|
295
|
+
? changes.approvedDirectories
|
|
296
|
+
: Array.isArray(current.approvedDirectories)
|
|
297
|
+
? current.approvedDirectories
|
|
298
|
+
: [];
|
|
299
|
+
|
|
300
|
+
const additionalApproved = [];
|
|
301
|
+
if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) {
|
|
302
|
+
additionalApproved.push(changes.lastDirectory);
|
|
303
|
+
}
|
|
304
|
+
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
|
|
305
|
+
additionalApproved.push(changes.homeDirectory);
|
|
306
|
+
}
|
|
307
|
+
const approvedSource = [...baseApproved, ...additionalApproved];
|
|
308
|
+
|
|
309
|
+
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
|
|
310
|
+
? changes.securityScopedBookmarks
|
|
311
|
+
: Array.isArray(current.securityScopedBookmarks)
|
|
312
|
+
? current.securityScopedBookmarks
|
|
313
|
+
: [];
|
|
314
|
+
|
|
315
|
+
const nextTypographySizes = changes.typographySizes
|
|
316
|
+
? {
|
|
317
|
+
...(current.typographySizes || {}),
|
|
318
|
+
...changes.typographySizes
|
|
319
|
+
}
|
|
320
|
+
: current.typographySizes;
|
|
321
|
+
|
|
322
|
+
const next = {
|
|
323
|
+
...current,
|
|
324
|
+
...changes,
|
|
325
|
+
approvedDirectories: Array.from(
|
|
326
|
+
new Set(
|
|
327
|
+
approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
328
|
+
)
|
|
329
|
+
),
|
|
330
|
+
securityScopedBookmarks: Array.from(
|
|
331
|
+
new Set(
|
|
332
|
+
baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
333
|
+
)
|
|
334
|
+
),
|
|
335
|
+
typographySizes: nextTypographySizes
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return next;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const formatSettingsResponse = (settings) => {
|
|
342
|
+
const sanitized = sanitizeSettingsUpdate(settings);
|
|
343
|
+
const approved = normalizeStringArray(settings.approvedDirectories);
|
|
344
|
+
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
...sanitized,
|
|
348
|
+
approvedDirectories: approved,
|
|
349
|
+
securityScopedBookmarks: bookmarks,
|
|
350
|
+
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
|
351
|
+
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
|
|
352
|
+
showReasoningTraces:
|
|
353
|
+
typeof settings.showReasoningTraces === 'boolean'
|
|
354
|
+
? settings.showReasoningTraces
|
|
355
|
+
: typeof sanitized.showReasoningTraces === 'boolean'
|
|
356
|
+
? sanitized.showReasoningTraces
|
|
357
|
+
: false
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const persistSettings = async (changes) => {
|
|
362
|
+
const current = await readSettingsFromDisk();
|
|
363
|
+
const sanitized = sanitizeSettingsUpdate(changes);
|
|
364
|
+
const next = mergePersistedSettings(current, sanitized);
|
|
365
|
+
await writeSettingsToDisk(next);
|
|
366
|
+
return formatSettingsResponse(next);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// Global state
|
|
370
|
+
let openCodeProcess = null;
|
|
371
|
+
let openCodePort = null;
|
|
372
|
+
let healthCheckInterval = null;
|
|
373
|
+
let server = null;
|
|
374
|
+
let isShuttingDown = false;
|
|
375
|
+
let cachedModelsMetadata = null;
|
|
376
|
+
let cachedModelsMetadataTimestamp = 0;
|
|
377
|
+
let expressApp = null;
|
|
378
|
+
let currentRestartPromise = null;
|
|
379
|
+
let isRestartingOpenCode = false;
|
|
380
|
+
let openCodeApiPrefix = '';
|
|
381
|
+
let openCodeApiPrefixDetected = false;
|
|
382
|
+
let openCodeApiDetectionTimer = null;
|
|
383
|
+
let isDetectingApiPrefix = false;
|
|
384
|
+
let openCodeApiDetectionPromise = null;
|
|
385
|
+
let lastOpenCodeError = null;
|
|
386
|
+
let openCodePortWaiters = [];
|
|
387
|
+
let isOpenCodeReady = false;
|
|
388
|
+
let openCodeNotReadySince = 0;
|
|
389
|
+
let exitOnShutdown = true;
|
|
390
|
+
let signalsAttached = false;
|
|
391
|
+
let openCodeWorkingDirectory = process.cwd();
|
|
392
|
+
let uiAuthController = null;
|
|
393
|
+
|
|
394
|
+
const OPENCODE_BINARY_ENV =
|
|
395
|
+
process.env.OPENCODE_BINARY ||
|
|
396
|
+
process.env.OPENCHAMBER_BINARY ||
|
|
397
|
+
process.env.OPENCODE_PATH ||
|
|
398
|
+
process.env.OPENCHAMBER_OPENCODE_PATH ||
|
|
399
|
+
null;
|
|
400
|
+
|
|
401
|
+
function buildAugmentedPath() {
|
|
402
|
+
const augmented = new Set();
|
|
403
|
+
|
|
404
|
+
const loginShellPath = getLoginShellPath();
|
|
405
|
+
if (loginShellPath) {
|
|
406
|
+
for (const segment of loginShellPath.split(path.delimiter)) {
|
|
407
|
+
if (segment) {
|
|
408
|
+
augmented.add(segment);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
414
|
+
for (const segment of current) {
|
|
415
|
+
augmented.add(segment);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return Array.from(augmented).join(path.delimiter);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function getLoginShellPath() {
|
|
422
|
+
if (process.platform === 'win32') {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const shell = process.env.SHELL || '/bin/zsh';
|
|
427
|
+
try {
|
|
428
|
+
const result = spawnSync(shell, ['-lic', 'echo -n "$PATH"'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
429
|
+
if (result.status === 0 && typeof result.stdout === 'string') {
|
|
430
|
+
const value = result.stdout.trim();
|
|
431
|
+
if (value) {
|
|
432
|
+
return value;
|
|
433
|
+
}
|
|
434
|
+
} else if (result.stderr) {
|
|
435
|
+
console.warn(`Failed to read PATH from login shell (${shell}): ${result.stderr}`);
|
|
436
|
+
}
|
|
437
|
+
} catch (error) {
|
|
438
|
+
console.warn(`Error executing login shell (${shell}) for PATH detection: ${error.message}`);
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function resolveBinaryFromPath(binaryName, searchPath) {
|
|
444
|
+
if (!binaryName) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
if (path.isAbsolute(binaryName)) {
|
|
448
|
+
return fs.existsSync(binaryName) ? binaryName : null;
|
|
449
|
+
}
|
|
450
|
+
const directories = searchPath.split(path.delimiter).filter(Boolean);
|
|
451
|
+
for (const directory of directories) {
|
|
452
|
+
try {
|
|
453
|
+
const candidate = path.join(directory, binaryName);
|
|
454
|
+
if (fs.existsSync(candidate)) {
|
|
455
|
+
const stats = fs.statSync(candidate);
|
|
456
|
+
if (stats.isFile()) {
|
|
457
|
+
return candidate;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
// Ignore resolution errors, continue searching
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function getOpencodeSpawnConfig() {
|
|
468
|
+
const envPath = buildAugmentedPath();
|
|
469
|
+
const resolvedEnv = { ...process.env, PATH: envPath };
|
|
470
|
+
|
|
471
|
+
if (OPENCODE_BINARY_ENV) {
|
|
472
|
+
const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, envPath);
|
|
473
|
+
if (explicit) {
|
|
474
|
+
console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`);
|
|
475
|
+
return { command: explicit, env: resolvedEnv };
|
|
476
|
+
}
|
|
477
|
+
console.warn(
|
|
478
|
+
`OPENCODE_BINARY path "${OPENCODE_BINARY_ENV}" not found. Falling back to search.`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { command: 'opencode', env: resolvedEnv };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const ENV_CONFIGURED_OPENCODE_PORT = (() => {
|
|
486
|
+
const raw =
|
|
487
|
+
process.env.OPENCODE_PORT ||
|
|
488
|
+
process.env.OPENCHAMBER_OPENCODE_PORT ||
|
|
489
|
+
process.env.OPENCHAMBER_INTERNAL_PORT;
|
|
490
|
+
if (!raw) {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
const parsed = parseInt(raw, 10);
|
|
494
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
495
|
+
})();
|
|
496
|
+
|
|
497
|
+
const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
|
|
498
|
+
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
if (ENV_CONFIGURED_API_PREFIX) {
|
|
502
|
+
openCodeApiPrefix = ENV_CONFIGURED_API_PREFIX;
|
|
503
|
+
openCodeApiPrefixDetected = true;
|
|
504
|
+
console.log(`Using OpenCode API prefix from environment: ${openCodeApiPrefix}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function setOpenCodePort(port) {
|
|
508
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const numericPort = Math.trunc(port);
|
|
513
|
+
const portChanged = openCodePort !== numericPort;
|
|
514
|
+
|
|
515
|
+
if (portChanged || openCodePort === null) {
|
|
516
|
+
openCodePort = numericPort;
|
|
517
|
+
console.log(`Detected OpenCode port: ${openCodePort}`);
|
|
518
|
+
|
|
519
|
+
if (portChanged) {
|
|
520
|
+
isOpenCodeReady = false;
|
|
521
|
+
}
|
|
522
|
+
openCodeNotReadySince = Date.now();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
lastOpenCodeError = null;
|
|
526
|
+
|
|
527
|
+
if (openCodePortWaiters.length > 0) {
|
|
528
|
+
const waiters = openCodePortWaiters;
|
|
529
|
+
openCodePortWaiters = [];
|
|
530
|
+
for (const notify of waiters) {
|
|
531
|
+
try {
|
|
532
|
+
notify(numericPort);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
console.warn('Failed to notify OpenCode port waiter:', error);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function waitForOpenCodePort(timeoutMs = 15000) {
|
|
541
|
+
if (openCodePort !== null) {
|
|
542
|
+
return openCodePort;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return new Promise((resolve, reject) => {
|
|
546
|
+
const onPortDetected = (port) => {
|
|
547
|
+
clearTimeout(timeout);
|
|
548
|
+
resolve(port);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const timeout = setTimeout(() => {
|
|
552
|
+
openCodePortWaiters = openCodePortWaiters.filter((cb) => cb !== onPortDetected);
|
|
553
|
+
reject(new Error('Timed out waiting for OpenCode port'));
|
|
554
|
+
}, timeoutMs);
|
|
555
|
+
|
|
556
|
+
openCodePortWaiters.push(onPortDetected);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const API_PREFIX_CANDIDATES = ['', '/api']; // Simplified - only check root and /api
|
|
561
|
+
|
|
562
|
+
function normalizeApiPrefix(prefix) {
|
|
563
|
+
if (!prefix) {
|
|
564
|
+
return '';
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (prefix.includes('://')) {
|
|
568
|
+
try {
|
|
569
|
+
const parsed = new URL(prefix);
|
|
570
|
+
return normalizeApiPrefix(parsed.pathname);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
return '';
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const trimmed = prefix.trim();
|
|
577
|
+
if (!trimmed || trimmed === '/') {
|
|
578
|
+
return '';
|
|
579
|
+
}
|
|
580
|
+
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
581
|
+
return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function setDetectedOpenCodeApiPrefix(prefix) {
|
|
585
|
+
const normalized = normalizeApiPrefix(prefix);
|
|
586
|
+
if (!openCodeApiPrefixDetected || openCodeApiPrefix !== normalized) {
|
|
587
|
+
openCodeApiPrefix = normalized;
|
|
588
|
+
openCodeApiPrefixDetected = true;
|
|
589
|
+
if (openCodeApiDetectionTimer) {
|
|
590
|
+
clearTimeout(openCodeApiDetectionTimer);
|
|
591
|
+
openCodeApiDetectionTimer = null;
|
|
592
|
+
}
|
|
593
|
+
console.log(`Detected OpenCode API prefix: ${normalized || '(root)'}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function detectPortFromLogMessage(message) {
|
|
598
|
+
if (openCodePort && ENV_CONFIGURED_OPENCODE_PORT) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const regex = /https?:\/\/[^:\s]+:(\d+)/gi;
|
|
603
|
+
let match;
|
|
604
|
+
while ((match = regex.exec(message)) !== null) {
|
|
605
|
+
const port = parseInt(match[1], 10);
|
|
606
|
+
if (Number.isFinite(port) && port > 0) {
|
|
607
|
+
setOpenCodePort(port);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const fallbackMatch = /(?:^|\s)(?:127\.0\.0\.1|localhost):(\d+)/i.exec(message);
|
|
613
|
+
if (fallbackMatch) {
|
|
614
|
+
const port = parseInt(fallbackMatch[1], 10);
|
|
615
|
+
if (Number.isFinite(port) && port > 0) {
|
|
616
|
+
setOpenCodePort(port);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function detectPrefixFromLogMessage(message) {
|
|
622
|
+
if (!openCodePort) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const urlRegex = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
|
|
627
|
+
let match;
|
|
628
|
+
|
|
629
|
+
while ((match = urlRegex.exec(message)) !== null) {
|
|
630
|
+
const portMatch = parseInt(match[1], 10);
|
|
631
|
+
if (portMatch !== openCodePort) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const path = match[2] || '';
|
|
636
|
+
const normalized = normalizeApiPrefix(path);
|
|
637
|
+
setDetectedOpenCodeApiPrefix(normalized);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function getCandidateApiPrefixes() {
|
|
643
|
+
if (openCodeApiPrefixDetected) {
|
|
644
|
+
return [openCodeApiPrefix];
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const candidates = [];
|
|
648
|
+
if (openCodeApiPrefix && !candidates.includes(openCodeApiPrefix)) {
|
|
649
|
+
candidates.push(openCodeApiPrefix);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
for (const candidate of API_PREFIX_CANDIDATES) {
|
|
653
|
+
if (!candidates.includes(candidate)) {
|
|
654
|
+
candidates.push(candidate);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return candidates;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function buildOpenCodeUrl(path, prefixOverride) {
|
|
662
|
+
if (!openCodePort) {
|
|
663
|
+
throw new Error('OpenCode port is not available');
|
|
664
|
+
}
|
|
665
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
666
|
+
const prefix = normalizeApiPrefix(
|
|
667
|
+
prefixOverride !== undefined ? prefixOverride : openCodeApiPrefixDetected ? openCodeApiPrefix : ''
|
|
668
|
+
);
|
|
669
|
+
const fullPath = `${prefix}${normalizedPath}`;
|
|
670
|
+
return `http://localhost:${openCodePort}${fullPath}`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function extractApiPrefixFromUrl(urlString, expectedSuffix) {
|
|
674
|
+
if (!urlString) {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
const parsed = new URL(urlString);
|
|
679
|
+
const pathname = parsed.pathname || '';
|
|
680
|
+
if (expectedSuffix && pathname.endsWith(expectedSuffix)) {
|
|
681
|
+
const prefix = pathname.slice(0, pathname.length - expectedSuffix.length);
|
|
682
|
+
return normalizeApiPrefix(prefix);
|
|
683
|
+
}
|
|
684
|
+
} catch (error) {
|
|
685
|
+
console.warn(`Failed to parse OpenCode URL "${urlString}": ${error.message}`);
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function tryDetectOpenCodeApiPrefix() {
|
|
691
|
+
if (!openCodePort) {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const docPrefix = await detectPrefixFromDocumentation();
|
|
696
|
+
if (docPrefix !== null) {
|
|
697
|
+
setDetectedOpenCodeApiPrefix(docPrefix);
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const candidates = getCandidateApiPrefixes();
|
|
702
|
+
|
|
703
|
+
for (const candidate of candidates) {
|
|
704
|
+
try {
|
|
705
|
+
const response = await fetch(buildOpenCodeUrl('/config', candidate), {
|
|
706
|
+
method: 'GET',
|
|
707
|
+
headers: { Accept: 'application/json' }
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
if (response.ok) {
|
|
711
|
+
await response.json().catch(() => null);
|
|
712
|
+
setDetectedOpenCodeApiPrefix(candidate);
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
} catch (error) {
|
|
716
|
+
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function detectOpenCodeApiPrefix() {
|
|
724
|
+
if (openCodeApiPrefixDetected) {
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (!openCodePort) {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (isDetectingApiPrefix) {
|
|
733
|
+
try {
|
|
734
|
+
await openCodeApiDetectionPromise;
|
|
735
|
+
} catch (error) {
|
|
736
|
+
|
|
737
|
+
}
|
|
738
|
+
return openCodeApiPrefixDetected;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
isDetectingApiPrefix = true;
|
|
742
|
+
openCodeApiDetectionPromise = (async () => {
|
|
743
|
+
const success = await tryDetectOpenCodeApiPrefix();
|
|
744
|
+
if (!success) {
|
|
745
|
+
console.warn('Failed to detect OpenCode API prefix via documentation or known candidates');
|
|
746
|
+
}
|
|
747
|
+
return success;
|
|
748
|
+
})();
|
|
749
|
+
|
|
750
|
+
try {
|
|
751
|
+
const result = await openCodeApiDetectionPromise;
|
|
752
|
+
return result;
|
|
753
|
+
} finally {
|
|
754
|
+
isDetectingApiPrefix = false;
|
|
755
|
+
openCodeApiDetectionPromise = null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function ensureOpenCodeApiPrefix() {
|
|
760
|
+
if (openCodeApiPrefixDetected) {
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const result = await detectOpenCodeApiPrefix();
|
|
765
|
+
if (!result) {
|
|
766
|
+
scheduleOpenCodeApiDetection();
|
|
767
|
+
}
|
|
768
|
+
return result;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function scheduleOpenCodeApiDetection(delayMs = 500) {
|
|
772
|
+
if (openCodeApiPrefixDetected) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (openCodeApiDetectionTimer) {
|
|
777
|
+
clearTimeout(openCodeApiDetectionTimer);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
openCodeApiDetectionTimer = setTimeout(async () => {
|
|
781
|
+
openCodeApiDetectionTimer = null;
|
|
782
|
+
const success = await detectOpenCodeApiPrefix();
|
|
783
|
+
if (!success) {
|
|
784
|
+
const nextDelay = Math.min(delayMs * 2, 8000);
|
|
785
|
+
scheduleOpenCodeApiDetection(nextDelay);
|
|
786
|
+
}
|
|
787
|
+
}, delayMs);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const OPENAPI_DOC_PATHS = ['/doc'];
|
|
791
|
+
|
|
792
|
+
function extractPrefixFromOpenApiDocument(content) {
|
|
793
|
+
|
|
794
|
+
const globalMatch = content.match(/__OPENCODE_API_BASE__\s*=\s*['"]([^'"]+)['"]/);
|
|
795
|
+
if (globalMatch && globalMatch[1]) {
|
|
796
|
+
return normalizeApiPrefix(globalMatch[1]);
|
|
797
|
+
}
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function detectPrefixFromDocumentation() {
|
|
802
|
+
if (!openCodePort) {
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const prefixesToTry = [...new Set(['', ...API_PREFIX_CANDIDATES])];
|
|
807
|
+
|
|
808
|
+
for (const prefix of prefixesToTry) {
|
|
809
|
+
for (const docPath of OPENAPI_DOC_PATHS) {
|
|
810
|
+
try {
|
|
811
|
+
const response = await fetch(buildOpenCodeUrl(docPath, prefix), {
|
|
812
|
+
method: 'GET',
|
|
813
|
+
headers: { Accept: '*/*' }
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
if (!response.ok) {
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const text = await response.text();
|
|
821
|
+
const extracted = extractPrefixFromOpenApiDocument(text);
|
|
822
|
+
if (extracted !== null) {
|
|
823
|
+
return extracted;
|
|
824
|
+
}
|
|
825
|
+
} catch (error) {
|
|
826
|
+
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
835
|
+
const args = Array.isArray(argv) ? [...argv] : [];
|
|
836
|
+
const envPassword =
|
|
837
|
+
process.env.OPENCHAMBER_UI_PASSWORD ||
|
|
838
|
+
process.env.OPENCODE_UI_PASSWORD ||
|
|
839
|
+
null;
|
|
840
|
+
const options = { port: DEFAULT_PORT, uiPassword: envPassword };
|
|
841
|
+
|
|
842
|
+
const consumeValue = (currentIndex, inlineValue) => {
|
|
843
|
+
if (typeof inlineValue === 'string') {
|
|
844
|
+
return { value: inlineValue, nextIndex: currentIndex };
|
|
845
|
+
}
|
|
846
|
+
const nextArg = args[currentIndex + 1];
|
|
847
|
+
if (typeof nextArg === 'string' && !nextArg.startsWith('--')) {
|
|
848
|
+
return { value: nextArg, nextIndex: currentIndex + 1 };
|
|
849
|
+
}
|
|
850
|
+
return { value: undefined, nextIndex: currentIndex };
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
for (let i = 0; i < args.length; i++) {
|
|
854
|
+
const arg = args[i];
|
|
855
|
+
if (!arg.startsWith('--')) {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const eqIndex = arg.indexOf('=');
|
|
860
|
+
const optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
|
|
861
|
+
const inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
|
|
862
|
+
|
|
863
|
+
if (optionName === 'port' || optionName === 'p') {
|
|
864
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
865
|
+
i = nextIndex;
|
|
866
|
+
const parsedPort = parseInt(value ?? '', 10);
|
|
867
|
+
options.port = Number.isFinite(parsedPort) ? parsedPort : DEFAULT_PORT;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (optionName === 'ui-password') {
|
|
872
|
+
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|
873
|
+
i = nextIndex;
|
|
874
|
+
options.uiPassword = typeof value === 'string' ? value : '';
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return options;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function startOpenCode() {
|
|
883
|
+
const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? DEFAULT_OPENCODE_PORT;
|
|
884
|
+
console.log(
|
|
885
|
+
desiredPort
|
|
886
|
+
? `Starting OpenCode on requested port ${desiredPort}...`
|
|
887
|
+
: 'Starting OpenCode with dynamic port assignment...'
|
|
888
|
+
);
|
|
889
|
+
console.log(`Starting OpenCode in working directory: ${openCodeWorkingDirectory}`);
|
|
890
|
+
|
|
891
|
+
const { command, env } = getOpencodeSpawnConfig();
|
|
892
|
+
const args = ['serve', '--port', desiredPort.toString()];
|
|
893
|
+
console.log(`Launching OpenCode via "${command}" with args ${args.join(' ')}`);
|
|
894
|
+
|
|
895
|
+
const child = spawn(command, args, {
|
|
896
|
+
stdio: 'pipe',
|
|
897
|
+
env,
|
|
898
|
+
cwd: openCodeWorkingDirectory
|
|
899
|
+
});
|
|
900
|
+
isOpenCodeReady = false;
|
|
901
|
+
openCodeNotReadySince = Date.now();
|
|
902
|
+
|
|
903
|
+
let firstSignalResolver;
|
|
904
|
+
const firstSignalPromise = new Promise((resolve) => {
|
|
905
|
+
firstSignalResolver = resolve;
|
|
906
|
+
});
|
|
907
|
+
let firstSignalSettled = false;
|
|
908
|
+
const settleFirstSignal = () => {
|
|
909
|
+
if (firstSignalSettled) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
firstSignalSettled = true;
|
|
913
|
+
clearTimeout(firstSignalTimer);
|
|
914
|
+
child.stdout.off('data', settleFirstSignal);
|
|
915
|
+
child.stderr.off('data', settleFirstSignal);
|
|
916
|
+
child.off('exit', settleFirstSignal);
|
|
917
|
+
if (firstSignalResolver) {
|
|
918
|
+
firstSignalResolver();
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
const firstSignalTimer = setTimeout(settleFirstSignal, 750);
|
|
922
|
+
|
|
923
|
+
child.stdout.once('data', settleFirstSignal);
|
|
924
|
+
child.stderr.once('data', settleFirstSignal);
|
|
925
|
+
child.once('exit', settleFirstSignal);
|
|
926
|
+
|
|
927
|
+
child.stdout.on('data', (data) => {
|
|
928
|
+
const text = data.toString();
|
|
929
|
+
console.log(`OpenCode: ${text.trim()}`);
|
|
930
|
+
detectPortFromLogMessage(text);
|
|
931
|
+
detectPrefixFromLogMessage(text);
|
|
932
|
+
settleFirstSignal();
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
child.stderr.on('data', (data) => {
|
|
936
|
+
const text = data.toString();
|
|
937
|
+
lastOpenCodeError = text.trim();
|
|
938
|
+
console.error(`OpenCode Error: ${lastOpenCodeError}`);
|
|
939
|
+
detectPortFromLogMessage(text);
|
|
940
|
+
detectPrefixFromLogMessage(text);
|
|
941
|
+
settleFirstSignal();
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
let startupError = await new Promise((resolve, reject) => {
|
|
945
|
+
const onSpawn = () => {
|
|
946
|
+
setOpenCodePort(desiredPort);
|
|
947
|
+
child.off('error', onError);
|
|
948
|
+
resolve(null);
|
|
949
|
+
};
|
|
950
|
+
const onError = (error) => {
|
|
951
|
+
child.off('spawn', onSpawn);
|
|
952
|
+
reject(error);
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
child.once('spawn', onSpawn);
|
|
956
|
+
child.once('error', onError);
|
|
957
|
+
}).catch((error) => {
|
|
958
|
+
lastOpenCodeError = error.message;
|
|
959
|
+
openCodePort = null;
|
|
960
|
+
settleFirstSignal();
|
|
961
|
+
return error;
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
if (startupError) {
|
|
965
|
+
if (startupError.code === 'ENOENT') {
|
|
966
|
+
const enhanced = new Error(
|
|
967
|
+
`Failed to start OpenCode – executable "${command}" not found. ` +
|
|
968
|
+
'Set OPENCODE_BINARY to the full path of the opencode CLI or ensure it is on PATH.'
|
|
969
|
+
);
|
|
970
|
+
enhanced.code = startupError.code;
|
|
971
|
+
startupError = enhanced;
|
|
972
|
+
}
|
|
973
|
+
throw startupError;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
child.on('exit', (code, signal) => {
|
|
977
|
+
lastOpenCodeError = `OpenCode exited with code ${code}, signal ${signal ?? 'null'}`;
|
|
978
|
+
isOpenCodeReady = false;
|
|
979
|
+
openCodeNotReadySince = Date.now();
|
|
980
|
+
|
|
981
|
+
if (!isShuttingDown && !isRestartingOpenCode) {
|
|
982
|
+
console.log(`OpenCode process exited with code ${code}, signal ${signal}`);
|
|
983
|
+
|
|
984
|
+
setTimeout(() => {
|
|
985
|
+
restartOpenCode().catch((err) => {
|
|
986
|
+
console.error('Failed to restart OpenCode after exit:', err);
|
|
987
|
+
});
|
|
988
|
+
}, 5000);
|
|
989
|
+
} else if (isRestartingOpenCode) {
|
|
990
|
+
console.log('OpenCode exit during controlled restart, not triggering auto-restart');
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
child.on('error', (error) => {
|
|
995
|
+
lastOpenCodeError = error.message;
|
|
996
|
+
isOpenCodeReady = false;
|
|
997
|
+
openCodeNotReadySince = Date.now();
|
|
998
|
+
console.error(`OpenCode process error: ${error.message}`);
|
|
999
|
+
if (!isShuttingDown) {
|
|
1000
|
+
|
|
1001
|
+
setTimeout(() => {
|
|
1002
|
+
restartOpenCode().catch((err) => {
|
|
1003
|
+
console.error('Failed to restart OpenCode after error:', err);
|
|
1004
|
+
});
|
|
1005
|
+
}, 5000);
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
await firstSignalPromise;
|
|
1010
|
+
|
|
1011
|
+
return child;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function restartOpenCode() {
|
|
1015
|
+
if (isShuttingDown) return;
|
|
1016
|
+
if (currentRestartPromise) {
|
|
1017
|
+
await currentRestartPromise;
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
currentRestartPromise = (async () => {
|
|
1022
|
+
isRestartingOpenCode = true;
|
|
1023
|
+
isOpenCodeReady = false;
|
|
1024
|
+
openCodeNotReadySince = Date.now();
|
|
1025
|
+
console.log('Restarting OpenCode process...');
|
|
1026
|
+
|
|
1027
|
+
if (openCodeProcess) {
|
|
1028
|
+
console.log('Waiting for OpenCode process to terminate...');
|
|
1029
|
+
const processToTerminate = openCodeProcess;
|
|
1030
|
+
let forcedTermination = false;
|
|
1031
|
+
|
|
1032
|
+
if (processToTerminate.exitCode === null && processToTerminate.signalCode === null) {
|
|
1033
|
+
processToTerminate.kill('SIGTERM');
|
|
1034
|
+
|
|
1035
|
+
await new Promise((resolve) => {
|
|
1036
|
+
let resolved = false;
|
|
1037
|
+
|
|
1038
|
+
const cleanup = () => {
|
|
1039
|
+
processToTerminate.off('exit', onExit);
|
|
1040
|
+
clearTimeout(forceKillTimer);
|
|
1041
|
+
clearTimeout(hardStopTimer);
|
|
1042
|
+
if (!resolved) {
|
|
1043
|
+
resolved = true;
|
|
1044
|
+
resolve();
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
const onExit = () => {
|
|
1049
|
+
cleanup();
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
const forceKillTimer = setTimeout(() => {
|
|
1053
|
+
if (resolved) {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
forcedTermination = true;
|
|
1057
|
+
console.warn('OpenCode process did not exit after SIGTERM, sending SIGKILL');
|
|
1058
|
+
processToTerminate.kill('SIGKILL');
|
|
1059
|
+
}, 3000);
|
|
1060
|
+
|
|
1061
|
+
const hardStopTimer = setTimeout(() => {
|
|
1062
|
+
if (resolved) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
console.warn('OpenCode process unresponsive after SIGKILL, continuing restart');
|
|
1066
|
+
cleanup();
|
|
1067
|
+
}, 5000);
|
|
1068
|
+
|
|
1069
|
+
processToTerminate.once('exit', onExit);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
if (forcedTermination) {
|
|
1073
|
+
console.log('OpenCode process terminated forcefully during restart');
|
|
1074
|
+
}
|
|
1075
|
+
} else {
|
|
1076
|
+
console.log('OpenCode process already stopped before restart command');
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
openCodeProcess = null;
|
|
1080
|
+
|
|
1081
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (ENV_CONFIGURED_OPENCODE_PORT) {
|
|
1085
|
+
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
|
|
1086
|
+
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
|
|
1087
|
+
} else {
|
|
1088
|
+
openCodePort = null;
|
|
1089
|
+
}
|
|
1090
|
+
openCodeApiPrefixDetected = false;
|
|
1091
|
+
if (openCodeApiDetectionTimer) {
|
|
1092
|
+
clearTimeout(openCodeApiDetectionTimer);
|
|
1093
|
+
openCodeApiDetectionTimer = null;
|
|
1094
|
+
}
|
|
1095
|
+
openCodeApiDetectionPromise = null;
|
|
1096
|
+
|
|
1097
|
+
lastOpenCodeError = null;
|
|
1098
|
+
openCodeProcess = await startOpenCode();
|
|
1099
|
+
|
|
1100
|
+
if (!ENV_CONFIGURED_OPENCODE_PORT) {
|
|
1101
|
+
await waitForOpenCodePort();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
if (expressApp) {
|
|
1105
|
+
setupProxy(expressApp);
|
|
1106
|
+
scheduleOpenCodeApiDetection();
|
|
1107
|
+
}
|
|
1108
|
+
})();
|
|
1109
|
+
|
|
1110
|
+
try {
|
|
1111
|
+
await currentRestartPromise;
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
console.error(`Failed to restart OpenCode: ${error.message}`);
|
|
1114
|
+
lastOpenCodeError = error.message;
|
|
1115
|
+
if (!ENV_CONFIGURED_OPENCODE_PORT) {
|
|
1116
|
+
openCodePort = null;
|
|
1117
|
+
}
|
|
1118
|
+
openCodeApiPrefixDetected = false;
|
|
1119
|
+
throw error;
|
|
1120
|
+
} finally {
|
|
1121
|
+
currentRestartPromise = null;
|
|
1122
|
+
isRestartingOpenCode = false;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async function waitForOpenCodeReady(timeoutMs = 20000, intervalMs = 400) {
|
|
1127
|
+
if (!openCodePort) {
|
|
1128
|
+
throw new Error('OpenCode port is not available');
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const deadline = Date.now() + timeoutMs;
|
|
1132
|
+
let lastError = null;
|
|
1133
|
+
|
|
1134
|
+
while (Date.now() < deadline) {
|
|
1135
|
+
const prefixes = getCandidateApiPrefixes();
|
|
1136
|
+
|
|
1137
|
+
for (const prefix of prefixes) {
|
|
1138
|
+
try {
|
|
1139
|
+
const normalizedPrefix = normalizeApiPrefix(prefix);
|
|
1140
|
+
const healthPromise = fetch(buildOpenCodeUrl('/health', normalizedPrefix), {
|
|
1141
|
+
method: 'GET',
|
|
1142
|
+
headers: { Accept: 'application/json' }
|
|
1143
|
+
}).catch((error) => error);
|
|
1144
|
+
|
|
1145
|
+
const configPromise = fetch(buildOpenCodeUrl('/config', normalizedPrefix), {
|
|
1146
|
+
method: 'GET',
|
|
1147
|
+
headers: { Accept: 'application/json' }
|
|
1148
|
+
}).catch((error) => error);
|
|
1149
|
+
|
|
1150
|
+
const [healthResult, configResult] = await Promise.all([healthPromise, configPromise]);
|
|
1151
|
+
|
|
1152
|
+
if (healthResult instanceof Error) {
|
|
1153
|
+
lastError = healthResult;
|
|
1154
|
+
} else if (healthResult.ok) {
|
|
1155
|
+
const healthData = await healthResult.json().catch(() => null);
|
|
1156
|
+
if (healthData && healthData.isOpenCodeReady === false) {
|
|
1157
|
+
lastError = new Error('OpenCode health indicates not ready');
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
} else {
|
|
1161
|
+
lastError = new Error(`OpenCode health endpoint responded with status ${healthResult.status}`);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (configResult instanceof Error) {
|
|
1165
|
+
lastError = configResult;
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (!configResult.ok) {
|
|
1170
|
+
if (configResult.status === 404 && !openCodeApiPrefixDetected && normalizedPrefix === '') {
|
|
1171
|
+
lastError = new Error('OpenCode config endpoint returned 404 on root prefix');
|
|
1172
|
+
} else {
|
|
1173
|
+
lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`);
|
|
1174
|
+
}
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
await configResult.json().catch(() => null);
|
|
1179
|
+
const detectedPrefix = extractApiPrefixFromUrl(configResult.url, '/config');
|
|
1180
|
+
if (detectedPrefix !== null) {
|
|
1181
|
+
setDetectedOpenCodeApiPrefix(detectedPrefix);
|
|
1182
|
+
} else if (normalizedPrefix) {
|
|
1183
|
+
setDetectedOpenCodeApiPrefix(normalizedPrefix);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const effectivePrefix = detectedPrefix !== null ? detectedPrefix : normalizedPrefix;
|
|
1187
|
+
|
|
1188
|
+
const agentResponse = await fetch(
|
|
1189
|
+
buildOpenCodeUrl('/agent', effectivePrefix),
|
|
1190
|
+
{
|
|
1191
|
+
method: 'GET',
|
|
1192
|
+
headers: { Accept: 'application/json' }
|
|
1193
|
+
}
|
|
1194
|
+
).catch((error) => error);
|
|
1195
|
+
|
|
1196
|
+
if (agentResponse instanceof Error) {
|
|
1197
|
+
lastError = agentResponse;
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (!agentResponse.ok) {
|
|
1202
|
+
lastError = new Error(`Agent endpoint responded with status ${agentResponse.status}`);
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
await agentResponse.json().catch(() => []);
|
|
1207
|
+
|
|
1208
|
+
if (detectedPrefix === null) {
|
|
1209
|
+
const agentPrefix = extractApiPrefixFromUrl(agentResponse.url, '/agent');
|
|
1210
|
+
if (agentPrefix !== null) {
|
|
1211
|
+
setDetectedOpenCodeApiPrefix(agentPrefix);
|
|
1212
|
+
} else if (normalizedPrefix) {
|
|
1213
|
+
setDetectedOpenCodeApiPrefix(normalizedPrefix);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
isOpenCodeReady = true;
|
|
1218
|
+
lastOpenCodeError = null;
|
|
1219
|
+
return;
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
lastError = error;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (lastError) {
|
|
1229
|
+
lastOpenCodeError = lastError.message || String(lastError);
|
|
1230
|
+
throw lastError;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const timeoutError = new Error('Timed out waiting for OpenCode to become ready');
|
|
1234
|
+
lastOpenCodeError = timeoutError.message;
|
|
1235
|
+
throw timeoutError;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async function waitForAgentPresence(agentName, timeoutMs = 15000, intervalMs = 300) {
|
|
1239
|
+
if (!openCodePort) {
|
|
1240
|
+
throw new Error('OpenCode port is not available');
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const deadline = Date.now() + timeoutMs;
|
|
1244
|
+
|
|
1245
|
+
while (Date.now() < deadline) {
|
|
1246
|
+
try {
|
|
1247
|
+
const response = await fetch(buildOpenCodeUrl('/agent'), {
|
|
1248
|
+
method: 'GET',
|
|
1249
|
+
headers: { Accept: 'application/json' }
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
if (response.ok) {
|
|
1253
|
+
const agents = await response.json();
|
|
1254
|
+
if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) {
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
throw new Error(`Agent "${agentName}" not available after OpenCode restart`);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async function fetchAgentsSnapshot() {
|
|
1269
|
+
if (!openCodePort) {
|
|
1270
|
+
throw new Error('OpenCode port is not available');
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const response = await fetch(buildOpenCodeUrl('/agent'), {
|
|
1274
|
+
method: 'GET',
|
|
1275
|
+
headers: { Accept: 'application/json' }
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
if (!response.ok) {
|
|
1279
|
+
throw new Error(`Failed to fetch agents snapshot (status ${response.status})`);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const agents = await response.json().catch(() => null);
|
|
1283
|
+
if (!Array.isArray(agents)) {
|
|
1284
|
+
throw new Error('Invalid agents payload from OpenCode');
|
|
1285
|
+
}
|
|
1286
|
+
return agents;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
async function fetchProvidersSnapshot() {
|
|
1290
|
+
if (!openCodePort) {
|
|
1291
|
+
throw new Error('OpenCode port is not available');
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const response = await fetch(buildOpenCodeUrl('/provider'), {
|
|
1295
|
+
method: 'GET',
|
|
1296
|
+
headers: { Accept: 'application/json' }
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
if (!response.ok) {
|
|
1300
|
+
throw new Error(`Failed to fetch providers snapshot (status ${response.status})`);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const providers = await response.json().catch(() => null);
|
|
1304
|
+
if (!Array.isArray(providers)) {
|
|
1305
|
+
throw new Error('Invalid providers payload from OpenCode');
|
|
1306
|
+
}
|
|
1307
|
+
return providers;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async function fetchModelsSnapshot() {
|
|
1311
|
+
if (!openCodePort) {
|
|
1312
|
+
throw new Error('OpenCode port is not available');
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const response = await fetch(buildOpenCodeUrl('/model'), {
|
|
1316
|
+
method: 'GET',
|
|
1317
|
+
headers: { Accept: 'application/json' }
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
if (!response.ok) {
|
|
1321
|
+
throw new Error(`Failed to fetch models snapshot (status ${response.status})`);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const models = await response.json().catch(() => null);
|
|
1325
|
+
if (!Array.isArray(models)) {
|
|
1326
|
+
throw new Error('Invalid models payload from OpenCode');
|
|
1327
|
+
}
|
|
1328
|
+
return models;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async function refreshOpenCodeAfterConfigChange(reason, options = {}) {
|
|
1332
|
+
const { agentName } = options;
|
|
1333
|
+
|
|
1334
|
+
console.log(`Refreshing OpenCode after ${reason}`);
|
|
1335
|
+
await restartOpenCode();
|
|
1336
|
+
|
|
1337
|
+
try {
|
|
1338
|
+
await waitForOpenCodeReady();
|
|
1339
|
+
isOpenCodeReady = true;
|
|
1340
|
+
openCodeNotReadySince = 0;
|
|
1341
|
+
|
|
1342
|
+
if (agentName) {
|
|
1343
|
+
await waitForAgentPresence(agentName);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
isOpenCodeReady = true;
|
|
1347
|
+
openCodeNotReadySince = 0;
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
|
|
1350
|
+
isOpenCodeReady = false;
|
|
1351
|
+
openCodeNotReadySince = Date.now();
|
|
1352
|
+
console.error(`Failed to refresh OpenCode after ${reason}:`, error.message);
|
|
1353
|
+
throw error;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function setupProxy(app) {
|
|
1358
|
+
if (!openCodePort) return;
|
|
1359
|
+
|
|
1360
|
+
if (app.get('opencodeProxyConfigured')) {
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
console.log(`Setting up proxy to OpenCode on port ${openCodePort}`);
|
|
1365
|
+
app.set('opencodeProxyConfigured', true);
|
|
1366
|
+
|
|
1367
|
+
app.use('/api', (req, res, next) => {
|
|
1368
|
+
if (
|
|
1369
|
+
req.path.startsWith('/themes/custom') ||
|
|
1370
|
+
req.path.startsWith('/config/agents') ||
|
|
1371
|
+
req.path.startsWith('/config/settings') ||
|
|
1372
|
+
req.path === '/health'
|
|
1373
|
+
) {
|
|
1374
|
+
return next();
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const waitElapsed = openCodeNotReadySince === 0 ? 0 : Date.now() - openCodeNotReadySince;
|
|
1378
|
+
const stillWaiting =
|
|
1379
|
+
(!isOpenCodeReady && (openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) ||
|
|
1380
|
+
isRestartingOpenCode ||
|
|
1381
|
+
!openCodePort;
|
|
1382
|
+
|
|
1383
|
+
if (stillWaiting) {
|
|
1384
|
+
return res.status(503).json({
|
|
1385
|
+
error: 'OpenCode is restarting',
|
|
1386
|
+
restarting: true,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
next();
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
app.use('/api', async (req, res, next) => {
|
|
1394
|
+
try {
|
|
1395
|
+
await ensureOpenCodeApiPrefix();
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
console.warn(`OpenCode API prefix detection failed for ${req.method} ${req.path}: ${error.message}`);
|
|
1398
|
+
}
|
|
1399
|
+
next();
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
app.use('/api', (req, res, next) => {
|
|
1403
|
+
if (
|
|
1404
|
+
req.path.startsWith('/themes/custom') ||
|
|
1405
|
+
req.path.startsWith('/config/agents') ||
|
|
1406
|
+
req.path.startsWith('/config/settings') ||
|
|
1407
|
+
req.path === '/health'
|
|
1408
|
+
) {
|
|
1409
|
+
return next();
|
|
1410
|
+
}
|
|
1411
|
+
console.log(`API → OpenCode: ${req.method} ${req.path}`);
|
|
1412
|
+
next();
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
const proxyMiddleware = createProxyMiddleware({
|
|
1416
|
+
target: openCodePort ? `http://localhost:${openCodePort}` : 'http://127.0.0.1:0',
|
|
1417
|
+
router: () => {
|
|
1418
|
+
if (!openCodePort) {
|
|
1419
|
+
return 'http://127.0.0.1:0';
|
|
1420
|
+
}
|
|
1421
|
+
return `http://localhost:${openCodePort}`;
|
|
1422
|
+
},
|
|
1423
|
+
changeOrigin: true,
|
|
1424
|
+
pathRewrite: (path) => {
|
|
1425
|
+
if (!path.startsWith('/api')) {
|
|
1426
|
+
return path;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const suffix = path.slice(4) || '/';
|
|
1430
|
+
|
|
1431
|
+
if (!openCodeApiPrefixDetected || openCodeApiPrefix === '') {
|
|
1432
|
+
return suffix;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return `${openCodeApiPrefix}${suffix}`;
|
|
1436
|
+
},
|
|
1437
|
+
ws: true,
|
|
1438
|
+
onError: (err, req, res) => {
|
|
1439
|
+
console.error(`Proxy error: ${err.message}`);
|
|
1440
|
+
if (!res.headersSent) {
|
|
1441
|
+
res.status(503).json({ error: 'OpenCode service unavailable' });
|
|
1442
|
+
}
|
|
1443
|
+
},
|
|
1444
|
+
onProxyReq: (proxyReq, req, res) => {
|
|
1445
|
+
console.log(`Proxying ${req.method} ${req.path} to OpenCode`);
|
|
1446
|
+
if (req.headers.accept && req.headers.accept.includes('text/event-stream')) {
|
|
1447
|
+
console.log(`[SSE] Setting up SSE proxy for ${req.method} ${req.path}`);
|
|
1448
|
+
proxyReq.setHeader('Accept', 'text/event-stream');
|
|
1449
|
+
proxyReq.setHeader('Cache-Control', 'no-cache');
|
|
1450
|
+
proxyReq.setHeader('Connection', 'keep-alive');
|
|
1451
|
+
}
|
|
1452
|
+
},
|
|
1453
|
+
onProxyRes: (proxyRes, req, res) => {
|
|
1454
|
+
if (req.url?.includes('/event')) {
|
|
1455
|
+
console.log(`[SSE] Proxy response for ${req.method} ${req.url} - Status: ${proxyRes.statusCode}`);
|
|
1456
|
+
proxyRes.headers['Access-Control-Allow-Origin'] = '*';
|
|
1457
|
+
proxyRes.headers['Access-Control-Allow-Headers'] = 'Cache-Control, Accept';
|
|
1458
|
+
proxyRes.headers['Content-Type'] = 'text/event-stream';
|
|
1459
|
+
proxyRes.headers['Cache-Control'] = 'no-cache';
|
|
1460
|
+
proxyRes.headers['Connection'] = 'keep-alive';
|
|
1461
|
+
|
|
1462
|
+
proxyRes.headers['X-Accel-Buffering'] = 'no';
|
|
1463
|
+
proxyRes.headers['X-Content-Type-Options'] = 'nosniff';
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
if (proxyRes.statusCode === 404 && !openCodeApiPrefixDetected) {
|
|
1467
|
+
scheduleOpenCodeApiDetection();
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
app.use('/api', proxyMiddleware);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function startHealthMonitoring() {
|
|
1476
|
+
if (healthCheckInterval) {
|
|
1477
|
+
clearInterval(healthCheckInterval);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
healthCheckInterval = setInterval(async () => {
|
|
1481
|
+
if (!openCodeProcess || isShuttingDown) return;
|
|
1482
|
+
|
|
1483
|
+
try {
|
|
1484
|
+
|
|
1485
|
+
if (openCodeProcess.exitCode !== null) {
|
|
1486
|
+
console.log('OpenCode process not running, restarting...');
|
|
1487
|
+
await restartOpenCode();
|
|
1488
|
+
}
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
console.error(`Health check error: ${error.message}`);
|
|
1491
|
+
}
|
|
1492
|
+
}, HEALTH_CHECK_INTERVAL);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
async function gracefulShutdown(options = {}) {
|
|
1496
|
+
if (isShuttingDown) return;
|
|
1497
|
+
|
|
1498
|
+
isShuttingDown = true;
|
|
1499
|
+
console.log('Starting graceful shutdown...');
|
|
1500
|
+
const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown;
|
|
1501
|
+
|
|
1502
|
+
if (healthCheckInterval) {
|
|
1503
|
+
clearInterval(healthCheckInterval);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
if (openCodeProcess) {
|
|
1507
|
+
console.log('Stopping OpenCode process...');
|
|
1508
|
+
openCodeProcess.kill('SIGTERM');
|
|
1509
|
+
|
|
1510
|
+
await new Promise((resolve) => {
|
|
1511
|
+
const timeout = setTimeout(() => {
|
|
1512
|
+
openCodeProcess.kill('SIGKILL');
|
|
1513
|
+
resolve();
|
|
1514
|
+
}, SHUTDOWN_TIMEOUT);
|
|
1515
|
+
|
|
1516
|
+
openCodeProcess.on('exit', () => {
|
|
1517
|
+
clearTimeout(timeout);
|
|
1518
|
+
resolve();
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
if (server) {
|
|
1524
|
+
await new Promise((resolve) => {
|
|
1525
|
+
server.close(() => {
|
|
1526
|
+
console.log('HTTP server closed');
|
|
1527
|
+
resolve();
|
|
1528
|
+
});
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if (uiAuthController) {
|
|
1533
|
+
uiAuthController.dispose();
|
|
1534
|
+
uiAuthController = null;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
console.log('Graceful shutdown complete');
|
|
1538
|
+
if (exitProcess) {
|
|
1539
|
+
process.exit(0);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
async function main(options = {}) {
|
|
1544
|
+
const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT;
|
|
1545
|
+
const attachSignals = options.attachSignals !== false;
|
|
1546
|
+
if (typeof options.exitOnShutdown === 'boolean') {
|
|
1547
|
+
exitOnShutdown = options.exitOnShutdown;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
console.log(`Starting OpenChamber on port ${port}`);
|
|
1551
|
+
|
|
1552
|
+
const app = express();
|
|
1553
|
+
expressApp = app;
|
|
1554
|
+
server = http.createServer(app);
|
|
1555
|
+
|
|
1556
|
+
app.get('/health', (req, res) => {
|
|
1557
|
+
res.json({
|
|
1558
|
+
status: 'ok',
|
|
1559
|
+
timestamp: new Date().toISOString(),
|
|
1560
|
+
openCodePort: openCodePort,
|
|
1561
|
+
openCodeRunning: Boolean(openCodeProcess && openCodeProcess.exitCode === null),
|
|
1562
|
+
openCodeApiPrefix,
|
|
1563
|
+
openCodeApiPrefixDetected,
|
|
1564
|
+
isOpenCodeReady,
|
|
1565
|
+
lastOpenCodeError
|
|
1566
|
+
});
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
app.use((req, res, next) => {
|
|
1570
|
+
if (
|
|
1571
|
+
req.path.startsWith('/api/config/agents') ||
|
|
1572
|
+
req.path.startsWith('/api/config/commands') ||
|
|
1573
|
+
req.path.startsWith('/api/config/settings') ||
|
|
1574
|
+
req.path.startsWith('/api/fs') ||
|
|
1575
|
+
req.path.startsWith('/api/git') ||
|
|
1576
|
+
req.path.startsWith('/api/prompts') ||
|
|
1577
|
+
req.path.startsWith('/api/terminal') ||
|
|
1578
|
+
req.path.startsWith('/api/opencode')
|
|
1579
|
+
) {
|
|
1580
|
+
|
|
1581
|
+
express.json()(req, res, next);
|
|
1582
|
+
} else if (req.path.startsWith('/api')) {
|
|
1583
|
+
|
|
1584
|
+
next();
|
|
1585
|
+
} else {
|
|
1586
|
+
|
|
1587
|
+
express.json()(req, res, next);
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
app.use(express.urlencoded({ extended: true }));
|
|
1591
|
+
|
|
1592
|
+
app.use((req, res, next) => {
|
|
1593
|
+
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
|
|
1594
|
+
next();
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : null;
|
|
1598
|
+
uiAuthController = createUiAuth({ password: uiPassword });
|
|
1599
|
+
if (uiAuthController.enabled) {
|
|
1600
|
+
console.log('UI password protection enabled for browser sessions');
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
app.get('/auth/session', (req, res) => uiAuthController.handleSessionStatus(req, res));
|
|
1604
|
+
app.post('/auth/session', (req, res) => uiAuthController.handleSessionCreate(req, res));
|
|
1605
|
+
|
|
1606
|
+
app.use('/api', (req, res, next) => uiAuthController.requireAuth(req, res, next));
|
|
1607
|
+
|
|
1608
|
+
app.get('/api/openchamber/models-metadata', async (req, res) => {
|
|
1609
|
+
const now = Date.now();
|
|
1610
|
+
|
|
1611
|
+
if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < MODELS_METADATA_CACHE_TTL) {
|
|
1612
|
+
res.setHeader('Cache-Control', 'public, max-age=60');
|
|
1613
|
+
return res.json(cachedModelsMetadata);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
1617
|
+
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
|
|
1618
|
+
|
|
1619
|
+
try {
|
|
1620
|
+
const response = await fetch(MODELS_DEV_API_URL, {
|
|
1621
|
+
signal: controller?.signal,
|
|
1622
|
+
headers: {
|
|
1623
|
+
Accept: 'application/json'
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
if (!response.ok) {
|
|
1628
|
+
throw new Error(`models.dev responded with status ${response.status}`);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const metadata = await response.json();
|
|
1632
|
+
cachedModelsMetadata = metadata;
|
|
1633
|
+
cachedModelsMetadataTimestamp = Date.now();
|
|
1634
|
+
|
|
1635
|
+
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
1636
|
+
res.json(metadata);
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
console.warn('Failed to fetch models.dev metadata via server:', error);
|
|
1639
|
+
|
|
1640
|
+
if (cachedModelsMetadata) {
|
|
1641
|
+
res.setHeader('Cache-Control', 'public, max-age=60');
|
|
1642
|
+
res.json(cachedModelsMetadata);
|
|
1643
|
+
} else {
|
|
1644
|
+
const statusCode = error?.name === 'AbortError' ? 504 : 502;
|
|
1645
|
+
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
|
|
1646
|
+
}
|
|
1647
|
+
} finally {
|
|
1648
|
+
if (timeout) {
|
|
1649
|
+
clearTimeout(timeout);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
app.get('/api/config/settings', async (_req, res) => {
|
|
1655
|
+
try {
|
|
1656
|
+
const settings = await readSettingsFromDisk();
|
|
1657
|
+
res.json(formatSettingsResponse(settings));
|
|
1658
|
+
} catch (error) {
|
|
1659
|
+
console.error('Failed to load settings:', error);
|
|
1660
|
+
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load settings' });
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
app.put('/api/config/settings', async (req, res) => {
|
|
1665
|
+
try {
|
|
1666
|
+
const updated = await persistSettings(req.body ?? {});
|
|
1667
|
+
res.json(updated);
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
console.error('Failed to save settings:', error);
|
|
1670
|
+
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to save settings' });
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
const {
|
|
1675
|
+
getAgentSources,
|
|
1676
|
+
createAgent,
|
|
1677
|
+
updateAgent,
|
|
1678
|
+
deleteAgent,
|
|
1679
|
+
getCommandSources,
|
|
1680
|
+
createCommand,
|
|
1681
|
+
updateCommand,
|
|
1682
|
+
deleteCommand
|
|
1683
|
+
} = await import('./lib/opencode-config.js');
|
|
1684
|
+
|
|
1685
|
+
app.get('/api/config/agents/:name', (req, res) => {
|
|
1686
|
+
try {
|
|
1687
|
+
const agentName = req.params.name;
|
|
1688
|
+
const sources = getAgentSources(agentName);
|
|
1689
|
+
|
|
1690
|
+
res.json({
|
|
1691
|
+
name: agentName,
|
|
1692
|
+
sources: sources,
|
|
1693
|
+
isBuiltIn: !sources.md.exists && !sources.json.exists
|
|
1694
|
+
});
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
console.error('Failed to get agent sources:', error);
|
|
1697
|
+
res.status(500).json({ error: 'Failed to get agent configuration metadata' });
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
app.post('/api/config/agents/:name', async (req, res) => {
|
|
1702
|
+
try {
|
|
1703
|
+
const agentName = req.params.name;
|
|
1704
|
+
const config = req.body;
|
|
1705
|
+
|
|
1706
|
+
createAgent(agentName, config);
|
|
1707
|
+
await refreshOpenCodeAfterConfigChange('agent creation', {
|
|
1708
|
+
agentName
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
res.json({
|
|
1712
|
+
success: true,
|
|
1713
|
+
requiresReload: true,
|
|
1714
|
+
message: `Agent ${agentName} created successfully. Reloading interface…`,
|
|
1715
|
+
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|
1716
|
+
});
|
|
1717
|
+
} catch (error) {
|
|
1718
|
+
console.error('Failed to create agent:', error);
|
|
1719
|
+
res.status(500).json({ error: error.message || 'Failed to create agent' });
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
app.patch('/api/config/agents/:name', async (req, res) => {
|
|
1724
|
+
try {
|
|
1725
|
+
const agentName = req.params.name;
|
|
1726
|
+
const updates = req.body;
|
|
1727
|
+
|
|
1728
|
+
console.log(`[Server] Updating agent: ${agentName}`);
|
|
1729
|
+
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
|
1730
|
+
|
|
1731
|
+
updateAgent(agentName, updates);
|
|
1732
|
+
await refreshOpenCodeAfterConfigChange('agent update');
|
|
1733
|
+
|
|
1734
|
+
console.log(`[Server] Agent ${agentName} updated successfully`);
|
|
1735
|
+
|
|
1736
|
+
res.json({
|
|
1737
|
+
success: true,
|
|
1738
|
+
requiresReload: true,
|
|
1739
|
+
message: `Agent ${agentName} updated successfully. Reloading interface…`,
|
|
1740
|
+
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|
1741
|
+
});
|
|
1742
|
+
} catch (error) {
|
|
1743
|
+
console.error('[Server] Failed to update agent:', error);
|
|
1744
|
+
console.error('[Server] Error stack:', error.stack);
|
|
1745
|
+
res.status(500).json({ error: error.message || 'Failed to update agent' });
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
app.delete('/api/config/agents/:name', async (req, res) => {
|
|
1750
|
+
try {
|
|
1751
|
+
const agentName = req.params.name;
|
|
1752
|
+
|
|
1753
|
+
deleteAgent(agentName);
|
|
1754
|
+
await refreshOpenCodeAfterConfigChange('agent deletion');
|
|
1755
|
+
|
|
1756
|
+
res.json({
|
|
1757
|
+
success: true,
|
|
1758
|
+
requiresReload: true,
|
|
1759
|
+
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
|
1760
|
+
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|
1761
|
+
});
|
|
1762
|
+
} catch (error) {
|
|
1763
|
+
console.error('Failed to delete agent:', error);
|
|
1764
|
+
res.status(500).json({ error: error.message || 'Failed to delete agent' });
|
|
1765
|
+
}
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
app.get('/api/config/commands/:name', (req, res) => {
|
|
1769
|
+
try {
|
|
1770
|
+
const commandName = req.params.name;
|
|
1771
|
+
const sources = getCommandSources(commandName);
|
|
1772
|
+
|
|
1773
|
+
res.json({
|
|
1774
|
+
name: commandName,
|
|
1775
|
+
sources: sources,
|
|
1776
|
+
isBuiltIn: !sources.md.exists && !sources.json.exists
|
|
1777
|
+
});
|
|
1778
|
+
} catch (error) {
|
|
1779
|
+
console.error('Failed to get command sources:', error);
|
|
1780
|
+
res.status(500).json({ error: 'Failed to get command configuration metadata' });
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
app.post('/api/config/commands/:name', async (req, res) => {
|
|
1785
|
+
try {
|
|
1786
|
+
const commandName = req.params.name;
|
|
1787
|
+
const config = req.body;
|
|
1788
|
+
|
|
1789
|
+
console.log('[Server] Creating command:', commandName);
|
|
1790
|
+
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
|
1791
|
+
|
|
1792
|
+
createCommand(commandName, config);
|
|
1793
|
+
await refreshOpenCodeAfterConfigChange('command creation', {
|
|
1794
|
+
commandName
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
res.json({
|
|
1798
|
+
success: true,
|
|
1799
|
+
requiresReload: true,
|
|
1800
|
+
message: `Command ${commandName} created successfully. Reloading interface…`,
|
|
1801
|
+
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|
1802
|
+
});
|
|
1803
|
+
} catch (error) {
|
|
1804
|
+
console.error('Failed to create command:', error);
|
|
1805
|
+
res.status(500).json({ error: error.message || 'Failed to create command' });
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
|
|
1809
|
+
app.patch('/api/config/commands/:name', async (req, res) => {
|
|
1810
|
+
try {
|
|
1811
|
+
const commandName = req.params.name;
|
|
1812
|
+
const updates = req.body;
|
|
1813
|
+
|
|
1814
|
+
console.log(`[Server] Updating command: ${commandName}`);
|
|
1815
|
+
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
|
1816
|
+
|
|
1817
|
+
updateCommand(commandName, updates);
|
|
1818
|
+
await refreshOpenCodeAfterConfigChange('command update');
|
|
1819
|
+
|
|
1820
|
+
console.log(`[Server] Command ${commandName} updated successfully`);
|
|
1821
|
+
|
|
1822
|
+
res.json({
|
|
1823
|
+
success: true,
|
|
1824
|
+
requiresReload: true,
|
|
1825
|
+
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
|
1826
|
+
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|
1827
|
+
});
|
|
1828
|
+
} catch (error) {
|
|
1829
|
+
console.error('[Server] Failed to update command:', error);
|
|
1830
|
+
console.error('[Server] Error stack:', error.stack);
|
|
1831
|
+
res.status(500).json({ error: error.message || 'Failed to update command' });
|
|
1832
|
+
}
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
app.delete('/api/config/commands/:name', async (req, res) => {
|
|
1836
|
+
try {
|
|
1837
|
+
const commandName = req.params.name;
|
|
1838
|
+
|
|
1839
|
+
deleteCommand(commandName);
|
|
1840
|
+
await refreshOpenCodeAfterConfigChange('command deletion');
|
|
1841
|
+
|
|
1842
|
+
res.json({
|
|
1843
|
+
success: true,
|
|
1844
|
+
requiresReload: true,
|
|
1845
|
+
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
|
1846
|
+
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|
1847
|
+
});
|
|
1848
|
+
} catch (error) {
|
|
1849
|
+
console.error('Failed to delete command:', error);
|
|
1850
|
+
res.status(500).json({ error: error.message || 'Failed to delete command' });
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
app.post('/api/config/reload', async (req, res) => {
|
|
1855
|
+
try {
|
|
1856
|
+
console.log('[Server] Manual configuration reload requested');
|
|
1857
|
+
|
|
1858
|
+
await refreshOpenCodeAfterConfigChange('manual configuration reload');
|
|
1859
|
+
|
|
1860
|
+
res.json({
|
|
1861
|
+
success: true,
|
|
1862
|
+
requiresReload: true,
|
|
1863
|
+
message: 'Configuration reloaded successfully. Refreshing interface…',
|
|
1864
|
+
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|
1865
|
+
});
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
console.error('[Server] Failed to reload configuration:', error);
|
|
1868
|
+
res.status(500).json({
|
|
1869
|
+
error: error.message || 'Failed to reload configuration',
|
|
1870
|
+
success: false
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
let gitLibraries = null;
|
|
1876
|
+
const getGitLibraries = async () => {
|
|
1877
|
+
if (!gitLibraries) {
|
|
1878
|
+
const [storage, service] = await Promise.all([
|
|
1879
|
+
import('./lib/git-identity-storage.js'),
|
|
1880
|
+
import('./lib/git-service.js')
|
|
1881
|
+
]);
|
|
1882
|
+
gitLibraries = { ...storage, ...service };
|
|
1883
|
+
}
|
|
1884
|
+
return gitLibraries;
|
|
1885
|
+
};
|
|
1886
|
+
|
|
1887
|
+
app.get('/api/git/identities', async (req, res) => {
|
|
1888
|
+
const { getProfiles } = await getGitLibraries();
|
|
1889
|
+
try {
|
|
1890
|
+
const profiles = getProfiles();
|
|
1891
|
+
res.json(profiles);
|
|
1892
|
+
} catch (error) {
|
|
1893
|
+
console.error('Failed to list git identity profiles:', error);
|
|
1894
|
+
res.status(500).json({ error: 'Failed to list git identity profiles' });
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
app.post('/api/git/identities', async (req, res) => {
|
|
1899
|
+
const { createProfile } = await getGitLibraries();
|
|
1900
|
+
try {
|
|
1901
|
+
const profile = createProfile(req.body);
|
|
1902
|
+
console.log(`Created git identity profile: ${profile.name} (${profile.id})`);
|
|
1903
|
+
res.json(profile);
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
console.error('Failed to create git identity profile:', error);
|
|
1906
|
+
res.status(400).json({ error: error.message || 'Failed to create git identity profile' });
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
app.put('/api/git/identities/:id', async (req, res) => {
|
|
1911
|
+
const { updateProfile } = await getGitLibraries();
|
|
1912
|
+
try {
|
|
1913
|
+
const profile = updateProfile(req.params.id, req.body);
|
|
1914
|
+
console.log(`Updated git identity profile: ${profile.name} (${profile.id})`);
|
|
1915
|
+
res.json(profile);
|
|
1916
|
+
} catch (error) {
|
|
1917
|
+
console.error('Failed to update git identity profile:', error);
|
|
1918
|
+
res.status(400).json({ error: error.message || 'Failed to update git identity profile' });
|
|
1919
|
+
}
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
app.delete('/api/git/identities/:id', async (req, res) => {
|
|
1923
|
+
const { deleteProfile } = await getGitLibraries();
|
|
1924
|
+
try {
|
|
1925
|
+
deleteProfile(req.params.id);
|
|
1926
|
+
console.log(`Deleted git identity profile: ${req.params.id}`);
|
|
1927
|
+
res.json({ success: true });
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
console.error('Failed to delete git identity profile:', error);
|
|
1930
|
+
res.status(400).json({ error: error.message || 'Failed to delete git identity profile' });
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
app.get('/api/git/global-identity', async (req, res) => {
|
|
1935
|
+
const { getGlobalIdentity } = await getGitLibraries();
|
|
1936
|
+
try {
|
|
1937
|
+
const identity = await getGlobalIdentity();
|
|
1938
|
+
res.json(identity);
|
|
1939
|
+
} catch (error) {
|
|
1940
|
+
console.error('Failed to get global git identity:', error);
|
|
1941
|
+
res.status(500).json({ error: 'Failed to get global git identity' });
|
|
1942
|
+
}
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
app.get('/api/git/check', async (req, res) => {
|
|
1946
|
+
const { isGitRepository } = await getGitLibraries();
|
|
1947
|
+
try {
|
|
1948
|
+
const directory = req.query.directory;
|
|
1949
|
+
if (!directory) {
|
|
1950
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
const isRepo = await isGitRepository(directory);
|
|
1954
|
+
res.json({ isGitRepository: isRepo });
|
|
1955
|
+
} catch (error) {
|
|
1956
|
+
console.error('Failed to check git repository:', error);
|
|
1957
|
+
res.status(500).json({ error: 'Failed to check git repository' });
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
app.get('/api/git/current-identity', async (req, res) => {
|
|
1962
|
+
const { getCurrentIdentity } = await getGitLibraries();
|
|
1963
|
+
try {
|
|
1964
|
+
const directory = req.query.directory;
|
|
1965
|
+
if (!directory) {
|
|
1966
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
const identity = await getCurrentIdentity(directory);
|
|
1970
|
+
res.json(identity);
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
console.error('Failed to get current git identity:', error);
|
|
1973
|
+
res.status(500).json({ error: 'Failed to get current git identity' });
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
app.post('/api/git/set-identity', async (req, res) => {
|
|
1978
|
+
const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries();
|
|
1979
|
+
try {
|
|
1980
|
+
const directory = req.query.directory;
|
|
1981
|
+
if (!directory) {
|
|
1982
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
const { profileId } = req.body;
|
|
1986
|
+
if (!profileId) {
|
|
1987
|
+
return res.status(400).json({ error: 'profileId is required' });
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
let profile = null;
|
|
1991
|
+
|
|
1992
|
+
if (profileId === 'global') {
|
|
1993
|
+
const globalIdentity = await getGlobalIdentity();
|
|
1994
|
+
if (!globalIdentity?.userName || !globalIdentity?.userEmail) {
|
|
1995
|
+
return res.status(404).json({ error: 'Global identity is not configured' });
|
|
1996
|
+
}
|
|
1997
|
+
profile = {
|
|
1998
|
+
id: 'global',
|
|
1999
|
+
name: 'Global Identity',
|
|
2000
|
+
userName: globalIdentity.userName,
|
|
2001
|
+
userEmail: globalIdentity.userEmail,
|
|
2002
|
+
sshKey: globalIdentity.sshCommand
|
|
2003
|
+
? globalIdentity.sshCommand.replace('ssh -i ', '')
|
|
2004
|
+
: null,
|
|
2005
|
+
};
|
|
2006
|
+
} else {
|
|
2007
|
+
profile = getProfile(profileId);
|
|
2008
|
+
if (!profile) {
|
|
2009
|
+
return res.status(404).json({ error: 'Profile not found' });
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
await setLocalIdentity(directory, profile);
|
|
2014
|
+
res.json({ success: true, profile });
|
|
2015
|
+
} catch (error) {
|
|
2016
|
+
console.error('Failed to set git identity:', error);
|
|
2017
|
+
res.status(500).json({ error: error.message || 'Failed to set git identity' });
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
app.get('/api/git/status', async (req, res) => {
|
|
2022
|
+
const { getStatus } = await getGitLibraries();
|
|
2023
|
+
try {
|
|
2024
|
+
const directory = req.query.directory;
|
|
2025
|
+
if (!directory) {
|
|
2026
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
const status = await getStatus(directory);
|
|
2030
|
+
res.json(status);
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
console.error('Failed to get git status:', error);
|
|
2033
|
+
res.status(500).json({ error: error.message || 'Failed to get git status' });
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
app.get('/api/git/diff', async (req, res) => {
|
|
2038
|
+
const { getDiff } = await getGitLibraries();
|
|
2039
|
+
try {
|
|
2040
|
+
const directory = req.query.directory;
|
|
2041
|
+
if (!directory) {
|
|
2042
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
const path = req.query.path;
|
|
2046
|
+
if (!path || typeof path !== 'string') {
|
|
2047
|
+
return res.status(400).json({ error: 'path parameter is required' });
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const staged = req.query.staged === 'true';
|
|
2051
|
+
const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined;
|
|
2052
|
+
|
|
2053
|
+
const diff = await getDiff(directory, {
|
|
2054
|
+
path,
|
|
2055
|
+
staged,
|
|
2056
|
+
contextLines: Number.isFinite(context) ? context : 3,
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
res.json({ diff });
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
console.error('Failed to get git diff:', error);
|
|
2062
|
+
res.status(500).json({ error: error.message || 'Failed to get git diff' });
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
app.get('/api/git/file-diff', async (req, res) => {
|
|
2067
|
+
const { getFileDiff } = await getGitLibraries();
|
|
2068
|
+
try {
|
|
2069
|
+
const directory = req.query.directory;
|
|
2070
|
+
if (!directory || typeof directory !== 'string') {
|
|
2071
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
const pathParam = req.query.path;
|
|
2075
|
+
if (!pathParam || typeof pathParam !== 'string') {
|
|
2076
|
+
return res.status(400).json({ error: 'path parameter is required' });
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
const staged = req.query.staged === 'true';
|
|
2080
|
+
|
|
2081
|
+
const result = await getFileDiff(directory, {
|
|
2082
|
+
path: pathParam,
|
|
2083
|
+
staged,
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
res.json({
|
|
2087
|
+
original: result.original,
|
|
2088
|
+
modified: result.modified,
|
|
2089
|
+
path: result.path,
|
|
2090
|
+
});
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
console.error('Failed to get git file diff:', error);
|
|
2093
|
+
res.status(500).json({ error: error.message || 'Failed to get git file diff' });
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
|
|
2097
|
+
app.post('/api/git/revert', async (req, res) => {
|
|
2098
|
+
const { revertFile } = await getGitLibraries();
|
|
2099
|
+
try {
|
|
2100
|
+
const directory = req.query.directory;
|
|
2101
|
+
if (!directory) {
|
|
2102
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
const { path } = req.body || {};
|
|
2106
|
+
if (!path || typeof path !== 'string') {
|
|
2107
|
+
return res.status(400).json({ error: 'path parameter is required' });
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
await revertFile(directory, path);
|
|
2111
|
+
res.json({ success: true });
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
console.error('Failed to revert git file:', error);
|
|
2114
|
+
res.status(500).json({ error: error.message || 'Failed to revert git file' });
|
|
2115
|
+
}
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
app.post('/api/git/commit-message', async (req, res) => {
|
|
2119
|
+
const { collectDiffs } = await getGitLibraries();
|
|
2120
|
+
try {
|
|
2121
|
+
const directory = req.query.directory;
|
|
2122
|
+
if (!directory || typeof directory !== 'string') {
|
|
2123
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
const files = Array.isArray(req.body?.files) ? req.body.files : [];
|
|
2127
|
+
if (files.length === 0) {
|
|
2128
|
+
return res.status(400).json({ error: 'At least one file is required' });
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
const diffs = await collectDiffs(directory, files);
|
|
2132
|
+
if (diffs.length === 0) {
|
|
2133
|
+
return res.status(400).json({ error: 'No diffs available for selected files' });
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
const MAX_DIFF_LENGTH = 4000;
|
|
2137
|
+
const diffSummaries = diffs
|
|
2138
|
+
.map(({ path, diff }) => {
|
|
2139
|
+
const trimmed = diff.length > MAX_DIFF_LENGTH ? `${diff.slice(0, MAX_DIFF_LENGTH)}\n...` : diff;
|
|
2140
|
+
return `FILE: ${path}\n${trimmed}`;
|
|
2141
|
+
})
|
|
2142
|
+
.join('\n\n');
|
|
2143
|
+
|
|
2144
|
+
const prompt = `You are drafting git commit notes for this codebase. Respond in JSON of the shape {"subject": string, "highlights": string[]} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:\n- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")\n- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)\n- summary must be imperative, concise, <= 70 characters, no trailing punctuation\n- scope is optional; include only when obvious from filenames/folders; do not invent scopes\n- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes\n- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.\n- text must be plain (no markdown bullets); each highlight should start with an uppercase verb\n\nDiff summary:\n${diffSummaries}`;
|
|
2145
|
+
|
|
2146
|
+
const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS);
|
|
2147
|
+
let response;
|
|
2148
|
+
try {
|
|
2149
|
+
response = await fetch('https://opencode.ai/zen/v1/chat/completions', {
|
|
2150
|
+
method: 'POST',
|
|
2151
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2152
|
+
body: JSON.stringify({
|
|
2153
|
+
model: 'big-pickle',
|
|
2154
|
+
messages: [{ role: 'user', content: prompt }],
|
|
2155
|
+
max_tokens: 3000,
|
|
2156
|
+
stream: false,
|
|
2157
|
+
reasoning: {
|
|
2158
|
+
effort: 'low'
|
|
2159
|
+
}
|
|
2160
|
+
}),
|
|
2161
|
+
signal: completionTimeout.signal,
|
|
2162
|
+
});
|
|
2163
|
+
} finally {
|
|
2164
|
+
completionTimeout.cleanup();
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
if (!response.ok) {
|
|
2168
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
2169
|
+
console.error('Commit message generation failed:', errorBody);
|
|
2170
|
+
return res.status(502).json({ error: 'Failed to generate commit message' });
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const data = await response.json();
|
|
2174
|
+
const raw = data?.choices?.[0]?.message?.content?.trim();
|
|
2175
|
+
|
|
2176
|
+
if (!raw) {
|
|
2177
|
+
return res.status(502).json({ error: 'No commit message returned by generator' });
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
const cleanedJson = stripJsonMarkdownWrapper(raw);
|
|
2181
|
+
const candidates = [cleanedJson, raw].filter((candidate, index, array) => {
|
|
2182
|
+
return candidate && array.indexOf(candidate) === index;
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
for (const candidate of candidates) {
|
|
2186
|
+
if (!(candidate.startsWith('{') || candidate.startsWith('['))) {
|
|
2187
|
+
continue;
|
|
2188
|
+
}
|
|
2189
|
+
try {
|
|
2190
|
+
const parsed = JSON.parse(candidate);
|
|
2191
|
+
return res.json({ message: parsed });
|
|
2192
|
+
} catch (parseError) {
|
|
2193
|
+
console.warn('Commit message generation returned non-JSON body:', parseError);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
res.json({ message: { subject: raw, highlights: [] } });
|
|
2198
|
+
} catch (error) {
|
|
2199
|
+
console.error('Failed to generate commit message:', error);
|
|
2200
|
+
res.status(500).json({ error: error.message || 'Failed to generate commit message' });
|
|
2201
|
+
}
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
app.post('/api/git/pull', async (req, res) => {
|
|
2205
|
+
const { pull } = await getGitLibraries();
|
|
2206
|
+
try {
|
|
2207
|
+
const directory = req.query.directory;
|
|
2208
|
+
if (!directory) {
|
|
2209
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
const result = await pull(directory, req.body);
|
|
2213
|
+
res.json(result);
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
console.error('Failed to pull:', error);
|
|
2216
|
+
res.status(500).json({ error: error.message || 'Failed to pull from remote' });
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
app.post('/api/git/push', async (req, res) => {
|
|
2221
|
+
const { push } = await getGitLibraries();
|
|
2222
|
+
try {
|
|
2223
|
+
const directory = req.query.directory;
|
|
2224
|
+
if (!directory) {
|
|
2225
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const result = await push(directory, req.body);
|
|
2229
|
+
res.json(result);
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
console.error('Failed to push:', error);
|
|
2232
|
+
res.status(500).json({ error: error.message || 'Failed to push to remote' });
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
app.post('/api/git/fetch', async (req, res) => {
|
|
2237
|
+
const { fetch: gitFetch } = await getGitLibraries();
|
|
2238
|
+
try {
|
|
2239
|
+
const directory = req.query.directory;
|
|
2240
|
+
if (!directory) {
|
|
2241
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const result = await gitFetch(directory, req.body);
|
|
2245
|
+
res.json(result);
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
console.error('Failed to fetch:', error);
|
|
2248
|
+
res.status(500).json({ error: error.message || 'Failed to fetch from remote' });
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
app.post('/api/git/commit', async (req, res) => {
|
|
2253
|
+
const { commit } = await getGitLibraries();
|
|
2254
|
+
try {
|
|
2255
|
+
const directory = req.query.directory;
|
|
2256
|
+
if (!directory) {
|
|
2257
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
const { message, addAll, files } = req.body;
|
|
2261
|
+
if (!message) {
|
|
2262
|
+
return res.status(400).json({ error: 'message is required' });
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
const result = await commit(directory, message, {
|
|
2266
|
+
addAll,
|
|
2267
|
+
files,
|
|
2268
|
+
});
|
|
2269
|
+
res.json(result);
|
|
2270
|
+
} catch (error) {
|
|
2271
|
+
console.error('Failed to commit:', error);
|
|
2272
|
+
res.status(500).json({ error: error.message || 'Failed to create commit' });
|
|
2273
|
+
}
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
app.get('/api/git/branches', async (req, res) => {
|
|
2277
|
+
const { getBranches } = await getGitLibraries();
|
|
2278
|
+
try {
|
|
2279
|
+
const directory = req.query.directory;
|
|
2280
|
+
if (!directory) {
|
|
2281
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
const branches = await getBranches(directory);
|
|
2285
|
+
res.json(branches);
|
|
2286
|
+
} catch (error) {
|
|
2287
|
+
console.error('Failed to get branches:', error);
|
|
2288
|
+
res.status(500).json({ error: error.message || 'Failed to get branches' });
|
|
2289
|
+
}
|
|
2290
|
+
});
|
|
2291
|
+
|
|
2292
|
+
app.post('/api/git/branches', async (req, res) => {
|
|
2293
|
+
const { createBranch } = await getGitLibraries();
|
|
2294
|
+
try {
|
|
2295
|
+
const directory = req.query.directory;
|
|
2296
|
+
if (!directory) {
|
|
2297
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
const { name, startPoint } = req.body;
|
|
2301
|
+
if (!name) {
|
|
2302
|
+
return res.status(400).json({ error: 'name is required' });
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
const result = await createBranch(directory, name, { startPoint });
|
|
2306
|
+
res.json(result);
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
console.error('Failed to create branch:', error);
|
|
2309
|
+
res.status(500).json({ error: error.message || 'Failed to create branch' });
|
|
2310
|
+
}
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
app.delete('/api/git/branches', async (req, res) => {
|
|
2314
|
+
const { deleteBranch } = await getGitLibraries();
|
|
2315
|
+
try {
|
|
2316
|
+
const directory = req.query.directory;
|
|
2317
|
+
if (!directory) {
|
|
2318
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
const { branch, force } = req.body;
|
|
2322
|
+
if (!branch) {
|
|
2323
|
+
return res.status(400).json({ error: 'branch is required' });
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
const result = await deleteBranch(directory, branch, { force });
|
|
2327
|
+
res.json(result);
|
|
2328
|
+
} catch (error) {
|
|
2329
|
+
console.error('Failed to delete branch:', error);
|
|
2330
|
+
res.status(500).json({ error: error.message || 'Failed to delete branch' });
|
|
2331
|
+
}
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
app.delete('/api/git/remote-branches', async (req, res) => {
|
|
2335
|
+
const { deleteRemoteBranch } = await getGitLibraries();
|
|
2336
|
+
try {
|
|
2337
|
+
const directory = req.query.directory;
|
|
2338
|
+
if (!directory) {
|
|
2339
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
const { branch, remote } = req.body;
|
|
2343
|
+
if (!branch) {
|
|
2344
|
+
return res.status(400).json({ error: 'branch is required' });
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
const result = await deleteRemoteBranch(directory, { branch, remote });
|
|
2348
|
+
res.json(result);
|
|
2349
|
+
} catch (error) {
|
|
2350
|
+
console.error('Failed to delete remote branch:', error);
|
|
2351
|
+
res.status(500).json({ error: error.message || 'Failed to delete remote branch' });
|
|
2352
|
+
}
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
app.post('/api/git/checkout', async (req, res) => {
|
|
2356
|
+
const { checkoutBranch } = await getGitLibraries();
|
|
2357
|
+
try {
|
|
2358
|
+
const directory = req.query.directory;
|
|
2359
|
+
if (!directory) {
|
|
2360
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
const { branch } = req.body;
|
|
2364
|
+
if (!branch) {
|
|
2365
|
+
return res.status(400).json({ error: 'branch is required' });
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
const result = await checkoutBranch(directory, branch);
|
|
2369
|
+
res.json(result);
|
|
2370
|
+
} catch (error) {
|
|
2371
|
+
console.error('Failed to checkout branch:', error);
|
|
2372
|
+
res.status(500).json({ error: error.message || 'Failed to checkout branch' });
|
|
2373
|
+
}
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
app.get('/api/git/worktrees', async (req, res) => {
|
|
2377
|
+
const { getWorktrees } = await getGitLibraries();
|
|
2378
|
+
try {
|
|
2379
|
+
const directory = req.query.directory;
|
|
2380
|
+
if (!directory) {
|
|
2381
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const worktrees = await getWorktrees(directory);
|
|
2385
|
+
res.json(worktrees);
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
console.error('Failed to get worktrees:', error);
|
|
2388
|
+
res.status(500).json({ error: error.message || 'Failed to get worktrees' });
|
|
2389
|
+
}
|
|
2390
|
+
});
|
|
2391
|
+
|
|
2392
|
+
app.post('/api/git/worktrees', async (req, res) => {
|
|
2393
|
+
const { addWorktree } = await getGitLibraries();
|
|
2394
|
+
try {
|
|
2395
|
+
const directory = req.query.directory;
|
|
2396
|
+
if (!directory) {
|
|
2397
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
const { path, branch, createBranch } = req.body;
|
|
2401
|
+
if (!path || !branch) {
|
|
2402
|
+
return res.status(400).json({ error: 'path and branch are required' });
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
const result = await addWorktree(directory, path, branch, { createBranch });
|
|
2406
|
+
res.json(result);
|
|
2407
|
+
} catch (error) {
|
|
2408
|
+
console.error('Failed to add worktree:', error);
|
|
2409
|
+
res.status(500).json({ error: error.message || 'Failed to add worktree' });
|
|
2410
|
+
}
|
|
2411
|
+
});
|
|
2412
|
+
|
|
2413
|
+
app.delete('/api/git/worktrees', async (req, res) => {
|
|
2414
|
+
const { removeWorktree } = await getGitLibraries();
|
|
2415
|
+
try {
|
|
2416
|
+
const directory = req.query.directory;
|
|
2417
|
+
if (!directory) {
|
|
2418
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
const { path, force } = req.body;
|
|
2422
|
+
if (!path) {
|
|
2423
|
+
return res.status(400).json({ error: 'path is required' });
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
const result = await removeWorktree(directory, path, { force });
|
|
2427
|
+
res.json(result);
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
console.error('Failed to remove worktree:', error);
|
|
2430
|
+
res.status(500).json({ error: error.message || 'Failed to remove worktree' });
|
|
2431
|
+
}
|
|
2432
|
+
});
|
|
2433
|
+
|
|
2434
|
+
app.post('/api/git/ignore-openchamber', async (req, res) => {
|
|
2435
|
+
const { ensureOpenChamberIgnored } = await getGitLibraries();
|
|
2436
|
+
try {
|
|
2437
|
+
const directory = req.query.directory;
|
|
2438
|
+
if (!directory) {
|
|
2439
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
await ensureOpenChamberIgnored(directory);
|
|
2443
|
+
res.json({ success: true });
|
|
2444
|
+
} catch (error) {
|
|
2445
|
+
console.error('Failed to ignore .openchamber directory:', error);
|
|
2446
|
+
res.status(500).json({ error: error.message || 'Failed to update git ignore' });
|
|
2447
|
+
}
|
|
2448
|
+
});
|
|
2449
|
+
|
|
2450
|
+
app.get('/api/git/worktree-type', async (req, res) => {
|
|
2451
|
+
const { isLinkedWorktree } = await getGitLibraries();
|
|
2452
|
+
try {
|
|
2453
|
+
const { directory } = req.query;
|
|
2454
|
+
if (!directory || typeof directory !== 'string') {
|
|
2455
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2456
|
+
}
|
|
2457
|
+
const linked = await isLinkedWorktree(directory);
|
|
2458
|
+
res.json({ linked });
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
console.error('Failed to determine worktree type:', error);
|
|
2461
|
+
res.status(500).json({ error: error.message || 'Failed to determine worktree type' });
|
|
2462
|
+
}
|
|
2463
|
+
});
|
|
2464
|
+
|
|
2465
|
+
app.get('/api/git/log', async (req, res) => {
|
|
2466
|
+
const { getLog } = await getGitLibraries();
|
|
2467
|
+
try {
|
|
2468
|
+
const directory = req.query.directory;
|
|
2469
|
+
if (!directory) {
|
|
2470
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
const { maxCount, from, to, file } = req.query;
|
|
2474
|
+
const log = await getLog(directory, {
|
|
2475
|
+
maxCount: maxCount ? parseInt(maxCount) : undefined,
|
|
2476
|
+
from,
|
|
2477
|
+
to,
|
|
2478
|
+
file
|
|
2479
|
+
});
|
|
2480
|
+
res.json(log);
|
|
2481
|
+
} catch (error) {
|
|
2482
|
+
console.error('Failed to get log:', error);
|
|
2483
|
+
res.status(500).json({ error: error.message || 'Failed to get commit log' });
|
|
2484
|
+
}
|
|
2485
|
+
});
|
|
2486
|
+
|
|
2487
|
+
app.get('/api/git/commit-files', async (req, res) => {
|
|
2488
|
+
const { getCommitFiles } = await getGitLibraries();
|
|
2489
|
+
try {
|
|
2490
|
+
const { directory, hash } = req.query;
|
|
2491
|
+
if (!directory) {
|
|
2492
|
+
return res.status(400).json({ error: 'directory parameter is required' });
|
|
2493
|
+
}
|
|
2494
|
+
if (!hash) {
|
|
2495
|
+
return res.status(400).json({ error: 'hash parameter is required' });
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
const result = await getCommitFiles(directory, hash);
|
|
2499
|
+
res.json(result);
|
|
2500
|
+
} catch (error) {
|
|
2501
|
+
console.error('Failed to get commit files:', error);
|
|
2502
|
+
res.status(500).json({ error: error.message || 'Failed to get commit files' });
|
|
2503
|
+
}
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
app.get('/api/fs/home', (req, res) => {
|
|
2507
|
+
try {
|
|
2508
|
+
const home = os.homedir();
|
|
2509
|
+
if (!home || typeof home !== 'string' || home.length === 0) {
|
|
2510
|
+
return res.status(500).json({ error: 'Failed to resolve home directory' });
|
|
2511
|
+
}
|
|
2512
|
+
res.json({ home });
|
|
2513
|
+
} catch (error) {
|
|
2514
|
+
console.error('Failed to resolve home directory:', error);
|
|
2515
|
+
res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' });
|
|
2516
|
+
}
|
|
2517
|
+
});
|
|
2518
|
+
|
|
2519
|
+
app.post('/api/fs/mkdir', (req, res) => {
|
|
2520
|
+
try {
|
|
2521
|
+
const { path: dirPath } = req.body;
|
|
2522
|
+
|
|
2523
|
+
if (!dirPath) {
|
|
2524
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
const normalizedPath = path.normalize(dirPath);
|
|
2528
|
+
if (normalizedPath.includes('..')) {
|
|
2529
|
+
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
2533
|
+
console.log(`Created directory: ${dirPath}`);
|
|
2534
|
+
|
|
2535
|
+
res.json({ success: true, path: dirPath });
|
|
2536
|
+
} catch (error) {
|
|
2537
|
+
console.error('Failed to create directory:', error);
|
|
2538
|
+
res.status(500).json({ error: error.message || 'Failed to create directory' });
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2541
|
+
|
|
2542
|
+
app.post('/api/opencode/directory', async (req, res) => {
|
|
2543
|
+
try {
|
|
2544
|
+
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
|
|
2545
|
+
if (!requestedPath) {
|
|
2546
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
const resolvedPath = path.resolve(requestedPath);
|
|
2550
|
+
let stats;
|
|
2551
|
+
try {
|
|
2552
|
+
stats = await fsPromises.stat(resolvedPath);
|
|
2553
|
+
} catch (error) {
|
|
2554
|
+
const err = error;
|
|
2555
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
2556
|
+
if (err.code === 'ENOENT') {
|
|
2557
|
+
return res.status(404).json({ error: 'Directory not found' });
|
|
2558
|
+
}
|
|
2559
|
+
if (err.code === 'EACCES') {
|
|
2560
|
+
return res.status(403).json({ error: 'Access to directory denied' });
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
throw error;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
if (!stats.isDirectory()) {
|
|
2567
|
+
return res.status(400).json({ error: 'Specified path is not a directory' });
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
if (openCodeWorkingDirectory === resolvedPath && openCodeProcess && openCodeProcess.exitCode === null) {
|
|
2571
|
+
return res.json({ success: true, restarted: false, path: resolvedPath });
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
openCodeWorkingDirectory = resolvedPath;
|
|
2575
|
+
|
|
2576
|
+
await refreshOpenCodeAfterConfigChange('directory change');
|
|
2577
|
+
|
|
2578
|
+
res.json({
|
|
2579
|
+
success: true,
|
|
2580
|
+
restarted: true,
|
|
2581
|
+
path: resolvedPath
|
|
2582
|
+
});
|
|
2583
|
+
} catch (error) {
|
|
2584
|
+
console.error('Failed to update OpenCode working directory:', error);
|
|
2585
|
+
res.status(500).json({ error: error.message || 'Failed to update working directory' });
|
|
2586
|
+
}
|
|
2587
|
+
});
|
|
2588
|
+
|
|
2589
|
+
app.get('/api/fs/list', async (req, res) => {
|
|
2590
|
+
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
|
|
2591
|
+
? req.query.path.trim()
|
|
2592
|
+
: os.homedir();
|
|
2593
|
+
|
|
2594
|
+
try {
|
|
2595
|
+
const resolvedPath = path.resolve(rawPath);
|
|
2596
|
+
|
|
2597
|
+
const stats = await fsPromises.stat(resolvedPath);
|
|
2598
|
+
if (!stats.isDirectory()) {
|
|
2599
|
+
return res.status(400).json({ error: 'Specified path is not a directory' });
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
|
|
2603
|
+
const entries = await Promise.all(
|
|
2604
|
+
dirents.map(async (dirent) => {
|
|
2605
|
+
const entryPath = path.join(resolvedPath, dirent.name);
|
|
2606
|
+
let isDirectory = dirent.isDirectory();
|
|
2607
|
+
const isSymbolicLink = dirent.isSymbolicLink();
|
|
2608
|
+
|
|
2609
|
+
if (!isDirectory && isSymbolicLink) {
|
|
2610
|
+
try {
|
|
2611
|
+
const linkStats = await fsPromises.stat(entryPath);
|
|
2612
|
+
isDirectory = linkStats.isDirectory();
|
|
2613
|
+
} catch {
|
|
2614
|
+
isDirectory = false;
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
return {
|
|
2619
|
+
name: dirent.name,
|
|
2620
|
+
path: entryPath,
|
|
2621
|
+
isDirectory,
|
|
2622
|
+
isFile: dirent.isFile(),
|
|
2623
|
+
isSymbolicLink
|
|
2624
|
+
};
|
|
2625
|
+
})
|
|
2626
|
+
);
|
|
2627
|
+
|
|
2628
|
+
res.json({
|
|
2629
|
+
path: resolvedPath,
|
|
2630
|
+
entries
|
|
2631
|
+
});
|
|
2632
|
+
} catch (error) {
|
|
2633
|
+
console.error('Failed to list directory:', error);
|
|
2634
|
+
const err = error;
|
|
2635
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
2636
|
+
const code = err.code;
|
|
2637
|
+
if (code === 'ENOENT') {
|
|
2638
|
+
return res.status(404).json({ error: 'Directory not found' });
|
|
2639
|
+
}
|
|
2640
|
+
if (code === 'EACCES') {
|
|
2641
|
+
return res.status(403).json({ error: 'Access to directory denied' });
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
|
|
2645
|
+
}
|
|
2646
|
+
});
|
|
2647
|
+
|
|
2648
|
+
app.get('/api/fs/search', async (req, res) => {
|
|
2649
|
+
const rawRoot = typeof req.query.root === 'string' && req.query.root.trim().length > 0
|
|
2650
|
+
? req.query.root.trim()
|
|
2651
|
+
: typeof req.query.directory === 'string' && req.query.directory.trim().length > 0
|
|
2652
|
+
? req.query.directory.trim()
|
|
2653
|
+
: os.homedir();
|
|
2654
|
+
const rawQuery = typeof req.query.q === 'string' ? req.query.q : '';
|
|
2655
|
+
const limitParam = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : undefined;
|
|
2656
|
+
const parsedLimit = Number.isFinite(limitParam) ? Number(limitParam) : DEFAULT_FILE_SEARCH_LIMIT;
|
|
2657
|
+
const limit = Math.max(1, Math.min(parsedLimit, MAX_FILE_SEARCH_LIMIT));
|
|
2658
|
+
|
|
2659
|
+
try {
|
|
2660
|
+
const resolvedRoot = path.resolve(rawRoot);
|
|
2661
|
+
const stats = await fsPromises.stat(resolvedRoot);
|
|
2662
|
+
if (!stats.isDirectory()) {
|
|
2663
|
+
return res.status(400).json({ error: 'Specified root is not a directory' });
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
const files = await searchFilesystemFiles(resolvedRoot, { limit, query: rawQuery || '' });
|
|
2667
|
+
res.json({
|
|
2668
|
+
root: resolvedRoot,
|
|
2669
|
+
count: files.length,
|
|
2670
|
+
files
|
|
2671
|
+
});
|
|
2672
|
+
} catch (error) {
|
|
2673
|
+
console.error('Failed to search filesystem:', error);
|
|
2674
|
+
const err = error;
|
|
2675
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
2676
|
+
const code = err.code;
|
|
2677
|
+
if (code === 'ENOENT') {
|
|
2678
|
+
return res.status(404).json({ error: 'Directory not found' });
|
|
2679
|
+
}
|
|
2680
|
+
if (code === 'EACCES') {
|
|
2681
|
+
return res.status(403).json({ error: 'Access to directory denied' });
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
res.status(500).json({ error: (error && error.message) || 'Failed to search files' });
|
|
2685
|
+
}
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
let ptyLib = null;
|
|
2689
|
+
let ptyLoadError = null;
|
|
2690
|
+
const getPtyLib = async () => {
|
|
2691
|
+
if (ptyLib) return ptyLib;
|
|
2692
|
+
if (ptyLoadError) throw ptyLoadError;
|
|
2693
|
+
|
|
2694
|
+
try {
|
|
2695
|
+
ptyLib = await import('node-pty');
|
|
2696
|
+
console.log('node-pty loaded successfully');
|
|
2697
|
+
return ptyLib;
|
|
2698
|
+
} catch (error) {
|
|
2699
|
+
ptyLoadError = error;
|
|
2700
|
+
console.error('Failed to load node-pty:', error.message);
|
|
2701
|
+
console.error('Terminal functionality will not be available.');
|
|
2702
|
+
console.error('To fix: run "npm rebuild node-pty" or "npm install"');
|
|
2703
|
+
throw new Error('node-pty is not available. Run: npm rebuild node-pty');
|
|
2704
|
+
}
|
|
2705
|
+
};
|
|
2706
|
+
|
|
2707
|
+
const terminalSessions = new Map();
|
|
2708
|
+
const MAX_TERMINAL_SESSIONS = 20;
|
|
2709
|
+
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
|
|
2710
|
+
|
|
2711
|
+
setInterval(() => {
|
|
2712
|
+
const now = Date.now();
|
|
2713
|
+
for (const [sessionId, session] of terminalSessions.entries()) {
|
|
2714
|
+
if (now - session.lastActivity > TERMINAL_IDLE_TIMEOUT) {
|
|
2715
|
+
console.log(`Cleaning up idle terminal session: ${sessionId}`);
|
|
2716
|
+
try {
|
|
2717
|
+
session.ptyProcess.kill();
|
|
2718
|
+
} catch (error) {
|
|
2719
|
+
|
|
2720
|
+
}
|
|
2721
|
+
terminalSessions.delete(sessionId);
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
}, 5 * 60 * 1000);
|
|
2725
|
+
|
|
2726
|
+
app.post('/api/terminal/create', async (req, res) => {
|
|
2727
|
+
try {
|
|
2728
|
+
if (terminalSessions.size >= MAX_TERMINAL_SESSIONS) {
|
|
2729
|
+
return res.status(429).json({ error: 'Maximum terminal sessions reached' });
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
const { cwd, cols, rows } = req.body;
|
|
2733
|
+
if (!cwd) {
|
|
2734
|
+
return res.status(400).json({ error: 'cwd is required' });
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
if (!fs.existsSync(cwd)) {
|
|
2738
|
+
return res.status(400).json({ error: 'Invalid working directory' });
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
const pty = await getPtyLib();
|
|
2742
|
+
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
|
|
2743
|
+
|
|
2744
|
+
const sessionId = Math.random().toString(36).substring(2, 15) +
|
|
2745
|
+
Math.random().toString(36).substring(2, 15);
|
|
2746
|
+
|
|
2747
|
+
const envPath = buildAugmentedPath();
|
|
2748
|
+
const resolvedEnv = { ...process.env, PATH: envPath };
|
|
2749
|
+
|
|
2750
|
+
const ptyProcess = pty.spawn(shell, [], {
|
|
2751
|
+
name: 'xterm-256color',
|
|
2752
|
+
cols: cols || 80,
|
|
2753
|
+
rows: rows || 24,
|
|
2754
|
+
cwd: cwd,
|
|
2755
|
+
env: {
|
|
2756
|
+
...resolvedEnv,
|
|
2757
|
+
TERM: 'xterm-256color',
|
|
2758
|
+
COLORTERM: 'truecolor',
|
|
2759
|
+
},
|
|
2760
|
+
});
|
|
2761
|
+
|
|
2762
|
+
const session = {
|
|
2763
|
+
ptyProcess,
|
|
2764
|
+
cwd,
|
|
2765
|
+
lastActivity: Date.now(),
|
|
2766
|
+
clients: new Set(),
|
|
2767
|
+
};
|
|
2768
|
+
|
|
2769
|
+
terminalSessions.set(sessionId, session);
|
|
2770
|
+
|
|
2771
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
2772
|
+
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
|
|
2773
|
+
terminalSessions.delete(sessionId);
|
|
2774
|
+
});
|
|
2775
|
+
|
|
2776
|
+
console.log(`Created terminal session: ${sessionId} in ${cwd}`);
|
|
2777
|
+
res.json({ sessionId, cols: cols || 80, rows: rows || 24 });
|
|
2778
|
+
} catch (error) {
|
|
2779
|
+
console.error('Failed to create terminal session:', error);
|
|
2780
|
+
res.status(500).json({ error: error.message || 'Failed to create terminal session' });
|
|
2781
|
+
}
|
|
2782
|
+
});
|
|
2783
|
+
|
|
2784
|
+
app.get('/api/terminal/:sessionId/stream', (req, res) => {
|
|
2785
|
+
const { sessionId } = req.params;
|
|
2786
|
+
const session = terminalSessions.get(sessionId);
|
|
2787
|
+
|
|
2788
|
+
if (!session) {
|
|
2789
|
+
return res.status(404).json({ error: 'Terminal session not found' });
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
2793
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
2794
|
+
res.setHeader('Connection', 'keep-alive');
|
|
2795
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
2796
|
+
|
|
2797
|
+
res.write('data: {"type":"connected"}\n\n');
|
|
2798
|
+
|
|
2799
|
+
const clientId = Math.random().toString(36).substring(7);
|
|
2800
|
+
session.clients.add(clientId);
|
|
2801
|
+
session.lastActivity = Date.now();
|
|
2802
|
+
|
|
2803
|
+
const heartbeatInterval = setInterval(() => {
|
|
2804
|
+
try {
|
|
2805
|
+
|
|
2806
|
+
res.write(': heartbeat\n\n');
|
|
2807
|
+
} catch (error) {
|
|
2808
|
+
console.error(`Heartbeat failed for client ${clientId}:`, error);
|
|
2809
|
+
clearInterval(heartbeatInterval);
|
|
2810
|
+
}
|
|
2811
|
+
}, 15000);
|
|
2812
|
+
|
|
2813
|
+
const dataHandler = (data) => {
|
|
2814
|
+
try {
|
|
2815
|
+
session.lastActivity = Date.now();
|
|
2816
|
+
const ok = res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`);
|
|
2817
|
+
if (!ok && session.ptyProcess && typeof session.ptyProcess.pause === 'function') {
|
|
2818
|
+
session.ptyProcess.pause();
|
|
2819
|
+
res.once('drain', () => {
|
|
2820
|
+
if (session.ptyProcess && typeof session.ptyProcess.resume === 'function') {
|
|
2821
|
+
session.ptyProcess.resume();
|
|
2822
|
+
}
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
} catch (error) {
|
|
2826
|
+
console.error(`Error sending data to client ${clientId}:`, error);
|
|
2827
|
+
cleanup();
|
|
2828
|
+
}
|
|
2829
|
+
};
|
|
2830
|
+
|
|
2831
|
+
const exitHandler = ({ exitCode, signal }) => {
|
|
2832
|
+
try {
|
|
2833
|
+
res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`);
|
|
2834
|
+
res.end();
|
|
2835
|
+
} catch (error) {
|
|
2836
|
+
|
|
2837
|
+
}
|
|
2838
|
+
cleanup();
|
|
2839
|
+
};
|
|
2840
|
+
|
|
2841
|
+
const dataDisposable = session.ptyProcess.onData(dataHandler);
|
|
2842
|
+
const exitDisposable = session.ptyProcess.onExit(exitHandler);
|
|
2843
|
+
|
|
2844
|
+
const cleanup = () => {
|
|
2845
|
+
clearInterval(heartbeatInterval);
|
|
2846
|
+
session.clients.delete(clientId);
|
|
2847
|
+
|
|
2848
|
+
if (dataDisposable && typeof dataDisposable.dispose === 'function') {
|
|
2849
|
+
dataDisposable.dispose();
|
|
2850
|
+
}
|
|
2851
|
+
if (exitDisposable && typeof exitDisposable.dispose === 'function') {
|
|
2852
|
+
exitDisposable.dispose();
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
try {
|
|
2856
|
+
res.end();
|
|
2857
|
+
} catch (error) {
|
|
2858
|
+
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
console.log(`Client ${clientId} disconnected from terminal session ${sessionId}`);
|
|
2862
|
+
};
|
|
2863
|
+
|
|
2864
|
+
req.on('close', cleanup);
|
|
2865
|
+
req.on('error', cleanup);
|
|
2866
|
+
|
|
2867
|
+
console.log(`Client ${clientId} connected to terminal session ${sessionId}`);
|
|
2868
|
+
});
|
|
2869
|
+
|
|
2870
|
+
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
|
|
2871
|
+
const { sessionId } = req.params;
|
|
2872
|
+
const session = terminalSessions.get(sessionId);
|
|
2873
|
+
|
|
2874
|
+
if (!session) {
|
|
2875
|
+
return res.status(404).json({ error: 'Terminal session not found' });
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
const data = typeof req.body === 'string' ? req.body : '';
|
|
2879
|
+
|
|
2880
|
+
try {
|
|
2881
|
+
session.ptyProcess.write(data);
|
|
2882
|
+
session.lastActivity = Date.now();
|
|
2883
|
+
res.json({ success: true });
|
|
2884
|
+
} catch (error) {
|
|
2885
|
+
console.error('Failed to write to terminal:', error);
|
|
2886
|
+
res.status(500).json({ error: error.message || 'Failed to write to terminal' });
|
|
2887
|
+
}
|
|
2888
|
+
});
|
|
2889
|
+
|
|
2890
|
+
app.post('/api/terminal/:sessionId/resize', (req, res) => {
|
|
2891
|
+
const { sessionId } = req.params;
|
|
2892
|
+
const session = terminalSessions.get(sessionId);
|
|
2893
|
+
|
|
2894
|
+
if (!session) {
|
|
2895
|
+
return res.status(404).json({ error: 'Terminal session not found' });
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
const { cols, rows } = req.body;
|
|
2899
|
+
if (!cols || !rows) {
|
|
2900
|
+
return res.status(400).json({ error: 'cols and rows are required' });
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
try {
|
|
2904
|
+
session.ptyProcess.resize(cols, rows);
|
|
2905
|
+
session.lastActivity = Date.now();
|
|
2906
|
+
res.json({ success: true, cols, rows });
|
|
2907
|
+
} catch (error) {
|
|
2908
|
+
console.error('Failed to resize terminal:', error);
|
|
2909
|
+
res.status(500).json({ error: error.message || 'Failed to resize terminal' });
|
|
2910
|
+
}
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2913
|
+
app.delete('/api/terminal/:sessionId', (req, res) => {
|
|
2914
|
+
const { sessionId } = req.params;
|
|
2915
|
+
const session = terminalSessions.get(sessionId);
|
|
2916
|
+
|
|
2917
|
+
if (!session) {
|
|
2918
|
+
return res.status(404).json({ error: 'Terminal session not found' });
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
try {
|
|
2922
|
+
session.ptyProcess.kill();
|
|
2923
|
+
terminalSessions.delete(sessionId);
|
|
2924
|
+
console.log(`Closed terminal session: ${sessionId}`);
|
|
2925
|
+
res.json({ success: true });
|
|
2926
|
+
} catch (error) {
|
|
2927
|
+
console.error('Failed to close terminal:', error);
|
|
2928
|
+
res.status(500).json({ error: error.message || 'Failed to close terminal' });
|
|
2929
|
+
}
|
|
2930
|
+
});
|
|
2931
|
+
|
|
2932
|
+
try {
|
|
2933
|
+
if (ENV_CONFIGURED_OPENCODE_PORT) {
|
|
2934
|
+
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
|
|
2935
|
+
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
|
|
2936
|
+
} else {
|
|
2937
|
+
openCodePort = null;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
lastOpenCodeError = null;
|
|
2941
|
+
openCodeProcess = await startOpenCode();
|
|
2942
|
+
await waitForOpenCodePort();
|
|
2943
|
+
try {
|
|
2944
|
+
await waitForOpenCodeReady();
|
|
2945
|
+
} catch (error) {
|
|
2946
|
+
console.error(`OpenCode readiness check failed: ${error.message}`);
|
|
2947
|
+
scheduleOpenCodeApiDetection();
|
|
2948
|
+
}
|
|
2949
|
+
setupProxy(app);
|
|
2950
|
+
scheduleOpenCodeApiDetection();
|
|
2951
|
+
startHealthMonitoring();
|
|
2952
|
+
} catch (error) {
|
|
2953
|
+
console.error(`Failed to start OpenCode: ${error.message}`);
|
|
2954
|
+
console.log('Continuing without OpenCode integration...');
|
|
2955
|
+
lastOpenCodeError = error.message;
|
|
2956
|
+
setupProxy(app);
|
|
2957
|
+
scheduleOpenCodeApiDetection();
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
const distPath = path.join(__dirname, '..', 'dist');
|
|
2961
|
+
if (fs.existsSync(distPath)) {
|
|
2962
|
+
console.log(`Serving static files from ${distPath}`);
|
|
2963
|
+
app.use(express.static(distPath));
|
|
2964
|
+
|
|
2965
|
+
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
|
2966
|
+
res.sendFile(path.join(distPath, 'index.html'));
|
|
2967
|
+
});
|
|
2968
|
+
} else {
|
|
2969
|
+
console.warn(`Warning: ${distPath} not found, static files will not be served`);
|
|
2970
|
+
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
|
2971
|
+
res.status(404).send('Static files not found. Please build the application first.');
|
|
2972
|
+
});
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
let activePort = port;
|
|
2976
|
+
|
|
2977
|
+
await new Promise((resolve, reject) => {
|
|
2978
|
+
const onError = (error) => {
|
|
2979
|
+
server.off('error', onError);
|
|
2980
|
+
reject(error);
|
|
2981
|
+
};
|
|
2982
|
+
server.once('error', onError);
|
|
2983
|
+
server.listen(port, () => {
|
|
2984
|
+
server.off('error', onError);
|
|
2985
|
+
const addressInfo = server.address();
|
|
2986
|
+
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
|
2987
|
+
console.log(`OpenChamber server running on port ${activePort}`);
|
|
2988
|
+
console.log(`Health check: http://localhost:${activePort}/health`);
|
|
2989
|
+
console.log(`Web interface: http://localhost:${activePort}`);
|
|
2990
|
+
resolve();
|
|
2991
|
+
});
|
|
2992
|
+
});
|
|
2993
|
+
|
|
2994
|
+
if (attachSignals && !signalsAttached) {
|
|
2995
|
+
process.on('SIGTERM', gracefulShutdown);
|
|
2996
|
+
process.on('SIGINT', gracefulShutdown);
|
|
2997
|
+
process.on('SIGQUIT', gracefulShutdown);
|
|
2998
|
+
signalsAttached = true;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
3002
|
+
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|
3003
|
+
});
|
|
3004
|
+
|
|
3005
|
+
process.on('uncaughtException', (error) => {
|
|
3006
|
+
console.error('Uncaught Exception:', error);
|
|
3007
|
+
gracefulShutdown();
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
return {
|
|
3011
|
+
expressApp: app,
|
|
3012
|
+
httpServer: server,
|
|
3013
|
+
getPort: () => activePort,
|
|
3014
|
+
getOpenCodePort: () => openCodePort,
|
|
3015
|
+
isReady: () => isOpenCodeReady,
|
|
3016
|
+
restartOpenCode: () => restartOpenCode(),
|
|
3017
|
+
stop: (shutdownOptions = {}) =>
|
|
3018
|
+
gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false })
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
const isCliExecution = process.argv[1] === __filename;
|
|
3023
|
+
|
|
3024
|
+
if (isCliExecution) {
|
|
3025
|
+
const cliOptions = parseArgs();
|
|
3026
|
+
exitOnShutdown = true;
|
|
3027
|
+
main({
|
|
3028
|
+
port: cliOptions.port,
|
|
3029
|
+
attachSignals: true,
|
|
3030
|
+
exitOnShutdown: true,
|
|
3031
|
+
uiPassword: cliOptions.uiPassword
|
|
3032
|
+
}).catch(error => {
|
|
3033
|
+
console.error('Failed to start server:', error);
|
|
3034
|
+
process.exit(1);
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
export { gracefulShutdown, setupProxy, restartOpenCode, main as startWebUiServer, parseArgs };
|