svamp-cli 0.1.32 → 0.1.34
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/dist/cli.mjs
CHANGED
|
@@ -86,10 +86,12 @@ async function main() {
|
|
|
86
86
|
await handleAgentCommand();
|
|
87
87
|
} else if (subcommand === "session") {
|
|
88
88
|
await handleSessionCommand();
|
|
89
|
-
} else if (subcommand === "--help" || subcommand === "-h"
|
|
89
|
+
} else if (subcommand === "--help" || subcommand === "-h") {
|
|
90
90
|
printHelp();
|
|
91
|
+
} else if (!subcommand || subcommand === "start") {
|
|
92
|
+
await handleInteractiveCommand();
|
|
91
93
|
} else if (subcommand === "--version" || subcommand === "-v") {
|
|
92
|
-
const pkg = await import('./package-
|
|
94
|
+
const pkg = await import('./package-D2n0SOTg.mjs').catch(() => ({ default: { version: "unknown" } }));
|
|
93
95
|
console.log(`svamp version: ${pkg.default.version}`);
|
|
94
96
|
} else {
|
|
95
97
|
console.error(`Unknown command: ${subcommand}`);
|
|
@@ -97,6 +99,45 @@ async function main() {
|
|
|
97
99
|
process.exit(1);
|
|
98
100
|
}
|
|
99
101
|
}
|
|
102
|
+
async function handleInteractiveCommand() {
|
|
103
|
+
const { runInteractive } = await import('./run-COUdcjR7.mjs');
|
|
104
|
+
const interactiveArgs = subcommand === "start" ? args.slice(1) : args;
|
|
105
|
+
let directory = process.cwd();
|
|
106
|
+
let resumeSessionId;
|
|
107
|
+
let continueSession = false;
|
|
108
|
+
let permissionMode;
|
|
109
|
+
const claudeArgs = [];
|
|
110
|
+
let pastSeparator = false;
|
|
111
|
+
for (let i = 0; i < interactiveArgs.length; i++) {
|
|
112
|
+
if (pastSeparator) {
|
|
113
|
+
claudeArgs.push(interactiveArgs[i]);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (interactiveArgs[i] === "--") {
|
|
117
|
+
pastSeparator = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if ((interactiveArgs[i] === "-d" || interactiveArgs[i] === "--directory") && i + 1 < interactiveArgs.length) {
|
|
121
|
+
directory = interactiveArgs[++i];
|
|
122
|
+
} else if ((interactiveArgs[i] === "--resume" || interactiveArgs[i] === "-r") && i + 1 < interactiveArgs.length) {
|
|
123
|
+
resumeSessionId = interactiveArgs[++i];
|
|
124
|
+
} else if (interactiveArgs[i] === "--continue" || interactiveArgs[i] === "-c") {
|
|
125
|
+
continueSession = true;
|
|
126
|
+
} else if ((interactiveArgs[i] === "--permission-mode" || interactiveArgs[i] === "-p") && i + 1 < interactiveArgs.length) {
|
|
127
|
+
permissionMode = interactiveArgs[++i];
|
|
128
|
+
} else if (interactiveArgs[i] === "--help" || interactiveArgs[i] === "-h") {
|
|
129
|
+
printInteractiveHelp();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await runInteractive({
|
|
134
|
+
directory,
|
|
135
|
+
resumeSessionId,
|
|
136
|
+
continueSession,
|
|
137
|
+
permissionMode,
|
|
138
|
+
claudeArgs: claudeArgs.length > 0 ? claudeArgs : void 0
|
|
139
|
+
});
|
|
140
|
+
}
|
|
100
141
|
async function handleAgentCommand() {
|
|
101
142
|
const agentArgs = args.slice(1);
|
|
102
143
|
if (agentArgs.length === 0 || agentArgs[0] === "--help" || agentArgs[0] === "-h") {
|
|
@@ -653,32 +694,36 @@ async function uninstallDaemonService() {
|
|
|
653
694
|
}
|
|
654
695
|
function printHelp() {
|
|
655
696
|
console.log(`
|
|
656
|
-
svamp \u2014
|
|
697
|
+
svamp \u2014 AI workspace on Hypha Cloud
|
|
657
698
|
|
|
658
699
|
Usage:
|
|
700
|
+
svamp Start interactive Claude session (synced to cloud)
|
|
701
|
+
svamp start [-d <path>] Same as above, with explicit directory
|
|
659
702
|
svamp login [url] Login to Hypha (opens browser, stores token)
|
|
660
703
|
svamp daemon start Start the daemon (detached)
|
|
661
704
|
svamp daemon stop Stop the daemon (sessions preserved for restart)
|
|
662
|
-
svamp daemon stop --cleanup Stop and mark all sessions as stopped
|
|
663
705
|
svamp daemon status Show daemon status
|
|
664
706
|
svamp daemon install Install as system service (launchd/systemd/wrapper)
|
|
665
|
-
svamp
|
|
666
|
-
svamp session list List active daemon sessions
|
|
667
|
-
svamp session machines List discoverable machines
|
|
707
|
+
svamp session list List active sessions
|
|
668
708
|
svamp session spawn Spawn a new session on the daemon
|
|
669
|
-
svamp session attach <id> Attach to a session
|
|
709
|
+
svamp session attach <id> Attach to a session
|
|
670
710
|
svamp session --help Show all session commands
|
|
671
711
|
svamp agent list List known agents
|
|
672
712
|
svamp agent <name> Start local agent session (gemini, codex)
|
|
673
|
-
svamp agent -- <cmd> Start custom ACP agent
|
|
674
713
|
svamp --version Show version
|
|
675
714
|
svamp --help Show this help
|
|
676
715
|
|
|
716
|
+
Interactive mode:
|
|
717
|
+
When you run 'svamp' with no arguments, Claude starts in your terminal
|
|
718
|
+
with full interactive access. Your session is synced to Hypha Cloud so
|
|
719
|
+
it's visible in the web app. When a message arrives from the web app,
|
|
720
|
+
svamp switches to remote mode to process it, then you can press
|
|
721
|
+
Space-Space to return to local mode.
|
|
722
|
+
|
|
677
723
|
Environment variables:
|
|
678
|
-
HYPHA_SERVER_URL Hypha server URL (required for
|
|
724
|
+
HYPHA_SERVER_URL Hypha server URL (required for cloud sync)
|
|
679
725
|
HYPHA_TOKEN Hypha auth token (stored by login command)
|
|
680
726
|
HYPHA_WORKSPACE Hypha workspace / user ID (stored by login command)
|
|
681
|
-
SVAMP_MACHINE_ID Machine identifier (optional, auto-generated)
|
|
682
727
|
SVAMP_HOME Config directory (default: ~/.svamp)
|
|
683
728
|
`);
|
|
684
729
|
}
|
|
@@ -736,6 +781,34 @@ Examples:
|
|
|
736
781
|
svamp session attach abc12345
|
|
737
782
|
`);
|
|
738
783
|
}
|
|
784
|
+
function printInteractiveHelp() {
|
|
785
|
+
console.log(`
|
|
786
|
+
svamp \u2014 Interactive Claude session with cloud sync
|
|
787
|
+
|
|
788
|
+
Usage:
|
|
789
|
+
svamp [-d <path>] [--resume <id>] [--continue] [--permission-mode <mode>] [-- <claude-args>]
|
|
790
|
+
svamp start [-d <path>] [...]
|
|
791
|
+
|
|
792
|
+
Options:
|
|
793
|
+
-d, --directory <path> Working directory (default: cwd)
|
|
794
|
+
-r, --resume <id> Resume a Claude session by ID
|
|
795
|
+
-c, --continue Continue the last Claude session
|
|
796
|
+
-p, --permission-mode <m> Permission mode: default, acceptEdits, bypassPermissions, plan
|
|
797
|
+
-- Pass remaining args to Claude CLI
|
|
798
|
+
|
|
799
|
+
Modes:
|
|
800
|
+
Local mode Full interactive Claude terminal UI (default)
|
|
801
|
+
Remote mode Processes messages from the web app
|
|
802
|
+
|
|
803
|
+
Switching:
|
|
804
|
+
When a message arrives from the web app \u2192 automatically switches to remote mode
|
|
805
|
+
Space Space Switch from remote mode back to local mode
|
|
806
|
+
Ctrl-C Ctrl-C Exit from remote mode
|
|
807
|
+
|
|
808
|
+
The session is synced to Hypha Cloud so it's visible in the web app.
|
|
809
|
+
Works offline if no Hypha credentials are configured.
|
|
810
|
+
`);
|
|
811
|
+
}
|
|
739
812
|
function printAgentHelp() {
|
|
740
813
|
console.log(`
|
|
741
814
|
svamp agent \u2014 Interactive agent sessions (local, no daemon required)
|
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import { randomUUID } from 'node:crypto';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { mkdirSync, writeFileSync, existsSync, unlinkSync, readFileSync, watch } from 'node:fs';
|
|
5
|
+
import { c as connectToHypha, a as registerSessionService } from './run-fEuWMTdD.mjs';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import 'os';
|
|
10
|
+
import 'fs/promises';
|
|
11
|
+
import 'fs';
|
|
12
|
+
import 'path';
|
|
13
|
+
import 'url';
|
|
14
|
+
import 'child_process';
|
|
15
|
+
import 'crypto';
|
|
16
|
+
import '@agentclientprotocol/sdk';
|
|
17
|
+
import '@modelcontextprotocol/sdk/client/index.js';
|
|
18
|
+
import '@modelcontextprotocol/sdk/client/stdio.js';
|
|
19
|
+
import '@modelcontextprotocol/sdk/types.js';
|
|
20
|
+
import 'zod';
|
|
21
|
+
import 'node:fs/promises';
|
|
22
|
+
import 'node:util';
|
|
23
|
+
|
|
24
|
+
async function startHookServer(onSessionHook, log) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const server = createServer(async (req, res) => {
|
|
27
|
+
if (req.method === "POST" && req.url === "/hook/session-start") {
|
|
28
|
+
const timeout = setTimeout(() => {
|
|
29
|
+
if (!res.headersSent) res.writeHead(408).end("timeout");
|
|
30
|
+
}, 5e3);
|
|
31
|
+
try {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
34
|
+
clearTimeout(timeout);
|
|
35
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
36
|
+
log("[hook] Received:", body.slice(0, 200));
|
|
37
|
+
let data = {};
|
|
38
|
+
try {
|
|
39
|
+
data = JSON.parse(body);
|
|
40
|
+
} catch {
|
|
41
|
+
}
|
|
42
|
+
const sessionId = data.session_id || data.sessionId;
|
|
43
|
+
if (sessionId) {
|
|
44
|
+
log(`[hook] Session ID: ${sessionId}`);
|
|
45
|
+
onSessionHook(sessionId);
|
|
46
|
+
}
|
|
47
|
+
res.writeHead(200).end("ok");
|
|
48
|
+
} catch {
|
|
49
|
+
clearTimeout(timeout);
|
|
50
|
+
if (!res.headersSent) res.writeHead(500).end("error");
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
res.writeHead(404).end("not found");
|
|
55
|
+
});
|
|
56
|
+
server.listen(0, "127.0.0.1", () => {
|
|
57
|
+
const addr = server.address();
|
|
58
|
+
if (!addr || typeof addr === "string") {
|
|
59
|
+
reject(new Error("Failed to get server address"));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
log(`[hook] Listening on port ${addr.port}`);
|
|
63
|
+
resolve({ port: addr.port, stop: () => server.close() });
|
|
64
|
+
});
|
|
65
|
+
server.on("error", reject);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SVAMP_HOME$1 = process.env.SVAMP_HOME || join(os.homedir(), ".svamp");
|
|
70
|
+
function generateHookSettings(port) {
|
|
71
|
+
const hooksDir = join(SVAMP_HOME$1, "tmp", "hooks");
|
|
72
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
73
|
+
const forwarderPath = join(hooksDir, `forwarder-${process.pid}.cjs`);
|
|
74
|
+
const forwarderCode = `#!/usr/bin/env node
|
|
75
|
+
const http = require('http');
|
|
76
|
+
const port = parseInt(process.argv[2], 10);
|
|
77
|
+
if (!port || isNaN(port)) process.exit(1);
|
|
78
|
+
const chunks = [];
|
|
79
|
+
process.stdin.on('data', c => chunks.push(c));
|
|
80
|
+
process.stdin.on('end', () => {
|
|
81
|
+
const body = Buffer.concat(chunks);
|
|
82
|
+
const req = http.request({
|
|
83
|
+
host: '127.0.0.1', port, method: 'POST',
|
|
84
|
+
path: '/hook/session-start',
|
|
85
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': body.length }
|
|
86
|
+
}, res => res.resume());
|
|
87
|
+
req.on('error', () => {});
|
|
88
|
+
req.end(body);
|
|
89
|
+
});
|
|
90
|
+
process.stdin.resume();
|
|
91
|
+
`;
|
|
92
|
+
writeFileSync(forwarderPath, forwarderCode, { mode: 493 });
|
|
93
|
+
const settingsPath = join(hooksDir, `session-hook-${process.pid}.json`);
|
|
94
|
+
const hookCommand = `node "${forwarderPath}" ${port}`;
|
|
95
|
+
const settings = {
|
|
96
|
+
hooks: {
|
|
97
|
+
SessionStart: [
|
|
98
|
+
{
|
|
99
|
+
matcher: "*",
|
|
100
|
+
hooks: [{ type: "command", command: hookCommand }]
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
106
|
+
const cleanup = () => {
|
|
107
|
+
try {
|
|
108
|
+
if (existsSync(settingsPath)) unlinkSync(settingsPath);
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
if (existsSync(forwarderPath)) unlinkSync(forwarderPath);
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
return { settingsPath, cleanup };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const INTERNAL_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
120
|
+
"file-history-snapshot",
|
|
121
|
+
"change",
|
|
122
|
+
"queue-operation"
|
|
123
|
+
]);
|
|
124
|
+
function getProjectDir(workingDirectory) {
|
|
125
|
+
const projectId = resolve(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
|
|
126
|
+
const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(os.homedir(), ".claude");
|
|
127
|
+
return join(claudeConfigDir, "projects", projectId);
|
|
128
|
+
}
|
|
129
|
+
function createSessionScanner(opts) {
|
|
130
|
+
const { workingDirectory, onMessage, log } = opts;
|
|
131
|
+
const projectDir = getProjectDir(workingDirectory);
|
|
132
|
+
const processedKeys = /* @__PURE__ */ new Set();
|
|
133
|
+
let currentSessionId = null;
|
|
134
|
+
let watcher = null;
|
|
135
|
+
let syncInterval = null;
|
|
136
|
+
let stopped = false;
|
|
137
|
+
function messageKey(msg) {
|
|
138
|
+
if (msg.type === "summary") return `summary:${msg.leafUuid}:${msg.summary}`;
|
|
139
|
+
return msg.uuid || "";
|
|
140
|
+
}
|
|
141
|
+
function readAndSync() {
|
|
142
|
+
if (stopped || !currentSessionId) return;
|
|
143
|
+
const filePath = join(projectDir, `${currentSessionId}.jsonl`);
|
|
144
|
+
if (!existsSync(filePath)) return;
|
|
145
|
+
let content;
|
|
146
|
+
try {
|
|
147
|
+
content = readFileSync(filePath, "utf-8");
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const lines = content.split("\n");
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (!trimmed) continue;
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(trimmed);
|
|
158
|
+
} catch {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (parsed.type && INTERNAL_EVENT_TYPES.has(parsed.type)) continue;
|
|
162
|
+
if (!["user", "assistant", "summary", "system"].includes(parsed.type)) continue;
|
|
163
|
+
const key = messageKey(parsed);
|
|
164
|
+
if (!key || processedKeys.has(key)) continue;
|
|
165
|
+
processedKeys.add(key);
|
|
166
|
+
onMessage(parsed);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function startWatching(sessionId) {
|
|
170
|
+
stopWatching();
|
|
171
|
+
currentSessionId = sessionId;
|
|
172
|
+
const filePath = join(projectDir, `${sessionId}.jsonl`);
|
|
173
|
+
log(`[scanner] Watching: ${filePath}`);
|
|
174
|
+
readAndSync();
|
|
175
|
+
try {
|
|
176
|
+
watcher = watch(filePath, { persistent: false }, () => {
|
|
177
|
+
readAndSync();
|
|
178
|
+
});
|
|
179
|
+
watcher.on("error", () => {
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
}
|
|
183
|
+
syncInterval = setInterval(readAndSync, 2e3);
|
|
184
|
+
}
|
|
185
|
+
function stopWatching() {
|
|
186
|
+
if (watcher) {
|
|
187
|
+
try {
|
|
188
|
+
watcher.close();
|
|
189
|
+
} catch {
|
|
190
|
+
}
|
|
191
|
+
watcher = null;
|
|
192
|
+
}
|
|
193
|
+
if (syncInterval) {
|
|
194
|
+
clearInterval(syncInterval);
|
|
195
|
+
syncInterval = null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
onNewSession(sessionId) {
|
|
200
|
+
if (sessionId === currentSessionId) return;
|
|
201
|
+
log(`[scanner] New session: ${sessionId}`);
|
|
202
|
+
startWatching(sessionId);
|
|
203
|
+
},
|
|
204
|
+
sync: readAndSync,
|
|
205
|
+
cleanup() {
|
|
206
|
+
stopped = true;
|
|
207
|
+
stopWatching();
|
|
208
|
+
if (currentSessionId) readAndSync();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function runLocalMode(opts) {
|
|
214
|
+
const { cwd, onSessionFound, onMessage, onThinkingChange, log } = opts;
|
|
215
|
+
const scanner = createSessionScanner({
|
|
216
|
+
workingDirectory: cwd,
|
|
217
|
+
onMessage,
|
|
218
|
+
log
|
|
219
|
+
});
|
|
220
|
+
if (opts.claudeSessionId) {
|
|
221
|
+
scanner.onNewSession(opts.claudeSessionId);
|
|
222
|
+
}
|
|
223
|
+
const args = [];
|
|
224
|
+
if (opts.claudeSessionId) {
|
|
225
|
+
args.push("--resume", opts.claudeSessionId);
|
|
226
|
+
}
|
|
227
|
+
if (opts.hookSettingsPath) {
|
|
228
|
+
args.push("--settings", opts.hookSettingsPath);
|
|
229
|
+
}
|
|
230
|
+
if (opts.claudeArgs) {
|
|
231
|
+
args.push(...opts.claudeArgs);
|
|
232
|
+
}
|
|
233
|
+
log(`[local] Spawning: claude ${args.join(" ")}`);
|
|
234
|
+
const claudeBin = findClaudeBinary();
|
|
235
|
+
if (!claudeBin) {
|
|
236
|
+
console.error("Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code");
|
|
237
|
+
return { type: "exit", code: 1 };
|
|
238
|
+
}
|
|
239
|
+
process.stdin.pause();
|
|
240
|
+
return new Promise((resolve) => {
|
|
241
|
+
const child = spawn(claudeBin, args, {
|
|
242
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
243
|
+
cwd,
|
|
244
|
+
signal: opts.abort,
|
|
245
|
+
env: process.env
|
|
246
|
+
});
|
|
247
|
+
child.on("error", (err) => {
|
|
248
|
+
log(`[local] Spawn error: ${err.message}`);
|
|
249
|
+
scanner.cleanup();
|
|
250
|
+
process.stdin.resume();
|
|
251
|
+
onThinkingChange(false);
|
|
252
|
+
if (err.code === "ABORT_ERR" || opts.abort.aborted) {
|
|
253
|
+
resolve({ type: "switch" });
|
|
254
|
+
} else {
|
|
255
|
+
resolve({ type: "exit", code: 1 });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
child.on("exit", (code, signal) => {
|
|
259
|
+
log(`[local] Claude exited: code=${code}, signal=${signal}`);
|
|
260
|
+
scanner.cleanup();
|
|
261
|
+
process.stdin.resume();
|
|
262
|
+
onThinkingChange(false);
|
|
263
|
+
if (signal === "SIGTERM" && opts.abort.aborted) {
|
|
264
|
+
resolve({ type: "switch" });
|
|
265
|
+
} else {
|
|
266
|
+
resolve({ type: "exit", code: code ?? 0 });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
if (opts.abort.aborted) {
|
|
270
|
+
try {
|
|
271
|
+
child.kill("SIGTERM");
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
function findClaudeBinary() {
|
|
278
|
+
try {
|
|
279
|
+
const { execSync } = require("child_process");
|
|
280
|
+
const path = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
281
|
+
if (path && existsSync(path)) return path;
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function runRemoteMode(opts) {
|
|
288
|
+
const { cwd, log, onThinkingChange, onMessage } = opts;
|
|
289
|
+
let claudeBin = null;
|
|
290
|
+
try {
|
|
291
|
+
const { execSync } = require("child_process");
|
|
292
|
+
claudeBin = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
293
|
+
} catch {
|
|
294
|
+
}
|
|
295
|
+
if (!claudeBin || !existsSync(claudeBin)) {
|
|
296
|
+
console.error("Claude Code CLI not found.");
|
|
297
|
+
return "exit";
|
|
298
|
+
}
|
|
299
|
+
console.log("\n\x1B[90m" + "\u2550".repeat(50) + "\x1B[0m");
|
|
300
|
+
console.log("\x1B[36m Remote mode\x1B[0m \u2014 processing message from web app");
|
|
301
|
+
console.log("\x1B[90m Press Space Space to switch to local mode\x1B[0m");
|
|
302
|
+
console.log("\x1B[90m Press Ctrl-C Ctrl-C to exit\x1B[0m");
|
|
303
|
+
console.log("\x1B[90m" + "\u2550".repeat(50) + "\x1B[0m\n");
|
|
304
|
+
let exitReason = null;
|
|
305
|
+
let lastSpace = 0;
|
|
306
|
+
let lastCtrlC = 0;
|
|
307
|
+
const DOUBLE_TAP_MS = 2e3;
|
|
308
|
+
let spaceHintShown = false;
|
|
309
|
+
let ctrlcHintShown = false;
|
|
310
|
+
const stdinWasRaw = process.stdin.isRaw;
|
|
311
|
+
if (process.stdin.isTTY) {
|
|
312
|
+
process.stdin.setRawMode(true);
|
|
313
|
+
}
|
|
314
|
+
process.stdin.resume();
|
|
315
|
+
process.stdin.setEncoding("utf8");
|
|
316
|
+
const keyHandler = (data) => {
|
|
317
|
+
const now = Date.now();
|
|
318
|
+
if (data === "") {
|
|
319
|
+
if (now - lastCtrlC < DOUBLE_TAP_MS) {
|
|
320
|
+
exitReason = "exit";
|
|
321
|
+
abortController.abort();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
lastCtrlC = now;
|
|
325
|
+
if (!ctrlcHintShown) {
|
|
326
|
+
ctrlcHintShown = true;
|
|
327
|
+
process.stdout.write("\n\x1B[33m Press Ctrl-C again to exit\x1B[0m\n");
|
|
328
|
+
setTimeout(() => {
|
|
329
|
+
ctrlcHintShown = false;
|
|
330
|
+
}, DOUBLE_TAP_MS);
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (data === " ") {
|
|
335
|
+
if (now - lastSpace < DOUBLE_TAP_MS) {
|
|
336
|
+
exitReason = "switch";
|
|
337
|
+
abortController.abort();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
lastSpace = now;
|
|
341
|
+
if (!spaceHintShown) {
|
|
342
|
+
spaceHintShown = true;
|
|
343
|
+
process.stdout.write("\n\x1B[33m Press Space again to switch to local mode\x1B[0m\n");
|
|
344
|
+
setTimeout(() => {
|
|
345
|
+
spaceHintShown = false;
|
|
346
|
+
}, DOUBLE_TAP_MS);
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
lastSpace = 0;
|
|
351
|
+
lastCtrlC = 0;
|
|
352
|
+
};
|
|
353
|
+
process.stdin.on("data", keyHandler);
|
|
354
|
+
const abortController = new AbortController();
|
|
355
|
+
if (opts.abort.aborted) {
|
|
356
|
+
abortController.abort();
|
|
357
|
+
} else {
|
|
358
|
+
opts.abort.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
while (!exitReason && !abortController.signal.aborted) {
|
|
362
|
+
const message = await Promise.race([
|
|
363
|
+
opts.nextMessage(),
|
|
364
|
+
new Promise((_, reject) => {
|
|
365
|
+
if (abortController.signal.aborted) reject(new Error("aborted"));
|
|
366
|
+
abortController.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
|
367
|
+
})
|
|
368
|
+
]).catch(() => null);
|
|
369
|
+
if (!message || exitReason || abortController.signal.aborted) break;
|
|
370
|
+
const turnResult = await runClaudeTurn({
|
|
371
|
+
claudeBin,
|
|
372
|
+
cwd,
|
|
373
|
+
message,
|
|
374
|
+
sessionId: opts.claudeSessionId,
|
|
375
|
+
permissionMode: opts.permissionMode,
|
|
376
|
+
hookSettingsPath: opts.hookSettingsPath,
|
|
377
|
+
claudeArgs: opts.claudeArgs,
|
|
378
|
+
signal: abortController.signal,
|
|
379
|
+
onSessionFound: opts.onSessionFound,
|
|
380
|
+
onThinkingChange,
|
|
381
|
+
onMessage,
|
|
382
|
+
log
|
|
383
|
+
});
|
|
384
|
+
if (turnResult === "error") {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (!exitReason && !abortController.signal.aborted) {
|
|
388
|
+
console.log("\n\x1B[90m Agent idle. Waiting for next message...\x1B[0m");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} finally {
|
|
392
|
+
process.stdin.removeListener("data", keyHandler);
|
|
393
|
+
if (process.stdin.isTTY) {
|
|
394
|
+
process.stdin.setRawMode(stdinWasRaw ?? false);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return exitReason || "exit";
|
|
398
|
+
}
|
|
399
|
+
async function runClaudeTurn(opts) {
|
|
400
|
+
const args = [
|
|
401
|
+
"--output-format",
|
|
402
|
+
"stream-json",
|
|
403
|
+
"--verbose",
|
|
404
|
+
"--input-format",
|
|
405
|
+
"stream-json",
|
|
406
|
+
"--permission-prompt-tool",
|
|
407
|
+
"stdio"
|
|
408
|
+
];
|
|
409
|
+
if (opts.hookSettingsPath) {
|
|
410
|
+
args.push("--settings", opts.hookSettingsPath);
|
|
411
|
+
}
|
|
412
|
+
if (opts.sessionId) {
|
|
413
|
+
args.push("--resume", opts.sessionId);
|
|
414
|
+
}
|
|
415
|
+
const claudeMode = mapPermissionMode(opts.permissionMode);
|
|
416
|
+
if (claudeMode) {
|
|
417
|
+
args.push("--permission-mode", claudeMode);
|
|
418
|
+
}
|
|
419
|
+
if (opts.claudeArgs) {
|
|
420
|
+
args.push(...opts.claudeArgs);
|
|
421
|
+
}
|
|
422
|
+
opts.log(`[remote] Spawning: claude ${args.join(" ")}`);
|
|
423
|
+
const child = spawn(opts.claudeBin, args, {
|
|
424
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
425
|
+
cwd: opts.cwd,
|
|
426
|
+
env: process.env
|
|
427
|
+
});
|
|
428
|
+
const userMsg = JSON.stringify({
|
|
429
|
+
type: "user",
|
|
430
|
+
message: { role: "user", content: opts.message }
|
|
431
|
+
});
|
|
432
|
+
child.stdin.write(userMsg + "\n");
|
|
433
|
+
child.stdin.end();
|
|
434
|
+
opts.onThinkingChange(true);
|
|
435
|
+
let currentText = "";
|
|
436
|
+
return new Promise((resolve) => {
|
|
437
|
+
const abortHandler = () => {
|
|
438
|
+
try {
|
|
439
|
+
child.kill("SIGTERM");
|
|
440
|
+
} catch {
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
if (opts.signal.aborted) {
|
|
444
|
+
abortHandler();
|
|
445
|
+
} else {
|
|
446
|
+
opts.signal.addEventListener("abort", abortHandler, { once: true });
|
|
447
|
+
}
|
|
448
|
+
const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
449
|
+
rl.on("line", (line) => {
|
|
450
|
+
const trimmed = line.trim();
|
|
451
|
+
if (!trimmed) return;
|
|
452
|
+
let msg;
|
|
453
|
+
try {
|
|
454
|
+
msg = JSON.parse(trimmed);
|
|
455
|
+
} catch {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (msg.type === "control_request") {
|
|
459
|
+
handleControlRequest(msg, child, opts.log);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
handleSDKMessage(msg, opts, (text) => {
|
|
463
|
+
process.stdout.write(text);
|
|
464
|
+
currentText += text;
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
if (child.stderr) {
|
|
468
|
+
child.stderr.on("data", (data) => {
|
|
469
|
+
opts.log(`[remote:stderr] ${data.toString().trim()}`);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
child.on("error", (err) => {
|
|
473
|
+
opts.log(`[remote] Error: ${err.message}`);
|
|
474
|
+
opts.onThinkingChange(false);
|
|
475
|
+
if (currentText && !currentText.endsWith("\n")) process.stdout.write("\n");
|
|
476
|
+
resolve("error");
|
|
477
|
+
});
|
|
478
|
+
child.on("exit", (code, signal) => {
|
|
479
|
+
opts.log(`[remote] Exit: code=${code}, signal=${signal}`);
|
|
480
|
+
opts.onThinkingChange(false);
|
|
481
|
+
if (currentText && !currentText.endsWith("\n")) process.stdout.write("\n");
|
|
482
|
+
resolve(code === 0 || signal === "SIGTERM" ? "ok" : "error");
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
function handleSDKMessage(msg, opts, write) {
|
|
487
|
+
switch (msg.type) {
|
|
488
|
+
case "system": {
|
|
489
|
+
if (msg.subtype === "init" && msg.session_id) {
|
|
490
|
+
opts.onSessionFound(msg.session_id);
|
|
491
|
+
}
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case "assistant": {
|
|
495
|
+
const content = msg.message?.content;
|
|
496
|
+
if (Array.isArray(content)) {
|
|
497
|
+
for (const block of content) {
|
|
498
|
+
if (block.type === "text" && block.text) {
|
|
499
|
+
write(block.text);
|
|
500
|
+
} else if (block.type === "tool_use") {
|
|
501
|
+
const argsStr = JSON.stringify(block.input || {}).slice(0, 100);
|
|
502
|
+
write(`
|
|
503
|
+
\x1B[33m[tool]\x1B[0m ${block.name}(${argsStr})
|
|
504
|
+
`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (msg.message) {
|
|
509
|
+
opts.onMessage({ type: "assistant", uuid: msg.uuid || msg.message?.id, message: msg.message });
|
|
510
|
+
}
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
case "user": {
|
|
514
|
+
const content = msg.message?.content;
|
|
515
|
+
if (Array.isArray(content)) {
|
|
516
|
+
for (const block of content) {
|
|
517
|
+
if (block.type === "tool_result") {
|
|
518
|
+
const text = typeof block.content === "string" ? block.content : JSON.stringify(block.content || "");
|
|
519
|
+
if (text.length > 0) {
|
|
520
|
+
const preview = text.length > 200 ? text.slice(0, 200) + "..." : text;
|
|
521
|
+
write(`\x1B[90m[result]\x1B[0m ${preview}
|
|
522
|
+
`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case "result": {
|
|
530
|
+
if (msg.result) {
|
|
531
|
+
write(`
|
|
532
|
+
\x1B[32m[done]\x1B[0m ${msg.result}
|
|
533
|
+
`);
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
default:
|
|
538
|
+
opts.log(`[remote] Unknown msg type: ${msg.type}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function handleControlRequest(msg, child, log) {
|
|
542
|
+
const request = msg.request;
|
|
543
|
+
const requestId = msg.request_id;
|
|
544
|
+
if (request?.subtype === "can_use_tool") {
|
|
545
|
+
log(`[remote] Auto-approving tool: ${request.tool_name}`);
|
|
546
|
+
const response = JSON.stringify({
|
|
547
|
+
type: "control_response",
|
|
548
|
+
response: {
|
|
549
|
+
subtype: "success",
|
|
550
|
+
request_id: requestId,
|
|
551
|
+
response: {
|
|
552
|
+
behavior: "allow",
|
|
553
|
+
updatedInput: request.input
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
try {
|
|
558
|
+
child.stdin.write(response + "\n");
|
|
559
|
+
} catch {
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function mapPermissionMode(mode) {
|
|
564
|
+
const map = {
|
|
565
|
+
"default": "default",
|
|
566
|
+
"acceptEdits": "acceptEdits",
|
|
567
|
+
"bypassPermissions": "bypassPermissions",
|
|
568
|
+
"plan": "plan",
|
|
569
|
+
"auto-approve-all": "bypassPermissions"
|
|
570
|
+
};
|
|
571
|
+
return map[mode] || null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function loop(opts) {
|
|
575
|
+
const { log } = opts;
|
|
576
|
+
let mode = opts.startingMode;
|
|
577
|
+
let claudeSessionId = null;
|
|
578
|
+
const onSessionFound = (id) => {
|
|
579
|
+
if (id !== claudeSessionId) {
|
|
580
|
+
log(`[loop] Session ID: ${id}`);
|
|
581
|
+
claudeSessionId = id;
|
|
582
|
+
opts.onSessionFound(id);
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
while (true) {
|
|
586
|
+
log(`[loop] Mode: ${mode}`);
|
|
587
|
+
switch (mode) {
|
|
588
|
+
case "local": {
|
|
589
|
+
if (opts.hasRemoteMessage()) {
|
|
590
|
+
log("[loop] Pending remote message, switching to remote");
|
|
591
|
+
mode = "remote";
|
|
592
|
+
opts.onModeChange(mode);
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
const abortController = new AbortController();
|
|
596
|
+
let messageWatcher = null;
|
|
597
|
+
messageWatcher = setInterval(() => {
|
|
598
|
+
if (opts.hasRemoteMessage() && !abortController.signal.aborted) {
|
|
599
|
+
log("[loop] Remote message received, switching to remote mode");
|
|
600
|
+
abortController.abort();
|
|
601
|
+
}
|
|
602
|
+
}, 500);
|
|
603
|
+
const result = await runLocalMode({
|
|
604
|
+
cwd: opts.cwd,
|
|
605
|
+
claudeSessionId,
|
|
606
|
+
onSessionFound,
|
|
607
|
+
onMessage: opts.onMessage,
|
|
608
|
+
onThinkingChange: opts.onThinkingChange,
|
|
609
|
+
abort: abortController.signal,
|
|
610
|
+
hookSettingsPath: opts.hookSettingsPath,
|
|
611
|
+
claudeArgs: opts.claudeArgs,
|
|
612
|
+
log
|
|
613
|
+
});
|
|
614
|
+
if (messageWatcher) clearInterval(messageWatcher);
|
|
615
|
+
if (result.type === "switch") {
|
|
616
|
+
mode = "remote";
|
|
617
|
+
opts.onModeChange(mode);
|
|
618
|
+
} else {
|
|
619
|
+
return result.code;
|
|
620
|
+
}
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
case "remote": {
|
|
624
|
+
const abortController = new AbortController();
|
|
625
|
+
const result = await runRemoteMode({
|
|
626
|
+
cwd: opts.cwd,
|
|
627
|
+
claudeSessionId,
|
|
628
|
+
onSessionFound,
|
|
629
|
+
onMessage: opts.onMessage,
|
|
630
|
+
onThinkingChange: opts.onThinkingChange,
|
|
631
|
+
nextMessage: opts.waitForRemoteMessage,
|
|
632
|
+
permissionMode: opts.permissionMode,
|
|
633
|
+
abort: abortController.signal,
|
|
634
|
+
hookSettingsPath: opts.hookSettingsPath,
|
|
635
|
+
claudeArgs: opts.claudeArgs,
|
|
636
|
+
log
|
|
637
|
+
});
|
|
638
|
+
if (result === "switch") {
|
|
639
|
+
mode = "local";
|
|
640
|
+
opts.onModeChange(mode);
|
|
641
|
+
} else {
|
|
642
|
+
return 0;
|
|
643
|
+
}
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const SVAMP_HOME = process.env.SVAMP_HOME || join(os.homedir(), ".svamp");
|
|
651
|
+
const ENV_FILE = join(SVAMP_HOME, ".env");
|
|
652
|
+
const DAEMON_STATE_FILE = join(SVAMP_HOME, "daemon.state.json");
|
|
653
|
+
const DEBUG = !!process.env.DEBUG;
|
|
654
|
+
const log = (...args) => {
|
|
655
|
+
if (DEBUG) console.error("[svamp]", ...args);
|
|
656
|
+
};
|
|
657
|
+
async function runInteractive(options) {
|
|
658
|
+
const cwd = options.directory;
|
|
659
|
+
const sessionId = randomUUID();
|
|
660
|
+
const permissionMode = options.permissionMode || "default";
|
|
661
|
+
log(`Starting interactive session: ${sessionId}`);
|
|
662
|
+
log(`Directory: ${cwd}`);
|
|
663
|
+
loadDotEnv();
|
|
664
|
+
let server = null;
|
|
665
|
+
let sessionService = null;
|
|
666
|
+
const serverUrl = process.env.HYPHA_SERVER_URL;
|
|
667
|
+
const token = process.env.HYPHA_TOKEN;
|
|
668
|
+
if (serverUrl && token) {
|
|
669
|
+
try {
|
|
670
|
+
const origLog = console.log;
|
|
671
|
+
const origWarn = console.warn;
|
|
672
|
+
console.log = () => {
|
|
673
|
+
};
|
|
674
|
+
console.warn = () => {
|
|
675
|
+
};
|
|
676
|
+
server = await connectToHypha({ serverUrl, token, name: "svamp-interactive" });
|
|
677
|
+
console.log = origLog;
|
|
678
|
+
console.warn = origWarn;
|
|
679
|
+
log("Connected to Hypha");
|
|
680
|
+
} catch (err) {
|
|
681
|
+
console.error(`\x1B[33mNote:\x1B[0m Could not connect to Hypha (${err.message}). Running in offline mode.`);
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
console.error("\x1B[33mNote:\x1B[0m No Hypha credentials found. Running in offline mode.");
|
|
685
|
+
console.error(' Run "svamp login <url>" to enable cloud sync.\n');
|
|
686
|
+
}
|
|
687
|
+
const messageQueue = [];
|
|
688
|
+
let messageWaiter = null;
|
|
689
|
+
function enqueueMessage(text) {
|
|
690
|
+
if (messageWaiter) {
|
|
691
|
+
const w = messageWaiter;
|
|
692
|
+
messageWaiter = null;
|
|
693
|
+
w.resolve(text);
|
|
694
|
+
} else {
|
|
695
|
+
messageQueue.push(text);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function waitForRemoteMessage() {
|
|
699
|
+
if (messageQueue.length > 0) {
|
|
700
|
+
return Promise.resolve(messageQueue.shift());
|
|
701
|
+
}
|
|
702
|
+
return new Promise((resolve) => {
|
|
703
|
+
messageWaiter = { resolve };
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
function hasRemoteMessage() {
|
|
707
|
+
return messageQueue.length > 0;
|
|
708
|
+
}
|
|
709
|
+
const machineId = readMachineId();
|
|
710
|
+
const metadata = {
|
|
711
|
+
path: cwd,
|
|
712
|
+
host: os.hostname(),
|
|
713
|
+
os: os.platform(),
|
|
714
|
+
machineId: machineId || void 0,
|
|
715
|
+
homeDir: os.homedir(),
|
|
716
|
+
svampHomeDir: SVAMP_HOME,
|
|
717
|
+
svampLibDir: "",
|
|
718
|
+
svampToolsDir: "",
|
|
719
|
+
startedBy: "terminal",
|
|
720
|
+
lifecycleState: "running",
|
|
721
|
+
flavor: "claude"
|
|
722
|
+
};
|
|
723
|
+
if (server) {
|
|
724
|
+
const callbacks = {
|
|
725
|
+
onUserMessage: (content, _meta) => {
|
|
726
|
+
const text = typeof content === "string" ? content : content?.text || content?.content?.text || JSON.stringify(content);
|
|
727
|
+
log(`[hypha] User message received: ${text.slice(0, 80)}`);
|
|
728
|
+
enqueueMessage(text);
|
|
729
|
+
},
|
|
730
|
+
onAbort: () => {
|
|
731
|
+
log("[hypha] Abort requested");
|
|
732
|
+
},
|
|
733
|
+
onPermissionResponse: (_params) => {
|
|
734
|
+
log("[hypha] Permission response");
|
|
735
|
+
},
|
|
736
|
+
onSwitchMode: (mode) => {
|
|
737
|
+
log(`[hypha] Switch mode: ${mode}`);
|
|
738
|
+
},
|
|
739
|
+
onRestartClaude: async () => {
|
|
740
|
+
log("[hypha] Restart requested");
|
|
741
|
+
return { success: false, message: "Restart not supported in interactive mode" };
|
|
742
|
+
},
|
|
743
|
+
onKillSession: () => {
|
|
744
|
+
log("[hypha] Kill requested");
|
|
745
|
+
cleanup();
|
|
746
|
+
process.exit(0);
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
try {
|
|
750
|
+
sessionService = await registerSessionService(
|
|
751
|
+
server,
|
|
752
|
+
sessionId,
|
|
753
|
+
metadata,
|
|
754
|
+
{ controlledByUser: true },
|
|
755
|
+
callbacks
|
|
756
|
+
);
|
|
757
|
+
log(`Session service registered: svamp-session-${sessionId}`);
|
|
758
|
+
} catch (err) {
|
|
759
|
+
console.error(`\x1B[33mNote:\x1B[0m Could not register session on Hypha (${err.message}).`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
let hookServer = null;
|
|
763
|
+
let hookSettings = null;
|
|
764
|
+
let claudeSessionId = options.resumeSessionId || null;
|
|
765
|
+
try {
|
|
766
|
+
hookServer = await startHookServer((id) => {
|
|
767
|
+
claudeSessionId = id;
|
|
768
|
+
log(`Claude session ID from hook: ${id}`);
|
|
769
|
+
if (sessionService) {
|
|
770
|
+
sessionService.updateMetadata({ ...metadata, claudeSessionId: id });
|
|
771
|
+
}
|
|
772
|
+
}, log);
|
|
773
|
+
hookSettings = generateHookSettings(hookServer.port);
|
|
774
|
+
log(`Hook settings: ${hookSettings.settingsPath}`);
|
|
775
|
+
} catch (err) {
|
|
776
|
+
log(`Failed to start hook server: ${err.message}`);
|
|
777
|
+
}
|
|
778
|
+
let keepAliveInterval = null;
|
|
779
|
+
if (sessionService) {
|
|
780
|
+
keepAliveInterval = setInterval(() => {
|
|
781
|
+
sessionService.sendKeepAlive(false);
|
|
782
|
+
}, 3e4);
|
|
783
|
+
}
|
|
784
|
+
const cleanup = async () => {
|
|
785
|
+
log("Cleaning up...");
|
|
786
|
+
if (keepAliveInterval) clearInterval(keepAliveInterval);
|
|
787
|
+
if (sessionService) {
|
|
788
|
+
sessionService.sendSessionEnd();
|
|
789
|
+
await sessionService.disconnect().catch(() => {
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
hookSettings?.cleanup();
|
|
793
|
+
hookServer?.stop();
|
|
794
|
+
if (server) {
|
|
795
|
+
await server.disconnect().catch(() => {
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
if (messageWaiter) {
|
|
799
|
+
messageWaiter.resolve(null);
|
|
800
|
+
messageWaiter = null;
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
let exiting = false;
|
|
804
|
+
const handleExit = async () => {
|
|
805
|
+
if (exiting) return;
|
|
806
|
+
exiting = true;
|
|
807
|
+
await cleanup();
|
|
808
|
+
process.exit(0);
|
|
809
|
+
};
|
|
810
|
+
process.on("SIGTERM", handleExit);
|
|
811
|
+
process.on("SIGINT", handleExit);
|
|
812
|
+
const claudeArgs = [...options.claudeArgs || []];
|
|
813
|
+
if (options.resumeSessionId) ; else if (options.continueSession) {
|
|
814
|
+
claudeArgs.push("--continue");
|
|
815
|
+
}
|
|
816
|
+
console.log(`\x1B[36mSvamp interactive mode\x1B[0m`);
|
|
817
|
+
if (server && sessionService) {
|
|
818
|
+
console.log(`\x1B[90mSession synced to Hypha \u2014 visible in the web app\x1B[0m`);
|
|
819
|
+
console.log(`\x1B[90mSession ID: ${sessionId.slice(0, 8)}\x1B[0m`);
|
|
820
|
+
}
|
|
821
|
+
console.log("");
|
|
822
|
+
try {
|
|
823
|
+
const exitCode = await loop({
|
|
824
|
+
cwd,
|
|
825
|
+
startingMode: "local",
|
|
826
|
+
hookSettingsPath: hookSettings?.settingsPath || "",
|
|
827
|
+
permissionMode,
|
|
828
|
+
claudeArgs: claudeArgs.length > 0 ? claudeArgs : void 0,
|
|
829
|
+
log,
|
|
830
|
+
onModeChange: (mode) => {
|
|
831
|
+
log(`Mode changed: ${mode}`);
|
|
832
|
+
if (sessionService) {
|
|
833
|
+
sessionService.updateMetadata({ ...metadata, lifecycleState: "running" });
|
|
834
|
+
sessionService.sendKeepAlive(false, mode);
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
onSessionFound: (id) => {
|
|
838
|
+
claudeSessionId = id;
|
|
839
|
+
if (sessionService) {
|
|
840
|
+
sessionService.updateMetadata({ ...metadata, claudeSessionId: id });
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
onMessage: (msg) => {
|
|
844
|
+
if (!sessionService) return;
|
|
845
|
+
if (msg.type === "assistant" && msg.message) {
|
|
846
|
+
sessionService.pushMessage(msg.message, "agent");
|
|
847
|
+
} else if (msg.type === "user" && msg.message) {
|
|
848
|
+
const text = typeof msg.message.content === "string" ? msg.message.content : msg.message.content?.text || JSON.stringify(msg.message.content);
|
|
849
|
+
sessionService.pushMessage({ type: "text", text }, "user");
|
|
850
|
+
} else if (msg.type === "summary") {
|
|
851
|
+
sessionService.updateMetadata({
|
|
852
|
+
...metadata,
|
|
853
|
+
claudeSessionId: claudeSessionId || void 0,
|
|
854
|
+
summary: { text: msg.summary || "", updatedAt: Date.now() }
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
},
|
|
858
|
+
onThinkingChange: (thinking) => {
|
|
859
|
+
if (sessionService) {
|
|
860
|
+
sessionService.sendKeepAlive(thinking);
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
waitForRemoteMessage,
|
|
864
|
+
hasRemoteMessage
|
|
865
|
+
});
|
|
866
|
+
await cleanup();
|
|
867
|
+
process.exit(exitCode);
|
|
868
|
+
} catch (err) {
|
|
869
|
+
log(`Loop error: ${err.message}`);
|
|
870
|
+
await cleanup();
|
|
871
|
+
process.exit(1);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
function loadDotEnv() {
|
|
875
|
+
if (!existsSync(ENV_FILE)) return;
|
|
876
|
+
const lines = readFileSync(ENV_FILE, "utf-8").split("\n");
|
|
877
|
+
for (const line of lines) {
|
|
878
|
+
const trimmed = line.trim();
|
|
879
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
880
|
+
const eqIdx = trimmed.indexOf("=");
|
|
881
|
+
if (eqIdx === -1) continue;
|
|
882
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
883
|
+
const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, "");
|
|
884
|
+
if (!process.env[key]) process.env[key] = value;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function readMachineId() {
|
|
888
|
+
try {
|
|
889
|
+
if (!existsSync(DAEMON_STATE_FILE)) return null;
|
|
890
|
+
const state = JSON.parse(readFileSync(DAEMON_STATE_FILE, "utf-8"));
|
|
891
|
+
return state.machineId || null;
|
|
892
|
+
} catch {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export { runInteractive };
|