svamp-cli 0.1.75 → 0.1.78

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.
@@ -1,7 +1,7 @@
1
1
  import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import os__default from 'os';
2
2
  import fs, { mkdir as mkdir$1, readdir, readFile, writeFile as writeFile$1, rename, unlink } from 'fs/promises';
3
3
  import { readFileSync as readFileSync$1, mkdirSync, writeFileSync, renameSync, existsSync as existsSync$1, copyFileSync, unlinkSync, watch, rmdirSync } from 'fs';
4
- import path, { join, dirname, resolve, basename } from 'path';
4
+ import path__default, { join, dirname, resolve, basename } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { spawn as spawn$1 } from 'child_process';
7
7
  import { randomUUID as randomUUID$1 } from 'crypto';
@@ -309,18 +309,6 @@ async function getPtyModule() {
309
309
  function generateTerminalId() {
310
310
  return `term-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
311
311
  }
312
- const ALLOWED_CONTROL_CHARS = /* @__PURE__ */ new Set([7, 8, 9, 10, 11, 12, 13, 27]);
313
- function filterForXterm(text) {
314
- if (!text) return text;
315
- let result = "";
316
- for (let i = 0; i < text.length; i++) {
317
- const code = text.charCodeAt(i);
318
- if (code >= 32 && code <= 126 || ALLOWED_CONTROL_CHARS.has(code) || code > 127) {
319
- result += text[i];
320
- }
321
- }
322
- return result;
323
- }
324
312
  function getMachineMetadataPath(svampHomeDir) {
325
313
  return join(svampHomeDir, "machine-metadata.json");
326
314
  }
@@ -705,43 +693,78 @@ async function registerMachineService(server, machineId, metadata, daemonState,
705
693
  const cwd = params.cwd || getHomedir();
706
694
  const shell = params.shell || process.env.SHELL || "bash";
707
695
  const sessionId = generateTerminalId();
696
+ const cleanEnv = { ...process.env };
697
+ const claudeEnvVars = Object.keys(cleanEnv).filter(
698
+ (k) => k.startsWith("CLAUDE_") || k.startsWith("MCP_") || k === "ANTHROPIC_API_KEY"
699
+ );
700
+ for (const k of claudeEnvVars) delete cleanEnv[k];
708
701
  const ptyProcess = pty.spawn(shell, [], {
709
702
  name: "xterm-256color",
710
703
  cols,
711
704
  rows,
712
705
  cwd,
713
- env: { ...process.env, TERM: "xterm-256color" }
706
+ env: {
707
+ ...cleanEnv,
708
+ TERM: "xterm-256color"
709
+ }
714
710
  });
711
+ if (shell.endsWith("zsh")) {
712
+ ptyProcess.write("unsetopt PROMPT_SP\n");
713
+ ptyProcess.write("clear\n");
714
+ }
715
715
  const session = {
716
716
  id: sessionId,
717
717
  pty: ptyProcess,
718
- listeners: [],
718
+ outputBuffer: [],
719
+ exited: false,
719
720
  cols,
720
721
  rows,
721
722
  createdAt: Date.now(),
722
723
  cwd
723
724
  };
724
725
  terminalSessions.set(sessionId, session);
725
- ptyProcess.onData((data) => {
726
- const filtered = filterForXterm(data);
727
- if (!filtered) return;
728
- const update = { type: "output", content: filtered, sessionId };
729
- for (const listener of session.listeners) {
730
- try {
731
- listener(update);
732
- } catch {
733
- }
726
+ let outputBatch = "";
727
+ let outputTimer = null;
728
+ const flushOutput = () => {
729
+ outputTimer = null;
730
+ if (!outputBatch) return;
731
+ const batch = outputBatch;
732
+ outputBatch = "";
733
+ session.outputBuffer.push(batch);
734
+ if (session.outputBuffer.length > 1e3) {
735
+ session.outputBuffer.splice(0, session.outputBuffer.length - 500);
734
736
  }
737
+ try {
738
+ server.emit({
739
+ type: "svamp:terminal-output",
740
+ data: { type: "output", content: batch, sessionId },
741
+ to: "*"
742
+ });
743
+ } catch {
744
+ }
745
+ };
746
+ ptyProcess.onData((data) => {
747
+ if (!data) return;
748
+ outputBatch += data;
749
+ if (!outputTimer) outputTimer = setTimeout(flushOutput, 16);
735
750
  });
736
751
  ptyProcess.onExit(({ exitCode, signal }) => {
737
- const update = { type: "exit", content: "", sessionId, exitCode, signal };
738
- for (const listener of session.listeners) {
739
- try {
740
- listener(update);
741
- } catch {
742
- }
752
+ session.exited = true;
753
+ session.exitCode = exitCode;
754
+ session.exitSignal = signal;
755
+ try {
756
+ server.emit({
757
+ type: "svamp:terminal-output",
758
+ data: { type: "exit", content: "", sessionId, exitCode, signal },
759
+ to: "*"
760
+ });
761
+ } catch {
743
762
  }
744
- terminalSessions.delete(sessionId);
763
+ setTimeout(() => {
764
+ if (terminalSessions.has(sessionId)) {
765
+ terminalSessions.delete(sessionId);
766
+ }
767
+ }, 6e4);
745
768
  });
746
769
  return { sessionId, cols, rows };
747
770
  },
@@ -764,23 +787,34 @@ async function registerMachineService(server, machineId, metadata, daemonState,
764
787
  return { success: true };
765
788
  },
766
789
  /**
767
- * Attach to a terminal session for real-time output streaming.
768
- * The onData callback receives { type: 'output'|'exit', content, sessionId }.
790
+ * Read buffered output from a terminal session (polling).
791
+ * Returns { output, exited, exitCode? }. Drains the buffer.
769
792
  */
770
- terminalAttach: async (params, context) => {
793
+ terminalRead: async (params, context) => {
771
794
  authorizeRequest(context, currentMetadata.sharing, "admin");
772
795
  const session = terminalSessions.get(params.sessionId);
773
796
  if (!session) throw new Error(`Terminal session ${params.sessionId} not found`);
774
- if (typeof params.onData !== "function") throw new Error("onData callback is required");
775
- session.listeners.push(params.onData);
776
- return { success: true, cols: session.cols, rows: session.rows };
797
+ const output = session.outputBuffer.splice(0).join("");
798
+ const result = {
799
+ output,
800
+ exited: session.exited
801
+ };
802
+ if (session.exited) {
803
+ result.exitCode = session.exitCode;
804
+ result.exitSignal = session.exitSignal;
805
+ if (!output) terminalSessions.delete(params.sessionId);
806
+ }
807
+ return result;
777
808
  },
778
- /** Stop (kill) a terminal session. */
809
+ /** Stop (kill) a terminal session. Idempotent — returns success even if already gone. */
779
810
  terminalStop: async (params, context) => {
780
811
  authorizeRequest(context, currentMetadata.sharing, "admin");
781
812
  const session = terminalSessions.get(params.sessionId);
782
- if (!session) throw new Error(`Terminal session ${params.sessionId} not found`);
783
- session.pty.kill();
813
+ if (!session) return { success: true };
814
+ try {
815
+ session.pty.kill();
816
+ } catch {
817
+ }
784
818
  terminalSessions.delete(params.sessionId);
785
819
  return { success: true };
786
820
  },
@@ -792,9 +826,23 @@ async function registerMachineService(server, machineId, metadata, daemonState,
792
826
  cols: s.cols,
793
827
  rows: s.rows,
794
828
  cwd: s.cwd,
795
- createdAt: s.createdAt
829
+ createdAt: s.createdAt,
830
+ exited: s.exited
796
831
  }));
797
832
  },
833
+ /** Reattach to an existing terminal session. Returns buffered output for scrollback replay. */
834
+ terminalReattach: async (params, context) => {
835
+ authorizeRequest(context, currentMetadata.sharing, "admin");
836
+ const session = terminalSessions.get(params.sessionId);
837
+ if (!session) throw new Error(`Terminal session ${params.sessionId} not found`);
838
+ const scrollback = session.outputBuffer.join("");
839
+ if (params.cols && params.rows) {
840
+ session.pty.resize(params.cols, params.rows);
841
+ session.cols = params.cols;
842
+ session.rows = params.rows;
843
+ }
844
+ return { sessionId: session.id, cols: session.cols, rows: session.rows, scrollback, exited: session.exited };
845
+ },
798
846
  // Machine-level directory listing (read-only, view role)
799
847
  listDirectory: async (path, options, context) => {
800
848
  authorizeRequest(context, currentMetadata.sharing, "view");
@@ -926,6 +974,48 @@ async function registerMachineService(server, machineId, metadata, daemonState,
926
974
  const { deleteServiceGroup } = await import('./api-BRbsyqJ4.mjs');
927
975
  return deleteServiceGroup(params.name);
928
976
  },
977
+ // ── Tunnel management ────────────────────────────────────────────
978
+ /** Start a reverse tunnel for a service group (local/cloud machine). */
979
+ tunnelStart: async (params, context) => {
980
+ authorizeRequest(context, currentMetadata.sharing, "admin");
981
+ const tunnels = handlers.tunnels;
982
+ if (!tunnels) throw new Error("Tunnel management not available");
983
+ if (tunnels.has(params.name)) throw new Error(`Tunnel '${params.name}' already running`);
984
+ const { TunnelClient } = await import('./tunnel-BDKdemh0.mjs');
985
+ const client = new TunnelClient({
986
+ name: params.name,
987
+ ports: params.ports,
988
+ maxReconnectAttempts: 0,
989
+ // infinite for daemon
990
+ onError: (err) => console.error(`[TUNNEL] ${params.name}: ${err.message}`),
991
+ onConnect: () => console.log(`[TUNNEL] ${params.name}: connected`),
992
+ onDisconnect: () => console.log(`[TUNNEL] ${params.name}: disconnected, reconnecting...`)
993
+ });
994
+ await client.connect();
995
+ tunnels.set(params.name, client);
996
+ return { name: params.name, ports: params.ports, ...client.status };
997
+ },
998
+ /** Stop a tunnel. */
999
+ tunnelStop: async (params, context) => {
1000
+ authorizeRequest(context, currentMetadata.sharing, "admin");
1001
+ const tunnels = handlers.tunnels;
1002
+ if (!tunnels) throw new Error("Tunnel management not available");
1003
+ const client = tunnels.get(params.name);
1004
+ if (!client) throw new Error(`Tunnel '${params.name}' not found`);
1005
+ client.destroy();
1006
+ tunnels.delete(params.name);
1007
+ return { name: params.name, stopped: true };
1008
+ },
1009
+ /** List active tunnels with health status. */
1010
+ tunnelList: async (context) => {
1011
+ authorizeRequest(context, currentMetadata.sharing, "view");
1012
+ const tunnels = handlers.tunnels;
1013
+ if (!tunnels) return [];
1014
+ return Array.from(tunnels.entries()).map(([name, client]) => ({
1015
+ name,
1016
+ ...client.status
1017
+ }));
1018
+ },
929
1019
  // WISE voice — create ephemeral token for OpenAI Realtime API
930
1020
  wiseCreateEphemeralToken: async (params, context) => {
931
1021
  authorizeRequest(context, currentMetadata.sharing, "interact");
@@ -999,6 +1089,28 @@ async function registerMachineService(server, machineId, metadata, daemonState,
999
1089
  };
1000
1090
  }
1001
1091
 
1092
+ function isStructuredMessage(msg) {
1093
+ return !!(msg.from && msg.subject);
1094
+ }
1095
+ function escapeXml(s) {
1096
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1097
+ }
1098
+ function formatInboxMessageXml(msg) {
1099
+ if (!isStructuredMessage(msg)) return msg.body;
1100
+ const attrs = [`message-id="${escapeXml(msg.messageId)}"`];
1101
+ if (msg.from) attrs.push(`from="${escapeXml(msg.from)}"`);
1102
+ if (msg.fromSession) attrs.push(`from-session="${escapeXml(msg.fromSession)}"`);
1103
+ if (msg.to) attrs.push(`to="${escapeXml(msg.to)}"`);
1104
+ if (msg.subject) attrs.push(`subject="${escapeXml(msg.subject)}"`);
1105
+ if (msg.urgency) attrs.push(`urgency="${msg.urgency}"`);
1106
+ if (msg.replyTo) attrs.push(`reply-to="${escapeXml(msg.replyTo)}"`);
1107
+ if (msg.cc && msg.cc.length > 0) attrs.push(`cc="${msg.cc.map(escapeXml).join(",")}"`);
1108
+ if (msg.threadId) attrs.push(`thread-id="${escapeXml(msg.threadId)}"`);
1109
+ attrs.push(`timestamp="${msg.timestamp}"`);
1110
+ return `<svamp-message ${attrs.join(" ")}>
1111
+ ${msg.body}
1112
+ </svamp-message>`;
1113
+ }
1002
1114
  function loadMessages(messagesDir, sessionId) {
1003
1115
  const filePath = join$1(messagesDir, "messages.jsonl");
1004
1116
  if (!existsSync(filePath)) return [];
@@ -1040,6 +1152,8 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
1040
1152
  mode: "remote",
1041
1153
  time: Date.now()
1042
1154
  };
1155
+ const inbox = [];
1156
+ const INBOX_MAX = 100;
1043
1157
  const listeners = [];
1044
1158
  const removeListener = (listener, reason) => {
1045
1159
  const idx = listeners.indexOf(listener);
@@ -1308,10 +1422,10 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
1308
1422
  if (!callbacks.onListDirectory) throw new Error("listDirectory not supported");
1309
1423
  return await callbacks.onListDirectory(path);
1310
1424
  },
1311
- bash: async (command, cwd, context) => {
1425
+ bash: async (command, cwd, timeout, context) => {
1312
1426
  authorizeRequest(context, metadata.sharing, "admin");
1313
1427
  if (!callbacks.onBash) throw new Error("bash not supported");
1314
- return await callbacks.onBash(command, cwd);
1428
+ return await callbacks.onBash(command, cwd, timeout);
1315
1429
  },
1316
1430
  ripgrep: async (args, cwd, context) => {
1317
1431
  authorizeRequest(context, metadata.sharing, "admin");
@@ -1357,7 +1471,8 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
1357
1471
  callbacks.onSharingUpdate?.(newSharing);
1358
1472
  return { success: true, sharing: newSharing };
1359
1473
  },
1360
- /** Update security context and restart the agent process with new rules */
1474
+ /** Update security context and restart the agent process with new rules.
1475
+ * Pass '__disable__' sentinel (from frontend) or null to disable isolation entirely. */
1361
1476
  updateSecurityContext: async (newSecurityContext, context) => {
1362
1477
  authorizeRequest(context, metadata.sharing, "admin");
1363
1478
  if (metadata.sharing && context?.user?.email && metadata.sharing.owner && context.user.email.toLowerCase() !== metadata.sharing.owner.toLowerCase()) {
@@ -1366,14 +1481,15 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
1366
1481
  if (!callbacks.onUpdateSecurityContext) {
1367
1482
  throw new Error("Security context updates are not supported for this session");
1368
1483
  }
1369
- metadata = { ...metadata, securityContext: newSecurityContext };
1484
+ const resolvedContext = newSecurityContext === "__disable__" ? null : newSecurityContext;
1485
+ metadata = { ...metadata, securityContext: resolvedContext };
1370
1486
  metadataVersion++;
1371
1487
  notifyListeners({
1372
1488
  type: "update-session",
1373
1489
  sessionId,
1374
1490
  metadata: { value: metadata, version: metadataVersion }
1375
1491
  });
1376
- return await callbacks.onUpdateSecurityContext(newSecurityContext);
1492
+ return await callbacks.onUpdateSecurityContext(resolvedContext);
1377
1493
  },
1378
1494
  /** Apply a new system prompt and restart the agent process */
1379
1495
  applySystemPrompt: async (prompt, context) => {
@@ -1383,6 +1499,43 @@ function createSessionStore(server, sessionId, initialMetadata, initialAgentStat
1383
1499
  }
1384
1500
  return await callbacks.onApplySystemPrompt(prompt);
1385
1501
  },
1502
+ // ── Inbox ──
1503
+ sendInboxMessage: async (message, context) => {
1504
+ authorizeRequest(context, metadata.sharing, "interact");
1505
+ inbox.push(message);
1506
+ while (inbox.length > INBOX_MAX) inbox.shift();
1507
+ callbacks.onInboxMessage?.(message);
1508
+ notifyListeners({ type: "inbox-update", sessionId, message });
1509
+ return { success: true, messageId: message.messageId };
1510
+ },
1511
+ getInbox: async (opts, context) => {
1512
+ authorizeRequest(context, metadata.sharing, "view");
1513
+ let result = [...inbox];
1514
+ if (opts?.unread) result = result.filter((m) => !m.read);
1515
+ result.sort((a, b) => b.timestamp - a.timestamp);
1516
+ if (opts?.limit && opts.limit > 0) result = result.slice(0, opts.limit);
1517
+ return { messages: result, total: inbox.length };
1518
+ },
1519
+ markInboxRead: async (messageId, context) => {
1520
+ authorizeRequest(context, metadata.sharing, "interact");
1521
+ const msg = inbox.find((m) => m.messageId === messageId);
1522
+ if (!msg) return { success: false, error: "Message not found" };
1523
+ msg.read = true;
1524
+ notifyListeners({ type: "inbox-update", sessionId, message: msg });
1525
+ return { success: true };
1526
+ },
1527
+ clearInbox: async (opts, context) => {
1528
+ authorizeRequest(context, metadata.sharing, "admin");
1529
+ if (opts?.all) {
1530
+ inbox.length = 0;
1531
+ } else {
1532
+ for (let i = inbox.length - 1; i >= 0; i--) {
1533
+ if (inbox[i].read) inbox.splice(i, 1);
1534
+ }
1535
+ }
1536
+ notifyListeners({ type: "inbox-update", sessionId, cleared: true });
1537
+ return { success: true, remaining: inbox.length };
1538
+ },
1386
1539
  // ── Listener Registration ──
1387
1540
  registerListener: async (callback, context) => {
1388
1541
  authorizeRequest(context, metadata.sharing, "view");
@@ -4167,6 +4320,8 @@ function sanitizeEnvForSharing(env) {
4167
4320
  const DEFAULT_PROBE_INTERVAL_S = 10;
4168
4321
  const DEFAULT_PROBE_TIMEOUT_S = 5;
4169
4322
  const DEFAULT_PROBE_FAILURE_THRESHOLD = 3;
4323
+ const MAX_RESTART_DELAY_S = 300;
4324
+ const BACKOFF_RESET_WINDOW_MS = 6e4;
4170
4325
  const MAX_LOG_LINES = 300;
4171
4326
  class ProcessSupervisor {
4172
4327
  entries = /* @__PURE__ */ new Map();
@@ -4364,7 +4519,7 @@ class ProcessSupervisor {
4364
4519
  for (const file of files) {
4365
4520
  if (!file.endsWith(".json")) continue;
4366
4521
  try {
4367
- const raw = await readFile(path.join(this.persistDir, file), "utf-8");
4522
+ const raw = await readFile(path__default.join(this.persistDir, file), "utf-8");
4368
4523
  const spec = JSON.parse(raw);
4369
4524
  const entry = this.makeEntry(spec);
4370
4525
  this.entries.set(spec.id, entry);
@@ -4385,14 +4540,14 @@ class ProcessSupervisor {
4385
4540
  }
4386
4541
  }
4387
4542
  async persistSpec(spec) {
4388
- const filePath = path.join(this.persistDir, `${spec.id}.json`);
4543
+ const filePath = path__default.join(this.persistDir, `${spec.id}.json`);
4389
4544
  const tmpPath = filePath + ".tmp";
4390
4545
  await writeFile$1(tmpPath, JSON.stringify(spec, null, 2), "utf-8");
4391
4546
  await rename(tmpPath, filePath);
4392
4547
  }
4393
4548
  async deleteSpec(id) {
4394
4549
  try {
4395
- await unlink(path.join(this.persistDir, `${id}.json`));
4550
+ await unlink(path__default.join(this.persistDir, `${id}.json`));
4396
4551
  } catch {
4397
4552
  }
4398
4553
  }
@@ -4498,8 +4653,19 @@ class ProcessSupervisor {
4498
4653
  state.status = "failed";
4499
4654
  return;
4500
4655
  }
4501
- const delayMs = spec.restartDelay * 1e3;
4502
- console.log(`[SUPERVISOR] Scheduling restart of '${spec.name}' in ${delayMs}ms (restart #${state.restartCount + 1})`);
4656
+ const uptime = state.startedAt ? Date.now() - state.startedAt : 0;
4657
+ const baseDelay = spec.restartDelay * 1e3;
4658
+ let delayMs;
4659
+ if (uptime > BACKOFF_RESET_WINDOW_MS) {
4660
+ state.restartCount = 0;
4661
+ delayMs = baseDelay;
4662
+ } else {
4663
+ const backoffExponent = Math.min(state.restartCount, 10);
4664
+ delayMs = Math.min(baseDelay * Math.pow(2, backoffExponent), MAX_RESTART_DELAY_S * 1e3);
4665
+ const jitter = (Math.random() * 0.2 - 0.1) * delayMs;
4666
+ delayMs = Math.max(baseDelay, Math.round(delayMs + jitter));
4667
+ }
4668
+ console.log(`[SUPERVISOR] Scheduling restart of '${spec.name}' in ${delayMs}ms (restart #${state.restartCount + 1}, uptime=${Math.round(uptime / 1e3)}s)`);
4503
4669
  entry.restartTimer = setTimeout(() => {
4504
4670
  if (entry.stopping) return;
4505
4671
  state.restartCount++;
@@ -5537,6 +5703,7 @@ async function startDaemon(options) {
5537
5703
  let server = null;
5538
5704
  const supervisor = new ProcessSupervisor(join(SVAMP_HOME, "processes"));
5539
5705
  await supervisor.init();
5706
+ const tunnels = /* @__PURE__ */ new Map();
5540
5707
  ensureAutoInstalledSkills(logger).catch(() => {
5541
5708
  });
5542
5709
  preventMachineSleep(logger);
@@ -5657,6 +5824,7 @@ async function startDaemon(options) {
5657
5824
  });
5658
5825
  }, buildIsolationConfig2 = function(dir) {
5659
5826
  if (!options2.forceIsolation && !sessionMetadata.sharing?.enabled) return null;
5827
+ if (sessionMetadata.securityContext === null) return null;
5660
5828
  const method = isolationCapabilities.preferred;
5661
5829
  if (!method) return null;
5662
5830
  const detail = isolationCapabilities.details[method];
@@ -5676,7 +5844,7 @@ async function startDaemon(options) {
5676
5844
  if (sessionMetadata.sharing?.enabled && stagedCredentials) {
5677
5845
  config.credentialStagingPath = stagedCredentials.homePath;
5678
5846
  }
5679
- const activeSecurityContext = sessionMetadata.securityContext || options2.securityContext;
5847
+ const activeSecurityContext = sessionMetadata.securityContext ?? options2.securityContext;
5680
5848
  if (activeSecurityContext) {
5681
5849
  config = applySecurityContext(config, activeSecurityContext);
5682
5850
  }
@@ -6335,6 +6503,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6335
6503
  logger.log(`[Session ${sessionId}] Retrying startup without --resume`);
6336
6504
  sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6337
6505
  sessionService.updateMetadata(sessionMetadata);
6506
+ sessionService.sendKeepAlive(true);
6338
6507
  spawnClaude(startupRetryMessage);
6339
6508
  return;
6340
6509
  }
@@ -6391,6 +6560,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6391
6560
  }
6392
6561
  if (claudeResumeId) {
6393
6562
  spawnClaude(void 0, { permissionMode: currentPermissionMode });
6563
+ sessionService.updateMetadata(sessionMetadata);
6394
6564
  logger.log(`[Session ${sessionId}] Claude respawned with --resume ${claudeResumeId}`);
6395
6565
  return { success: true, message: "Claude process restarted successfully." };
6396
6566
  } else {
@@ -6596,6 +6766,25 @@ The automated loop has finished. Review the progress above and let me know if yo
6596
6766
  logger.log(`[Session ${sessionId}] Kill session requested`);
6597
6767
  stopSession(sessionId);
6598
6768
  },
6769
+ onInboxMessage: (message) => {
6770
+ if (trackedSession?.stopped) return;
6771
+ logger.log(`[Session ${sessionId}] Inbox message received (urgency: ${message.urgency || "normal"}, from: ${message.from || "unknown"})`);
6772
+ if (message.urgency === "urgent") {
6773
+ const formatted = formatInboxMessageXml(message);
6774
+ logger.log(`[Session ${sessionId}] Delivering urgent inbox message to agent`);
6775
+ if (!claudeProcess || claudeProcess.exitCode !== null) {
6776
+ spawnClaude(formatted);
6777
+ } else {
6778
+ const stdinMsg = JSON.stringify({
6779
+ type: "user",
6780
+ message: { role: "user", content: formatted }
6781
+ });
6782
+ claudeProcess.stdin?.write(stdinMsg + "\n");
6783
+ }
6784
+ signalProcessing(true);
6785
+ sessionWasProcessing = true;
6786
+ }
6787
+ },
6599
6788
  onMetadataUpdate: (newMeta) => {
6600
6789
  const prevRalphLoop = sessionMetadata.ralphLoop;
6601
6790
  sessionMetadata = {
@@ -6625,11 +6814,12 @@ The automated loop has finished. Review the progress above and let me know if yo
6625
6814
  onUpdateConfig: (patch) => {
6626
6815
  writeSvampConfigPatch(patch);
6627
6816
  },
6628
- onBash: async (command, cwd) => {
6629
- logger.log(`[Session ${sessionId}] Bash: ${command} (cwd: ${cwd || directory})`);
6817
+ onBash: async (command, cwd, timeout) => {
6818
+ const execTimeout = timeout || 12e4;
6819
+ logger.log(`[Session ${sessionId}] Bash: ${command} (cwd: ${cwd || directory}, timeout: ${execTimeout}ms)`);
6630
6820
  const { exec } = await import('child_process');
6631
6821
  return new Promise((resolve2) => {
6632
- exec(command, { cwd: cwd || directory, timeout: 3e4, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
6822
+ exec(command, { cwd: cwd || directory, timeout: execTimeout, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
6633
6823
  if (err) {
6634
6824
  resolve2({ success: false, stdout: stdout || "", stderr: stderr || err.message, exitCode: err.code ?? 1 });
6635
6825
  } else {
@@ -6938,6 +7128,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6938
7128
  }
6939
7129
  sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6940
7130
  sessionService.updateMetadata(sessionMetadata);
7131
+ sessionService.sendKeepAlive(true);
6941
7132
  agentBackend.sendPrompt(sessionId, text).catch((err) => {
6942
7133
  logger.error(`[${agentName} Session ${sessionId}] Error sending prompt:`, err);
6943
7134
  if (!acpStopped) {
@@ -6999,6 +7190,25 @@ The automated loop has finished. Review the progress above and let me know if yo
6999
7190
  logger.log(`[${agentName} Session ${sessionId}] Kill session requested`);
7000
7191
  stopSession(sessionId);
7001
7192
  },
7193
+ onInboxMessage: (message) => {
7194
+ if (acpStopped) return;
7195
+ logger.log(`[${agentName} Session ${sessionId}] Inbox message received (urgency: ${message.urgency || "normal"}, from: ${message.from || "unknown"})`);
7196
+ if (message.urgency === "urgent" && acpBackendReady) {
7197
+ const formatted = formatInboxMessageXml(message);
7198
+ logger.log(`[${agentName} Session ${sessionId}] Delivering urgent inbox message to agent`);
7199
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
7200
+ sessionService.updateMetadata(sessionMetadata);
7201
+ sessionService.sendKeepAlive(true);
7202
+ agentBackend.sendPrompt(sessionId, formatted).catch((err) => {
7203
+ logger.error(`[${agentName} Session ${sessionId}] Error delivering urgent inbox message: ${err.message}`);
7204
+ if (!acpStopped) {
7205
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
7206
+ sessionService.updateMetadata(sessionMetadata);
7207
+ sessionService.sendSessionEnd();
7208
+ }
7209
+ });
7210
+ }
7211
+ },
7002
7212
  onMetadataUpdate: (newMeta) => {
7003
7213
  const prevRalphLoop = sessionMetadata.ralphLoop;
7004
7214
  sessionMetadata = {
@@ -7038,10 +7248,11 @@ The automated loop has finished. Review the progress above and let me know if yo
7038
7248
  onUpdateConfig: (patch) => {
7039
7249
  writeSvampConfigPatchAcp(patch);
7040
7250
  },
7041
- onBash: async (command, cwd) => {
7251
+ onBash: async (command, cwd, timeout) => {
7252
+ const execTimeout = timeout || 12e4;
7042
7253
  const { exec } = await import('child_process');
7043
7254
  return new Promise((resolve2) => {
7044
- exec(command, { cwd: cwd || directory, timeout: 3e4, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
7255
+ exec(command, { cwd: cwd || directory, timeout: execTimeout, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
7045
7256
  if (err) {
7046
7257
  resolve2({ success: false, stdout: stdout || "", stderr: stderr || err.message, exitCode: err.code ?? 1 });
7047
7258
  } else {
@@ -7492,7 +7703,8 @@ The automated loop has finished. Review the progress above and let me know if yo
7492
7703
  }
7493
7704
  return ids;
7494
7705
  },
7495
- supervisor
7706
+ supervisor,
7707
+ tunnels
7496
7708
  }
7497
7709
  );
7498
7710
  logger.log(`Machine service registered: svamp-machine-${machineId}`);
@@ -7691,7 +7903,6 @@ The automated loop has finished. Review the progress above and let me know if yo
7691
7903
  console.log(` Log file: ${logger.logFilePath}`);
7692
7904
  const HEARTBEAT_INTERVAL_MS = 1e4;
7693
7905
  const PING_TIMEOUT_MS = 6e4;
7694
- const MAX_FAILURES = 60;
7695
7906
  const POST_RECONNECT_GRACE_MS = 2e4;
7696
7907
  let heartbeatRunning = false;
7697
7908
  let lastReconnectAt = 0;
@@ -7762,7 +7973,7 @@ The automated loop has finished. Review the progress above and let me know if yo
7762
7973
  if (consecutiveHeartbeatFailures === 1) {
7763
7974
  logger.log(`Ping failed: ${err.message}`);
7764
7975
  } else if (consecutiveHeartbeatFailures % 6 === 0) {
7765
- logger.log(`Connection down for ${consecutiveHeartbeatFailures * HEARTBEAT_INTERVAL_MS / 1e3}s (${consecutiveHeartbeatFailures}/${MAX_FAILURES})`);
7976
+ logger.log(`Connection down for ${consecutiveHeartbeatFailures * HEARTBEAT_INTERVAL_MS / 1e3}s (${consecutiveHeartbeatFailures} failures, retrying indefinitely)`);
7766
7977
  }
7767
7978
  if (consecutiveHeartbeatFailures === 1 || consecutiveHeartbeatFailures % 3 === 0) {
7768
7979
  const conn = server.rpc?._connection;
@@ -7775,17 +7986,19 @@ The automated loop has finished. Review the progress above and let me know if yo
7775
7986
  }
7776
7987
  }
7777
7988
  if (conn?._reader) {
7778
- logger.log("Aborting stale HTTP stream to trigger reconnection");
7989
+ logger.log("Cancelling stale HTTP stream reader to trigger reconnection");
7779
7990
  try {
7780
7991
  conn._reader.cancel?.("Stale connection");
7781
7992
  } catch {
7782
7993
  }
7994
+ } else if (conn?._abort_controller) {
7995
+ logger.log("Aborting stale HTTP stream to trigger reconnection");
7996
+ try {
7997
+ conn._abort_controller.abort();
7998
+ } catch {
7999
+ }
7783
8000
  }
7784
8001
  }
7785
- if (consecutiveHeartbeatFailures >= MAX_FAILURES) {
7786
- logger.log(`Heartbeat failed ${MAX_FAILURES} times. Shutting down.`);
7787
- requestShutdown("heartbeat-timeout", err.message);
7788
- }
7789
8002
  }
7790
8003
  }
7791
8004
  } finally {
@@ -7870,6 +8083,11 @@ The automated loop has finished. Review the progress above and let me know if yo
7870
8083
  }
7871
8084
  await supervisor.stopAll().catch(() => {
7872
8085
  });
8086
+ for (const [name, client] of tunnels) {
8087
+ client.destroy();
8088
+ logger.log(`Tunnel '${name}' destroyed`);
8089
+ }
8090
+ tunnels.clear();
7873
8091
  artifactSync.destroy();
7874
8092
  try {
7875
8093
  await server.disconnect();
@@ -7880,8 +8098,11 @@ The automated loop has finished. Review the progress above and let me know if yo
7880
8098
  };
7881
8099
  const shutdownReq = await resolvesWhenShutdownRequested;
7882
8100
  await cleanup(shutdownReq.source);
7883
- if (process.env.SVAMP_SUPERVISED === "1" && shutdownReq.source === "version-update") {
7884
- process.exit(1);
8101
+ if (process.env.SVAMP_SUPERVISED === "1") {
8102
+ const intentionalSources = ["os-signal", "os-signal-cleanup", "hypha-app"];
8103
+ if (!intentionalSources.includes(shutdownReq.source)) {
8104
+ process.exit(1);
8105
+ }
7885
8106
  }
7886
8107
  process.exit(0);
7887
8108
  } catch (error) {
@@ -7897,33 +8118,97 @@ The automated loop has finished. Review the progress above and let me know if yo
7897
8118
  }
7898
8119
  }
7899
8120
  async function stopDaemon(options) {
8121
+ const signal = options?.cleanup ? "SIGUSR1" : "SIGTERM";
8122
+ const mode = options?.cleanup ? "cleanup (sessions will be stopped)" : "quick (sessions preserved for auto-restore)";
8123
+ const pidsToSignal = [];
8124
+ const supervisorPidFile = join(SVAMP_HOME, "supervisor.pid");
8125
+ try {
8126
+ if (existsSync$1(supervisorPidFile)) {
8127
+ const supervisorPid = parseInt(readFileSync$1(supervisorPidFile, "utf-8").trim(), 10);
8128
+ if (supervisorPid && !isNaN(supervisorPid)) {
8129
+ try {
8130
+ process.kill(supervisorPid, 0);
8131
+ pidsToSignal.push(supervisorPid);
8132
+ } catch {
8133
+ }
8134
+ }
8135
+ }
8136
+ } catch {
8137
+ }
7900
8138
  const state = readDaemonStateFile();
7901
- if (!state) {
8139
+ if (state) {
8140
+ try {
8141
+ process.kill(state.pid, 0);
8142
+ if (!pidsToSignal.includes(state.pid)) {
8143
+ pidsToSignal.push(state.pid);
8144
+ }
8145
+ } catch {
8146
+ }
8147
+ }
8148
+ if (pidsToSignal.length === 0) {
8149
+ try {
8150
+ const { execSync } = await import('child_process');
8151
+ const pgrepOutput = execSync(
8152
+ 'pgrep -f "svamp daemon start-(supervised|sync)" 2>/dev/null || true',
8153
+ { encoding: "utf-8" }
8154
+ ).trim();
8155
+ if (pgrepOutput) {
8156
+ for (const line of pgrepOutput.split("\n")) {
8157
+ const pid = parseInt(line.trim(), 10);
8158
+ if (pid && !isNaN(pid) && pid !== process.pid) {
8159
+ pidsToSignal.push(pid);
8160
+ }
8161
+ }
8162
+ }
8163
+ } catch {
8164
+ }
8165
+ }
8166
+ if (pidsToSignal.length === 0) {
7902
8167
  console.log("No daemon running");
8168
+ cleanupDaemonStateFile();
7903
8169
  return;
7904
8170
  }
7905
- const signal = options?.cleanup ? "SIGUSR1" : "SIGTERM";
7906
- const mode = options?.cleanup ? "cleanup (sessions will be stopped)" : "quick (sessions preserved for auto-restore)";
7907
- try {
7908
- process.kill(state.pid, 0);
7909
- process.kill(state.pid, signal);
7910
- console.log(`Sent ${signal} to daemon PID ${state.pid} \u2014 ${mode}`);
7911
- for (let i = 0; i < 100; i++) {
7912
- await new Promise((r) => setTimeout(r, 100));
8171
+ for (const pid of pidsToSignal) {
8172
+ try {
8173
+ process.kill(pid, signal);
8174
+ console.log(`Sent ${signal} to PID ${pid} \u2014 ${mode}`);
8175
+ } catch {
8176
+ console.log(`PID ${pid} already gone`);
8177
+ }
8178
+ }
8179
+ pidsToSignal[0];
8180
+ for (let i = 0; i < 100; i++) {
8181
+ await new Promise((r) => setTimeout(r, 100));
8182
+ const anyAlive = pidsToSignal.some((pid) => {
7913
8183
  try {
7914
- process.kill(state.pid, 0);
8184
+ process.kill(pid, 0);
8185
+ return true;
8186
+ } catch {
8187
+ return false;
8188
+ }
8189
+ });
8190
+ if (!anyAlive) {
8191
+ console.log("Daemon stopped");
8192
+ cleanupDaemonStateFile();
8193
+ try {
8194
+ if (existsSync$1(supervisorPidFile)) await import('fs').then((fs2) => fs2.promises.unlink(supervisorPidFile));
7915
8195
  } catch {
7916
- console.log("Daemon stopped");
7917
- cleanupDaemonStateFile();
7918
- return;
7919
8196
  }
8197
+ return;
8198
+ }
8199
+ }
8200
+ console.log("Daemon did not stop in time, sending SIGKILL");
8201
+ for (const pid of pidsToSignal) {
8202
+ try {
8203
+ process.kill(pid, "SIGKILL");
8204
+ } catch {
7920
8205
  }
7921
- console.log("Daemon did not stop in time, sending SIGKILL");
7922
- process.kill(state.pid, "SIGKILL");
7923
- } catch {
7924
- console.log("Daemon is not running (stale state file)");
7925
8206
  }
7926
8207
  cleanupDaemonStateFile();
8208
+ try {
8209
+ if (existsSync$1(supervisorPidFile)) await import('fs').then((fs2) => fs2.promises.unlink(supervisorPidFile));
8210
+ } catch {
8211
+ }
7927
8212
  }
7928
8213
  function daemonStatus() {
7929
8214
  const state = readDaemonStateFile();