jarvis-hq 0.0.3
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/LICENSE +21 -0
- package/README.md +484 -0
- package/dist/bot-routes.js +196 -0
- package/dist/bot-store.js +297 -0
- package/dist/client-html.js +2241 -0
- package/dist/client-vendor.js +32 -0
- package/dist/client.js +118 -0
- package/dist/dictation.js +128 -0
- package/dist/env-file.js +79 -0
- package/dist/index.js +41 -0
- package/dist/jarvis-tools.js +143 -0
- package/dist/jarvis.js +341 -0
- package/dist/ls.js +46 -0
- package/dist/progress.js +33 -0
- package/dist/relay-client.js +216 -0
- package/dist/relay-types.js +2 -0
- package/dist/server-common.js +663 -0
- package/dist/server.js +530 -0
- package/dist/start-claude-code.js +557 -0
- package/dist/start-codex.js +886 -0
- package/dist/start-opencode.js +452 -0
- package/dist/start.js +742 -0
- package/dist/sync.js +15 -0
- package/dist/trophy.js +7 -0
- package/dist/turns.js +91 -0
- package/dist/workspace.js +152 -0
- package/package.json +55 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.permissionsEmitter = exports.sessions = exports.TOOL_BLACKLIST = exports.EDIT_TOOLS = exports.PORT_RANGE_END = exports.PORT_RANGE_START = void 0;
|
|
7
|
+
exports.findAvailablePort = findAvailablePort;
|
|
8
|
+
exports.getLocalIP = getLocalIP;
|
|
9
|
+
exports.getPublicIP = getPublicIP;
|
|
10
|
+
exports.getTailscaleIP = getTailscaleIP;
|
|
11
|
+
exports.showQR = showQR;
|
|
12
|
+
exports.showRelayQR = showRelayQR;
|
|
13
|
+
exports.maybeCaffeinate = maybeCaffeinate;
|
|
14
|
+
exports.killCaffeinate = killCaffeinate;
|
|
15
|
+
exports.botPermissionToSession = botPermissionToSession;
|
|
16
|
+
exports.shouldAutoApprove = shouldAutoApprove;
|
|
17
|
+
exports.buildPermissionsDump = buildPermissionsDump;
|
|
18
|
+
exports.buildSessionsDump = buildSessionsDump;
|
|
19
|
+
exports.notifyPermissionsChanged = notifyPermissionsChanged;
|
|
20
|
+
exports.setPushNotificationSender = setPushNotificationSender;
|
|
21
|
+
exports.sendPushViaRelay = sendPushViaRelay;
|
|
22
|
+
exports.notifyNewPermission = notifyNewPermission;
|
|
23
|
+
exports.notifySessionDone = notifySessionDone;
|
|
24
|
+
exports.createSession = createSession;
|
|
25
|
+
exports.scheduleCleanup = scheduleCleanup;
|
|
26
|
+
exports.emitEvent = emitEvent;
|
|
27
|
+
exports.relayTokenPath = relayTokenPath;
|
|
28
|
+
exports.notifySessionStarted = notifySessionStarted;
|
|
29
|
+
exports.notifySessionEnded = notifySessionEnded;
|
|
30
|
+
exports.createHttpServer = createHttpServer;
|
|
31
|
+
exports.setupShutdown = setupShutdown;
|
|
32
|
+
exports.sseHeaders = sseHeaders;
|
|
33
|
+
exports.writeSseEvent = writeSseEvent;
|
|
34
|
+
exports.parseQuery = parseQuery;
|
|
35
|
+
exports.parsePathParam = parsePathParam;
|
|
36
|
+
exports.jsonOk = jsonOk;
|
|
37
|
+
exports.jsonError = jsonError;
|
|
38
|
+
exports.readBody = readBody;
|
|
39
|
+
exports.handleWorkspaceRoutes = handleWorkspaceRoutes;
|
|
40
|
+
const os_1 = require("os");
|
|
41
|
+
const fs_1 = require("fs");
|
|
42
|
+
const path_1 = require("path");
|
|
43
|
+
const child_process_1 = require("child_process");
|
|
44
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
45
|
+
const events_1 = require("events");
|
|
46
|
+
const qrcode_terminal_1 = __importDefault(require("qrcode-terminal"));
|
|
47
|
+
const client_html_1 = require("./client-html");
|
|
48
|
+
const client_vendor_1 = require("./client-vendor");
|
|
49
|
+
const workspace_1 = require("./workspace");
|
|
50
|
+
const SERVER_VERSION = require("../package.json").version;
|
|
51
|
+
// Semver range of client app versions this server build can service.
|
|
52
|
+
// Raise the floor when a breaking protocol change makes old clients incompatible.
|
|
53
|
+
const CLIENT_VERSION_RANGE = ">=1.0.0";
|
|
54
|
+
exports.PORT_RANGE_START = 32100;
|
|
55
|
+
exports.PORT_RANGE_END = 32199;
|
|
56
|
+
function findAvailablePort(startPort, endPort) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const tryPort = (port) => {
|
|
59
|
+
if (port > endPort) {
|
|
60
|
+
reject(new Error(`No available port found in range ${startPort}–${endPort}`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const server = node_http_1.default.createServer();
|
|
64
|
+
server.listen(port, () => {
|
|
65
|
+
server.close(() => resolve(port));
|
|
66
|
+
});
|
|
67
|
+
server.on("error", () => tryPort(port + 1));
|
|
68
|
+
};
|
|
69
|
+
tryPort(startPort);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function getLocalIP() {
|
|
73
|
+
const nets = (0, os_1.networkInterfaces)();
|
|
74
|
+
for (const name of Object.keys(nets)) {
|
|
75
|
+
for (const net of nets[name]) {
|
|
76
|
+
if (net.family === "IPv4" && !net.internal) {
|
|
77
|
+
return net.address;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return "localhost";
|
|
82
|
+
}
|
|
83
|
+
function getPublicIP() {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
node_http_1.default.get("http://api.ipify.org", (res) => {
|
|
86
|
+
let data = "";
|
|
87
|
+
res.on("data", (chunk) => { data += chunk; });
|
|
88
|
+
res.on("end", () => resolve(data.trim() || null));
|
|
89
|
+
res.on("error", () => resolve(null));
|
|
90
|
+
}).on("error", () => resolve(null));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function getTailscaleIP() {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
(0, child_process_1.execFile)("tailscale", ["status"], { timeout: 2000 }, (err) => {
|
|
96
|
+
if (err)
|
|
97
|
+
return resolve(null);
|
|
98
|
+
(0, child_process_1.execFile)("tailscale", ["ip", "-4"], { timeout: 2000 }, (err, stdout) => {
|
|
99
|
+
if (err)
|
|
100
|
+
return resolve(null);
|
|
101
|
+
const ip = stdout.trim().split("\n")[0];
|
|
102
|
+
resolve(ip || null);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async function showQR(network, port) {
|
|
108
|
+
let ip;
|
|
109
|
+
let label;
|
|
110
|
+
if (network === "tailscale") {
|
|
111
|
+
const tsIP = await getTailscaleIP();
|
|
112
|
+
if (!tsIP) {
|
|
113
|
+
console.error(" Tailscale IP not found. Is Tailscale running?");
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
ip = tsIP;
|
|
117
|
+
label = "Tailscale";
|
|
118
|
+
}
|
|
119
|
+
else if (network === "remote-ip") {
|
|
120
|
+
const publicIP = await getPublicIP();
|
|
121
|
+
if (!publicIP) {
|
|
122
|
+
console.error(" Could not determine public IP address.");
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
ip = publicIP;
|
|
126
|
+
label = "Public";
|
|
127
|
+
}
|
|
128
|
+
else if (network === "local") {
|
|
129
|
+
ip = getLocalIP();
|
|
130
|
+
label = "Local Network";
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
ip = network;
|
|
134
|
+
label = "Custom";
|
|
135
|
+
}
|
|
136
|
+
const url = `http://${ip}:${port}`;
|
|
137
|
+
console.log(`\n ${label} ${url}\n`);
|
|
138
|
+
const qrCode = await new Promise((resolve) => {
|
|
139
|
+
qrcode_terminal_1.default.generate(url, { small: true }, (code) => {
|
|
140
|
+
resolve(code.trimEnd());
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
console.log(qrCode);
|
|
144
|
+
}
|
|
145
|
+
async function showRelayQR(relayBaseUrl, token) {
|
|
146
|
+
// Convert ws(s):// to http(s):// for the app-facing URL
|
|
147
|
+
const appUrl = relayBaseUrl.replace(/^ws(s?):\/\//, "http$1://") + `/s/${token}`;
|
|
148
|
+
console.log(`\n Relay ${appUrl}\n`);
|
|
149
|
+
const qrCode = await new Promise((resolve) => {
|
|
150
|
+
qrcode_terminal_1.default.generate(appUrl, { small: true }, (code) => {
|
|
151
|
+
resolve(code.trimEnd());
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
console.log(qrCode);
|
|
155
|
+
}
|
|
156
|
+
function maybeCaffeinate(enabled) {
|
|
157
|
+
if (!enabled)
|
|
158
|
+
return null;
|
|
159
|
+
try {
|
|
160
|
+
(0, child_process_1.execSync)("which caffeinate", { stdio: "ignore" });
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const hours = 8;
|
|
166
|
+
const seconds = hours * 60 * 60;
|
|
167
|
+
const child = (0, child_process_1.spawn)("caffeinate", ["-t", String(seconds)], {
|
|
168
|
+
detached: true,
|
|
169
|
+
stdio: "ignore",
|
|
170
|
+
});
|
|
171
|
+
child.unref();
|
|
172
|
+
console.log(` caffeinate: running for ${hours}h (pid ${child.pid})`);
|
|
173
|
+
return child.pid ?? null;
|
|
174
|
+
}
|
|
175
|
+
function killCaffeinate(pid) {
|
|
176
|
+
if (pid === null)
|
|
177
|
+
return;
|
|
178
|
+
try {
|
|
179
|
+
process.kill(pid);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// already gone
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
exports.EDIT_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]);
|
|
186
|
+
exports.TOOL_BLACKLIST = {
|
|
187
|
+
"claude-code": new Set(["ExitPlanMode", "AskUserQuestion"]),
|
|
188
|
+
"opencode": new Set(),
|
|
189
|
+
"codex": new Set(),
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Bot presets describe permissions in the hub's words; sessions want a
|
|
193
|
+
* PermissionMode (and, for plan, a run mode). Keep the two vocabularies in sync
|
|
194
|
+
* here — a preset value with no translation silently ends up asking for
|
|
195
|
+
* everything.
|
|
196
|
+
*/
|
|
197
|
+
function botPermissionToSession(botMode) {
|
|
198
|
+
switch (botMode) {
|
|
199
|
+
case "auto-approve": return { permissionMode: "yolo" };
|
|
200
|
+
case "plan": return { permissionMode: "yolo", mode: "plan" };
|
|
201
|
+
default: return { permissionMode: "ask-permissions" };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function shouldAutoApprove(agent, toolName, mode) {
|
|
205
|
+
if (mode === "ask-permissions")
|
|
206
|
+
return false;
|
|
207
|
+
if (exports.TOOL_BLACKLIST[agent].has(toolName))
|
|
208
|
+
return false;
|
|
209
|
+
if (mode === "yolo")
|
|
210
|
+
return true;
|
|
211
|
+
if (mode === "allow-all-edits")
|
|
212
|
+
return exports.EDIT_TOOLS.has(toolName);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
exports.sessions = new Map();
|
|
216
|
+
// --- Global permissions stream ---
|
|
217
|
+
exports.permissionsEmitter = new events_1.EventEmitter();
|
|
218
|
+
function buildPermissionsDump() {
|
|
219
|
+
const items = [];
|
|
220
|
+
for (const store of exports.sessions.values()) {
|
|
221
|
+
const repoName = store.repoPath.split("/").filter(Boolean).pop() ?? store.repoPath;
|
|
222
|
+
for (const perm of store.pendingPermissions.values()) {
|
|
223
|
+
items.push({
|
|
224
|
+
sessionId: store.gitbotId,
|
|
225
|
+
sdkSessionId: store.sdkSessionId,
|
|
226
|
+
agent: store.agent,
|
|
227
|
+
repoPath: store.repoPath,
|
|
228
|
+
repoName,
|
|
229
|
+
toolUseID: perm.toolUseID,
|
|
230
|
+
toolName: perm.toolName,
|
|
231
|
+
input: perm.input,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return items;
|
|
236
|
+
}
|
|
237
|
+
function buildSessionsDump() {
|
|
238
|
+
return [...exports.sessions.values()].map(store => ({
|
|
239
|
+
gitbotId: store.gitbotId,
|
|
240
|
+
sessionId: store.sdkSessionId,
|
|
241
|
+
status: store.pendingPermissions.size > 0 ? "awaiting_permissions" : store.status,
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
function notifyPermissionsChanged() {
|
|
245
|
+
exports.permissionsEmitter.emit("update", buildPermissionsDump(), buildSessionsDump());
|
|
246
|
+
}
|
|
247
|
+
// --- Push notification bridge (avoids circular import with relay-client) ---
|
|
248
|
+
let _pushSender = null;
|
|
249
|
+
function setPushNotificationSender(fn) {
|
|
250
|
+
_pushSender = fn;
|
|
251
|
+
}
|
|
252
|
+
function sendPushViaRelay(title, body, data) {
|
|
253
|
+
_pushSender?.(title, body, data);
|
|
254
|
+
}
|
|
255
|
+
function notifyNewPermission(toolName) {
|
|
256
|
+
notifyPermissionsChanged();
|
|
257
|
+
if (exports.permissionsEmitter.listenerCount("update") === 0) {
|
|
258
|
+
sendPushViaRelay("Permission required", `Jarvis wants to use ${toolName}. Tap to review.`, { type: "permission" });
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// SSE listener exists but may be stale (iOS keeps connections alive after backgrounding).
|
|
262
|
+
// Wait 5s — if the permission is still pending the user hasn't responded, so push.
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
if (buildPermissionsDump().length > 0) {
|
|
265
|
+
sendPushViaRelay("Permission required", `Jarvis wants to use ${toolName}. Tap to review.`, { type: "permission" });
|
|
266
|
+
}
|
|
267
|
+
}, 10000);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function notifySessionDone(store) {
|
|
271
|
+
// A turn Jarvis is waiting on reports through Jarvis, not on its own.
|
|
272
|
+
if (store.silent)
|
|
273
|
+
return;
|
|
274
|
+
const repoName = store.repoPath.split("/").filter(Boolean).pop() ?? store.repoPath;
|
|
275
|
+
let title = "Task complete";
|
|
276
|
+
let body = `Jarvis finished working on ${repoName}. Tap to see the response.`;
|
|
277
|
+
if (store.threadId) {
|
|
278
|
+
// Lazy: bot-store is a leaf module, but keep the import out of the hot path.
|
|
279
|
+
const { getThread, getBot } = require("./bot-store");
|
|
280
|
+
const thread = getThread(store.threadId);
|
|
281
|
+
const bot = thread && getBot(thread.botId);
|
|
282
|
+
if (thread && bot) {
|
|
283
|
+
title = bot.name;
|
|
284
|
+
body = `${thread.title} \u2014 done. Tap to see the result.`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const send = () => sendPushViaRelay(title, body, { type: "task_complete", sessionId: store.gitbotId, ...(store.threadId ? { threadId: store.threadId } : {}) });
|
|
288
|
+
if (store.emitter.listenerCount("event") === 0) {
|
|
289
|
+
send();
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
setTimeout(send, 2000);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function createSession(gitbotId, agent, repoPath, model, mode, permissionMode, bot) {
|
|
296
|
+
const store = {
|
|
297
|
+
gitbotId,
|
|
298
|
+
sdkSessionId: null,
|
|
299
|
+
agent,
|
|
300
|
+
repoPath,
|
|
301
|
+
model,
|
|
302
|
+
mode,
|
|
303
|
+
permissionMode: permissionMode ?? "ask-permissions",
|
|
304
|
+
seq: 0,
|
|
305
|
+
events: [],
|
|
306
|
+
status: "running",
|
|
307
|
+
emitter: new events_1.EventEmitter(),
|
|
308
|
+
abortController: null,
|
|
309
|
+
pendingPermissions: new Map(),
|
|
310
|
+
cleanupTimer: null,
|
|
311
|
+
threadId: bot?.threadId,
|
|
312
|
+
botPreset: bot?.preset,
|
|
313
|
+
};
|
|
314
|
+
exports.sessions.set(gitbotId, store);
|
|
315
|
+
return store;
|
|
316
|
+
}
|
|
317
|
+
function scheduleCleanup(_store) {
|
|
318
|
+
// Session cleanup disabled — sessions are kept in memory indefinitely
|
|
319
|
+
// if (store.cleanupTimer) clearTimeout(store.cleanupTimer);
|
|
320
|
+
// store.cleanupTimer = setTimeout(() => {
|
|
321
|
+
// sessions.delete(store.gitbotId);
|
|
322
|
+
// }, 60 * 60 * 1000);
|
|
323
|
+
}
|
|
324
|
+
function emitEvent(store, type, data) {
|
|
325
|
+
const seq = ++store.seq;
|
|
326
|
+
const event = { seq, type, ...data };
|
|
327
|
+
store.events.push(event);
|
|
328
|
+
store.emitter.emit("event", event);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Where the relay token lives for a workspace. `.gitbot-relay-token` and
|
|
332
|
+
* `.grass-relay-token` are the pre-rename names: keep honouring one when it is
|
|
333
|
+
* the only one present, so an upgrade doesn't hand the relay a brand-new identity.
|
|
334
|
+
*/
|
|
335
|
+
function relayTokenPath(cwd) {
|
|
336
|
+
const current = (0, path_1.join)(cwd, ".jarvis-relay-token");
|
|
337
|
+
if ((0, fs_1.existsSync)(current))
|
|
338
|
+
return current;
|
|
339
|
+
for (const legacy of [(0, path_1.join)(cwd, ".gitbot-relay-token"), (0, path_1.join)(cwd, ".grass-relay-token")]) {
|
|
340
|
+
if ((0, fs_1.existsSync)(legacy))
|
|
341
|
+
return legacy;
|
|
342
|
+
}
|
|
343
|
+
return current;
|
|
344
|
+
}
|
|
345
|
+
let _keepAliveInterval = null;
|
|
346
|
+
let _activeSessionCount = 0;
|
|
347
|
+
function _pingActivity() {
|
|
348
|
+
const apiUrl = "https://api.codeongrass.com/v1";
|
|
349
|
+
if (!apiUrl)
|
|
350
|
+
return;
|
|
351
|
+
let token;
|
|
352
|
+
try {
|
|
353
|
+
token = (0, fs_1.readFileSync)(relayTokenPath(process.cwd()), "utf8").trim();
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (!token)
|
|
359
|
+
return;
|
|
360
|
+
fetch(`${apiUrl}/containers/activity`, {
|
|
361
|
+
method: "POST",
|
|
362
|
+
headers: { "x-relay-token": token },
|
|
363
|
+
signal: AbortSignal.timeout(10000),
|
|
364
|
+
}).catch(() => { });
|
|
365
|
+
}
|
|
366
|
+
function notifySessionStarted() {
|
|
367
|
+
_activeSessionCount++;
|
|
368
|
+
if (_keepAliveInterval === null) {
|
|
369
|
+
_pingActivity();
|
|
370
|
+
_keepAliveInterval = setInterval(_pingActivity, 2 * 60 * 1000);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function notifySessionEnded() {
|
|
374
|
+
_activeSessionCount = Math.max(0, _activeSessionCount - 1);
|
|
375
|
+
if (_activeSessionCount === 0 && _keepAliveInterval !== null) {
|
|
376
|
+
clearInterval(_keepAliveInterval);
|
|
377
|
+
_keepAliveInterval = null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// --- HTTP Server ---
|
|
381
|
+
async function createHttpServer(opts) {
|
|
382
|
+
const caffeinatePid = maybeCaffeinate(opts.caffeinate);
|
|
383
|
+
console.log(` workspace: ${process.cwd()}`);
|
|
384
|
+
let PORT;
|
|
385
|
+
if (opts.portOverride !== undefined) {
|
|
386
|
+
PORT = opts.portOverride;
|
|
387
|
+
console.log(` port: ${PORT} (specified)`);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
try {
|
|
391
|
+
PORT = await findAvailablePort(exports.PORT_RANGE_START, exports.PORT_RANGE_END);
|
|
392
|
+
console.log(` port: ${PORT} (auto-selected from ${exports.PORT_RANGE_START}–${exports.PORT_RANGE_END})`);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
console.error(`\n No available port found in range ${exports.PORT_RANGE_START}–${exports.PORT_RANGE_END}.`);
|
|
396
|
+
console.error(` Try stopping other jarvis sessions, or run with -p to specify a port.\n`);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const server = node_http_1.default.createServer();
|
|
401
|
+
// Serve the SPA for GET /
|
|
402
|
+
server.on("request", (req, res) => {
|
|
403
|
+
if (req.method === "GET" && (req.url === "/" || req.url === "")) {
|
|
404
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
405
|
+
// Function replacement: the vendor bundles contain `$&`-style sequences
|
|
406
|
+
// that a string replacement would interpret.
|
|
407
|
+
res.end(client_html_1.html.replace("<!--vendor-->", () => (0, client_vendor_1.vendorScripts)()));
|
|
408
|
+
}
|
|
409
|
+
// All other routes handled by server.ts listener
|
|
410
|
+
});
|
|
411
|
+
server.on("error", (err) => {
|
|
412
|
+
if (err.code === "EADDRINUSE") {
|
|
413
|
+
console.error(`\n Port ${PORT} is not available.`);
|
|
414
|
+
if (opts.portOverride !== undefined) {
|
|
415
|
+
console.error(` Please choose a different port with -p, or run without -p to auto-select one.\n`);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
console.error(` Try stopping other jarvis sessions, or run with -p to specify a port.\n`);
|
|
419
|
+
}
|
|
420
|
+
process.exit(1);
|
|
421
|
+
}
|
|
422
|
+
throw err;
|
|
423
|
+
});
|
|
424
|
+
await new Promise((resolve) => {
|
|
425
|
+
server.listen(PORT, async () => {
|
|
426
|
+
await showQR(opts.network, PORT);
|
|
427
|
+
resolve();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
return { server, PORT, caffeinatePid };
|
|
431
|
+
}
|
|
432
|
+
function setupShutdown(cleanup, caffeinatePid) {
|
|
433
|
+
const shutdown = () => {
|
|
434
|
+
console.log("\nShutting down...");
|
|
435
|
+
killCaffeinate(caffeinatePid);
|
|
436
|
+
cleanup();
|
|
437
|
+
};
|
|
438
|
+
process.on("SIGINT", shutdown);
|
|
439
|
+
process.on("SIGTERM", shutdown);
|
|
440
|
+
process.on("exit", () => killCaffeinate(caffeinatePid));
|
|
441
|
+
process.on("uncaughtException", (err) => {
|
|
442
|
+
console.error("Uncaught exception:", err);
|
|
443
|
+
killCaffeinate(caffeinatePid);
|
|
444
|
+
process.exit(1);
|
|
445
|
+
});
|
|
446
|
+
process.on("unhandledRejection", (reason) => {
|
|
447
|
+
console.error("[jarvis] unhandledRejection — process kept alive:", reason);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
// --- SSE helper ---
|
|
451
|
+
function sseHeaders() {
|
|
452
|
+
return {
|
|
453
|
+
"Content-Type": "text/event-stream",
|
|
454
|
+
"Cache-Control": "no-cache",
|
|
455
|
+
"Connection": "keep-alive",
|
|
456
|
+
"Access-Control-Allow-Origin": "*",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function writeSseEvent(res, event) {
|
|
460
|
+
if (res.writableEnded)
|
|
461
|
+
return;
|
|
462
|
+
res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
463
|
+
}
|
|
464
|
+
// --- Route helpers ---
|
|
465
|
+
function parseQuery(url) {
|
|
466
|
+
const idx = url.indexOf("?");
|
|
467
|
+
if (idx === -1)
|
|
468
|
+
return {};
|
|
469
|
+
const params = {};
|
|
470
|
+
for (const [k, v] of new URLSearchParams(url.slice(idx + 1))) {
|
|
471
|
+
params[k] = v;
|
|
472
|
+
}
|
|
473
|
+
return params;
|
|
474
|
+
}
|
|
475
|
+
function parsePathParam(url, prefix) {
|
|
476
|
+
const path = url.split("?")[0];
|
|
477
|
+
if (!path.startsWith(prefix))
|
|
478
|
+
return null;
|
|
479
|
+
return path.slice(prefix.length) || null;
|
|
480
|
+
}
|
|
481
|
+
function jsonOk(res, body) {
|
|
482
|
+
const data = JSON.stringify(body);
|
|
483
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
484
|
+
res.end(data);
|
|
485
|
+
}
|
|
486
|
+
/** `extra` rides alongside the message for errors the client acts on, not just shows. */
|
|
487
|
+
function jsonError(res, status, message, extra) {
|
|
488
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
489
|
+
res.end(JSON.stringify({ error: message, ...extra }));
|
|
490
|
+
}
|
|
491
|
+
function readBody(req) {
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
let raw = "";
|
|
494
|
+
req.on("data", (chunk) => { raw += chunk; });
|
|
495
|
+
req.on("end", () => {
|
|
496
|
+
try {
|
|
497
|
+
resolve(JSON.parse(raw));
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
resolve({});
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
req.on("error", reject);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
// --- Workspace REST handlers ---
|
|
507
|
+
async function handleWorkspaceRoutes(req, res, workspaceCwd, availableAgents) {
|
|
508
|
+
const url = req.url ?? "/";
|
|
509
|
+
const method = req.method ?? "GET";
|
|
510
|
+
const path = url.split("?")[0];
|
|
511
|
+
const query = parseQuery(url);
|
|
512
|
+
if (method === "GET" && path === "/health") {
|
|
513
|
+
_pingActivity();
|
|
514
|
+
jsonOk(res, { status: "ok", cwd: workspaceCwd, serverVersion: SERVER_VERSION, clientVersionRange: CLIENT_VERSION_RANGE });
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
if (method === "GET" && path === "/agents") {
|
|
518
|
+
jsonOk(res, { agents: availableAgents });
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
if (method === "GET" && path === "/repos") {
|
|
522
|
+
const repos = await (0, workspace_1.listRepos)(workspaceCwd);
|
|
523
|
+
jsonOk(res, { repos });
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
if (method === "GET" && path === "/repos/details") {
|
|
527
|
+
const repoPath = query.repoPath;
|
|
528
|
+
if (!repoPath) {
|
|
529
|
+
jsonError(res, 400, "repoPath is required");
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const details = (0, workspace_1.getRepoDetails)(repoPath);
|
|
534
|
+
jsonOk(res, details);
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
jsonError(res, 500, err?.message ?? "Failed to get repo details");
|
|
538
|
+
}
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
if (method === "POST" && path === "/repos/clone") {
|
|
542
|
+
const body = await readBody(req);
|
|
543
|
+
const { url: cloneUrl } = body;
|
|
544
|
+
if (!cloneUrl) {
|
|
545
|
+
jsonError(res, 400, "url is required");
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const clonedPath = (0, workspace_1.cloneRepo)(cloneUrl, workspaceCwd);
|
|
550
|
+
const { basename } = await import("path");
|
|
551
|
+
jsonOk(res, { path: clonedPath, name: basename(clonedPath) });
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
jsonError(res, 500, err?.message ?? "Clone failed");
|
|
555
|
+
}
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
if (method === "POST" && path === "/folders") {
|
|
559
|
+
const body = await readBody(req);
|
|
560
|
+
const { name } = body;
|
|
561
|
+
if (!name) {
|
|
562
|
+
jsonError(res, 400, "name is required");
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
const createdPath = await (0, workspace_1.createFolder)(name, workspaceCwd);
|
|
567
|
+
const { basename } = await import("path");
|
|
568
|
+
jsonOk(res, { path: createdPath, name: basename(createdPath) });
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
jsonError(res, 500, err?.message ?? "Create failed");
|
|
572
|
+
}
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
// GET /browse?path= — subdirectories only, for the folder picker. Defaults to
|
|
576
|
+
// the workspace, but roams the whole filesystem from there.
|
|
577
|
+
if (method === "GET" && path === "/browse") {
|
|
578
|
+
try {
|
|
579
|
+
jsonOk(res, await (0, workspace_1.browseDirs)(query.path ?? workspaceCwd, workspaceCwd));
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
jsonError(res, 400, err?.message ?? "Failed to browse directory");
|
|
583
|
+
}
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
if (method === "GET" && path === "/dir") {
|
|
587
|
+
const repoPath = query.repoPath;
|
|
588
|
+
if (!repoPath) {
|
|
589
|
+
jsonError(res, 400, "repoPath is required");
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
const targetPath = query.path ?? repoPath;
|
|
593
|
+
try {
|
|
594
|
+
const entries = await (0, workspace_1.listDir)(targetPath, repoPath);
|
|
595
|
+
jsonOk(res, { entries });
|
|
596
|
+
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
jsonError(res, 400, err?.message ?? "Failed to list directory");
|
|
599
|
+
}
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
if (method === "GET" && path === "/file") {
|
|
603
|
+
const repoPath = query.repoPath;
|
|
604
|
+
const filePath = query.path;
|
|
605
|
+
if (!repoPath) {
|
|
606
|
+
jsonError(res, 400, "repoPath is required");
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
if (!filePath) {
|
|
610
|
+
jsonError(res, 400, "path is required");
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
const { content, size } = await (0, workspace_1.readFile)(filePath, repoPath);
|
|
615
|
+
jsonOk(res, { content, size });
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
jsonError(res, 400, err?.message ?? "Failed to read file");
|
|
619
|
+
}
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
if (method === "GET" && path === "/diffs") {
|
|
623
|
+
const repoPath = query.repoPath ?? workspaceCwd;
|
|
624
|
+
let diff = "";
|
|
625
|
+
try {
|
|
626
|
+
diff = (0, child_process_1.execSync)("git diff HEAD", { cwd: repoPath, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
diff = "";
|
|
630
|
+
}
|
|
631
|
+
// Append untracked (never-committed) files as synthetic diffs
|
|
632
|
+
try {
|
|
633
|
+
const { readFileSync, statSync } = await import("fs");
|
|
634
|
+
const { join } = await import("path");
|
|
635
|
+
const untrackedRaw = (0, child_process_1.execSync)("git ls-files --others --exclude-standard", {
|
|
636
|
+
cwd: repoPath,
|
|
637
|
+
encoding: "utf-8",
|
|
638
|
+
}).trim();
|
|
639
|
+
for (const file of untrackedRaw.split("\n").filter(Boolean)) {
|
|
640
|
+
try {
|
|
641
|
+
const fullPath = join(repoPath, file);
|
|
642
|
+
if (statSync(fullPath).size > 1024 * 1024)
|
|
643
|
+
continue; // skip files > 1 MB
|
|
644
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
645
|
+
const lines = content.split("\n");
|
|
646
|
+
if (lines[lines.length - 1] === "")
|
|
647
|
+
lines.pop();
|
|
648
|
+
const hunkLines = lines.map((l) => `+${l}`).join("\n");
|
|
649
|
+
diff += `\ndiff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${hunkLines}\n`;
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
// binary or unreadable — skip
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// git ls-files unavailable — skip untracked
|
|
658
|
+
}
|
|
659
|
+
jsonOk(res, { diff });
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
return false;
|
|
663
|
+
}
|