svamp-cli 0.1.0
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/bin/svamp.mjs +35 -0
- package/dist/cli.mjs +200 -0
- package/dist/index.mjs +15 -0
- package/dist/run-DgPbD8x5.mjs +1659 -0
- package/package.json +39 -0
|
@@ -0,0 +1,1659 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { existsSync as existsSync$1, readFileSync as readFileSync$1, writeFileSync, readdirSync, mkdirSync as mkdirSync$1, unlinkSync } from 'fs';
|
|
4
|
+
import { dirname, join as join$1, resolve, basename } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { randomUUID as randomUUID$1 } from 'crypto';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
|
|
16
|
+
let connectToServerFn = null;
|
|
17
|
+
async function getConnectToServer() {
|
|
18
|
+
if (!connectToServerFn) {
|
|
19
|
+
const mod = await import('hypha-rpc');
|
|
20
|
+
connectToServerFn = mod.connectToServer || mod.default && mod.default.connectToServer || mod.hyphaWebsocketClient && mod.hyphaWebsocketClient.connectToServer;
|
|
21
|
+
if (!connectToServerFn) {
|
|
22
|
+
throw new Error("Could not find connectToServer in hypha-rpc module");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return connectToServerFn;
|
|
26
|
+
}
|
|
27
|
+
async function connectToHypha(config) {
|
|
28
|
+
const connectToServer = await getConnectToServer();
|
|
29
|
+
const workspace = config.token ? parseWorkspaceFromToken(config.token) : void 0;
|
|
30
|
+
const server = await connectToServer({
|
|
31
|
+
server_url: config.serverUrl,
|
|
32
|
+
token: config.token,
|
|
33
|
+
client_id: config.clientId,
|
|
34
|
+
name: config.name || "svamp-cli",
|
|
35
|
+
workspace
|
|
36
|
+
});
|
|
37
|
+
return server;
|
|
38
|
+
}
|
|
39
|
+
function parseWorkspaceFromToken(token) {
|
|
40
|
+
try {
|
|
41
|
+
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
|
|
42
|
+
const scope = payload.scope || "";
|
|
43
|
+
const match = scope.match(/wid:([^\s]+)/);
|
|
44
|
+
return match ? match[1] : void 0;
|
|
45
|
+
} catch {
|
|
46
|
+
return void 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function getHyphaServerUrl() {
|
|
50
|
+
return process.env.HYPHA_SERVER_URL || "http://localhost:9527";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function registerMachineService(server, machineId, metadata, daemonState, handlers) {
|
|
54
|
+
let currentMetadata = { ...metadata };
|
|
55
|
+
let currentDaemonState = { ...daemonState };
|
|
56
|
+
let metadataVersion = 1;
|
|
57
|
+
let daemonStateVersion = 1;
|
|
58
|
+
const listeners = [];
|
|
59
|
+
const removeListener = (listener, reason) => {
|
|
60
|
+
const idx = listeners.indexOf(listener);
|
|
61
|
+
if (idx >= 0) {
|
|
62
|
+
listeners.splice(idx, 1);
|
|
63
|
+
console.log(`[HYPHA MACHINE] Listener removed (${reason}), remaining: ${listeners.length}`);
|
|
64
|
+
const rintfId = listener._rintf_service_id;
|
|
65
|
+
if (rintfId) {
|
|
66
|
+
server.unregisterService(rintfId).catch(() => {
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const notifyListeners = (update) => {
|
|
72
|
+
for (let i = listeners.length - 1; i >= 0; i--) {
|
|
73
|
+
try {
|
|
74
|
+
const result = listeners[i].onUpdate(update);
|
|
75
|
+
if (result && typeof result.catch === "function") {
|
|
76
|
+
const listener = listeners[i];
|
|
77
|
+
result.catch((err) => {
|
|
78
|
+
console.error(`[HYPHA MACHINE] Async listener error:`, err);
|
|
79
|
+
removeListener(listener, "async error");
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(`[HYPHA MACHINE] Listener error:`, err);
|
|
84
|
+
removeListener(listeners[i], "sync error");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const serviceInfo = await server.registerService(
|
|
89
|
+
{
|
|
90
|
+
id: `svamp-machine-${machineId}`,
|
|
91
|
+
name: `Svamp Machine ${machineId}`,
|
|
92
|
+
type: "svamp-machine",
|
|
93
|
+
config: { visibility: "public" },
|
|
94
|
+
// Machine info
|
|
95
|
+
getMachineInfo: async () => ({
|
|
96
|
+
machineId,
|
|
97
|
+
metadata: currentMetadata,
|
|
98
|
+
metadataVersion,
|
|
99
|
+
daemonState: currentDaemonState,
|
|
100
|
+
daemonStateVersion
|
|
101
|
+
}),
|
|
102
|
+
// Heartbeat
|
|
103
|
+
heartbeat: async () => ({
|
|
104
|
+
time: Date.now(),
|
|
105
|
+
status: currentDaemonState.status,
|
|
106
|
+
machineId
|
|
107
|
+
}),
|
|
108
|
+
// List active sessions on this machine
|
|
109
|
+
listSessions: async () => {
|
|
110
|
+
return handlers.getTrackedSessions();
|
|
111
|
+
},
|
|
112
|
+
// Spawn a new session
|
|
113
|
+
spawnSession: async (options) => {
|
|
114
|
+
return await handlers.spawnSession({
|
|
115
|
+
...options,
|
|
116
|
+
machineId
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
// Stop a session
|
|
120
|
+
stopSession: async (sessionId) => {
|
|
121
|
+
return handlers.stopSession(sessionId);
|
|
122
|
+
},
|
|
123
|
+
// Stop the daemon
|
|
124
|
+
stopDaemon: async () => {
|
|
125
|
+
handlers.requestShutdown();
|
|
126
|
+
return { success: true };
|
|
127
|
+
},
|
|
128
|
+
// Metadata management (with optimistic concurrency)
|
|
129
|
+
getMetadata: async () => ({
|
|
130
|
+
metadata: currentMetadata,
|
|
131
|
+
version: metadataVersion
|
|
132
|
+
}),
|
|
133
|
+
updateMetadata: async (newMetadata, expectedVersion) => {
|
|
134
|
+
if (expectedVersion !== metadataVersion) {
|
|
135
|
+
return {
|
|
136
|
+
result: "version-mismatch",
|
|
137
|
+
version: metadataVersion,
|
|
138
|
+
metadata: currentMetadata
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
currentMetadata = newMetadata;
|
|
142
|
+
metadataVersion++;
|
|
143
|
+
notifyListeners({
|
|
144
|
+
type: "update-machine",
|
|
145
|
+
machineId,
|
|
146
|
+
metadata: { value: currentMetadata, version: metadataVersion }
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
result: "success",
|
|
150
|
+
version: metadataVersion,
|
|
151
|
+
metadata: currentMetadata
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
// Daemon state management
|
|
155
|
+
getDaemonState: async () => ({
|
|
156
|
+
daemonState: currentDaemonState,
|
|
157
|
+
version: daemonStateVersion
|
|
158
|
+
}),
|
|
159
|
+
updateDaemonState: async (newState, expectedVersion) => {
|
|
160
|
+
if (expectedVersion !== daemonStateVersion) {
|
|
161
|
+
return {
|
|
162
|
+
result: "version-mismatch",
|
|
163
|
+
version: daemonStateVersion,
|
|
164
|
+
daemonState: currentDaemonState
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
currentDaemonState = newState;
|
|
168
|
+
daemonStateVersion++;
|
|
169
|
+
notifyListeners({
|
|
170
|
+
type: "update-machine",
|
|
171
|
+
machineId,
|
|
172
|
+
daemonState: { value: currentDaemonState, version: daemonStateVersion }
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
result: "success",
|
|
176
|
+
version: daemonStateVersion,
|
|
177
|
+
daemonState: currentDaemonState
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
// Register a listener for real-time updates (app calls this with _rintf callback)
|
|
181
|
+
registerListener: async (callback) => {
|
|
182
|
+
listeners.push(callback);
|
|
183
|
+
console.log(`[HYPHA MACHINE] Listener registered (total: ${listeners.length})`);
|
|
184
|
+
return { success: true, listenerId: listeners.length - 1 };
|
|
185
|
+
},
|
|
186
|
+
// Shell access
|
|
187
|
+
bash: async (command, cwd) => {
|
|
188
|
+
const { exec } = await import('child_process');
|
|
189
|
+
const { homedir } = await import('os');
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
exec(command, { cwd: cwd || homedir(), timeout: 3e4, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
192
|
+
if (err) {
|
|
193
|
+
resolve({ success: false, stdout: stdout || "", stderr: stderr || err.message, exitCode: err.code ?? 1 });
|
|
194
|
+
} else {
|
|
195
|
+
resolve({ success: true, stdout, stderr: stderr || "", exitCode: 0 });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
// WISE voice — create ephemeral token for OpenAI Realtime API
|
|
201
|
+
wiseCreateEphemeralToken: async (params) => {
|
|
202
|
+
const apiKey = params.apiKey || process.env.OPENAI_API_KEY;
|
|
203
|
+
if (!apiKey) {
|
|
204
|
+
return { success: false, error: "No OpenAI API key found. Set OPENAI_API_KEY or pass apiKey." };
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const response = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: {
|
|
210
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
211
|
+
"Content-Type": "application/json"
|
|
212
|
+
},
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
session: {
|
|
215
|
+
type: "realtime",
|
|
216
|
+
model: params.model || "gpt-realtime-mini"
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
});
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
return { success: false, error: `OpenAI API error: ${response.status}` };
|
|
222
|
+
}
|
|
223
|
+
const result = await response.json();
|
|
224
|
+
return { success: true, clientSecret: result.value };
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return { success: false, error: error instanceof Error ? error.message : "Failed to create token" };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{ overwrite: true }
|
|
231
|
+
);
|
|
232
|
+
console.log(`[HYPHA MACHINE] Machine service registered: ${serviceInfo.id}`);
|
|
233
|
+
return {
|
|
234
|
+
serviceInfo,
|
|
235
|
+
updateMetadata: (newMetadata) => {
|
|
236
|
+
currentMetadata = newMetadata;
|
|
237
|
+
metadataVersion++;
|
|
238
|
+
notifyListeners({
|
|
239
|
+
type: "update-machine",
|
|
240
|
+
machineId,
|
|
241
|
+
metadata: { value: currentMetadata, version: metadataVersion }
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
updateDaemonState: (newState) => {
|
|
245
|
+
currentDaemonState = newState;
|
|
246
|
+
daemonStateVersion++;
|
|
247
|
+
notifyListeners({
|
|
248
|
+
type: "update-machine",
|
|
249
|
+
machineId,
|
|
250
|
+
daemonState: { value: currentDaemonState, version: daemonStateVersion }
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
disconnect: async () => {
|
|
254
|
+
await server.unregisterService(serviceInfo.id);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function loadMessages(messagesDir, sessionId) {
|
|
260
|
+
const filePath = join(messagesDir, `${sessionId}.messages.jsonl`);
|
|
261
|
+
if (!existsSync(filePath)) return [];
|
|
262
|
+
try {
|
|
263
|
+
const lines = readFileSync(filePath, "utf-8").split("\n").filter((l) => l.trim());
|
|
264
|
+
const messages = [];
|
|
265
|
+
for (const line of lines) {
|
|
266
|
+
try {
|
|
267
|
+
messages.push(JSON.parse(line));
|
|
268
|
+
} catch {
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return messages.slice(-5e3);
|
|
272
|
+
} catch {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function appendMessage(messagesDir, sessionId, msg) {
|
|
277
|
+
const filePath = join(messagesDir, `${sessionId}.messages.jsonl`);
|
|
278
|
+
if (!existsSync(messagesDir)) {
|
|
279
|
+
mkdirSync(messagesDir, { recursive: true });
|
|
280
|
+
}
|
|
281
|
+
appendFileSync(filePath, JSON.stringify(msg) + "\n");
|
|
282
|
+
}
|
|
283
|
+
async function registerSessionService(server, sessionId, initialMetadata, initialAgentState, callbacks, options) {
|
|
284
|
+
const messages = options?.messagesDir ? loadMessages(options.messagesDir, sessionId) : [];
|
|
285
|
+
let nextSeq = messages.length > 0 ? messages[messages.length - 1].seq + 1 : 1;
|
|
286
|
+
let metadata = { ...initialMetadata };
|
|
287
|
+
let metadataVersion = 1;
|
|
288
|
+
let agentState = initialAgentState ? { ...initialAgentState } : null;
|
|
289
|
+
let agentStateVersion = 1;
|
|
290
|
+
let lastActivity = {
|
|
291
|
+
active: false,
|
|
292
|
+
thinking: false,
|
|
293
|
+
mode: "remote",
|
|
294
|
+
time: Date.now()
|
|
295
|
+
};
|
|
296
|
+
const listeners = [];
|
|
297
|
+
const removeListener = (listener, reason) => {
|
|
298
|
+
const idx = listeners.indexOf(listener);
|
|
299
|
+
if (idx >= 0) {
|
|
300
|
+
listeners.splice(idx, 1);
|
|
301
|
+
console.log(`[HYPHA SESSION ${sessionId}] Listener removed (${reason}), remaining: ${listeners.length}`);
|
|
302
|
+
const rintfId = listener._rintf_service_id;
|
|
303
|
+
if (rintfId) {
|
|
304
|
+
server.unregisterService(rintfId).catch(() => {
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
const notifyListeners = (update) => {
|
|
310
|
+
for (let i = listeners.length - 1; i >= 0; i--) {
|
|
311
|
+
try {
|
|
312
|
+
const result = listeners[i].onUpdate(update);
|
|
313
|
+
if (result && typeof result.catch === "function") {
|
|
314
|
+
const listener = listeners[i];
|
|
315
|
+
result.catch((err) => {
|
|
316
|
+
console.error(`[HYPHA SESSION ${sessionId}] Async listener error:`, err);
|
|
317
|
+
removeListener(listener, "async error");
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error(`[HYPHA SESSION ${sessionId}] Listener error:`, err);
|
|
322
|
+
removeListener(listeners[i], "sync error");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
const pushMessage = (content, role = "agent") => {
|
|
327
|
+
let wrappedContent;
|
|
328
|
+
if (role === "agent") {
|
|
329
|
+
const data = { ...content };
|
|
330
|
+
if ((data.type === "assistant" || data.type === "user") && !data.uuid) {
|
|
331
|
+
data.uuid = randomUUID();
|
|
332
|
+
}
|
|
333
|
+
wrappedContent = { role: "agent", content: { type: "output", data } };
|
|
334
|
+
} else if (role === "session") {
|
|
335
|
+
wrappedContent = { role: "session", content: { type: "session", data: content } };
|
|
336
|
+
} else {
|
|
337
|
+
const text = typeof content === "string" ? content : content?.text || content?.content || JSON.stringify(content);
|
|
338
|
+
wrappedContent = { role: "user", content: { type: "text", text } };
|
|
339
|
+
}
|
|
340
|
+
const msg = {
|
|
341
|
+
id: randomUUID(),
|
|
342
|
+
seq: nextSeq++,
|
|
343
|
+
content: wrappedContent,
|
|
344
|
+
localId: null,
|
|
345
|
+
createdAt: Date.now(),
|
|
346
|
+
updatedAt: Date.now()
|
|
347
|
+
};
|
|
348
|
+
messages.push(msg);
|
|
349
|
+
if (options?.messagesDir) {
|
|
350
|
+
appendMessage(options.messagesDir, sessionId, msg);
|
|
351
|
+
}
|
|
352
|
+
notifyListeners({
|
|
353
|
+
type: "new-message",
|
|
354
|
+
sessionId,
|
|
355
|
+
message: msg
|
|
356
|
+
});
|
|
357
|
+
return msg;
|
|
358
|
+
};
|
|
359
|
+
const serviceInfo = await server.registerService(
|
|
360
|
+
{
|
|
361
|
+
id: `svamp-session-${sessionId}`,
|
|
362
|
+
name: `Svamp Session ${sessionId.slice(0, 8)}`,
|
|
363
|
+
type: "svamp-session",
|
|
364
|
+
config: { visibility: "public" },
|
|
365
|
+
// ── Messages ──
|
|
366
|
+
getMessages: async (afterSeq, limit) => {
|
|
367
|
+
const after = afterSeq ?? 0;
|
|
368
|
+
const lim = Math.min(limit ?? 100, 500);
|
|
369
|
+
const filtered = messages.filter((m) => m.seq > after);
|
|
370
|
+
const page = filtered.slice(0, lim);
|
|
371
|
+
return {
|
|
372
|
+
messages: page,
|
|
373
|
+
hasMore: filtered.length > lim
|
|
374
|
+
};
|
|
375
|
+
},
|
|
376
|
+
sendMessage: async (content, localId, meta) => {
|
|
377
|
+
if (localId) {
|
|
378
|
+
const existing = messages.find((m) => m.localId === localId);
|
|
379
|
+
if (existing) {
|
|
380
|
+
return { id: existing.id, seq: existing.seq, localId: existing.localId };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
let parsed = content;
|
|
384
|
+
if (typeof parsed === "string") {
|
|
385
|
+
try {
|
|
386
|
+
parsed = JSON.parse(parsed);
|
|
387
|
+
} catch {
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (parsed && typeof parsed.content === "string" && !parsed.role) {
|
|
391
|
+
try {
|
|
392
|
+
const inner = JSON.parse(parsed.content);
|
|
393
|
+
if (inner && typeof inner === "object") parsed = inner;
|
|
394
|
+
} catch {
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const wrappedContent = parsed && parsed.role === "user" ? { role: "user", content: parsed.content } : { role: "user", content: { type: "text", text: typeof parsed === "string" ? parsed : JSON.stringify(parsed) } };
|
|
398
|
+
const msg = {
|
|
399
|
+
id: randomUUID(),
|
|
400
|
+
seq: nextSeq++,
|
|
401
|
+
content: wrappedContent,
|
|
402
|
+
localId: localId || randomUUID(),
|
|
403
|
+
createdAt: Date.now(),
|
|
404
|
+
updatedAt: Date.now()
|
|
405
|
+
};
|
|
406
|
+
messages.push(msg);
|
|
407
|
+
if (options?.messagesDir) {
|
|
408
|
+
appendMessage(options.messagesDir, sessionId, msg);
|
|
409
|
+
}
|
|
410
|
+
notifyListeners({
|
|
411
|
+
type: "new-message",
|
|
412
|
+
sessionId,
|
|
413
|
+
message: msg
|
|
414
|
+
});
|
|
415
|
+
callbacks.onUserMessage(content, meta);
|
|
416
|
+
return { id: msg.id, seq: msg.seq, localId: msg.localId };
|
|
417
|
+
},
|
|
418
|
+
// ── Metadata ──
|
|
419
|
+
getMetadata: async () => ({
|
|
420
|
+
metadata,
|
|
421
|
+
version: metadataVersion
|
|
422
|
+
}),
|
|
423
|
+
updateMetadata: async (newMetadata, expectedVersion) => {
|
|
424
|
+
if (expectedVersion !== void 0 && expectedVersion !== metadataVersion) {
|
|
425
|
+
return {
|
|
426
|
+
result: "version-mismatch",
|
|
427
|
+
version: metadataVersion,
|
|
428
|
+
metadata
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
metadata = newMetadata;
|
|
432
|
+
metadataVersion++;
|
|
433
|
+
notifyListeners({
|
|
434
|
+
type: "update-session",
|
|
435
|
+
sessionId,
|
|
436
|
+
metadata: { value: metadata, version: metadataVersion }
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
result: "success",
|
|
440
|
+
version: metadataVersion,
|
|
441
|
+
metadata
|
|
442
|
+
};
|
|
443
|
+
},
|
|
444
|
+
// ── Agent State ──
|
|
445
|
+
getAgentState: async () => ({
|
|
446
|
+
agentState,
|
|
447
|
+
version: agentStateVersion
|
|
448
|
+
}),
|
|
449
|
+
updateAgentState: async (newState, expectedVersion) => {
|
|
450
|
+
if (expectedVersion !== void 0 && expectedVersion !== agentStateVersion) {
|
|
451
|
+
return {
|
|
452
|
+
result: "version-mismatch",
|
|
453
|
+
version: agentStateVersion,
|
|
454
|
+
agentState
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
agentState = newState;
|
|
458
|
+
agentStateVersion++;
|
|
459
|
+
notifyListeners({
|
|
460
|
+
type: "update-session",
|
|
461
|
+
sessionId,
|
|
462
|
+
agentState: { value: agentState, version: agentStateVersion }
|
|
463
|
+
});
|
|
464
|
+
return {
|
|
465
|
+
result: "success",
|
|
466
|
+
version: agentStateVersion,
|
|
467
|
+
agentState
|
|
468
|
+
};
|
|
469
|
+
},
|
|
470
|
+
// ── Session Control RPCs ──
|
|
471
|
+
abort: async () => {
|
|
472
|
+
callbacks.onAbort();
|
|
473
|
+
return { success: true };
|
|
474
|
+
},
|
|
475
|
+
permissionResponse: async (params) => {
|
|
476
|
+
callbacks.onPermissionResponse(params);
|
|
477
|
+
return { success: true };
|
|
478
|
+
},
|
|
479
|
+
switchMode: async (mode) => {
|
|
480
|
+
callbacks.onSwitchMode(mode);
|
|
481
|
+
return { success: true };
|
|
482
|
+
},
|
|
483
|
+
restartClaude: async () => {
|
|
484
|
+
callbacks.onRestartClaude();
|
|
485
|
+
return { success: true };
|
|
486
|
+
},
|
|
487
|
+
killSession: async () => {
|
|
488
|
+
callbacks.onKillSession();
|
|
489
|
+
return { success: true };
|
|
490
|
+
},
|
|
491
|
+
// ── Activity ──
|
|
492
|
+
keepAlive: async (thinking, mode) => {
|
|
493
|
+
lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
|
|
494
|
+
notifyListeners({
|
|
495
|
+
type: "activity",
|
|
496
|
+
sessionId,
|
|
497
|
+
...lastActivity
|
|
498
|
+
});
|
|
499
|
+
},
|
|
500
|
+
sessionEnd: async () => {
|
|
501
|
+
lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
|
|
502
|
+
notifyListeners({
|
|
503
|
+
type: "activity",
|
|
504
|
+
sessionId,
|
|
505
|
+
...lastActivity
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
// ── File Operations (optional) ──
|
|
509
|
+
readFile: async (path) => {
|
|
510
|
+
if (!callbacks.onReadFile) throw new Error("readFile not supported");
|
|
511
|
+
return await callbacks.onReadFile(path);
|
|
512
|
+
},
|
|
513
|
+
writeFile: async (path, content) => {
|
|
514
|
+
if (!callbacks.onWriteFile) throw new Error("writeFile not supported");
|
|
515
|
+
await callbacks.onWriteFile(path, content);
|
|
516
|
+
return { success: true };
|
|
517
|
+
},
|
|
518
|
+
listDirectory: async (path) => {
|
|
519
|
+
if (!callbacks.onListDirectory) throw new Error("listDirectory not supported");
|
|
520
|
+
return await callbacks.onListDirectory(path);
|
|
521
|
+
},
|
|
522
|
+
bash: async (command, cwd) => {
|
|
523
|
+
if (!callbacks.onBash) throw new Error("bash not supported");
|
|
524
|
+
return await callbacks.onBash(command, cwd);
|
|
525
|
+
},
|
|
526
|
+
ripgrep: async (args, cwd) => {
|
|
527
|
+
if (!callbacks.onRipgrep) throw new Error("ripgrep not supported");
|
|
528
|
+
return await callbacks.onRipgrep(args, cwd);
|
|
529
|
+
},
|
|
530
|
+
getDirectoryTree: async (path, maxDepth) => {
|
|
531
|
+
if (!callbacks.onGetDirectoryTree) throw new Error("getDirectoryTree not supported");
|
|
532
|
+
return await callbacks.onGetDirectoryTree(path, maxDepth ?? 3);
|
|
533
|
+
},
|
|
534
|
+
// ── Listener Registration ──
|
|
535
|
+
registerListener: async (callback) => {
|
|
536
|
+
listeners.push(callback);
|
|
537
|
+
console.log(`[HYPHA SESSION ${sessionId}] Listener registered (total: ${listeners.length}), replaying ${messages.length} messages`);
|
|
538
|
+
for (const msg of messages) {
|
|
539
|
+
try {
|
|
540
|
+
const result = callback.onUpdate({
|
|
541
|
+
type: "new-message",
|
|
542
|
+
sessionId,
|
|
543
|
+
message: msg
|
|
544
|
+
});
|
|
545
|
+
if (result && typeof result.catch === "function") {
|
|
546
|
+
result.catch((err) => {
|
|
547
|
+
console.error(`[HYPHA SESSION ${sessionId}] Replay listener error:`, err);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
} catch (err) {
|
|
551
|
+
console.error(`[HYPHA SESSION ${sessionId}] Replay listener error:`, err);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
const result = callback.onUpdate({
|
|
556
|
+
type: "update-session",
|
|
557
|
+
sessionId,
|
|
558
|
+
metadata: { value: metadata, version: metadataVersion },
|
|
559
|
+
agentState: { value: agentState, version: agentStateVersion }
|
|
560
|
+
});
|
|
561
|
+
if (result && typeof result.catch === "function") {
|
|
562
|
+
result.catch(() => {
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const result = callback.onUpdate({
|
|
569
|
+
type: "activity",
|
|
570
|
+
sessionId,
|
|
571
|
+
...lastActivity
|
|
572
|
+
});
|
|
573
|
+
if (result && typeof result.catch === "function") {
|
|
574
|
+
result.catch(() => {
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
}
|
|
579
|
+
return { success: true, listenerId: listeners.length - 1 };
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
{ overwrite: true }
|
|
583
|
+
);
|
|
584
|
+
console.log(`[HYPHA SESSION] Session service registered: ${serviceInfo.id}`);
|
|
585
|
+
return {
|
|
586
|
+
serviceInfo,
|
|
587
|
+
pushMessage,
|
|
588
|
+
get _agentState() {
|
|
589
|
+
return agentState;
|
|
590
|
+
},
|
|
591
|
+
updateMetadata: (newMetadata) => {
|
|
592
|
+
metadata = newMetadata;
|
|
593
|
+
metadataVersion++;
|
|
594
|
+
notifyListeners({
|
|
595
|
+
type: "update-session",
|
|
596
|
+
sessionId,
|
|
597
|
+
metadata: { value: metadata, version: metadataVersion }
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
updateAgentState: (newAgentState) => {
|
|
601
|
+
agentState = newAgentState;
|
|
602
|
+
agentStateVersion++;
|
|
603
|
+
notifyListeners({
|
|
604
|
+
type: "update-session",
|
|
605
|
+
sessionId,
|
|
606
|
+
agentState: { value: agentState, version: agentStateVersion }
|
|
607
|
+
});
|
|
608
|
+
},
|
|
609
|
+
sendKeepAlive: (thinking, mode) => {
|
|
610
|
+
lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
|
|
611
|
+
notifyListeners({
|
|
612
|
+
type: "activity",
|
|
613
|
+
sessionId,
|
|
614
|
+
...lastActivity
|
|
615
|
+
});
|
|
616
|
+
},
|
|
617
|
+
sendSessionEnd: () => {
|
|
618
|
+
lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
|
|
619
|
+
notifyListeners({
|
|
620
|
+
type: "activity",
|
|
621
|
+
sessionId,
|
|
622
|
+
...lastActivity
|
|
623
|
+
});
|
|
624
|
+
},
|
|
625
|
+
disconnect: async () => {
|
|
626
|
+
await server.unregisterService(serviceInfo.id);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function startSvampServer(ctx) {
|
|
632
|
+
const { sessionService, getMetadata, setMetadata, logger } = ctx;
|
|
633
|
+
logger.log("[svampMCP] Starting MCP server");
|
|
634
|
+
const mcp = new McpServer({
|
|
635
|
+
name: "Svamp MCP",
|
|
636
|
+
version: "1.0.0"
|
|
637
|
+
});
|
|
638
|
+
mcp.registerTool("change_title", {
|
|
639
|
+
description: "Change the title of the current chat session",
|
|
640
|
+
title: "Change Chat Title",
|
|
641
|
+
inputSchema: {
|
|
642
|
+
title: z.string().describe("The new title for the chat session")
|
|
643
|
+
}
|
|
644
|
+
}, async (args) => {
|
|
645
|
+
try {
|
|
646
|
+
setMetadata((m) => ({
|
|
647
|
+
...m,
|
|
648
|
+
summary: { text: args.title, updatedAt: Date.now() }
|
|
649
|
+
}));
|
|
650
|
+
sessionService.pushMessage({
|
|
651
|
+
type: "summary",
|
|
652
|
+
summary: args.title
|
|
653
|
+
}, "session");
|
|
654
|
+
return {
|
|
655
|
+
content: [{ type: "text", text: `Successfully changed chat title to: "${args.title}"` }],
|
|
656
|
+
isError: false
|
|
657
|
+
};
|
|
658
|
+
} catch (error) {
|
|
659
|
+
return {
|
|
660
|
+
content: [{ type: "text", text: `Failed to change chat title: ${String(error)}` }],
|
|
661
|
+
isError: true
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
mcp.registerTool("set_session_link", {
|
|
666
|
+
description: "Set a link for the current session. A clickable button with the label will appear next to the session title. Use this after creating a dashboard, report, or any web page the user should access.",
|
|
667
|
+
title: "Set Session Link",
|
|
668
|
+
inputSchema: {
|
|
669
|
+
url: z.string().describe("The URL to display"),
|
|
670
|
+
label: z.string().optional().describe('Short button label (1-2 chars or up to 2 words). Defaults to "View"')
|
|
671
|
+
}
|
|
672
|
+
}, async (args) => {
|
|
673
|
+
const label = args.label || "View";
|
|
674
|
+
try {
|
|
675
|
+
setMetadata((m) => ({
|
|
676
|
+
...m,
|
|
677
|
+
sessionLink: { url: args.url, label, updatedAt: Date.now() }
|
|
678
|
+
}));
|
|
679
|
+
const currentSummary = getMetadata().summary?.text;
|
|
680
|
+
if (currentSummary) {
|
|
681
|
+
sessionService.pushMessage({
|
|
682
|
+
type: "summary",
|
|
683
|
+
summary: currentSummary
|
|
684
|
+
}, "session");
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
content: [{ type: "text", text: `Session link set: "${label}" \u2192 ${args.url}. A button will appear next to the session title.` }],
|
|
688
|
+
isError: false
|
|
689
|
+
};
|
|
690
|
+
} catch (error) {
|
|
691
|
+
return {
|
|
692
|
+
content: [{ type: "text", text: `Failed to set session link: ${String(error)}` }],
|
|
693
|
+
isError: true
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
const transport = new StreamableHTTPServerTransport({
|
|
698
|
+
sessionIdGenerator: () => crypto.randomUUID()
|
|
699
|
+
});
|
|
700
|
+
await mcp.connect(transport);
|
|
701
|
+
const server = createServer(async (req, res) => {
|
|
702
|
+
try {
|
|
703
|
+
await transport.handleRequest(req, res);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
logger.log(`[svampMCP] Error handling request: ${error}`);
|
|
706
|
+
if (!res.headersSent) {
|
|
707
|
+
res.writeHead(500).end();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
const baseUrl = await new Promise((resolve) => {
|
|
712
|
+
server.listen(0, "127.0.0.1", () => {
|
|
713
|
+
const addr = server.address();
|
|
714
|
+
resolve(new URL(`http://127.0.0.1:${addr.port}`));
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
logger.log(`[svampMCP] Server ready at ${baseUrl.toString()}`);
|
|
718
|
+
return {
|
|
719
|
+
url: baseUrl.toString(),
|
|
720
|
+
toolNames: ["change_title", "set_session_link"],
|
|
721
|
+
stop: () => {
|
|
722
|
+
logger.log("[svampMCP] Stopping server");
|
|
723
|
+
mcp.close();
|
|
724
|
+
server.close();
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const __filename$1 = fileURLToPath(import.meta.url);
|
|
730
|
+
const __dirname$1 = dirname(__filename$1);
|
|
731
|
+
function loadDotEnv() {
|
|
732
|
+
const envDir = process.env.SVAMP_HOME || join$1(os.homedir(), ".svamp");
|
|
733
|
+
const envFile = join$1(envDir, ".env");
|
|
734
|
+
if (existsSync$1(envFile)) {
|
|
735
|
+
const lines = readFileSync$1(envFile, "utf-8").split("\n");
|
|
736
|
+
for (const line of lines) {
|
|
737
|
+
const trimmed = line.trim();
|
|
738
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
739
|
+
const eqIdx = trimmed.indexOf("=");
|
|
740
|
+
if (eqIdx === -1) continue;
|
|
741
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
742
|
+
const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "");
|
|
743
|
+
if (!process.env[key]) {
|
|
744
|
+
process.env[key] = value;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
loadDotEnv();
|
|
750
|
+
const SVAMP_HOME = process.env.SVAMP_HOME || join$1(os.homedir(), ".svamp");
|
|
751
|
+
const DAEMON_STATE_FILE = join$1(SVAMP_HOME, "daemon.state.json");
|
|
752
|
+
const DAEMON_LOCK_FILE = join$1(SVAMP_HOME, "daemon.lock");
|
|
753
|
+
const LOGS_DIR = join$1(SVAMP_HOME, "logs");
|
|
754
|
+
const SESSIONS_DIR = join$1(SVAMP_HOME, "sessions");
|
|
755
|
+
function saveSession(session) {
|
|
756
|
+
if (!existsSync$1(SESSIONS_DIR)) {
|
|
757
|
+
mkdirSync$1(SESSIONS_DIR, { recursive: true });
|
|
758
|
+
}
|
|
759
|
+
writeFileSync(
|
|
760
|
+
join$1(SESSIONS_DIR, `${session.sessionId}.json`),
|
|
761
|
+
JSON.stringify(session, null, 2),
|
|
762
|
+
"utf-8"
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
function deletePersistedSession(sessionId) {
|
|
766
|
+
const sessionFile = join$1(SESSIONS_DIR, `${sessionId}.json`);
|
|
767
|
+
try {
|
|
768
|
+
if (existsSync$1(sessionFile)) unlinkSync(sessionFile);
|
|
769
|
+
} catch {
|
|
770
|
+
}
|
|
771
|
+
const messagesFile = join$1(SESSIONS_DIR, `${sessionId}.messages.jsonl`);
|
|
772
|
+
try {
|
|
773
|
+
if (existsSync$1(messagesFile)) unlinkSync(messagesFile);
|
|
774
|
+
} catch {
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function loadPersistedSessions() {
|
|
778
|
+
if (!existsSync$1(SESSIONS_DIR)) return [];
|
|
779
|
+
const sessions = [];
|
|
780
|
+
for (const file of readdirSync(SESSIONS_DIR)) {
|
|
781
|
+
if (!file.endsWith(".json")) continue;
|
|
782
|
+
try {
|
|
783
|
+
const data = JSON.parse(readFileSync$1(join$1(SESSIONS_DIR, file), "utf-8"));
|
|
784
|
+
if (data.sessionId && data.directory) {
|
|
785
|
+
sessions.push(data);
|
|
786
|
+
}
|
|
787
|
+
} catch {
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return sessions;
|
|
791
|
+
}
|
|
792
|
+
function ensureHomeDir() {
|
|
793
|
+
if (!existsSync$1(SVAMP_HOME)) {
|
|
794
|
+
mkdirSync$1(SVAMP_HOME, { recursive: true });
|
|
795
|
+
}
|
|
796
|
+
if (!existsSync$1(LOGS_DIR)) {
|
|
797
|
+
mkdirSync$1(LOGS_DIR, { recursive: true });
|
|
798
|
+
}
|
|
799
|
+
if (!existsSync$1(SESSIONS_DIR)) {
|
|
800
|
+
mkdirSync$1(SESSIONS_DIR, { recursive: true });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function createLogger() {
|
|
804
|
+
ensureHomeDir();
|
|
805
|
+
const logFile = join$1(LOGS_DIR, `daemon-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.log`);
|
|
806
|
+
return {
|
|
807
|
+
logFilePath: logFile,
|
|
808
|
+
log: (...args) => {
|
|
809
|
+
const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
810
|
+
`;
|
|
811
|
+
fs.appendFile(logFile, line).catch(() => {
|
|
812
|
+
});
|
|
813
|
+
if (process.env.DEBUG) {
|
|
814
|
+
console.log(...args);
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
error: (...args) => {
|
|
818
|
+
const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
819
|
+
`;
|
|
820
|
+
fs.appendFile(logFile, line).catch(() => {
|
|
821
|
+
});
|
|
822
|
+
console.error(...args);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
function writeDaemonStateFile(state) {
|
|
827
|
+
ensureHomeDir();
|
|
828
|
+
writeFileSync(DAEMON_STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
|
|
829
|
+
}
|
|
830
|
+
function readDaemonStateFile() {
|
|
831
|
+
try {
|
|
832
|
+
if (!existsSync$1(DAEMON_STATE_FILE)) return null;
|
|
833
|
+
return JSON.parse(readFileSync$1(DAEMON_STATE_FILE, "utf-8"));
|
|
834
|
+
} catch {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function cleanupDaemonStateFile() {
|
|
839
|
+
try {
|
|
840
|
+
if (existsSync$1(DAEMON_STATE_FILE)) {
|
|
841
|
+
fs.unlink(DAEMON_STATE_FILE).catch(() => {
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
if (existsSync$1(DAEMON_LOCK_FILE)) {
|
|
845
|
+
fs.unlink(DAEMON_LOCK_FILE).catch(() => {
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
} catch {
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
function isDaemonAlive() {
|
|
852
|
+
const state = readDaemonStateFile();
|
|
853
|
+
if (!state) return false;
|
|
854
|
+
try {
|
|
855
|
+
process.kill(state.pid, 0);
|
|
856
|
+
return true;
|
|
857
|
+
} catch {
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
async function startDaemon() {
|
|
862
|
+
const logger = createLogger();
|
|
863
|
+
let requestShutdown;
|
|
864
|
+
const resolvesWhenShutdownRequested = new Promise((resolve2) => {
|
|
865
|
+
requestShutdown = (source, errorMessage) => {
|
|
866
|
+
logger.log(`Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`);
|
|
867
|
+
setTimeout(() => {
|
|
868
|
+
logger.log("Forced exit after timeout");
|
|
869
|
+
process.exit(1);
|
|
870
|
+
}, 5e3);
|
|
871
|
+
resolve2({ source, errorMessage });
|
|
872
|
+
};
|
|
873
|
+
});
|
|
874
|
+
process.on("SIGINT", () => requestShutdown("os-signal"));
|
|
875
|
+
process.on("SIGTERM", () => requestShutdown("os-signal"));
|
|
876
|
+
process.on("uncaughtException", (error) => {
|
|
877
|
+
logger.error("Uncaught exception:", error);
|
|
878
|
+
requestShutdown("exception", error.message);
|
|
879
|
+
});
|
|
880
|
+
process.on("unhandledRejection", (reason) => {
|
|
881
|
+
const msg = String(reason);
|
|
882
|
+
logger.error("Unhandled rejection:", reason);
|
|
883
|
+
if (msg.includes("_rintf") || msg.includes("Method call timed out")) {
|
|
884
|
+
logger.log("Ignoring _rintf callback timeout (app likely disconnected)");
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
requestShutdown("exception", msg);
|
|
888
|
+
});
|
|
889
|
+
if (isDaemonAlive()) {
|
|
890
|
+
console.log("Svamp daemon is already running");
|
|
891
|
+
process.exit(0);
|
|
892
|
+
}
|
|
893
|
+
const hyphaServerUrl = process.env.HYPHA_SERVER_URL;
|
|
894
|
+
if (!hyphaServerUrl) {
|
|
895
|
+
console.error("HYPHA_SERVER_URL is required.");
|
|
896
|
+
console.error('Run "svamp login <server-url>" first, or set it in .env or environment.');
|
|
897
|
+
process.exit(1);
|
|
898
|
+
}
|
|
899
|
+
const hyphaToken = process.env.HYPHA_TOKEN;
|
|
900
|
+
const hyphaWorkspace = process.env.HYPHA_WORKSPACE;
|
|
901
|
+
if (!hyphaToken) {
|
|
902
|
+
logger.log('Warning: No HYPHA_TOKEN set. Run "svamp login" to authenticate.');
|
|
903
|
+
logger.log("Connecting anonymously...");
|
|
904
|
+
}
|
|
905
|
+
const machineId = process.env.SVAMP_MACHINE_ID || `machine-${os.hostname()}-${randomUUID$1().slice(0, 8)}`;
|
|
906
|
+
logger.log("Starting svamp daemon...");
|
|
907
|
+
logger.log(` Server: ${hyphaServerUrl}`);
|
|
908
|
+
logger.log(` Workspace: ${hyphaWorkspace || "(default)"}`);
|
|
909
|
+
logger.log(` Machine ID: ${machineId}`);
|
|
910
|
+
let server = null;
|
|
911
|
+
try {
|
|
912
|
+
logger.log("Connecting to Hypha server...");
|
|
913
|
+
server = await connectToHypha({
|
|
914
|
+
serverUrl: hyphaServerUrl,
|
|
915
|
+
token: hyphaToken,
|
|
916
|
+
name: `svamp-machine-${machineId}`
|
|
917
|
+
});
|
|
918
|
+
logger.log(`Connected to Hypha (workspace: ${server.config.workspace})`);
|
|
919
|
+
const pidToTrackedSession = /* @__PURE__ */ new Map();
|
|
920
|
+
const getCurrentChildren = () => {
|
|
921
|
+
return Array.from(pidToTrackedSession.values()).map((s) => ({
|
|
922
|
+
sessionId: s.svampSessionId || `PID-${s.pid}`,
|
|
923
|
+
pid: s.pid,
|
|
924
|
+
startedBy: s.startedBy,
|
|
925
|
+
directory: s.directory,
|
|
926
|
+
active: true
|
|
927
|
+
}));
|
|
928
|
+
};
|
|
929
|
+
const spawnSession = async (options) => {
|
|
930
|
+
logger.log("Spawning session:", JSON.stringify(options));
|
|
931
|
+
const { directory, approvedNewDirectoryCreation = true, resumeSessionId } = options;
|
|
932
|
+
try {
|
|
933
|
+
await fs.access(directory);
|
|
934
|
+
} catch {
|
|
935
|
+
if (!approvedNewDirectoryCreation) {
|
|
936
|
+
return { type: "requestToApproveDirectoryCreation", directory };
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
await fs.mkdir(directory, { recursive: true });
|
|
940
|
+
} catch (err) {
|
|
941
|
+
return { type: "error", errorMessage: `Failed to create directory: ${err.message}` };
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const sessionId = options.sessionId || randomUUID$1();
|
|
945
|
+
try {
|
|
946
|
+
let parseBashPermission2 = function(permission) {
|
|
947
|
+
if (permission === "Bash") return;
|
|
948
|
+
const match = permission.match(/^Bash\((.+?)\)$/);
|
|
949
|
+
if (!match) return;
|
|
950
|
+
const command = match[1];
|
|
951
|
+
if (command.endsWith(":*")) {
|
|
952
|
+
allowedBashPrefixes.add(command.slice(0, -2));
|
|
953
|
+
} else {
|
|
954
|
+
allowedBashLiterals.add(command);
|
|
955
|
+
}
|
|
956
|
+
}, shouldAutoAllow2 = function(toolName, toolInput) {
|
|
957
|
+
if (toolName === "Bash") {
|
|
958
|
+
const inputObj = toolInput;
|
|
959
|
+
if (inputObj?.command) {
|
|
960
|
+
if (allowedBashLiterals.has(inputObj.command)) return true;
|
|
961
|
+
for (const prefix of allowedBashPrefixes) {
|
|
962
|
+
if (inputObj.command.startsWith(prefix)) return true;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
} else if (allowedTools.has(toolName)) {
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
if (currentPermissionMode === "bypassPermissions") return true;
|
|
969
|
+
if (currentPermissionMode === "acceptEdits" && EDIT_TOOLS.has(toolName)) return true;
|
|
970
|
+
return false;
|
|
971
|
+
};
|
|
972
|
+
var parseBashPermission = parseBashPermission2, shouldAutoAllow = shouldAutoAllow2;
|
|
973
|
+
let sessionMetadata = {
|
|
974
|
+
path: directory,
|
|
975
|
+
host: os.hostname(),
|
|
976
|
+
version: "0.1.0",
|
|
977
|
+
machineId,
|
|
978
|
+
homeDir: os.homedir(),
|
|
979
|
+
svampHomeDir: SVAMP_HOME,
|
|
980
|
+
svampLibDir: join$1(__dirname$1, ".."),
|
|
981
|
+
svampToolsDir: join$1(__dirname$1, "..", "tools"),
|
|
982
|
+
startedFromDaemon: true,
|
|
983
|
+
startedBy: "daemon",
|
|
984
|
+
lifecycleState: resumeSessionId ? "idle" : "starting"
|
|
985
|
+
};
|
|
986
|
+
let claudeProcess = null;
|
|
987
|
+
const persisted = loadPersistedSessions().find((p) => p.sessionId === sessionId);
|
|
988
|
+
let claudeResumeId = persisted?.claudeResumeId || (resumeSessionId || void 0);
|
|
989
|
+
let currentPermissionMode = persisted?.permissionMode || "default";
|
|
990
|
+
const allowedTools = /* @__PURE__ */ new Set();
|
|
991
|
+
const allowedBashLiterals = /* @__PURE__ */ new Set();
|
|
992
|
+
const allowedBashPrefixes = /* @__PURE__ */ new Set();
|
|
993
|
+
const EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
|
|
994
|
+
const pendingPermissions = /* @__PURE__ */ new Map();
|
|
995
|
+
let backgroundTaskCount = 0;
|
|
996
|
+
let backgroundTaskNames = [];
|
|
997
|
+
let userMessagePending = false;
|
|
998
|
+
let turnInitiatedByUser = true;
|
|
999
|
+
const spawnClaude = (initialMessage, meta) => {
|
|
1000
|
+
const permissionMode = meta?.permissionMode || currentPermissionMode;
|
|
1001
|
+
currentPermissionMode = permissionMode;
|
|
1002
|
+
const model = meta?.model || void 0;
|
|
1003
|
+
const appendSystemPrompt = meta?.appendSystemPrompt || void 0;
|
|
1004
|
+
const mcpConfigPath = join$1(SVAMP_HOME, "logs", `mcp-config-${sessionId}.json`);
|
|
1005
|
+
writeFileSync(mcpConfigPath, JSON.stringify({
|
|
1006
|
+
mcpServers: {
|
|
1007
|
+
svamp: { type: "http", url: svampServer.url }
|
|
1008
|
+
}
|
|
1009
|
+
}));
|
|
1010
|
+
const args = [
|
|
1011
|
+
"--output-format",
|
|
1012
|
+
"stream-json",
|
|
1013
|
+
"--input-format",
|
|
1014
|
+
"stream-json",
|
|
1015
|
+
"--verbose",
|
|
1016
|
+
"--permission-prompt-tool",
|
|
1017
|
+
"stdio",
|
|
1018
|
+
"--permission-mode",
|
|
1019
|
+
permissionMode,
|
|
1020
|
+
"--mcp-config",
|
|
1021
|
+
mcpConfigPath,
|
|
1022
|
+
"--allowedTools",
|
|
1023
|
+
svampServer.toolNames.map((t) => `mcp__svamp__${t}`).join(",")
|
|
1024
|
+
];
|
|
1025
|
+
if (model) args.push("--model", model);
|
|
1026
|
+
if (appendSystemPrompt) args.push("--append-system-prompt", appendSystemPrompt);
|
|
1027
|
+
if (claudeResumeId) args.push("--resume", claudeResumeId);
|
|
1028
|
+
logger.log(`[Session ${sessionId}] Spawning Claude: claude ${args.join(" ")} (cwd: ${directory})`);
|
|
1029
|
+
const spawnEnv = { ...process.env };
|
|
1030
|
+
delete spawnEnv.CLAUDECODE;
|
|
1031
|
+
const child = spawn("claude", args, {
|
|
1032
|
+
cwd: directory,
|
|
1033
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1034
|
+
env: spawnEnv,
|
|
1035
|
+
shell: process.platform === "win32"
|
|
1036
|
+
});
|
|
1037
|
+
claudeProcess = child;
|
|
1038
|
+
logger.log(`[Session ${sessionId}] Claude PID: ${child.pid}, stdin: ${!!child.stdin}, stdout: ${!!child.stdout}, stderr: ${!!child.stderr}`);
|
|
1039
|
+
child.on("error", (err) => {
|
|
1040
|
+
logger.log(`[Session ${sessionId}] Claude process error: ${err.message}`);
|
|
1041
|
+
});
|
|
1042
|
+
let stdoutBuffer = "";
|
|
1043
|
+
child.stdout?.on("data", (chunk) => {
|
|
1044
|
+
stdoutBuffer += chunk.toString();
|
|
1045
|
+
const lines = stdoutBuffer.split("\n");
|
|
1046
|
+
stdoutBuffer = lines.pop() || "";
|
|
1047
|
+
for (const line of lines) {
|
|
1048
|
+
if (!line.trim()) continue;
|
|
1049
|
+
logger.log(`[Session ${sessionId}] stdout line (${line.length} chars): ${line.slice(0, 100)}`);
|
|
1050
|
+
try {
|
|
1051
|
+
const msg = JSON.parse(line);
|
|
1052
|
+
logger.log(`[Session ${sessionId}] Parsed type=${msg.type} subtype=${msg.subtype || "n/a"}`);
|
|
1053
|
+
if (msg.type === "control_request" && msg.request?.subtype === "can_use_tool") {
|
|
1054
|
+
const requestId = msg.request_id;
|
|
1055
|
+
const toolName = msg.request.tool_name;
|
|
1056
|
+
const toolInput = msg.request.input;
|
|
1057
|
+
logger.log(`[Session ${sessionId}] Permission request: ${requestId} tool=${toolName}`);
|
|
1058
|
+
if (shouldAutoAllow2(toolName, toolInput)) {
|
|
1059
|
+
logger.log(`[Session ${sessionId}] Auto-allowing ${toolName} (mode=${currentPermissionMode})`);
|
|
1060
|
+
if (claudeProcess && !claudeProcess.killed && claudeProcess.stdin) {
|
|
1061
|
+
const controlResponse = JSON.stringify({
|
|
1062
|
+
type: "control_response",
|
|
1063
|
+
response: {
|
|
1064
|
+
subtype: "success",
|
|
1065
|
+
request_id: requestId,
|
|
1066
|
+
response: { behavior: "allow", updatedInput: toolInput || {} }
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
claudeProcess.stdin.write(controlResponse + "\n");
|
|
1070
|
+
}
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
const permissionPromise = new Promise((resolve2) => {
|
|
1074
|
+
pendingPermissions.set(requestId, { resolve: resolve2, toolName, input: toolInput });
|
|
1075
|
+
});
|
|
1076
|
+
const currentRequests = { ...sessionService._agentState?.requests };
|
|
1077
|
+
sessionService.updateAgentState({
|
|
1078
|
+
controlledByUser: false,
|
|
1079
|
+
requests: {
|
|
1080
|
+
...currentRequests,
|
|
1081
|
+
[requestId]: {
|
|
1082
|
+
tool: toolName,
|
|
1083
|
+
arguments: toolInput,
|
|
1084
|
+
createdAt: Date.now()
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
permissionPromise.then((result) => {
|
|
1089
|
+
if (claudeProcess && !claudeProcess.killed && claudeProcess.stdin) {
|
|
1090
|
+
const controlResponse = JSON.stringify({
|
|
1091
|
+
type: "control_response",
|
|
1092
|
+
response: {
|
|
1093
|
+
subtype: "success",
|
|
1094
|
+
request_id: requestId,
|
|
1095
|
+
response: result
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
logger.log(`[Session ${sessionId}] Sending control_response: ${controlResponse.slice(0, 200)}`);
|
|
1099
|
+
claudeProcess.stdin.write(controlResponse + "\n");
|
|
1100
|
+
}
|
|
1101
|
+
const reqs = { ...sessionService._agentState?.requests };
|
|
1102
|
+
delete reqs[requestId];
|
|
1103
|
+
sessionService.updateAgentState({
|
|
1104
|
+
controlledByUser: false,
|
|
1105
|
+
requests: reqs,
|
|
1106
|
+
completedRequests: {
|
|
1107
|
+
...sessionService._agentState?.completedRequests,
|
|
1108
|
+
[requestId]: {
|
|
1109
|
+
tool: toolName,
|
|
1110
|
+
arguments: toolInput,
|
|
1111
|
+
completedAt: Date.now(),
|
|
1112
|
+
status: result.behavior === "allow" ? "approved" : "denied"
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
} else if (msg.type === "control_cancel_request") {
|
|
1118
|
+
const requestId = msg.request_id;
|
|
1119
|
+
logger.log(`[Session ${sessionId}] Permission cancel: ${requestId}`);
|
|
1120
|
+
const pending = pendingPermissions.get(requestId);
|
|
1121
|
+
if (pending) {
|
|
1122
|
+
pending.resolve({ behavior: "deny", message: "Cancelled" });
|
|
1123
|
+
pendingPermissions.delete(requestId);
|
|
1124
|
+
}
|
|
1125
|
+
} else if (msg.type === "control_response") {
|
|
1126
|
+
logger.log(`[Session ${sessionId}] Control response: ${JSON.stringify(msg).slice(0, 200)}`);
|
|
1127
|
+
} else if (msg.type === "assistant" || msg.type === "result") {
|
|
1128
|
+
if (msg.type === "assistant" && Array.isArray(msg.content)) {
|
|
1129
|
+
for (const block of msg.content) {
|
|
1130
|
+
if (block.type === "tool_use" && block.input?.run_in_background === true) {
|
|
1131
|
+
backgroundTaskCount++;
|
|
1132
|
+
const label = block.tool_name || block.name || "unknown";
|
|
1133
|
+
backgroundTaskNames.push(label);
|
|
1134
|
+
logger.log(`[Session ${sessionId}] Background task launched: ${label} (count=${backgroundTaskCount})`);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (msg.type === "result") {
|
|
1139
|
+
if (!turnInitiatedByUser) {
|
|
1140
|
+
logger.log(`[Session ${sessionId}] Skipping stale result from SDK-initiated turn`);
|
|
1141
|
+
const hasBackgroundTasks = backgroundTaskCount > 0;
|
|
1142
|
+
if (hasBackgroundTasks) {
|
|
1143
|
+
const taskInfo = `Background tasks still running (${backgroundTaskCount}): ${backgroundTaskNames.join(", ")}`;
|
|
1144
|
+
sessionService.pushMessage({ type: "session_event", message: taskInfo }, "session");
|
|
1145
|
+
}
|
|
1146
|
+
sessionService.sendKeepAlive(false);
|
|
1147
|
+
turnInitiatedByUser = true;
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
sessionService.sendKeepAlive(false);
|
|
1151
|
+
if (backgroundTaskCount > 0) {
|
|
1152
|
+
const taskInfo = `Background tasks still running (${backgroundTaskCount}): ${backgroundTaskNames.join(", ")}`;
|
|
1153
|
+
logger.log(`[Session ${sessionId}] ${taskInfo}`);
|
|
1154
|
+
sessionService.pushMessage({ type: "session_event", message: taskInfo }, "session");
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
sessionService.pushMessage(msg, "agent");
|
|
1158
|
+
if (msg.session_id) {
|
|
1159
|
+
claudeResumeId = msg.session_id;
|
|
1160
|
+
}
|
|
1161
|
+
} else if (msg.type === "system" && msg.subtype === "init") {
|
|
1162
|
+
if (!userMessagePending) {
|
|
1163
|
+
turnInitiatedByUser = false;
|
|
1164
|
+
logger.log(`[Session ${sessionId}] SDK-initiated turn (likely stale task_notification)`);
|
|
1165
|
+
}
|
|
1166
|
+
userMessagePending = false;
|
|
1167
|
+
if (msg.session_id) {
|
|
1168
|
+
claudeResumeId = msg.session_id;
|
|
1169
|
+
sessionService.updateMetadata({
|
|
1170
|
+
...sessionMetadata,
|
|
1171
|
+
claudeSessionId: msg.session_id
|
|
1172
|
+
});
|
|
1173
|
+
saveSession({
|
|
1174
|
+
sessionId,
|
|
1175
|
+
directory,
|
|
1176
|
+
claudeResumeId,
|
|
1177
|
+
permissionMode: currentPermissionMode,
|
|
1178
|
+
metadata: sessionMetadata,
|
|
1179
|
+
createdAt: Date.now()
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
sessionService.pushMessage(msg, "session");
|
|
1183
|
+
} else if (msg.type === "system" && msg.subtype === "task_notification" && msg.status === "completed") {
|
|
1184
|
+
backgroundTaskCount = Math.max(0, backgroundTaskCount - 1);
|
|
1185
|
+
if (backgroundTaskNames.length > 0) {
|
|
1186
|
+
const completed = backgroundTaskNames.shift();
|
|
1187
|
+
logger.log(`[Session ${sessionId}] Background task completed: ${completed} (remaining=${backgroundTaskCount})`);
|
|
1188
|
+
}
|
|
1189
|
+
sessionService.pushMessage(msg, "agent");
|
|
1190
|
+
} else {
|
|
1191
|
+
sessionService.pushMessage(msg, "agent");
|
|
1192
|
+
}
|
|
1193
|
+
} catch {
|
|
1194
|
+
logger.log(`[Session ${sessionId}] Claude stdout (non-JSON): ${line}`);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
child.stderr?.on("data", (chunk) => {
|
|
1199
|
+
logger.log(`[Session ${sessionId}] Claude stderr: ${chunk.toString().trim()}`);
|
|
1200
|
+
});
|
|
1201
|
+
child.on("exit", (code, signal) => {
|
|
1202
|
+
logger.log(`[Session ${sessionId}] Claude exited: code=${code}, signal=${signal}`);
|
|
1203
|
+
claudeProcess = null;
|
|
1204
|
+
for (const [id, pending] of pendingPermissions) {
|
|
1205
|
+
pending.resolve({ behavior: "deny", message: "Claude process exited" });
|
|
1206
|
+
}
|
|
1207
|
+
pendingPermissions.clear();
|
|
1208
|
+
sessionService.updateMetadata({
|
|
1209
|
+
...sessionMetadata,
|
|
1210
|
+
lifecycleState: claudeResumeId ? "idle" : "stopped"
|
|
1211
|
+
});
|
|
1212
|
+
sessionService.sendKeepAlive(false);
|
|
1213
|
+
if (claudeResumeId && !trackedSession.stopped) {
|
|
1214
|
+
saveSession({
|
|
1215
|
+
sessionId,
|
|
1216
|
+
directory,
|
|
1217
|
+
claudeResumeId,
|
|
1218
|
+
permissionMode: currentPermissionMode,
|
|
1219
|
+
metadata: sessionMetadata,
|
|
1220
|
+
createdAt: Date.now()
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
if (initialMessage && child.stdin) {
|
|
1225
|
+
const stdinMsg = JSON.stringify({
|
|
1226
|
+
type: "user",
|
|
1227
|
+
message: { role: "user", content: initialMessage }
|
|
1228
|
+
});
|
|
1229
|
+
child.stdin.write(stdinMsg + "\n");
|
|
1230
|
+
}
|
|
1231
|
+
return child;
|
|
1232
|
+
};
|
|
1233
|
+
const sessionService = await registerSessionService(
|
|
1234
|
+
server,
|
|
1235
|
+
sessionId,
|
|
1236
|
+
sessionMetadata,
|
|
1237
|
+
{ controlledByUser: false },
|
|
1238
|
+
{
|
|
1239
|
+
onUserMessage: (content, meta) => {
|
|
1240
|
+
logger.log(`[Session ${sessionId}] User message received`);
|
|
1241
|
+
userMessagePending = true;
|
|
1242
|
+
turnInitiatedByUser = true;
|
|
1243
|
+
let text;
|
|
1244
|
+
let msgMeta = meta;
|
|
1245
|
+
try {
|
|
1246
|
+
let parsed = typeof content === "string" ? JSON.parse(content) : content;
|
|
1247
|
+
if (parsed?.content && typeof parsed.content === "string") {
|
|
1248
|
+
try {
|
|
1249
|
+
const inner = JSON.parse(parsed.content);
|
|
1250
|
+
if (inner && typeof inner === "object") parsed = inner;
|
|
1251
|
+
} catch {
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
text = parsed?.content?.text || parsed?.text || (typeof parsed === "string" ? parsed : JSON.stringify(parsed));
|
|
1255
|
+
if (parsed?.meta) msgMeta = { ...msgMeta, ...parsed.meta };
|
|
1256
|
+
} catch {
|
|
1257
|
+
text = typeof content === "string" ? content : JSON.stringify(content);
|
|
1258
|
+
}
|
|
1259
|
+
if (msgMeta?.permissionMode) {
|
|
1260
|
+
currentPermissionMode = msgMeta.permissionMode;
|
|
1261
|
+
logger.log(`[Session ${sessionId}] Permission mode updated to: ${currentPermissionMode}`);
|
|
1262
|
+
}
|
|
1263
|
+
if (!claudeProcess || claudeProcess.killed) {
|
|
1264
|
+
spawnClaude(text, msgMeta);
|
|
1265
|
+
} else {
|
|
1266
|
+
const stdinMsg = JSON.stringify({
|
|
1267
|
+
type: "user",
|
|
1268
|
+
message: { role: "user", content: text }
|
|
1269
|
+
});
|
|
1270
|
+
claudeProcess.stdin?.write(stdinMsg + "\n");
|
|
1271
|
+
}
|
|
1272
|
+
sessionService.sendKeepAlive(true);
|
|
1273
|
+
},
|
|
1274
|
+
onAbort: () => {
|
|
1275
|
+
logger.log(`[Session ${sessionId}] Abort requested`);
|
|
1276
|
+
if (claudeProcess && !claudeProcess.killed) {
|
|
1277
|
+
claudeProcess.kill("SIGINT");
|
|
1278
|
+
}
|
|
1279
|
+
},
|
|
1280
|
+
onPermissionResponse: (params) => {
|
|
1281
|
+
logger.log(`[Session ${sessionId}] Permission response:`, JSON.stringify(params));
|
|
1282
|
+
const requestId = params.id;
|
|
1283
|
+
const pending = pendingPermissions.get(requestId);
|
|
1284
|
+
if (params.mode) {
|
|
1285
|
+
logger.log(`[Session ${sessionId}] Permission mode changed to: ${params.mode}`);
|
|
1286
|
+
currentPermissionMode = params.mode;
|
|
1287
|
+
}
|
|
1288
|
+
if (params.allowTools && Array.isArray(params.allowTools)) {
|
|
1289
|
+
for (const tool of params.allowTools) {
|
|
1290
|
+
if (tool.startsWith("Bash(") || tool === "Bash") {
|
|
1291
|
+
parseBashPermission2(tool);
|
|
1292
|
+
} else {
|
|
1293
|
+
allowedTools.add(tool);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
logger.log(`[Session ${sessionId}] Updated allowed tools: ${[...allowedTools].join(", ")}`);
|
|
1297
|
+
}
|
|
1298
|
+
if (pending) {
|
|
1299
|
+
pendingPermissions.delete(requestId);
|
|
1300
|
+
if (params.approved) {
|
|
1301
|
+
pending.resolve({
|
|
1302
|
+
behavior: "allow",
|
|
1303
|
+
updatedInput: pending.input || {}
|
|
1304
|
+
});
|
|
1305
|
+
} else {
|
|
1306
|
+
pending.resolve({
|
|
1307
|
+
behavior: "deny",
|
|
1308
|
+
message: params.reason || "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed."
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
} else {
|
|
1312
|
+
logger.log(`[Session ${sessionId}] No pending permission for id=${requestId}`);
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
onSwitchMode: (mode) => {
|
|
1316
|
+
logger.log(`[Session ${sessionId}] Switch mode: ${mode}`);
|
|
1317
|
+
currentPermissionMode = mode;
|
|
1318
|
+
if (claudeProcess && !claudeProcess.killed) {
|
|
1319
|
+
claudeProcess.kill("SIGTERM");
|
|
1320
|
+
setTimeout(() => {
|
|
1321
|
+
if (!claudeProcess || claudeProcess.killed) {
|
|
1322
|
+
spawnClaude(void 0, { permissionMode: mode });
|
|
1323
|
+
}
|
|
1324
|
+
}, 1e3);
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
onRestartClaude: () => {
|
|
1328
|
+
logger.log(`[Session ${sessionId}] Restart Claude requested`);
|
|
1329
|
+
if (claudeProcess && !claudeProcess.killed) {
|
|
1330
|
+
claudeProcess.kill("SIGTERM");
|
|
1331
|
+
}
|
|
1332
|
+
},
|
|
1333
|
+
onKillSession: () => {
|
|
1334
|
+
logger.log(`[Session ${sessionId}] Kill session requested`);
|
|
1335
|
+
stopSession(sessionId);
|
|
1336
|
+
},
|
|
1337
|
+
onBash: async (command, cwd) => {
|
|
1338
|
+
logger.log(`[Session ${sessionId}] Bash: ${command} (cwd: ${cwd || directory})`);
|
|
1339
|
+
const { exec } = await import('child_process');
|
|
1340
|
+
return new Promise((resolve2) => {
|
|
1341
|
+
exec(command, { cwd: cwd || directory, timeout: 3e4, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1342
|
+
if (err) {
|
|
1343
|
+
resolve2({ success: false, stdout: stdout || "", stderr: stderr || err.message, exitCode: err.code ?? 1 });
|
|
1344
|
+
} else {
|
|
1345
|
+
resolve2({ success: true, stdout, stderr: stderr || "", exitCode: 0 });
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
});
|
|
1349
|
+
},
|
|
1350
|
+
onReadFile: async (path) => {
|
|
1351
|
+
return await fs.readFile(path, "utf-8");
|
|
1352
|
+
},
|
|
1353
|
+
onWriteFile: async (path, content) => {
|
|
1354
|
+
const resolvedPath = resolve(directory, path);
|
|
1355
|
+
if (!resolvedPath.startsWith(resolve(directory))) {
|
|
1356
|
+
throw new Error("Path outside working directory");
|
|
1357
|
+
}
|
|
1358
|
+
await fs.mkdir(dirname(resolvedPath), { recursive: true });
|
|
1359
|
+
await fs.writeFile(resolvedPath, Buffer.from(content, "base64"));
|
|
1360
|
+
},
|
|
1361
|
+
onListDirectory: async (path) => {
|
|
1362
|
+
const entries = await fs.readdir(path, { withFileTypes: true });
|
|
1363
|
+
return entries.map((e) => ({ name: e.name, isDirectory: e.isDirectory() }));
|
|
1364
|
+
},
|
|
1365
|
+
onGetDirectoryTree: async (treePath, maxDepth) => {
|
|
1366
|
+
async function buildTree(p, name, depth) {
|
|
1367
|
+
try {
|
|
1368
|
+
const stats = await fs.stat(p);
|
|
1369
|
+
const node = {
|
|
1370
|
+
name,
|
|
1371
|
+
path: p,
|
|
1372
|
+
type: stats.isDirectory() ? "directory" : "file",
|
|
1373
|
+
size: stats.size,
|
|
1374
|
+
modified: stats.mtime.getTime()
|
|
1375
|
+
};
|
|
1376
|
+
if (stats.isDirectory() && depth < maxDepth) {
|
|
1377
|
+
const entries = await fs.readdir(p, { withFileTypes: true });
|
|
1378
|
+
const children = [];
|
|
1379
|
+
await Promise.all(entries.map(async (entry) => {
|
|
1380
|
+
if (entry.isSymbolicLink()) return;
|
|
1381
|
+
const childPath = join$1(p, entry.name);
|
|
1382
|
+
const childNode = await buildTree(childPath, entry.name, depth + 1);
|
|
1383
|
+
if (childNode) children.push(childNode);
|
|
1384
|
+
}));
|
|
1385
|
+
children.sort((a, b) => {
|
|
1386
|
+
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
1387
|
+
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
1388
|
+
return a.name.localeCompare(b.name);
|
|
1389
|
+
});
|
|
1390
|
+
node.children = children;
|
|
1391
|
+
}
|
|
1392
|
+
return node;
|
|
1393
|
+
} catch {
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
const resolvedPath = resolve(directory, treePath);
|
|
1398
|
+
const tree = await buildTree(resolvedPath, basename(resolvedPath), 0);
|
|
1399
|
+
return { success: !!tree, tree };
|
|
1400
|
+
}
|
|
1401
|
+
},
|
|
1402
|
+
{ messagesDir: SESSIONS_DIR }
|
|
1403
|
+
);
|
|
1404
|
+
const svampServer = await startSvampServer({
|
|
1405
|
+
sessionService,
|
|
1406
|
+
getMetadata: () => sessionMetadata,
|
|
1407
|
+
setMetadata: (updater) => {
|
|
1408
|
+
sessionMetadata = updater(sessionMetadata);
|
|
1409
|
+
sessionService.updateMetadata(sessionMetadata);
|
|
1410
|
+
},
|
|
1411
|
+
logger
|
|
1412
|
+
});
|
|
1413
|
+
const trackedSession = {
|
|
1414
|
+
startedBy: "daemon",
|
|
1415
|
+
pid: process.pid,
|
|
1416
|
+
svampSessionId: sessionId,
|
|
1417
|
+
hyphaService: sessionService,
|
|
1418
|
+
svampMcpServer: svampServer,
|
|
1419
|
+
directory,
|
|
1420
|
+
get childProcess() {
|
|
1421
|
+
return claudeProcess || void 0;
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
pidToTrackedSession.set(process.pid + Math.floor(Math.random() * 1e5), trackedSession);
|
|
1425
|
+
sessionService.updateMetadata({
|
|
1426
|
+
...sessionMetadata,
|
|
1427
|
+
lifecycleState: "idle"
|
|
1428
|
+
});
|
|
1429
|
+
logger.log(`Session ${sessionId} registered on Hypha, waiting for first message to spawn Claude`);
|
|
1430
|
+
return {
|
|
1431
|
+
type: "success",
|
|
1432
|
+
sessionId,
|
|
1433
|
+
message: `Session registered on Hypha as svamp-session-${sessionId}`
|
|
1434
|
+
};
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
logger.error(`Failed to spawn session:`, err);
|
|
1437
|
+
return {
|
|
1438
|
+
type: "error",
|
|
1439
|
+
errorMessage: `Failed to register session service: ${err.message}`
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
const stopSession = (sessionId) => {
|
|
1444
|
+
logger.log(`Stopping session: ${sessionId}`);
|
|
1445
|
+
for (const [pid, session] of pidToTrackedSession) {
|
|
1446
|
+
if (session.svampSessionId === sessionId) {
|
|
1447
|
+
session.stopped = true;
|
|
1448
|
+
session.svampMcpServer?.stop();
|
|
1449
|
+
session.hyphaService?.disconnect().catch(() => {
|
|
1450
|
+
});
|
|
1451
|
+
if (session.childProcess) {
|
|
1452
|
+
try {
|
|
1453
|
+
session.childProcess.kill("SIGTERM");
|
|
1454
|
+
} catch {
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
pidToTrackedSession.delete(pid);
|
|
1458
|
+
deletePersistedSession(sessionId);
|
|
1459
|
+
logger.log(`Session ${sessionId} stopped`);
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
logger.log(`Session ${sessionId} not found`);
|
|
1464
|
+
return false;
|
|
1465
|
+
};
|
|
1466
|
+
const machineMetadata = {
|
|
1467
|
+
host: os.hostname(),
|
|
1468
|
+
platform: os.platform(),
|
|
1469
|
+
svampVersion: "0.1.0 (hypha)",
|
|
1470
|
+
homeDir: os.homedir(),
|
|
1471
|
+
svampHomeDir: SVAMP_HOME,
|
|
1472
|
+
svampLibDir: join$1(__dirname$1, "..")
|
|
1473
|
+
};
|
|
1474
|
+
const initialDaemonState = {
|
|
1475
|
+
status: "running",
|
|
1476
|
+
pid: process.pid,
|
|
1477
|
+
startedAt: Date.now()
|
|
1478
|
+
};
|
|
1479
|
+
const machineService = await registerMachineService(
|
|
1480
|
+
server,
|
|
1481
|
+
machineId,
|
|
1482
|
+
machineMetadata,
|
|
1483
|
+
initialDaemonState,
|
|
1484
|
+
{
|
|
1485
|
+
spawnSession,
|
|
1486
|
+
stopSession,
|
|
1487
|
+
requestShutdown: () => requestShutdown("hypha-app"),
|
|
1488
|
+
getTrackedSessions: getCurrentChildren
|
|
1489
|
+
}
|
|
1490
|
+
);
|
|
1491
|
+
logger.log(`Machine service registered: svamp-machine-${machineId}`);
|
|
1492
|
+
const persistedSessions = loadPersistedSessions();
|
|
1493
|
+
if (persistedSessions.length > 0) {
|
|
1494
|
+
logger.log(`Restoring ${persistedSessions.length} persisted session(s)...`);
|
|
1495
|
+
for (const persisted of persistedSessions) {
|
|
1496
|
+
try {
|
|
1497
|
+
const result = await spawnSession({
|
|
1498
|
+
directory: persisted.directory,
|
|
1499
|
+
sessionId: persisted.sessionId,
|
|
1500
|
+
resumeSessionId: persisted.claudeResumeId
|
|
1501
|
+
});
|
|
1502
|
+
if (result.type === "success") {
|
|
1503
|
+
logger.log(`Restored session ${persisted.sessionId} (resume=${persisted.claudeResumeId})`);
|
|
1504
|
+
for (const [, tracked] of pidToTrackedSession) {
|
|
1505
|
+
if (tracked.svampSessionId === persisted.sessionId) {
|
|
1506
|
+
break;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
} else {
|
|
1510
|
+
logger.log(`Failed to restore session ${persisted.sessionId}: ${result.type}`);
|
|
1511
|
+
}
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
logger.error(`Error restoring session ${persisted.sessionId}:`, err.message);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
let appToken;
|
|
1518
|
+
try {
|
|
1519
|
+
appToken = await server.generateToken({});
|
|
1520
|
+
logger.log(`App connection token generated`);
|
|
1521
|
+
} catch (err) {
|
|
1522
|
+
logger.log("Could not generate token (server may not support it):", err);
|
|
1523
|
+
}
|
|
1524
|
+
const localState = {
|
|
1525
|
+
pid: process.pid,
|
|
1526
|
+
startTime: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1527
|
+
version: "0.1.0",
|
|
1528
|
+
hyphaServerUrl,
|
|
1529
|
+
workspace: server.config.workspace
|
|
1530
|
+
};
|
|
1531
|
+
writeDaemonStateFile(localState);
|
|
1532
|
+
console.log("Svamp daemon started successfully!");
|
|
1533
|
+
console.log(` Machine ID: ${machineId}`);
|
|
1534
|
+
console.log(` Hypha server: ${hyphaServerUrl}`);
|
|
1535
|
+
console.log(` Workspace: ${server.config.workspace}`);
|
|
1536
|
+
if (appToken) {
|
|
1537
|
+
console.log(` App token: ${appToken}`);
|
|
1538
|
+
}
|
|
1539
|
+
console.log(` Service: svamp-machine-${machineId}`);
|
|
1540
|
+
console.log(` Log file: ${logger.logFilePath}`);
|
|
1541
|
+
const heartbeatInterval = setInterval(async () => {
|
|
1542
|
+
for (const [key, session] of pidToTrackedSession) {
|
|
1543
|
+
const child = session.childProcess;
|
|
1544
|
+
if (child && child.pid) {
|
|
1545
|
+
try {
|
|
1546
|
+
process.kill(child.pid, 0);
|
|
1547
|
+
} catch {
|
|
1548
|
+
logger.log(`Removing stale session (child PID ${child.pid} dead): ${session.svampSessionId}`);
|
|
1549
|
+
session.hyphaService?.disconnect().catch(() => {
|
|
1550
|
+
});
|
|
1551
|
+
pidToTrackedSession.delete(key);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
try {
|
|
1556
|
+
const state = readDaemonStateFile();
|
|
1557
|
+
if (state && state.pid === process.pid) {
|
|
1558
|
+
state.lastHeartbeat = (/* @__PURE__ */ new Date()).toISOString();
|
|
1559
|
+
writeDaemonStateFile(state);
|
|
1560
|
+
}
|
|
1561
|
+
} catch {
|
|
1562
|
+
}
|
|
1563
|
+
}, 6e4);
|
|
1564
|
+
const cleanup = async (source) => {
|
|
1565
|
+
logger.log(`Cleaning up (source: ${source})...`);
|
|
1566
|
+
clearInterval(heartbeatInterval);
|
|
1567
|
+
machineService.updateDaemonState({
|
|
1568
|
+
...initialDaemonState,
|
|
1569
|
+
status: "shutting-down",
|
|
1570
|
+
shutdownRequestedAt: Date.now(),
|
|
1571
|
+
shutdownSource: source
|
|
1572
|
+
});
|
|
1573
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1574
|
+
for (const [pid, session] of pidToTrackedSession) {
|
|
1575
|
+
session.hyphaService?.disconnect().catch(() => {
|
|
1576
|
+
});
|
|
1577
|
+
if (session.childProcess) {
|
|
1578
|
+
try {
|
|
1579
|
+
session.childProcess.kill("SIGTERM");
|
|
1580
|
+
} catch {
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
try {
|
|
1585
|
+
await machineService.disconnect();
|
|
1586
|
+
} catch {
|
|
1587
|
+
}
|
|
1588
|
+
try {
|
|
1589
|
+
await server.disconnect();
|
|
1590
|
+
} catch {
|
|
1591
|
+
}
|
|
1592
|
+
cleanupDaemonStateFile();
|
|
1593
|
+
logger.log("Cleanup complete");
|
|
1594
|
+
};
|
|
1595
|
+
const shutdownReq = await resolvesWhenShutdownRequested;
|
|
1596
|
+
await cleanup(shutdownReq.source);
|
|
1597
|
+
process.exit(0);
|
|
1598
|
+
} catch (error) {
|
|
1599
|
+
logger.error("Fatal error:", error);
|
|
1600
|
+
cleanupDaemonStateFile();
|
|
1601
|
+
if (server) {
|
|
1602
|
+
try {
|
|
1603
|
+
await server.disconnect();
|
|
1604
|
+
} catch {
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
process.exit(1);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
async function stopDaemon() {
|
|
1611
|
+
const state = readDaemonStateFile();
|
|
1612
|
+
if (!state) {
|
|
1613
|
+
console.log("No daemon running");
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
try {
|
|
1617
|
+
process.kill(state.pid, 0);
|
|
1618
|
+
process.kill(state.pid, "SIGTERM");
|
|
1619
|
+
console.log(`Sent SIGTERM to daemon PID ${state.pid}`);
|
|
1620
|
+
for (let i = 0; i < 30; i++) {
|
|
1621
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1622
|
+
try {
|
|
1623
|
+
process.kill(state.pid, 0);
|
|
1624
|
+
} catch {
|
|
1625
|
+
console.log("Daemon stopped");
|
|
1626
|
+
cleanupDaemonStateFile();
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
console.log("Daemon did not stop in time, sending SIGKILL");
|
|
1631
|
+
process.kill(state.pid, "SIGKILL");
|
|
1632
|
+
} catch {
|
|
1633
|
+
console.log("Daemon is not running (stale state file)");
|
|
1634
|
+
}
|
|
1635
|
+
cleanupDaemonStateFile();
|
|
1636
|
+
}
|
|
1637
|
+
function daemonStatus() {
|
|
1638
|
+
const state = readDaemonStateFile();
|
|
1639
|
+
if (!state) {
|
|
1640
|
+
console.log("Status: Not running");
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const alive = isDaemonAlive();
|
|
1644
|
+
console.log(`Status: ${alive ? "Running" : "Dead (stale state)"}`);
|
|
1645
|
+
console.log(` PID: ${state.pid}`);
|
|
1646
|
+
console.log(` Started: ${state.startTime}`);
|
|
1647
|
+
console.log(` Hypha server: ${state.hyphaServerUrl}`);
|
|
1648
|
+
if (state.workspace) {
|
|
1649
|
+
console.log(` Workspace: ${state.workspace}`);
|
|
1650
|
+
}
|
|
1651
|
+
if (state.lastHeartbeat) {
|
|
1652
|
+
console.log(` Last heartbeat: ${state.lastHeartbeat}`);
|
|
1653
|
+
}
|
|
1654
|
+
if (!alive) {
|
|
1655
|
+
cleanupDaemonStateFile();
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
export { registerSessionService as a, stopDaemon as b, connectToHypha as c, daemonStatus as d, getHyphaServerUrl as g, registerMachineService as r, startDaemon as s };
|