svamp-cli 0.2.129 → 0.2.131

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,9 +1,10 @@
1
1
  import { spawn, execSync } from 'child_process';
2
+ import { createServer } from 'net';
2
3
  import { mkdirSync, writeFileSync, unlinkSync, existsSync, chmodSync, readFileSync } from 'fs';
3
4
  import { join } from 'path';
4
5
  import { homedir, platform, arch } from 'os';
5
- import { createHash, randomUUID } from 'crypto';
6
- import { h as getFrpsSubdomainHost, i as getFrpsServerPort, j as getFrpsServerAddr } from './run-BaTwfE1Q.mjs';
6
+ import { randomUUID, createHash } from 'crypto';
7
+ import { h as getFrpsSubdomainHost, i as getFrpsServerPort, j as getFrpsServerAddr } from './run-BIwWpHSR.mjs';
7
8
  import 'fs/promises';
8
9
  import 'url';
9
10
  import 'node:crypto';
@@ -124,7 +125,7 @@ async function ensureFrpc(log) {
124
125
  }
125
126
  return FRPC_BIN;
126
127
  }
127
- function generateFrpcConfig(config, proxies) {
128
+ function generateFrpcConfig(config, proxies, admin) {
128
129
  const useWSS = config.serverPort === 443;
129
130
  const lines = [
130
131
  "# Auto-generated by svamp \u2014 do not edit",
@@ -154,6 +155,14 @@ function generateFrpcConfig(config, proxies) {
154
155
  'log.level = "info"',
155
156
  ""
156
157
  ];
158
+ if (admin) {
159
+ lines.push("# Local admin/status API (loopback only \u2014 polled by the svamp daemon)");
160
+ lines.push('webServer.addr = "127.0.0.1"');
161
+ lines.push(`webServer.port = ${admin.port}`);
162
+ lines.push(`webServer.user = "${admin.user}"`);
163
+ lines.push(`webServer.password = "${admin.password}"`);
164
+ lines.push("");
165
+ }
157
166
  for (const proxy of proxies) {
158
167
  lines.push(`[[proxies]]`);
159
168
  lines.push(`name = "${proxy.name}"`);
@@ -189,6 +198,42 @@ function generateFrpcConfig(config, proxies) {
189
198
  }
190
199
  return lines.join("\n");
191
200
  }
201
+ function getFreePort() {
202
+ return new Promise((resolve, reject) => {
203
+ const srv = createServer();
204
+ srv.unref();
205
+ srv.on("error", reject);
206
+ srv.listen(0, "127.0.0.1", () => {
207
+ const addr = srv.address();
208
+ const port = typeof addr === "object" && addr ? addr.port : 0;
209
+ srv.close(() => port ? resolve(port) : reject(new Error("no port")));
210
+ });
211
+ });
212
+ }
213
+ function parseFrpcStatus(payload, proxyNames) {
214
+ const byName = /* @__PURE__ */ new Map();
215
+ if (payload && typeof payload === "object") {
216
+ for (const group of Object.values(payload)) {
217
+ if (!Array.isArray(group)) continue;
218
+ for (const p of group) {
219
+ if (p && typeof p.name === "string") {
220
+ byName.set(p.name, { status: String(p.status ?? ""), err: p.err ? String(p.err) : void 0 });
221
+ }
222
+ }
223
+ }
224
+ }
225
+ const missing = [];
226
+ const failed = [];
227
+ for (const name of proxyNames) {
228
+ const entry = byName.get(name);
229
+ if (!entry) {
230
+ missing.push(name);
231
+ continue;
232
+ }
233
+ if (entry.status !== "running") failed.push({ name, status: entry.status, err: entry.err });
234
+ }
235
+ return { allRunning: missing.length === 0 && failed.length === 0, missing, failed };
236
+ }
192
237
  class FrpcTunnel {
193
238
  process = null;
194
239
  _connected = false;
@@ -211,6 +256,11 @@ class FrpcTunnel {
211
256
  _lastProbeOkAt = 0;
212
257
  _lastProbeFailAt = 0;
213
258
  _probeOk = false;
259
+ // frpc admin/status API state (set when options.adminStatus is true).
260
+ _adminPort = 0;
261
+ _adminUser = "svamp";
262
+ _adminPassword = randomUUID();
263
+ _statusTimer = null;
214
264
  constructor(options) {
215
265
  this.options = options;
216
266
  this.log = options.log || ((msg) => console.log(`[FRPC] ${msg}`));
@@ -255,7 +305,18 @@ class FrpcTunnel {
255
305
  if (this._destroyed) return;
256
306
  if (this.process) return;
257
307
  const frpcPath = await ensureFrpc(this.log);
258
- const configContent = generateFrpcConfig(this.serverConfig, this.proxies);
308
+ let admin;
309
+ if (this.options.adminStatus) {
310
+ if (!this._adminPort) {
311
+ try {
312
+ this._adminPort = await getFreePort();
313
+ } catch (err) {
314
+ this.log(`admin port alloc failed: ${err?.message ?? err}`);
315
+ }
316
+ }
317
+ if (this._adminPort) admin = { port: this._adminPort, user: this._adminUser, password: this._adminPassword };
318
+ }
319
+ const configContent = generateFrpcConfig(this.serverConfig, this.proxies, admin);
259
320
  writeFileSync(this.configPath, configContent);
260
321
  this.log(`Config written to ${this.configPath}`);
261
322
  return new Promise((resolve, reject) => {
@@ -335,6 +396,8 @@ class FrpcTunnel {
335
396
  }
336
397
  }
337
398
  });
399
+ } else if (this.options.adminStatus && this._adminPort) {
400
+ this.startAdminStatusLoop();
338
401
  }
339
402
  setTimeout(() => {
340
403
  if (!resolved) {
@@ -433,11 +496,86 @@ class FrpcTunnel {
433
496
  this._probeTimer = null;
434
497
  }
435
498
  }
499
+ /**
500
+ * Poll frpc's loopback admin API (`/api/status`) to track whether this
501
+ * tunnel's proxies are registered ("running"). Feeds the same `_probeOk` /
502
+ * staleness fields the daemon health loop watches, so a ghosted/stuck proxy
503
+ * is detected and recreated even with no HTTP health endpoint on the backend.
504
+ *
505
+ * Conservative semantics (critical infra — must not churn healthy tunnels):
506
+ * - all proxies "running" → ok (refresh lastProbeOkAt)
507
+ * - a proxy present but not running, or missing after a grace period
508
+ * → fail (sets lastProbeFailAt; daemon recreates after staleness)
509
+ * - admin API unreachable → INCONCLUSIVE: leave _probeOk untouched
510
+ * (a transient blip can't flip ok→fail)
511
+ */
512
+ startAdminStatusLoop() {
513
+ this.stopAdminStatusLoop();
514
+ if (!this._adminPort) return;
515
+ const proxyNames = this.proxies.map((p) => p.name);
516
+ const intervalMs = this.options.probeIntervalMs ?? 3e4;
517
+ const startedAt = Date.now();
518
+ const auth = "Basic " + Buffer.from(`${this._adminUser}:${this._adminPassword}`).toString("base64");
519
+ this._probeOk = true;
520
+ this._lastProbeOkAt = Date.now();
521
+ let inFlight = false;
522
+ const poll = async () => {
523
+ if (this._destroyed || inFlight || !this.process) return;
524
+ inFlight = true;
525
+ try {
526
+ const ctrl = new AbortController();
527
+ const timer = setTimeout(() => ctrl.abort(), 5e3);
528
+ let payload;
529
+ try {
530
+ const resp = await fetch(`http://127.0.0.1:${this._adminPort}/api/status`, {
531
+ headers: { Authorization: auth },
532
+ signal: ctrl.signal
533
+ });
534
+ if (!resp.ok) throw new Error(`admin status ${resp.status}`);
535
+ payload = await resp.json();
536
+ } finally {
537
+ clearTimeout(timer);
538
+ }
539
+ const { allRunning, missing, failed } = parseFrpcStatus(payload, proxyNames);
540
+ const graceElapsed = Date.now() - startedAt > 2e4;
541
+ if (allRunning || !failed.length && !graceElapsed) {
542
+ const wasFailing = !this._probeOk;
543
+ this._probeOk = true;
544
+ this._lastProbeOkAt = Date.now();
545
+ if (wasFailing) this.log(`admin status ok: all proxies running`);
546
+ } else {
547
+ const wasOk = this._probeOk;
548
+ this._probeOk = false;
549
+ this._lastProbeFailAt = Date.now();
550
+ if (wasOk) {
551
+ const detail = [
552
+ ...failed.map((f) => `${f.name}=${f.status}${f.err ? ` (${f.err})` : ""}`),
553
+ ...missing.map((m) => `${m}=missing`)
554
+ ].join(", ");
555
+ this.log(`admin status fail: ${detail}`);
556
+ this.options.onProbeFail?.(new Error(`frpc proxy not running: ${detail}`));
557
+ }
558
+ }
559
+ } catch {
560
+ } finally {
561
+ inFlight = false;
562
+ }
563
+ };
564
+ if (intervalMs > 0) this._statusTimer = setInterval(poll, intervalMs);
565
+ setTimeout(() => void poll(), 3e3);
566
+ }
567
+ stopAdminStatusLoop() {
568
+ if (this._statusTimer) {
569
+ clearInterval(this._statusTimer);
570
+ this._statusTimer = null;
571
+ }
572
+ }
436
573
  /** Disconnect and stop the frpc process. */
437
574
  destroy() {
438
575
  this._destroyed = true;
439
576
  this._connected = false;
440
577
  this.stopProbeLoop();
578
+ this.stopAdminStatusLoop();
441
579
  if (this.process) {
442
580
  this.process.kill("SIGTERM");
443
581
  const p = this.process;
@@ -473,8 +611,8 @@ class FrpcTunnel {
473
611
  firstErrorAt: this._firstErrorAt,
474
612
  restartAttempts: this._restartAttempts,
475
613
  failingDurationMs: this._firstErrorAt > 0 ? Date.now() - this._firstErrorAt : 0,
476
- probe: this.resolveProbeUrl() ? {
477
- url: this.resolveProbeUrl(),
614
+ probe: this.resolveProbeUrl() || this.options.adminStatus && this._adminPort ? {
615
+ url: this.resolveProbeUrl() || `frpc-admin:${this._adminPort}/api/status`,
478
616
  ok: this._probeOk,
479
617
  lastOkAt: this._lastProbeOkAt,
480
618
  lastFailAt: this._lastProbeFailAt,
@@ -485,7 +623,8 @@ class FrpcTunnel {
485
623
  /** Update the Hypha token. Rewrites config; takes effect on next frpc restart. */
486
624
  updateToken(newToken) {
487
625
  this.serverConfig.hyphaToken = newToken;
488
- const configContent = generateFrpcConfig(this.serverConfig, this.proxies);
626
+ const admin = this.options.adminStatus && this._adminPort ? { port: this._adminPort, user: this._adminUser, password: this._adminPassword } : void 0;
627
+ const configContent = generateFrpcConfig(this.serverConfig, this.proxies, admin);
489
628
  writeFileSync(this.configPath, configContent);
490
629
  this.log("Config updated with fresh token");
491
630
  }
@@ -539,4 +678,4 @@ async function runFrpcTunnel(name, ports, serverConfig, tunnelOptions) {
539
678
  }
540
679
  }
541
680
 
542
- export { FrpcTunnel, ensureFrpc, generateFrpcConfig, runFrpcTunnel };
681
+ export { FrpcTunnel, ensureFrpc, generateFrpcConfig, getFreePort, parseFrpcStatus, runFrpcTunnel };
@@ -1,5 +1,5 @@
1
- import { D as resolveModel, V as describeMisconfiguration, W as buildMachineDeps } from './run-BaTwfE1Q.mjs';
2
- import { handleRealtimeEvent, initMachineVoiceSession } from './sideband-D_Hzijan.mjs';
1
+ import { D as resolveModel, V as describeMisconfiguration, W as buildMachineDeps } from './run-BIwWpHSR.mjs';
2
+ import { handleRealtimeEvent, initMachineVoiceSession } from './sideband-BnEpfp8h.mjs';
3
3
  import { WebSocket } from 'ws';
4
4
  import { execSync, spawn } from 'child_process';
5
5
  import 'os';
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export { c as connectToHypha, a as createSessionStore, d as daemonStatus, g as getHyphaServerUrl, r as registerMachineService, s as startDaemon, b as stopDaemon } from './run-BaTwfE1Q.mjs';
1
+ export { c as connectToHypha, a as createSessionStore, d as daemonStatus, g as getHyphaServerUrl, r as registerMachineService, s as startDaemon, b as stopDaemon } from './run-BIwWpHSR.mjs';
2
2
  import 'os';
3
3
  import 'fs/promises';
4
4
  import 'fs';
@@ -1,5 +1,5 @@
1
1
  var name = "svamp-cli";
2
- var version = "0.2.129";
2
+ var version = "0.2.131";
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";
@@ -19,7 +19,7 @@ var exports$1 = {
19
19
  var scripts = {
20
20
  build: "rm -rf dist bin/skills && mkdir -p bin/skills && cp -r ../../skills/artifact bin/skills/artifact && cp -r ../../skills/loop bin/skills/loop && cp -r ../../skills/crew bin/skills/crew && tsc --noEmit && pkgroll",
21
21
  typecheck: "tsc --noEmit",
22
- test: "npx tsx test/test-context-window.mjs && npx tsx test/test-instance-config.mjs && npx tsx test/test-authorize.mjs && npx tsx test/test-normalize-allowed-user.mjs && npx tsx test/test-share-url.mjs && npx tsx test/test-update-sharing-normalization.mjs && npx tsx test/test-staged-homes-sweep.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-isolation-decision.mjs && npx tsx test/test-loop-activation.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-claude-auth.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-inbox-guard.mjs && npx tsx test/test-auto-topic.mjs && npx tsx test/test-project-info.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-session-send-query.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-supervisor-lock.mjs && node test/test-supervisor-restart.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs && npx tsx test/test-inbox.mjs && npx tsx test/test-checklist.mjs && npx tsx test/test-short-id.mjs && npx tsx test/test-transcript-edit.mjs && npx tsx test/test-edit-history.mjs && npx tsx test/test-friendly-name.mjs && npx tsx test/test-session-rpc-dispatch.mjs && npx tsx test/test-sandbox-cli.mjs && npx tsx test/test-serve-manager.mjs && npx tsx test/test-serve-stability.mjs && npx tsx test/test-frpc-e2e.mjs --unit-only && node test/pinnedClaudeCode.test.mjs && node test/fleet.test.mjs && npx tsx test/test-routine.mjs && npx tsx test/test-routine-rpc.mjs && npx tsx test/test-checklist-watchdog.mjs && npx tsx test/test-session-file.mjs && npx tsx test/test-channel-rpc.mjs && npx tsx test/test-wise-agent.mjs && npx tsx test/test-channel-agent.mjs && npx tsx test/test-channels-service.mjs && npx tsx test/test-channel-async-reply.mjs && npx tsx test/test-channel-binding.mjs && npx tsx test/test-channel-identity.mjs && npx tsx test/test-wise-agent-auth.mjs && npx tsx test/test-channel-http.mjs && npx tsx test/test-wise-voice.mjs && npx tsx test/test-wise-headless.mjs && npx tsx test/test-wise-machine.mjs && npx tsx test/test-crew-merge.mjs",
22
+ test: "npx tsx test/test-context-window.mjs && npx tsx test/test-instance-config.mjs && npx tsx test/test-authorize.mjs && npx tsx test/test-normalize-allowed-user.mjs && npx tsx test/test-share-url.mjs && npx tsx test/test-update-sharing-normalization.mjs && npx tsx test/test-staged-homes-sweep.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-isolation-decision.mjs && npx tsx test/test-loop-activation.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-claude-auth.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-inbox-guard.mjs && npx tsx test/test-auto-topic.mjs && npx tsx test/test-project-info.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-session-send-query.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-supervisor-lock.mjs && node test/test-supervisor-restart.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs && npx tsx test/test-inbox.mjs && npx tsx test/test-checklist.mjs && npx tsx test/test-short-id.mjs && npx tsx test/test-transcript-edit.mjs && npx tsx test/test-edit-history.mjs && npx tsx test/test-friendly-name.mjs && npx tsx test/test-session-rpc-dispatch.mjs && npx tsx test/test-sandbox-cli.mjs && npx tsx test/test-serve-manager.mjs && npx tsx test/test-serve-stability.mjs && npx tsx test/test-frpc-e2e.mjs --unit-only && npx tsx test/test-frpc-status.mjs && node test/pinnedClaudeCode.test.mjs && node test/fleet.test.mjs && npx tsx test/test-routine.mjs && npx tsx test/test-routine-rpc.mjs && npx tsx test/test-checklist-watchdog.mjs && npx tsx test/test-session-file.mjs && npx tsx test/test-channel-rpc.mjs && npx tsx test/test-wise-agent.mjs && npx tsx test/test-channel-agent.mjs && npx tsx test/test-channels-service.mjs && npx tsx test/test-channel-async-reply.mjs && npx tsx test/test-channel-binding.mjs && npx tsx test/test-channel-identity.mjs && npx tsx test/test-wise-agent-auth.mjs && npx tsx test/test-channel-http.mjs && npx tsx test/test-wise-voice.mjs && npx tsx test/test-wise-headless.mjs && npx tsx test/test-wise-machine.mjs && npx tsx test/test-crew-merge.mjs",
23
23
  "test:hypha": "node --no-warnings test/test-hypha-service.mjs",
24
24
  dev: "tsx src/cli.ts",
25
25
  "dev:daemon": "tsx src/cli.ts daemon start-sync",
@@ -2677,7 +2677,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
2677
2677
  const tunnels = handlers.tunnels;
2678
2678
  if (!tunnels) throw new Error("Tunnel management not available");
2679
2679
  if (tunnels.has(params.name)) throw new Error(`Tunnel '${params.name}' already running`);
2680
- const { FrpcTunnel } = await import('./frpc-BpBOWRbL.mjs');
2680
+ const { FrpcTunnel } = await import('./frpc-DjCP6NoG.mjs');
2681
2681
  const tunnel = new FrpcTunnel({
2682
2682
  name: params.name,
2683
2683
  ports: params.ports,
@@ -2686,6 +2686,10 @@ async function registerMachineService(server, machineId, metadata, daemonState,
2686
2686
  healthCheckType: params.healthCheckType,
2687
2687
  healthCheckPath: params.healthCheckPath,
2688
2688
  healthCheckInterval: params.healthCheckInterval,
2689
+ // Poll frpc's loopback admin API for backend-agnostic
2690
+ // ghost/stuck-proxy detection (the daemon health loop recreates
2691
+ // on persistent failure).
2692
+ adminStatus: true,
2689
2693
  onError: (err) => console.error(`[FRPC] ${params.name}: ${err.message}`),
2690
2694
  onConnect: () => console.log(`[FRPC] ${params.name}: connected`),
2691
2695
  onDisconnect: () => console.log(`[FRPC] ${params.name}: disconnected, will auto-reconnect`)
@@ -2718,14 +2722,47 @@ async function registerMachineService(server, machineId, metadata, daemonState,
2718
2722
  handlers.forgetExposedTunnel?.(params.name);
2719
2723
  return { name: params.name, stopped: true };
2720
2724
  },
2721
- /** List active tunnels with health status. */
2725
+ /**
2726
+ * List the full CONFIGURED set of daemon-managed tunnels with a
2727
+ * derived per-tunnel `state`, not just the ones currently live. The
2728
+ * configured set comes from the persisted specs (survives restarts +
2729
+ * mid-recreate gaps); each is joined with its live FrpcTunnel status
2730
+ * if present. A tunnel that's persisted but not live (failed restore,
2731
+ * recreating) shows up as `reconnecting` instead of silently vanishing.
2732
+ */
2722
2733
  tunnelList: async (context) => {
2723
2734
  authorizeRequest(context, currentMetadata.sharing, "view");
2724
2735
  const tunnels = handlers.tunnels;
2725
2736
  if (!tunnels) return [];
2726
- return Array.from(tunnels.entries()).map(([, tunnel]) => ({
2727
- ...tunnel.status
2728
- }));
2737
+ const specs = handlers.listExposedTunnels?.() ?? [];
2738
+ const seen = /* @__PURE__ */ new Set();
2739
+ const rows = [];
2740
+ const stateOf = (s) => {
2741
+ if (!s) return "reconnecting";
2742
+ if (s.probe && !s.probe.ok) return "failed";
2743
+ if (s.connected) return "connected";
2744
+ return s.restartAttempts > 0 ? "reconnecting" : "failed";
2745
+ };
2746
+ for (const spec of specs) {
2747
+ seen.add(spec.name);
2748
+ const tunnel = tunnels.get(spec.name);
2749
+ const status = tunnel ? tunnel.status : null;
2750
+ rows.push({
2751
+ name: spec.name,
2752
+ ports: spec.ports,
2753
+ group: spec.group,
2754
+ configured: true,
2755
+ live: !!tunnel,
2756
+ state: stateOf(status),
2757
+ ...status ?? {}
2758
+ });
2759
+ }
2760
+ for (const [name, tunnel] of tunnels) {
2761
+ if (seen.has(name)) continue;
2762
+ const status = tunnel.status;
2763
+ rows.push({ ...status, name, configured: false, live: true, state: stateOf(status) });
2764
+ }
2765
+ return rows;
2729
2766
  },
2730
2767
  // ── Shared static file server ────────────────────────────────────
2731
2768
  /** Add a mount to the shared static file server. */
@@ -2938,7 +2975,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
2938
2975
  }
2939
2976
  const deps = buildSessionDeps(rpc, { cwd, ownerEmail: owner });
2940
2977
  const sender = { name: context?.user?.email || context?.user?.id || "user", kind: "user", verified: true };
2941
- const { toolsForRole } = await import('./sideband-D_Hzijan.mjs');
2978
+ const { toolsForRole } = await import('./sideband-BnEpfp8h.mjs');
2942
2979
  const r2 = await runWiseAgent({ message: params.message, sender, config: { tools: toolsForRole(role2) }, deps, transport, model: resolved.model });
2943
2980
  return fmt(r2);
2944
2981
  }
@@ -3037,7 +3074,7 @@ QUESTION: ${params.question || "Summarize this concisely."}` }
3037
3074
  if (r.error || !r.sender) return { error: r.error || "unauthorized" };
3038
3075
  const callId = "call_" + Math.random().toString(16).slice(2, 12);
3039
3076
  const rendered = renderMessage(c, { sender: r.sender, body: { message: kwargs.message }, callId });
3040
- const { queryCore } = await import('./commands-BV30A1zt.mjs');
3077
+ const { queryCore } = await import('./commands-CP0LaCNP.mjs');
3041
3078
  const timeout = c.reply?.timeout_sec || 120;
3042
3079
  let result;
3043
3080
  try {
@@ -3519,9 +3556,20 @@ function patchStoredText(msg, newText) {
3519
3556
  return true;
3520
3557
  }
3521
3558
  if (data?.type === "assistant" && Array.isArray(data?.message?.content)) {
3522
- const textBlocks = data.message.content.filter((b) => b && b.type === "text" && typeof b.text === "string");
3523
- if (textBlocks.length === 1) {
3559
+ const content = data.message.content;
3560
+ const isText = (b) => b && b.type === "text" && typeof b.text === "string";
3561
+ const textBlocks = content.filter(isText);
3562
+ if (textBlocks.length >= 1) {
3524
3563
  textBlocks[0].text = newText;
3564
+ let seen = false;
3565
+ data.message.content = content.filter((b) => {
3566
+ if (!isText(b)) return true;
3567
+ if (!seen) {
3568
+ seen = true;
3569
+ return true;
3570
+ }
3571
+ return false;
3572
+ });
3525
3573
  return true;
3526
3574
  }
3527
3575
  }
@@ -7734,11 +7782,21 @@ function applyTranscriptEdit(file, index, newText) {
7734
7782
  if (target.type === "assistant") {
7735
7783
  const content = target?.message?.content;
7736
7784
  if (!Array.isArray(content)) return { ok: false, reason: "assistant content is not a block array" };
7737
- const textBlocks = content.filter((b) => b && b.type === "text" && typeof b.text === "string");
7738
- if (textBlocks.length !== 1) {
7739
- return { ok: false, reason: `expected exactly 1 text block, found ${textBlocks.length}` };
7785
+ const isText = (b) => b && b.type === "text" && typeof b.text === "string";
7786
+ const textBlocks = content.filter(isText);
7787
+ if (textBlocks.length === 0) {
7788
+ return { ok: false, reason: "expected at least 1 text block, found 0" };
7740
7789
  }
7741
7790
  textBlocks[0].text = newText;
7791
+ let seen = false;
7792
+ target.message.content = content.filter((b) => {
7793
+ if (!isText(b)) return true;
7794
+ if (!seen) {
7795
+ seen = true;
7796
+ return true;
7797
+ }
7798
+ return false;
7799
+ });
7742
7800
  } else if (target.type === "user") {
7743
7801
  if (typeof target?.message?.content !== "string") {
7744
7802
  return { ok: false, reason: "user content is not a string" };
@@ -11328,7 +11386,28 @@ async function startDaemon(options) {
11328
11386
  const list = loadExposedTunnels().filter((t) => t.name !== name);
11329
11387
  saveExposedTunnels(list);
11330
11388
  }
11331
- const { ServeManager } = await import('./serveManager-cPgahjYE.mjs');
11389
+ async function createExposedTunnel(spec) {
11390
+ const { FrpcTunnel } = await import('./frpc-DjCP6NoG.mjs');
11391
+ const tunnel = new FrpcTunnel({
11392
+ name: spec.name,
11393
+ ports: spec.ports,
11394
+ group: spec.group,
11395
+ groupKey: spec.groupKey,
11396
+ healthCheckType: spec.healthCheckType,
11397
+ healthCheckPath: spec.healthCheckPath,
11398
+ healthCheckInterval: spec.healthCheckInterval,
11399
+ // Backend-agnostic ghost/stuck-proxy detection via frpc's loopback
11400
+ // admin API — exposed backends rarely have an HTTP health endpoint.
11401
+ adminStatus: true,
11402
+ onError: (err) => logger.log(`[FRPC ${spec.name}] ${err.message}`),
11403
+ onConnect: () => logger.log(`[FRPC ${spec.name}] connected`),
11404
+ onDisconnect: () => logger.log(`[FRPC ${spec.name}] disconnected, will auto-reconnect`)
11405
+ });
11406
+ await tunnel.connect();
11407
+ return tunnel;
11408
+ }
11409
+ const tunnelRecreateState = /* @__PURE__ */ new Map();
11410
+ const { ServeManager } = await import('./serveManager-D7_YZY-E.mjs');
11332
11411
  const serveManager = new ServeManager(SVAMP_HOME, (msg) => logger.log(`[SERVE] ${msg}`), hyphaServerUrl);
11333
11412
  ensureAutoInstalledSkills(logger).catch(() => {
11334
11413
  });
@@ -13988,7 +14067,8 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
13988
14067
  serveManager,
13989
14068
  sharingNotificationSync,
13990
14069
  persistExposedTunnel,
13991
- forgetExposedTunnel
14070
+ forgetExposedTunnel,
14071
+ listExposedTunnels: () => loadExposedTunnels().map((t) => ({ name: t.name, ports: t.ports, group: t.group, addedAt: t.addedAt }))
13992
14072
  }
13993
14073
  );
13994
14074
  logger.log(`Machine service registered: svamp-machine-${machineId}`);
@@ -14016,23 +14096,10 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
14016
14096
  const specs = loadExposedTunnels();
14017
14097
  if (specs.length === 0) return;
14018
14098
  logger.log(`[exposed-tunnels] Restoring ${specs.length} tunnel(s) from ${EXPOSED_TUNNELS_FILE}`);
14019
- const { FrpcTunnel } = await import('./frpc-BpBOWRbL.mjs');
14020
14099
  for (const spec of specs) {
14021
14100
  if (tunnels.has(spec.name)) continue;
14022
14101
  try {
14023
- const tunnel = new FrpcTunnel({
14024
- name: spec.name,
14025
- ports: spec.ports,
14026
- group: spec.group,
14027
- groupKey: spec.groupKey,
14028
- healthCheckType: spec.healthCheckType,
14029
- healthCheckPath: spec.healthCheckPath,
14030
- healthCheckInterval: spec.healthCheckInterval,
14031
- onError: (err) => logger.log(`[FRPC ${spec.name}] ${err.message}`),
14032
- onConnect: () => logger.log(`[FRPC ${spec.name}] connected (restored)`),
14033
- onDisconnect: () => logger.log(`[FRPC ${spec.name}] disconnected, will auto-reconnect`)
14034
- });
14035
- await tunnel.connect();
14102
+ const tunnel = await createExposedTunnel(spec);
14036
14103
  tunnels.set(spec.name, tunnel);
14037
14104
  logger.log(`[exposed-tunnels] Restored: ${spec.name} \u2192 ports ${spec.ports.join(",")}`);
14038
14105
  } catch (err) {
@@ -14124,7 +14191,8 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
14124
14191
  }
14125
14192
  if (persistedSessions.length > 0) {
14126
14193
  logger.log(`Restoring ${persistedSessions.length} persisted session(s)...`);
14127
- for (const persisted of persistedSessions) {
14194
+ const restoreConcurrency = Math.max(1, parseInt(process.env.SVAMP_RESTORE_CONCURRENCY || "8", 10) || 8);
14195
+ const restoreOne = async (persisted) => {
14128
14196
  try {
14129
14197
  const isOrphaned = persisted.machineId && persisted.machineId !== machineId;
14130
14198
  if (isOrphaned) {
@@ -14175,7 +14243,19 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
14175
14243
  } catch (err) {
14176
14244
  logger.error(`Error restoring session ${persisted.sessionId}:`, err.message);
14177
14245
  }
14178
- }
14246
+ };
14247
+ let restoreCursor = 0;
14248
+ const restoreWorker = async () => {
14249
+ while (restoreCursor < persistedSessions.length) {
14250
+ const persisted = persistedSessions[restoreCursor++];
14251
+ await restoreOne(persisted);
14252
+ }
14253
+ };
14254
+ const t0 = Date.now();
14255
+ await Promise.all(
14256
+ Array.from({ length: Math.min(restoreConcurrency, persistedSessions.length) }, () => restoreWorker())
14257
+ );
14258
+ logger.log(`Restored ${persistedSessions.length} session(s) in ${Date.now() - t0}ms (concurrency ${restoreConcurrency})`);
14179
14259
  }
14180
14260
  if (sessionsToAutoContinue.length > 0 && !options?.noAutoContinue) {
14181
14261
  logger.log(`Auto-continuing ${sessionsToAutoContinue.length} interrupted session(s)...`);
@@ -14390,11 +14470,39 @@ ${capturedError}${buildClaudeErrorHint(capturedError)}`;
14390
14470
  for (const [name, tunnel] of tunnels) {
14391
14471
  const health = tunnel.status;
14392
14472
  const reason = tunnelLooksDead(health);
14393
- if (reason) {
14394
- logger.log(`frpc tunnel '${name}' ${reason} \u2014 destroying stale tunnel`);
14395
- tunnel.destroy();
14473
+ if (!reason) {
14474
+ tunnelRecreateState.delete(name);
14475
+ continue;
14476
+ }
14477
+ const spec = loadExposedTunnels().find((t) => t.name === name);
14478
+ if (!spec) {
14479
+ logger.log(`frpc tunnel '${name}' ${reason}, no persisted spec \u2014 destroying (cannot recreate)`);
14480
+ try {
14481
+ tunnel.destroy();
14482
+ } catch {
14483
+ }
14396
14484
  tunnels.delete(name);
14485
+ tunnelRecreateState.delete(name);
14486
+ continue;
14397
14487
  }
14488
+ const now = Date.now();
14489
+ const st = tunnelRecreateState.get(name) ?? { nextAttemptAt: 0, attempts: 0 };
14490
+ if (now < st.nextAttemptAt) continue;
14491
+ st.attempts++;
14492
+ st.nextAttemptAt = now + Math.min(15e3 * Math.pow(2, st.attempts - 1), 5 * 6e4);
14493
+ tunnelRecreateState.set(name, st);
14494
+ logger.log(`frpc tunnel '${name}' ${reason} \u2014 recreating (attempt ${st.attempts})`);
14495
+ try {
14496
+ tunnel.destroy();
14497
+ } catch {
14498
+ }
14499
+ tunnels.delete(name);
14500
+ createExposedTunnel(spec).then((fresh) => {
14501
+ tunnels.set(name, fresh);
14502
+ logger.log(`frpc tunnel '${name}' recreated`);
14503
+ }).catch((err) => {
14504
+ logger.log(`frpc tunnel '${name}' recreate failed: ${err.message} (will retry)`);
14505
+ });
14398
14506
  }
14399
14507
  } finally {
14400
14508
  heartbeatRunning = false;
@@ -1,4 +1,4 @@
1
- import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import { X as generateFriendlyName, m as shortId, c as connectToHypha, a as createSessionStore, r as registerMachineService, Y as generateHookSettings } from './run-BaTwfE1Q.mjs';
1
+ import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import { X as generateFriendlyName, m as shortId, c as connectToHypha, a as createSessionStore, r as registerMachineService, Y as generateHookSettings } from './run-BIwWpHSR.mjs';
2
2
  import os from 'node:os';
3
3
  import { resolve, join } from 'node:path';
4
4
  import { existsSync, readFileSync, watch } from 'node:fs';
@@ -54,7 +54,7 @@ async function handleServeCommand() {
54
54
  }
55
55
  }
56
56
  async function serveAdd(args, machineId) {
57
- const { connectAndGetMachine } = await import('./commands-BV30A1zt.mjs');
57
+ const { connectAndGetMachine } = await import('./commands-CP0LaCNP.mjs');
58
58
  const pos = positionalArgs(args);
59
59
  const name = pos[0];
60
60
  if (!name) {
@@ -93,7 +93,7 @@ async function serveAdd(args, machineId) {
93
93
  }
94
94
  }
95
95
  async function serveApply(args, machineId) {
96
- const { connectAndGetMachine } = await import('./commands-BV30A1zt.mjs');
96
+ const { connectAndGetMachine } = await import('./commands-CP0LaCNP.mjs');
97
97
  const fs = await import('fs');
98
98
  const yaml = await import('yaml');
99
99
  const file = positionalArgs(args)[0];
@@ -182,7 +182,7 @@ async function serveApply(args, machineId) {
182
182
  }
183
183
  }
184
184
  async function serveRemove(args, machineId) {
185
- const { connectAndGetMachine } = await import('./commands-BV30A1zt.mjs');
185
+ const { connectAndGetMachine } = await import('./commands-CP0LaCNP.mjs');
186
186
  const pos = positionalArgs(args);
187
187
  const name = pos[0];
188
188
  if (!name) {
@@ -202,7 +202,7 @@ async function serveRemove(args, machineId) {
202
202
  }
203
203
  }
204
204
  async function serveList(args, machineId) {
205
- const { connectAndGetMachine } = await import('./commands-BV30A1zt.mjs');
205
+ const { connectAndGetMachine } = await import('./commands-CP0LaCNP.mjs');
206
206
  const all = hasFlag(args, "--all", "-a");
207
207
  const json = hasFlag(args, "--json");
208
208
  const sessionId = getFlag(args, "--session");
@@ -235,7 +235,7 @@ async function serveList(args, machineId) {
235
235
  }
236
236
  }
237
237
  async function serveInfo(machineId) {
238
- const { connectAndGetMachine } = await import('./commands-BV30A1zt.mjs');
238
+ const { connectAndGetMachine } = await import('./commands-CP0LaCNP.mjs');
239
239
  const { machine, server } = await connectAndGetMachine(machineId);
240
240
  try {
241
241
  const info = await machine.serveInfo();
@@ -4,7 +4,7 @@ import * as fs from 'fs';
4
4
  import * as http from 'http';
5
5
  import * as net from 'net';
6
6
  import * as path from 'path';
7
- import { k as getHyphaServerUrl, S as ServeAuth, l as hasCookieToken } from './run-BaTwfE1Q.mjs';
7
+ import { k as getHyphaServerUrl, S as ServeAuth, l as hasCookieToken } from './run-BIwWpHSR.mjs';
8
8
  import 'os';
9
9
  import 'fs/promises';
10
10
  import 'url';
@@ -713,7 +713,7 @@ class ServeManager {
713
713
  const mount = this.mounts.get(mountName);
714
714
  const subdomainOverride = mount?.access === "link" && mount.linkToken ? /* @__PURE__ */ new Map([[this.port, `static-${subdomainSafe}-${mount.linkToken}`]]) : void 0;
715
715
  try {
716
- const { FrpcTunnel } = await import('./frpc-BpBOWRbL.mjs');
716
+ const { FrpcTunnel } = await import('./frpc-DjCP6NoG.mjs');
717
717
  let tunnel;
718
718
  tunnel = new FrpcTunnel({
719
719
  name: tunnelName,
@@ -1,4 +1,4 @@
1
- import { R as READ_ONLY_TOOLS, z as loadMachineContext, A as buildMachineInstructions, B as machineToolsForRole, C as buildMachineTools } from './run-BaTwfE1Q.mjs';
1
+ import { R as READ_ONLY_TOOLS, z as loadMachineContext, A as buildMachineInstructions, B as machineToolsForRole, C as buildMachineTools } from './run-BIwWpHSR.mjs';
2
2
  import 'node:child_process';
3
3
  import 'os';
4
4
  import 'fs/promises';