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" || !subcommand) {
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-CMmjP_vD.mjs').catch(() => ({ default: { version: "unknown" } }));
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 Svamp CLI with Hypha transport
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 daemon uninstall Remove system service
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 (interactive)
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 daemon)
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)
@@ -1,5 +1,5 @@
1
1
  var name = "svamp-cli";
2
- var version = "0.1.32";
2
+ var version = "0.1.34";
3
3
  var description = "Svamp CLI — AI workspace daemon on Hypha Cloud";
4
4
  var author = "Amun AI AB";
5
5
  var license = "SEE LICENSE IN LICENSE";
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svamp-cli",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Svamp CLI — AI workspace daemon on Hypha Cloud",
5
5
  "author": "Amun AI AB",
6
6
  "license": "SEE LICENSE IN LICENSE",