svamp-cli 0.1.64 → 0.1.66

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.
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { execSync } from 'node:child_process';
3
3
  import { resolve, join } from 'node:path';
4
4
  import os from 'node:os';
5
- import { l as loadSecurityContextConfig, e as resolveSecurityContext, f as buildSecurityContextFromFlags, m as mergeSecurityContexts, c as connectToHypha } from './run-BImPgXHd.mjs';
5
+ import { l as loadSecurityContextConfig, e as resolveSecurityContext, f as buildSecurityContextFromFlags, m as mergeSecurityContexts, c as connectToHypha } from './run-BDmLH9T8.mjs';
6
6
  import 'os';
7
7
  import 'fs/promises';
8
8
  import 'fs';
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export { c as connectToHypha, d as daemonStatus, g as getHyphaServerUrl, r as registerMachineService, a as registerSessionService, s as startDaemon, b as stopDaemon } from './run-BImPgXHd.mjs';
1
+ export { c as connectToHypha, d as daemonStatus, g as getHyphaServerUrl, r as registerMachineService, a as registerSessionService, s as startDaemon, b as stopDaemon } from './run-BDmLH9T8.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.1.64";
2
+ var version = "0.1.66";
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 && tsc --noEmit && pkgroll",
21
21
  typecheck: "tsc --noEmit",
22
- test: "npx tsx test/test-authorize.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-ralph-loop.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-output-formatters.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-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-ralph-loop-integration.mjs && npx tsx test/test-ralph-loop-modes.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs",
22
+ test: "npx tsx test/test-authorize.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-ralph-loop.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-output-formatters.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-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-ralph-loop-integration.mjs && npx tsx test/test-ralph-loop-modes.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-clear-detection.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",
@@ -385,21 +385,22 @@ async function registerMachineService(server, machineId, metadata, daemonState,
385
385
  const machineOwner = currentMetadata.sharing?.owner;
386
386
  const isSharedUser = callerEmail && machineOwner && callerEmail.toLowerCase() !== machineOwner.toLowerCase();
387
387
  if (isSharedUser) {
388
- const machineUser = currentMetadata.sharing?.allowedUsers?.find(
389
- (u) => u.email.toLowerCase() === callerEmail.toLowerCase()
390
- );
391
- const callerRole = machineUser?.role || "interact";
392
388
  const sharing = {
393
389
  enabled: true,
394
- owner: machineOwner,
390
+ owner: callerEmail,
391
+ // spawning user owns their session
395
392
  allowedUsers: [
396
- ...options.sharing?.allowedUsers || [],
393
+ // Machine owner gets admin access (can monitor/control sessions on their machine)
397
394
  {
398
- email: callerEmail,
399
- role: callerRole,
395
+ email: machineOwner,
396
+ role: "admin",
400
397
  addedAt: Date.now(),
401
398
  addedBy: "machine-auto"
402
- }
399
+ },
400
+ // Preserve any explicitly requested allowedUsers (e.g. additional collaborators)
401
+ ...(options.sharing?.allowedUsers || []).filter(
402
+ (u) => u.email.toLowerCase() !== machineOwner.toLowerCase()
403
+ )
403
404
  ]
404
405
  };
405
406
  options = { ...options, sharing };
@@ -419,16 +420,11 @@ async function registerMachineService(server, machineId, metadata, daemonState,
419
420
  ...options,
420
421
  securityContext: mergeSecurityContexts(machineCtx, options.securityContext)
421
422
  };
422
- if (machineCtx.role && options.sharing?.enabled) {
423
- const user = options.sharing.allowedUsers?.find(
424
- (u) => u.email.toLowerCase() === callerEmail.toLowerCase()
425
- );
426
- if (user && !user.role) {
427
- user.role = machineCtx.role;
428
- }
429
- }
430
423
  }
431
424
  }
425
+ if (options.injectPlatformGuidance === void 0 && currentMetadata.injectPlatformGuidance !== void 0) {
426
+ options = { ...options, injectPlatformGuidance: currentMetadata.injectPlatformGuidance };
427
+ }
432
428
  const result = await handlers.spawnSession({
433
429
  ...options,
434
430
  machineId
@@ -486,7 +482,8 @@ async function registerMachineService(server, machineId, metadata, daemonState,
486
482
  metadataVersion++;
487
483
  savePersistedMachineMetadata(metadata.svampHomeDir, {
488
484
  sharing: currentMetadata.sharing,
489
- securityContextConfig: currentMetadata.securityContextConfig
485
+ securityContextConfig: currentMetadata.securityContextConfig,
486
+ injectPlatformGuidance: currentMetadata.injectPlatformGuidance
490
487
  });
491
488
  notifyListeners({
492
489
  type: "update-machine",
@@ -546,7 +543,8 @@ async function registerMachineService(server, machineId, metadata, daemonState,
546
543
  metadataVersion++;
547
544
  savePersistedMachineMetadata(metadata.svampHomeDir, {
548
545
  sharing: currentMetadata.sharing,
549
- securityContextConfig: currentMetadata.securityContextConfig
546
+ securityContextConfig: currentMetadata.securityContextConfig,
547
+ injectPlatformGuidance: currentMetadata.injectPlatformGuidance
550
548
  });
551
549
  notifyListeners({
552
550
  type: "update-machine",
@@ -567,7 +565,8 @@ async function registerMachineService(server, machineId, metadata, daemonState,
567
565
  metadataVersion++;
568
566
  savePersistedMachineMetadata(metadata.svampHomeDir, {
569
567
  sharing: currentMetadata.sharing,
570
- securityContextConfig: currentMetadata.securityContextConfig
568
+ securityContextConfig: currentMetadata.securityContextConfig,
569
+ injectPlatformGuidance: currentMetadata.injectPlatformGuidance
571
570
  });
572
571
  notifyListeners({
573
572
  type: "update-machine",
@@ -3754,13 +3753,17 @@ async function verifyNonoIsolation(binaryPath) {
3754
3753
  "-s",
3755
3754
  "--allow",
3756
3755
  workDir,
3757
- "--allow-cwd",
3756
+ // NOTE: Do NOT add --allow-cwd here. If the daemon's CWD happens to be
3757
+ // $HOME (common when started interactively), --allow-cwd would grant
3758
+ // access to $HOME, allowing the probe file write to succeed and making
3759
+ // verification incorrectly fail ("file leaked to host filesystem").
3760
+ // We already grant --allow workDir explicitly, so --allow-cwd is redundant.
3758
3761
  "--trust-override",
3759
3762
  "--",
3760
3763
  "sh",
3761
3764
  "-c",
3762
3765
  testScript
3763
- ], { timeout: 15e3 });
3766
+ ], { timeout: 15e3, cwd: workDir });
3764
3767
  return parseIsolationTestOutput(stdout, probeFile);
3765
3768
  } catch (e) {
3766
3769
  return { passed: false, error: e.message };
@@ -4408,6 +4411,83 @@ class ProcessSupervisor {
4408
4411
 
4409
4412
  const __filename$1 = fileURLToPath(import.meta.url);
4410
4413
  const __dirname$1 = dirname(__filename$1);
4414
+ const CLAUDE_SKILLS_DIR = join(os__default.homedir(), ".claude", "skills");
4415
+ async function installSkillFromEndpoint(name, baseUrl) {
4416
+ const resp = await fetch(baseUrl, { signal: AbortSignal.timeout(15e3) });
4417
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${baseUrl}`);
4418
+ const index = await resp.json();
4419
+ const files = index.files || [];
4420
+ if (files.length === 0) throw new Error(`Skill index at ${baseUrl} has no files`);
4421
+ const targetDir = join(CLAUDE_SKILLS_DIR, name);
4422
+ mkdirSync(targetDir, { recursive: true });
4423
+ for (const filePath of files) {
4424
+ if (!filePath) continue;
4425
+ const url = `${baseUrl}${filePath}`;
4426
+ const fileResp = await fetch(url, { signal: AbortSignal.timeout(3e4) });
4427
+ if (!fileResp.ok) throw new Error(`Failed to download ${filePath}: HTTP ${fileResp.status}`);
4428
+ const content = await fileResp.text();
4429
+ const localPath = join(targetDir, filePath);
4430
+ if (!localPath.startsWith(targetDir + "/")) continue;
4431
+ mkdirSync(dirname(localPath), { recursive: true });
4432
+ writeFileSync(localPath, content, "utf-8");
4433
+ }
4434
+ }
4435
+ async function installSkillFromMarketplace(name) {
4436
+ const BASE = `https://hypha.aicell.io/hypha-cloud/artifacts/${name}`;
4437
+ async function collectFiles(dir = "") {
4438
+ const url = dir ? `${BASE}/files/${dir}` : `${BASE}/files/`;
4439
+ const resp = await fetch(url, { signal: AbortSignal.timeout(15e3) });
4440
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} listing files`);
4441
+ const data = await resp.json();
4442
+ const items = Array.isArray(data) ? data : data.items || [];
4443
+ const result = [];
4444
+ for (const item of items) {
4445
+ const itemPath = dir ? `${dir}/${item.name}` : item.name;
4446
+ if (item.type === "directory") {
4447
+ result.push(...await collectFiles(itemPath));
4448
+ } else {
4449
+ result.push(itemPath);
4450
+ }
4451
+ }
4452
+ return result;
4453
+ }
4454
+ const files = await collectFiles();
4455
+ if (files.length === 0) throw new Error(`Skill ${name} has no files in marketplace`);
4456
+ const targetDir = join(CLAUDE_SKILLS_DIR, name);
4457
+ mkdirSync(targetDir, { recursive: true });
4458
+ for (const filePath of files) {
4459
+ const url = `${BASE}/files/${filePath}`;
4460
+ const resp = await fetch(url, { signal: AbortSignal.timeout(3e4) });
4461
+ if (!resp.ok) throw new Error(`Failed to download ${filePath}: HTTP ${resp.status}`);
4462
+ const content = await resp.text();
4463
+ const localPath = join(targetDir, filePath);
4464
+ if (!localPath.startsWith(targetDir + "/")) continue;
4465
+ mkdirSync(dirname(localPath), { recursive: true });
4466
+ writeFileSync(localPath, content, "utf-8");
4467
+ }
4468
+ }
4469
+ async function ensureAutoInstalledSkills(logger) {
4470
+ const tasks = [
4471
+ {
4472
+ name: "svamp",
4473
+ install: () => installSkillFromMarketplace("svamp")
4474
+ },
4475
+ {
4476
+ name: "hypha",
4477
+ install: () => installSkillFromEndpoint("hypha", "https://hypha.aicell.io/ws/agent-skills/")
4478
+ }
4479
+ ];
4480
+ for (const task of tasks) {
4481
+ const targetDir = join(CLAUDE_SKILLS_DIR, task.name);
4482
+ if (existsSync$1(targetDir)) continue;
4483
+ try {
4484
+ await task.install();
4485
+ logger.log(`[skills] Auto-installed: ${task.name}`);
4486
+ } catch (err) {
4487
+ logger.log(`[skills] Auto-install of "${task.name}" failed (non-fatal): ${err.message}`);
4488
+ }
4489
+ }
4490
+ }
4411
4491
  function loadEnvFile(path) {
4412
4492
  if (!existsSync$1(path)) return false;
4413
4493
  const lines = readFileSync$1(path, "utf-8").split("\n");
@@ -5172,6 +5252,8 @@ async function startDaemon(options) {
5172
5252
  let server = null;
5173
5253
  const supervisor = new ProcessSupervisor(join(SVAMP_HOME, "processes"));
5174
5254
  await supervisor.init();
5255
+ ensureAutoInstalledSkills(logger).catch(() => {
5256
+ });
5175
5257
  try {
5176
5258
  logger.log("Connecting to Hypha server...");
5177
5259
  server = await connectToHypha({
@@ -5328,7 +5410,12 @@ async function startDaemon(options) {
5328
5410
  sharing: options2.sharing,
5329
5411
  securityContext: options2.securityContext,
5330
5412
  tags: options2.tags,
5331
- parentSessionId: options2.parentSessionId
5413
+ parentSessionId: options2.parentSessionId,
5414
+ ...options2.parentSessionId && (() => {
5415
+ const parentTracked = Array.from(pidToTrackedSession.values()).find((t) => t.svampSessionId === options2.parentSessionId);
5416
+ return parentTracked?.directory ? { parentSessionPath: parentTracked.directory } : {};
5417
+ })(),
5418
+ ...options2.injectPlatformGuidance !== void 0 && { injectPlatformGuidance: options2.injectPlatformGuidance }
5332
5419
  };
5333
5420
  let claudeProcess = null;
5334
5421
  const allPersisted = loadPersistedSessions();
@@ -5338,11 +5425,15 @@ async function startDaemon(options) {
5338
5425
  let lastSpawnMeta = persisted?.spawnMeta || {};
5339
5426
  let sessionWasProcessing = !!options2.wasProcessing;
5340
5427
  let lastAssistantText = "";
5428
+ let spawnHasReceivedInit = false;
5341
5429
  const signalProcessing = (processing) => {
5342
5430
  sessionService.sendKeepAlive(processing);
5343
5431
  const newState = processing ? "running" : "idle";
5344
5432
  if (sessionMetadata.lifecycleState !== newState) {
5345
5433
  sessionMetadata = { ...sessionMetadata, lifecycleState: newState };
5434
+ if (!processing) {
5435
+ sessionMetadata = { ...sessionMetadata, unread: true };
5436
+ }
5346
5437
  sessionService.updateMetadata(sessionMetadata);
5347
5438
  }
5348
5439
  };
@@ -5449,6 +5540,7 @@ async function startDaemon(options) {
5449
5540
  shell: process.platform === "win32"
5450
5541
  });
5451
5542
  claudeProcess = child;
5543
+ spawnHasReceivedInit = false;
5452
5544
  logger.log(`[Session ${sessionId}] Claude PID: ${child.pid}, stdin: ${!!child.stdin}, stdout: ${!!child.stdout}, stderr: ${!!child.stderr}`);
5453
5545
  child.stdin?.on("error", (err) => {
5454
5546
  logger.log(`[Session ${sessionId}] Claude stdin error: ${err.message}`);
@@ -5807,7 +5899,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5807
5899
  }
5808
5900
  userMessagePending = false;
5809
5901
  if (msg.session_id) {
5810
- const isConversationClear = claudeResumeId && msg.session_id !== claudeResumeId;
5902
+ const isResumeFailure = !spawnHasReceivedInit && claudeResumeId && msg.session_id !== claudeResumeId;
5903
+ const isConversationClear = spawnHasReceivedInit && claudeResumeId && msg.session_id !== claudeResumeId;
5904
+ spawnHasReceivedInit = true;
5811
5905
  claudeResumeId = msg.session_id;
5812
5906
  sessionMetadata = { ...sessionMetadata, claudeSessionId: msg.session_id };
5813
5907
  sessionService.updateMetadata(sessionMetadata);
@@ -5825,7 +5919,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5825
5919
  });
5826
5920
  artifactSync.scheduleDebouncedSync(sessionId, getSessionDir(directory, sessionId), sessionMetadata, machineId);
5827
5921
  }
5828
- if (isConversationClear) {
5922
+ if (isResumeFailure) {
5923
+ logger.log(`[Session ${sessionId}] Resume failed \u2014 Claude started fresh session (tried: ${persisted?.claudeResumeId ?? "unknown"}, got: ${msg.session_id})`);
5924
+ } else if (isConversationClear) {
5829
5925
  logger.log(`[Session ${sessionId}] Conversation cleared (/clear) \u2014 new Claude session: ${msg.session_id}`);
5830
5926
  sessionService.clearMessages();
5831
5927
  sessionService.pushMessage(
@@ -5957,7 +6053,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5957
6053
  isRestartingClaude = false;
5958
6054
  }
5959
6055
  };
5960
- if (sessionMetadata.sharing?.enabled && isolationCapabilities.preferred) {
6056
+ if (sessionMetadata.sharing?.enabled) {
5961
6057
  try {
5962
6058
  stagedCredentials = await stageCredentialsForSharing(sessionId);
5963
6059
  logger.log(`[Session ${sessionId}] Credentials staged at ${stagedCredentials.homePath}`);
@@ -6954,7 +7050,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6954
7050
  const defaultHomeDir = existsSync$1("/data") ? "/data" : os__default.homedir();
6955
7051
  const persistedMachineMeta = loadPersistedMachineMetadata(SVAMP_HOME);
6956
7052
  if (persistedMachineMeta) {
6957
- logger.log(`Restored machine metadata (sharing=${!!persistedMachineMeta.sharing}, securityContextConfig=${!!persistedMachineMeta.securityContextConfig})`);
7053
+ logger.log(`Restored machine metadata (sharing=${!!persistedMachineMeta.sharing}, securityContextConfig=${!!persistedMachineMeta.securityContextConfig}, injectPlatformGuidance=${persistedMachineMeta.injectPlatformGuidance})`);
6958
7054
  }
6959
7055
  const machineMetadata = {
6960
7056
  host: os__default.hostname(),
@@ -6965,9 +7061,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6965
7061
  svampLibDir: join(__dirname$1, ".."),
6966
7062
  displayName: process.env.SVAMP_DISPLAY_NAME || void 0,
6967
7063
  isolationCapabilities,
6968
- // Restore persisted sharing & security context config
7064
+ // Restore persisted sharing, security context config, and platform guidance flag
6969
7065
  ...persistedMachineMeta?.sharing && { sharing: persistedMachineMeta.sharing },
6970
- ...persistedMachineMeta?.securityContextConfig && { securityContextConfig: persistedMachineMeta.securityContextConfig }
7066
+ ...persistedMachineMeta?.securityContextConfig && { securityContextConfig: persistedMachineMeta.securityContextConfig },
7067
+ ...persistedMachineMeta?.injectPlatformGuidance !== void 0 && { injectPlatformGuidance: persistedMachineMeta.injectPlatformGuidance }
6971
7068
  };
6972
7069
  const initialDaemonState = {
6973
7070
  status: "running",
@@ -2,7 +2,7 @@ import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(im
2
2
  import os from 'node:os';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { mkdirSync, writeFileSync, existsSync, unlinkSync, readFileSync, watch } from 'node:fs';
5
- import { c as connectToHypha, a as registerSessionService } from './run-BImPgXHd.mjs';
5
+ import { c as connectToHypha, a as registerSessionService } from './run-BDmLH9T8.mjs';
6
6
  import { createServer } from 'node:http';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { createInterface } from 'node:readline';
@@ -0,0 +1,154 @@
1
+ import { getSandboxEnv } from './api-BRbsyqJ4.mjs';
2
+
3
+ async function adminFetch(path, method = "GET", body) {
4
+ const env = getSandboxEnv();
5
+ if (!env.apiKey) {
6
+ throw new Error('No API credentials. Run "svamp login" or set SANDBOX_API_KEY.');
7
+ }
8
+ const url = `${env.apiUrl.replace(/\/+$/, "")}${path}`;
9
+ const res = await fetch(url, {
10
+ method,
11
+ headers: {
12
+ "Authorization": `Bearer ${env.apiKey}`,
13
+ "Content-Type": "application/json"
14
+ },
15
+ ...{}
16
+ });
17
+ if (!res.ok) {
18
+ const text = await res.text().catch(() => "");
19
+ throw new Error(`HTTP ${res.status} ${method} ${path}: ${text}`);
20
+ }
21
+ return res.json();
22
+ }
23
+ function fmtAge(ts) {
24
+ if (!ts) return "?";
25
+ try {
26
+ const dt = /^\d+$/.test(ts) ? new Date(parseInt(ts, 10) * 1e3) : new Date(ts);
27
+ const secs = (Date.now() - dt.getTime()) / 1e3;
28
+ const d = Math.floor(secs / 86400);
29
+ const h = Math.floor(secs % 86400 / 3600);
30
+ return d > 0 ? `${d}d ${h}h ago` : `${h}h ago`;
31
+ } catch {
32
+ return ts;
33
+ }
34
+ }
35
+ function pad(s, n) {
36
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
37
+ }
38
+ async function storageList() {
39
+ const data = await adminFetch("/admin/storage/pvcs");
40
+ const pvcs = data.pvcs ?? [];
41
+ if (!pvcs.length) {
42
+ console.log("No managed PVCs found.");
43
+ return;
44
+ }
45
+ console.log("");
46
+ console.log(`${pad("NAMESPACE", 36)}${pad("SIZE", 9)}${pad("STATUS", 9)}${pad("CREATED", 20)}ORPHANED`);
47
+ console.log("\u2500".repeat(90));
48
+ for (const p of pvcs.sort((a, b) => a.namespace.localeCompare(b.namespace))) {
49
+ const orphan = p.orphaned_at ? `\u26A0 ${fmtAge(p.orphaned_at)}` : "\u2014";
50
+ console.log(`${pad(p.namespace, 36)}${pad(p.capacity ?? "?", 9)}${pad(p.status ?? "?", 9)}${pad(fmtAge(p.created_at), 20)}${orphan}`);
51
+ }
52
+ console.log(`
53
+ Total: ${data.count} PVC(s)`);
54
+ }
55
+ async function storageOrphans(mark = false) {
56
+ const data = await adminFetch(`/admin/orphaned-pvcs?dry_run=${mark ? "false" : "true"}`);
57
+ const orphans = data.orphaned_pvcs ?? [];
58
+ if (!orphans.length) {
59
+ console.log("\u2705 No orphaned PVCs found.");
60
+ return;
61
+ }
62
+ console.log(`
63
+ \u26A0 ${orphans.length} orphaned PVC(s) (namespace has no running pods):
64
+ `);
65
+ console.log(`${pad("NAMESPACE", 36)}${pad("SIZE", 9)}${pad("MARKED", 9)}ORPHANED FOR`);
66
+ console.log("\u2500".repeat(72));
67
+ for (const o of orphans) {
68
+ const age = o.orphaned_for || (o.marked ? "?" : "not marked");
69
+ console.log(`${pad(o.namespace, 36)}${pad(o.storage ?? "?", 9)}${pad(o.marked ? "yes" : "no", 9)}${age}`);
70
+ }
71
+ if (!mark) {
72
+ console.log("\nTip: pass --mark to start the retention clock on unmarked orphans.");
73
+ }
74
+ console.log("\nTo delete: svamp machine storage delete <namespace>");
75
+ }
76
+ async function storageMarkOrphan(namespace) {
77
+ const data = await adminFetch(`/admin/storage/mark-orphan/${namespace}`, "POST");
78
+ if (data.marked) {
79
+ console.log(`\u2705 PVC for '${namespace}' marked as orphaned.`);
80
+ console.log(` To delete: svamp machine storage delete ${namespace}`);
81
+ } else {
82
+ console.log(`\u2139 PVC for '${namespace}' was already marked or not found.`);
83
+ }
84
+ }
85
+ async function storageUnmarkOrphan(namespace) {
86
+ const data = await adminFetch(`/admin/storage/unmark-orphan/${namespace}`, "POST");
87
+ if (data.unmarked) {
88
+ console.log(`\u2705 Orphan mark removed from '${namespace}'.`);
89
+ } else {
90
+ console.log(`\u2139 PVC for '${namespace}' was not marked or not found.`);
91
+ }
92
+ }
93
+ async function storageDelete(namespace) {
94
+ const readline = await import('readline');
95
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
96
+ const ask = (q) => new Promise((res) => rl.question(q, res));
97
+ try {
98
+ const data2 = await adminFetch("/admin/storage/pvcs");
99
+ const pvc = (data2.pvcs ?? []).find((p) => p.namespace === namespace);
100
+ if (!pvc) {
101
+ console.error(`No PVC found for namespace '${namespace}'.`);
102
+ rl.close();
103
+ process.exit(1);
104
+ }
105
+ console.log(`
106
+ \u26A0 Namespace: ${pvc.namespace}`);
107
+ console.log(` Size: ${pvc.capacity ?? "?"}`);
108
+ console.log(` Created: ${fmtAge(pvc.created_at)}`);
109
+ if (pvc.orphaned_at) console.log(` Orphaned: ${fmtAge(pvc.orphaned_at)}`);
110
+ } catch {
111
+ }
112
+ console.log("\n\u26A0 PERMANENTLY deleting this PVC will IRREVERSIBLY destroy all user data.\n");
113
+ const a1 = (await ask(`Type the namespace name to confirm: `)).trim();
114
+ if (a1 !== namespace) {
115
+ console.log("Confirmation mismatch \u2014 aborted.");
116
+ rl.close();
117
+ return;
118
+ }
119
+ const a2 = (await ask(`Type it again to double-confirm: `)).trim();
120
+ rl.close();
121
+ if (a2 !== namespace) {
122
+ console.log("Confirmation mismatch \u2014 aborted.");
123
+ return;
124
+ }
125
+ const data = await adminFetch(`/admin/storage/pvc/${namespace}`, "DELETE");
126
+ if (data.deleted) {
127
+ console.log(`\u2705 PVC for '${namespace}' permanently deleted.`);
128
+ } else {
129
+ console.log(`\u2139 PVC for '${namespace}' was not found or already deleted.`);
130
+ }
131
+ }
132
+ function printStorageHelp() {
133
+ console.log(`
134
+ svamp machine storage \u2014 sandbox PVC lifecycle management (admin only)
135
+
136
+ Usage:
137
+ svamp machine storage list List all PVCs with status
138
+ svamp machine storage orphans [--mark] Show PVCs with no active namespace
139
+ svamp machine storage mark-orphan <ns> Flag a PVC as orphaned (no deletion)
140
+ svamp machine storage unmark-orphan <ns> Remove orphan flag
141
+ svamp machine storage delete <ns> Permanently delete a PVC (double confirmation)
142
+
143
+ Environment:
144
+ SANDBOX_API_URL Sandbox API base URL (default: https://agent-sandbox.aicell.io)
145
+ SANDBOX_API_KEY Admin API key (or use HYPHA_TOKEN from svamp login)
146
+
147
+ Safety rules:
148
+ - Automated systems NEVER delete PVCs
149
+ - delete requires typing the namespace name twice
150
+ - delete checks for running pods and refuses if any are active
151
+ `);
152
+ }
153
+
154
+ export { printStorageHelp, storageDelete, storageList, storageMarkOrphan, storageOrphans, storageUnmarkOrphan };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "svamp-cli",
3
- "version": "0.1.64",
4
- "description": "Svamp CLI AI workspace daemon on Hypha Cloud",
3
+ "version": "0.1.66",
4
+ "description": "Svamp CLI \u2014 AI workspace daemon on Hypha Cloud",
5
5
  "author": "Amun AI AB",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  "scripts": {
21
21
  "build": "rm -rf dist && tsc --noEmit && pkgroll",
22
22
  "typecheck": "tsc --noEmit",
23
- "test": "npx tsx test/test-authorize.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-ralph-loop.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-output-formatters.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-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-ralph-loop-integration.mjs && npx tsx test/test-ralph-loop-modes.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs",
23
+ "test": "npx tsx test/test-authorize.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-ralph-loop.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-output-formatters.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-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-ralph-loop-integration.mjs && npx tsx test/test-ralph-loop-modes.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-clear-detection.mjs",
24
24
  "test:hypha": "node --no-warnings test/test-hypha-service.mjs",
25
25
  "dev": "tsx src/cli.ts",
26
26
  "dev:daemon": "tsx src/cli.ts daemon start-sync",