volute 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,7 @@ One background process runs everything. `volute up` starts it; `volute down` sto
36
36
  volute up # start (default port 4200)
37
37
  volute up --port 8080 # custom port
38
38
  volute down # stop all agents and shut down
39
+ volute status # check daemon status, version, and agents
39
40
  ```
40
41
 
41
42
  The daemon handles agent lifecycle, crash recovery (auto-restarts after 3 seconds), connector processes, scheduled messages, and the web dashboard.
@@ -232,7 +233,7 @@ The container runs with per-agent user isolation enabled — each agent gets its
232
233
 
233
234
  ### Bare metal (Linux / Raspberry Pi)
234
235
 
235
- One-liner install on a fresh Debian/Ubuntu system:
236
+ One-liner install on a fresh Linux system (Debian/Ubuntu, RHEL/Fedora, Arch, Alpine, SUSE):
236
237
 
237
238
  ```sh
238
239
  curl -fsSL <install-url> | sudo bash
@@ -21,14 +21,14 @@ async function run(args) {
21
21
  await import("./delete-BOTVU4YO.js").then((m) => m.run(args.slice(1)));
22
22
  break;
23
23
  case "list":
24
- await import("./status-SIMKH3ZE.js").then((m) => m.run(args.slice(1)));
24
+ await import("./status-CTWXP6UW.js").then((m) => m.run(args.slice(1)));
25
25
  break;
26
26
  case "status": {
27
27
  const rest = args.slice(1);
28
28
  if (!rest[0] && process.env.VOLUTE_AGENT) {
29
29
  rest.unshift(process.env.VOLUTE_AGENT);
30
30
  }
31
- await import("./status-SIMKH3ZE.js").then((m) => m.run(rest));
31
+ await import("./status-CTWXP6UW.js").then((m) => m.run(rest));
32
32
  break;
33
33
  }
34
34
  case "logs": {
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ execInherit
4
+ } from "./chunk-5C5JWR2L.js";
5
+ import {
6
+ voluteHome
7
+ } from "./chunk-DP2DX4WV.js";
8
+
9
+ // src/lib/service-mode.ts
10
+ import { execFileSync } from "child_process";
11
+ import { existsSync, readFileSync } from "fs";
12
+ import { homedir } from "os";
13
+ import { resolve } from "path";
14
+ var SYSTEM_SERVICE_PATH = "/etc/systemd/system/volute.service";
15
+ var USER_SYSTEMD_UNIT = resolve(homedir(), ".config", "systemd", "user", "volute.service");
16
+ var LAUNCHD_PLIST_LABEL = "com.volute.daemon";
17
+ var LAUNCHD_PLIST_PATH = resolve(
18
+ homedir(),
19
+ "Library",
20
+ "LaunchAgents",
21
+ `${LAUNCHD_PLIST_LABEL}.plist`
22
+ );
23
+ var HEALTH_POLL_TIMEOUT = 3e4;
24
+ var STOP_GRACE_TIMEOUT = 1e4;
25
+ var POLL_INTERVAL = 500;
26
+ function getServiceMode() {
27
+ if (existsSync(SYSTEM_SERVICE_PATH)) {
28
+ try {
29
+ execFileSync("systemctl", ["is-enabled", "--quiet", "volute"]);
30
+ return "system";
31
+ } catch {
32
+ }
33
+ }
34
+ if (existsSync(USER_SYSTEMD_UNIT)) {
35
+ try {
36
+ execFileSync("systemctl", ["--user", "is-enabled", "--quiet", "volute"]);
37
+ return "user-systemd";
38
+ } catch {
39
+ }
40
+ }
41
+ if (process.platform === "darwin" && existsSync(LAUNCHD_PLIST_PATH)) {
42
+ return "user-launchd";
43
+ }
44
+ return "manual";
45
+ }
46
+ function getDaemonUrl(hostname, port) {
47
+ const url = new URL("http://localhost");
48
+ let h = hostname;
49
+ if (h === "0.0.0.0" || h === "::") h = "localhost";
50
+ else if (h.includes(":") && !h.startsWith("[")) h = `[${h}]`;
51
+ url.hostname = h;
52
+ url.port = String(port);
53
+ return url.origin;
54
+ }
55
+ async function pollHealth(hostname, port, timeout = HEALTH_POLL_TIMEOUT) {
56
+ const url = `${getDaemonUrl(hostname, port)}/api/health`;
57
+ const start = Date.now();
58
+ while (Date.now() - start < timeout) {
59
+ try {
60
+ const res = await fetch(url);
61
+ if (res.ok) {
62
+ const body = await res.json().catch(() => null);
63
+ if (body && body.ok) return true;
64
+ }
65
+ } catch {
66
+ }
67
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL));
68
+ }
69
+ return false;
70
+ }
71
+ async function pollHealthDown(hostname, port, timeout = STOP_GRACE_TIMEOUT) {
72
+ const url = `${getDaemonUrl(hostname, port)}/api/health`;
73
+ const start = Date.now();
74
+ while (Date.now() - start < timeout) {
75
+ try {
76
+ const res = await fetch(url);
77
+ if (!res.ok) return true;
78
+ const body = await res.json().catch(() => null);
79
+ if (!body || !body.ok) return true;
80
+ } catch {
81
+ return true;
82
+ }
83
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL));
84
+ }
85
+ return false;
86
+ }
87
+ async function startService(mode) {
88
+ switch (mode) {
89
+ case "system":
90
+ await execInherit("sudo", ["systemctl", "start", "volute"]);
91
+ break;
92
+ case "user-systemd":
93
+ await execInherit("systemctl", ["--user", "start", "volute"]);
94
+ break;
95
+ case "user-launchd":
96
+ await execInherit("launchctl", ["load", LAUNCHD_PLIST_PATH]);
97
+ break;
98
+ }
99
+ }
100
+ async function stopService(mode) {
101
+ switch (mode) {
102
+ case "system":
103
+ await execInherit("sudo", ["systemctl", "stop", "volute"]);
104
+ break;
105
+ case "user-systemd":
106
+ await execInherit("systemctl", ["--user", "stop", "volute"]);
107
+ break;
108
+ case "user-launchd":
109
+ await execInherit("launchctl", ["unload", LAUNCHD_PLIST_PATH]);
110
+ break;
111
+ }
112
+ }
113
+ async function restartService(mode) {
114
+ switch (mode) {
115
+ case "system":
116
+ await execInherit("sudo", ["systemctl", "restart", "volute"]);
117
+ break;
118
+ case "user-systemd":
119
+ await execInherit("systemctl", ["--user", "restart", "volute"]);
120
+ break;
121
+ case "user-launchd":
122
+ try {
123
+ await execInherit("launchctl", ["unload", LAUNCHD_PLIST_PATH]);
124
+ } catch (err) {
125
+ console.warn(
126
+ `Warning: launchctl unload failed: ${err instanceof Error ? err.message : err}`
127
+ );
128
+ }
129
+ await execInherit("launchctl", ["load", LAUNCHD_PLIST_PATH]);
130
+ break;
131
+ }
132
+ }
133
+ function readDaemonConfig() {
134
+ const configPath = resolve(voluteHome(), "daemon.json");
135
+ if (!existsSync(configPath)) return { hostname: "127.0.0.1", port: 4200 };
136
+ try {
137
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
138
+ return {
139
+ hostname: config.hostname || "127.0.0.1",
140
+ port: config.port ?? 4200,
141
+ token: config.token
142
+ };
143
+ } catch {
144
+ console.error("Warning: could not read daemon config, using defaults.");
145
+ return { hostname: "127.0.0.1", port: 4200 };
146
+ }
147
+ }
148
+ function modeLabel(mode) {
149
+ switch (mode) {
150
+ case "system":
151
+ return "system service (systemd)";
152
+ case "user-systemd":
153
+ return "user service (systemd)";
154
+ case "user-launchd":
155
+ return "user service (launchd)";
156
+ case "manual":
157
+ return "manual";
158
+ }
159
+ }
160
+
161
+ export {
162
+ SYSTEM_SERVICE_PATH,
163
+ USER_SYSTEMD_UNIT,
164
+ LAUNCHD_PLIST_LABEL,
165
+ LAUNCHD_PLIST_PATH,
166
+ getServiceMode,
167
+ getDaemonUrl,
168
+ pollHealth,
169
+ pollHealthDown,
170
+ startService,
171
+ stopService,
172
+ restartService,
173
+ readDaemonConfig,
174
+ modeLabel
175
+ };
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ getServiceMode,
4
+ modeLabel,
5
+ pollHealth,
6
+ startService
7
+ } from "./chunk-25GGN3OV.js";
2
8
  import {
3
9
  parseArgs
4
10
  } from "./chunk-D424ZQGI.js";
@@ -7,7 +13,7 @@ import {
7
13
  } from "./chunk-DP2DX4WV.js";
8
14
 
9
15
  // src/commands/up.ts
10
- import { execFileSync, spawn } from "child_process";
16
+ import { spawn } from "child_process";
11
17
  import { existsSync, mkdirSync, openSync, readFileSync } from "fs";
12
18
  import { dirname, resolve } from "path";
13
19
  function readGlobalConfig() {
@@ -20,26 +26,31 @@ function readGlobalConfig() {
20
26
  process.exit(1);
21
27
  }
22
28
  }
23
- function isSystemdServiceEnabled() {
24
- try {
25
- execFileSync("systemctl", ["is-enabled", "--quiet", "volute"]);
26
- return true;
27
- } catch {
28
- return false;
29
- }
30
- }
31
29
  async function run(args) {
32
30
  const { flags } = parseArgs(args, {
33
31
  port: { type: "number" },
34
32
  host: { type: "string" },
35
33
  foreground: { type: "boolean" }
36
34
  });
37
- if (!flags.foreground && isSystemdServiceEnabled()) {
38
- console.error("Volute is managed by a systemd service.");
39
- console.error("Use: sudo systemctl start volute");
40
- console.error(" sudo systemctl restart volute");
41
- console.error(" systemctl status volute");
42
- process.exit(1);
35
+ const mode = getServiceMode();
36
+ if (!flags.foreground && mode !== "manual") {
37
+ console.log(`Starting volute (${modeLabel(mode)})...`);
38
+ try {
39
+ await startService(mode);
40
+ } catch (err) {
41
+ console.error(`Failed to start service: ${err instanceof Error ? err.message : err}`);
42
+ process.exit(1);
43
+ }
44
+ const config2 = readGlobalConfig();
45
+ const h = flags.host ?? config2.hostname ?? "127.0.0.1";
46
+ const p = flags.port ?? config2.port ?? 4200;
47
+ if (await pollHealth(h, p)) {
48
+ console.log(`Volute daemon running on ${h}:${p}`);
49
+ } else {
50
+ console.error("Service started but daemon did not become healthy within 30s.");
51
+ process.exit(1);
52
+ }
53
+ return;
43
54
  }
44
55
  const config = readGlobalConfig();
45
56
  const port = flags.port ?? config.port ?? 4200;
@@ -59,10 +70,13 @@ async function run(args) {
59
70
  try {
60
71
  const res = await fetch(`http://${pollHost}:${port}/api/health`);
61
72
  if (res.ok) {
62
- console.error(
63
- `Port ${port} is already in use by a Volute daemon. Use 'volute down' first, or kill the process on that port.`
64
- );
65
- process.exit(1);
73
+ const body = await res.json().catch(() => null);
74
+ if (body && body.ok) {
75
+ console.error(
76
+ `Port ${port} is already in use by a Volute daemon. Use 'volute down' first, or kill the process on that port.`
77
+ );
78
+ process.exit(1);
79
+ }
66
80
  }
67
81
  } catch {
68
82
  }
@@ -1,31 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- daemonFetch
4
- } from "./chunk-STOEJOJO.js";
3
+ getServiceMode,
4
+ modeLabel,
5
+ pollHealthDown,
6
+ readDaemonConfig,
7
+ stopService
8
+ } from "./chunk-25GGN3OV.js";
5
9
  import {
6
10
  voluteHome
7
11
  } from "./chunk-DP2DX4WV.js";
8
- import {
9
- getClient,
10
- urlOf
11
- } from "./chunk-4RQBJWQX.js";
12
12
 
13
13
  // src/commands/down.ts
14
- import { execFileSync } from "child_process";
15
14
  import { existsSync, readFileSync, unlinkSync } from "fs";
16
15
  import { resolve } from "path";
17
- function isSystemdServiceEnabled() {
18
- try {
19
- execFileSync("systemctl", ["is-enabled", "--quiet", "volute"]);
20
- return true;
21
- } catch {
22
- return false;
23
- }
24
- }
25
16
  async function stopDaemon() {
26
- if (isSystemdServiceEnabled()) {
27
- return { stopped: false, reason: "systemd" };
28
- }
29
17
  const home = voluteHome();
30
18
  const pidPath = resolve(home, "daemon.pid");
31
19
  if (!existsSync(pidPath)) {
@@ -82,7 +70,7 @@ async function stopDaemon() {
82
70
  console.error(
83
71
  `Failed to send SIGTERM to daemon (pid ${pid}): ${e instanceof Error ? e.message : e}`
84
72
  );
85
- return { stopped: false, reason: "kill-failed", port: pid };
73
+ return { stopped: false, reason: "kill-failed" };
86
74
  }
87
75
  }
88
76
  const maxWait = 1e4;
@@ -116,49 +104,34 @@ async function stopDaemon() {
116
104
  return { stopped: true, clean: false };
117
105
  }
118
106
  async function run(_args) {
119
- const result = await stopDaemon();
120
- if (result.stopped) return;
121
- if (result.reason === "systemd") {
122
- const client = getClient();
123
- await daemonFetch(urlOf(client.api.system.stop.$url()), { method: "POST" });
124
- const home = voluteHome();
125
- const configPath = resolve(home, "daemon.json");
126
- let hostname = "localhost";
127
- let port = 4200;
128
- if (existsSync(configPath)) {
129
- try {
130
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
131
- hostname = config.hostname || "localhost";
132
- port = config.port ?? 4200;
133
- } catch {
134
- }
107
+ const mode = getServiceMode();
108
+ if (mode !== "manual") {
109
+ console.log(`Stopping volute (${modeLabel(mode)})...`);
110
+ try {
111
+ await stopService(mode);
112
+ } catch (err) {
113
+ console.error(`Failed to stop service: ${err instanceof Error ? err.message : err}`);
114
+ process.exit(1);
135
115
  }
136
- if (hostname === "0.0.0.0") hostname = "127.0.0.1";
137
- if (hostname === "::") hostname = "[::1]";
138
- const url = new URL("http://localhost");
139
- url.hostname = hostname;
140
- url.port = String(port);
141
- const healthUrl = `${url.origin}/api/health`;
142
- const maxWait = 1e4;
143
- const start = Date.now();
144
- while (Date.now() - start < maxWait) {
145
- await new Promise((r) => setTimeout(r, 500));
146
- try {
147
- await fetch(healthUrl);
148
- } catch {
149
- console.log("Daemon stopped.");
150
- return;
151
- }
116
+ const { hostname, port } = readDaemonConfig();
117
+ if (await pollHealthDown(hostname, port)) {
118
+ console.log("Daemon stopped.");
119
+ } else {
120
+ console.error("Service stopped but daemon may still be responding.");
121
+ process.exit(1);
152
122
  }
153
- console.error("Daemon may not have stopped. Check with: volute service status");
154
- process.exit(1);
155
- } else if (result.reason === "orphan") {
123
+ return;
124
+ }
125
+ const result = await stopDaemon();
126
+ if (result.stopped) return;
127
+ if (result.reason === "orphan") {
156
128
  console.error(`Daemon appears to be running on port ${result.port} but PID file is missing.`);
157
129
  console.error(`Kill the process manually: lsof -ti :${result.port} | xargs kill`);
158
- } else if (result.reason === "not-running") {
159
- console.error("Daemon is not running (no PID file found).");
130
+ process.exit(1);
131
+ } else if (result.reason === "kill-failed") {
132
+ process.exit(1);
160
133
  }
161
- process.exit(1);
134
+ console.log("Daemon is not running.");
162
135
  }
163
136
 
164
137
  export {
package/dist/cli.js CHANGED
@@ -9,13 +9,13 @@ if (!process.env.VOLUTE_HOME) {
9
9
  var command = process.argv[2];
10
10
  var args = process.argv.slice(3);
11
11
  if (command === "--version" || command === "-v") {
12
- const { default: pkg } = await import("./package-TNE337RE.js");
12
+ const { default: pkg } = await import("./package-4QEVVIDG.js");
13
13
  console.log(pkg.version);
14
14
  process.exit(0);
15
15
  }
16
16
  switch (command) {
17
17
  case "agent":
18
- await import("./agent-ECRX44DB.js").then((m) => m.run(args));
18
+ await import("./agent-JGO6N7IA.js").then((m) => m.run(args));
19
19
  break;
20
20
  case "send":
21
21
  await import("./send-X6OQGSD6.js").then((m) => m.run(args));
@@ -39,22 +39,25 @@ switch (command) {
39
39
  await import("./env-CGORIKVF.js").then((m) => m.run(args));
40
40
  break;
41
41
  case "up":
42
- await import("./up-FCYL2IPZ.js").then((m) => m.run(args));
42
+ await import("./up-6ORVZLBR.js").then((m) => m.run(args));
43
43
  break;
44
44
  case "down":
45
- await import("./down-4LIQG3CE.js").then((m) => m.run(args));
45
+ await import("./down-XV2OQJ7O.js").then((m) => m.run(args));
46
46
  break;
47
47
  case "restart":
48
- await import("./daemon-restart-7X72OXOW.js").then((m) => m.run(args));
48
+ await import("./daemon-restart-BO25T4J6.js").then((m) => m.run(args));
49
49
  break;
50
50
  case "setup":
51
- await import("./setup-6QFIHXSH.js").then((m) => m.run(args));
51
+ await import("./setup-GMZOD52B.js").then((m) => m.run(args));
52
52
  break;
53
53
  case "service":
54
- await import("./service-OW35VZ5G.js").then((m) => m.run(args));
54
+ await import("./service-U6SN5OZO.js").then((m) => m.run(args));
55
55
  break;
56
56
  case "update":
57
- await import("./update-3TGXUTO2.js").then((m) => m.run(args));
57
+ await import("./update-BPKQX5OY.js").then((m) => m.run(args));
58
+ break;
59
+ case "status":
60
+ await import("./status-CTWXP6UW.js").then((m) => m.run(args));
58
61
  break;
59
62
  case "--help":
60
63
  case "-h":
@@ -106,6 +109,7 @@ Commands:
106
109
  volute setup uninstall [--force] Remove system service + isolation
107
110
 
108
111
  volute update Update to latest version
112
+ volute status Show daemon status and agents
109
113
 
110
114
  Options:
111
115
  --version, -v Show version number
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ run
4
+ } from "./chunk-34GBEBQH.js";
5
+ import {
6
+ stopDaemon
7
+ } from "./chunk-GGLTM53H.js";
8
+ import {
9
+ getServiceMode,
10
+ modeLabel,
11
+ pollHealth,
12
+ readDaemonConfig,
13
+ restartService
14
+ } from "./chunk-25GGN3OV.js";
15
+ import "./chunk-5C5JWR2L.js";
16
+ import "./chunk-D424ZQGI.js";
17
+ import "./chunk-DP2DX4WV.js";
18
+ import "./chunk-K3NQKI34.js";
19
+
20
+ // src/commands/daemon-restart.ts
21
+ async function run2(args) {
22
+ const mode = getServiceMode();
23
+ if (mode !== "manual") {
24
+ console.log(`Restarting volute (${modeLabel(mode)})...`);
25
+ try {
26
+ await restartService(mode);
27
+ } catch (err) {
28
+ console.error(`Failed to restart service: ${err instanceof Error ? err.message : err}`);
29
+ process.exit(1);
30
+ }
31
+ const { hostname, port } = readDaemonConfig();
32
+ if (await pollHealth(hostname, port)) {
33
+ console.log("Daemon restarted.");
34
+ } else {
35
+ console.error("Service restarted but daemon did not become healthy within 30s.");
36
+ process.exit(1);
37
+ }
38
+ return;
39
+ }
40
+ const result = await stopDaemon();
41
+ if (!result.stopped && result.reason === "kill-failed") {
42
+ console.error("Cannot restart: failed to stop the running daemon.");
43
+ process.exit(1);
44
+ }
45
+ await run(args);
46
+ }
47
+ export {
48
+ run2 as run
49
+ };
package/dist/daemon.js CHANGED
@@ -7,11 +7,6 @@ import {
7
7
  loadJsonMap,
8
8
  saveJsonMap
9
9
  } from "./chunk-KR6WRAJ4.js";
10
- import {
11
- checkForUpdate,
12
- checkForUpdateCached,
13
- getCurrentVersion
14
- } from "./chunk-RT6Y7AR3.js";
15
10
  import {
16
11
  applyIsolation,
17
12
  chownAgentDir,
@@ -21,10 +16,6 @@ import {
21
16
  getAgentUserIds,
22
17
  isIsolationEnabled
23
18
  } from "./chunk-46S7YHUB.js";
24
- import {
25
- exec,
26
- resolveVoluteBin
27
- } from "./chunk-5C5JWR2L.js";
28
19
  import {
29
20
  findOpenClawSession,
30
21
  importOpenClawConnectors,
@@ -44,6 +35,15 @@ import {
44
35
  CHANNELS,
45
36
  getChannelDriver
46
37
  } from "./chunk-LIPPXNIE.js";
38
+ import {
39
+ exec,
40
+ resolveVoluteBin
41
+ } from "./chunk-5C5JWR2L.js";
42
+ import {
43
+ checkForUpdate,
44
+ checkForUpdateCached,
45
+ getCurrentVersion
46
+ } from "./chunk-RT6Y7AR3.js";
47
47
  import "./chunk-D424ZQGI.js";
48
48
  import {
49
49
  slugify,
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  run,
4
4
  stopDaemon
5
- } from "./chunk-FYQGANL6.js";
6
- import "./chunk-STOEJOJO.js";
5
+ } from "./chunk-GGLTM53H.js";
6
+ import "./chunk-25GGN3OV.js";
7
+ import "./chunk-5C5JWR2L.js";
7
8
  import "./chunk-DP2DX4WV.js";
8
- import "./chunk-4RQBJWQX.js";
9
9
  import "./chunk-K3NQKI34.js";
10
10
  export {
11
11
  run,
@@ -4,7 +4,7 @@ import "./chunk-K3NQKI34.js";
4
4
  // package.json
5
5
  var package_default = {
6
6
  name: "volute",
7
- version: "0.10.1",
7
+ version: "0.11.0",
8
8
  description: "CLI for creating and managing self-modifying AI agents powered by the Claude Agent SDK",
9
9
  type: "module",
10
10
  license: "MIT",
@@ -20,7 +20,7 @@ var package_default = {
20
20
  "anthropic"
21
21
  ],
22
22
  engines: {
23
- node: ">=22"
23
+ node: ">=24"
24
24
  },
25
25
  bin: {
26
26
  volute: "dist/cli.js"
@@ -1,10 +1,17 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ LAUNCHD_PLIST_LABEL,
4
+ LAUNCHD_PLIST_PATH,
5
+ SYSTEM_SERVICE_PATH,
6
+ USER_SYSTEMD_UNIT
7
+ } from "./chunk-25GGN3OV.js";
2
8
  import {
3
9
  resolveVoluteBin
4
10
  } from "./chunk-5C5JWR2L.js";
5
11
  import {
6
12
  parseArgs
7
13
  } from "./chunk-D424ZQGI.js";
14
+ import "./chunk-DP2DX4WV.js";
8
15
  import "./chunk-K3NQKI34.js";
9
16
 
10
17
  // src/commands/service.ts
@@ -15,7 +22,6 @@ import { resolve } from "path";
15
22
  import { promisify } from "util";
16
23
  var execFileAsync = promisify(execFile);
17
24
  var HOST_RE = /^[a-zA-Z0-9.:_-]+$/;
18
- var SYSTEM_SERVICE_PATH = "/etc/systemd/system/volute.service";
19
25
  function validateHost(host) {
20
26
  if (!HOST_RE.test(host)) {
21
27
  throw new Error(`Invalid host: ${host}`);
@@ -24,8 +30,6 @@ function validateHost(host) {
24
30
  function escapeXml(s) {
25
31
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
26
32
  }
27
- var PLIST_LABEL = "com.volute.daemon";
28
- var plistPath = () => resolve(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
29
33
  function generatePlist(voluteBin, port, host) {
30
34
  const args = ["up", "--foreground"];
31
35
  if (port != null) args.push("--port", String(port));
@@ -35,7 +39,7 @@ function generatePlist(voluteBin, port, host) {
35
39
  <plist version="1.0">
36
40
  <dict>
37
41
  <key>Label</key>
38
- <string>${PLIST_LABEL}</string>
42
+ <string>${LAUNCHD_PLIST_LABEL}</string>
39
43
  <key>ProgramArguments</key>
40
44
  <array>
41
45
  ${[voluteBin, ...args].map((a) => `<string>${escapeXml(a)}</string>`).join("\n ")}
@@ -51,8 +55,6 @@ function generatePlist(voluteBin, port, host) {
51
55
  </dict>
52
56
  </plist>`;
53
57
  }
54
- var unitName = "volute.service";
55
- var unitPath = () => resolve(homedir(), ".config", "systemd", "user", unitName);
56
58
  function generateUnit(voluteBin, port, host) {
57
59
  const args = ["up", "--foreground"];
58
60
  if (port != null) args.push("--port", String(port));
@@ -76,7 +78,7 @@ async function install(port, host) {
76
78
  const voluteBin = resolveVoluteBin();
77
79
  const platform = process.platform;
78
80
  if (platform === "darwin") {
79
- const path = plistPath();
81
+ const path = LAUNCHD_PLIST_PATH;
80
82
  mkdirSync(resolve(homedir(), "Library", "LaunchAgents"), { recursive: true });
81
83
  writeFileSync(path, generatePlist(voluteBin, port, host));
82
84
  console.log(`Wrote ${path}`);
@@ -96,7 +98,7 @@ async function install(port, host) {
96
98
  console.error("Use `volute setup` instead to install a system-level service.");
97
99
  process.exit(1);
98
100
  }
99
- const path = unitPath();
101
+ const path = USER_SYSTEMD_UNIT;
100
102
  mkdirSync(resolve(homedir(), ".config", "systemd", "user"), { recursive: true });
101
103
  writeFileSync(path, generateUnit(voluteBin, port, host));
102
104
  console.log(`Wrote ${path}`);
@@ -110,7 +112,7 @@ async function install(port, host) {
110
112
  async function uninstall() {
111
113
  const platform = process.platform;
112
114
  if (platform === "darwin") {
113
- const path = plistPath();
115
+ const path = LAUNCHD_PLIST_PATH;
114
116
  if (existsSync(path)) {
115
117
  try {
116
118
  await execFileAsync("launchctl", ["unload", path]);
@@ -123,7 +125,7 @@ async function uninstall() {
123
125
  console.log("Service not installed.");
124
126
  }
125
127
  } else if (platform === "linux") {
126
- const path = unitPath();
128
+ const path = USER_SYSTEMD_UNIT;
127
129
  if (existsSync(path)) {
128
130
  try {
129
131
  await execFileAsync("systemctl", ["--user", "disable", "--now", "volute"]);
@@ -143,12 +145,12 @@ async function uninstall() {
143
145
  async function status() {
144
146
  const platform = process.platform;
145
147
  if (platform === "darwin") {
146
- if (!existsSync(plistPath())) {
148
+ if (!existsSync(LAUNCHD_PLIST_PATH)) {
147
149
  console.log("Service not installed.");
148
150
  return;
149
151
  }
150
152
  try {
151
- const { stdout } = await execFileAsync("launchctl", ["list", PLIST_LABEL]);
153
+ const { stdout } = await execFileAsync("launchctl", ["list", LAUNCHD_PLIST_LABEL]);
152
154
  console.log(stdout);
153
155
  } catch {
154
156
  console.log("Service installed but not currently loaded.");
@@ -171,7 +173,7 @@ async function status() {
171
173
  }
172
174
  return;
173
175
  }
174
- if (!existsSync(unitPath())) {
176
+ if (!existsSync(USER_SYSTEMD_UNIT)) {
175
177
  console.log("Service not installed.");
176
178
  return;
177
179
  }
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ SYSTEM_SERVICE_PATH
4
+ } from "./chunk-25GGN3OV.js";
2
5
  import {
3
6
  ensureVoluteGroup
4
7
  } from "./chunk-46S7YHUB.js";
@@ -17,11 +20,11 @@ import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "fs";
17
20
  import { homedir } from "os";
18
21
  import { dirname } from "path";
19
22
  var SERVICE_NAME = "volute.service";
20
- var SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}`;
21
23
  var PROFILE_PATH = "/etc/profile.d/volute.sh";
22
24
  var WRAPPER_PATH = "/usr/local/bin/volute";
23
25
  var DATA_DIR = "/var/lib/volute";
24
26
  var AGENTS_DIR = "/agents";
27
+ var CLAUDE_DIR = `${DATA_DIR}/.claude`;
25
28
  var HOST_RE = /^[a-zA-Z0-9.:_-]+$/;
26
29
  function validateHost(host) {
27
30
  if (!HOST_RE.test(host)) {
@@ -59,10 +62,11 @@ function generateUnit(voluteBin, port, host) {
59
62
  `Environment=VOLUTE_HOME=${DATA_DIR}`,
60
63
  `Environment=VOLUTE_AGENTS_DIR=${AGENTS_DIR}`,
61
64
  "Environment=VOLUTE_ISOLATION=user",
65
+ `Environment=CLAUDE_CONFIG_DIR=${CLAUDE_DIR}`,
62
66
  "Restart=on-failure",
63
67
  "RestartSec=5",
64
- "ProtectSystem=strict",
65
- `ReadWritePaths=${DATA_DIR} ${AGENTS_DIR} /etc`,
68
+ "ProtectSystem=true",
69
+ `ReadWritePaths=${DATA_DIR} ${AGENTS_DIR}`,
66
70
  "PrivateTmp=yes"
67
71
  ];
68
72
  if (!binUnderHome) {
@@ -92,6 +96,10 @@ function install(port, host) {
92
96
  console.log(`Created ${AGENTS_DIR}`);
93
97
  ensureVoluteGroup({ force: true });
94
98
  console.log("Ensured volute group exists");
99
+ mkdirSync(CLAUDE_DIR, { recursive: true });
100
+ execFileSync("chown", ["root:volute", CLAUDE_DIR]);
101
+ execFileSync("chmod", ["750", CLAUDE_DIR]);
102
+ console.log(`Created ${CLAUDE_DIR}`);
95
103
  execFileSync("chmod", ["755", DATA_DIR]);
96
104
  execFileSync("chmod", ["755", AGENTS_DIR]);
97
105
  console.log("Set permissions on directories");
@@ -113,13 +121,13 @@ exec "${voluteBin}" "$@"
113
121
  writeFileSync(WRAPPER_PATH, wrapper, { mode: 493 });
114
122
  console.log(`Wrote ${WRAPPER_PATH} (wrapper for ${voluteBin})`);
115
123
  }
116
- writeFileSync(SERVICE_PATH, generateUnit(voluteBin, port, host ?? "0.0.0.0"));
117
- console.log(`Wrote ${SERVICE_PATH}`);
124
+ writeFileSync(SYSTEM_SERVICE_PATH, generateUnit(voluteBin, port, host ?? "0.0.0.0"));
125
+ console.log(`Wrote ${SYSTEM_SERVICE_PATH}`);
118
126
  try {
119
127
  execFileSync("systemctl", ["daemon-reload"]);
120
128
  } catch (err) {
121
129
  const e = err;
122
- console.error(`Failed to reload systemd after writing ${SERVICE_PATH}.`);
130
+ console.error(`Failed to reload systemd after writing ${SYSTEM_SERVICE_PATH}.`);
123
131
  if (e.stderr) console.error(e.stderr.toString().trim());
124
132
  console.error(
125
133
  "Try running `systemctl daemon-reload` manually, then `systemctl enable --now volute`."
@@ -129,9 +137,16 @@ exec "${voluteBin}" "$@"
129
137
  try {
130
138
  execFileSync("systemctl", ["enable", "--now", SERVICE_NAME]);
131
139
  console.log("Service installed, enabled, and started.");
140
+ console.log(
141
+ "Run `source /etc/profile.d/volute.sh` or start a new shell to use volute CLI commands."
142
+ );
132
143
  console.log(`
133
144
  Volute daemon is running. Data directory: ${DATA_DIR}`);
134
145
  console.log("Use `systemctl status volute` to check status.");
146
+ console.log(
147
+ `
148
+ For agent-sdk agents, copy ~/.claude/.credentials.json to ${CLAUDE_DIR}/.credentials.json`
149
+ );
135
150
  } catch (err) {
136
151
  const e = err;
137
152
  console.error("Service installed but failed to start.");
@@ -145,7 +160,7 @@ function uninstall(force) {
145
160
  console.error("Error: volute setup uninstall must be run as root (use sudo).");
146
161
  process.exit(1);
147
162
  }
148
- if (!existsSync(SERVICE_PATH)) {
163
+ if (!existsSync(SYSTEM_SERVICE_PATH)) {
149
164
  console.log("Service not installed.");
150
165
  return;
151
166
  }
@@ -154,7 +169,7 @@ function uninstall(force) {
154
169
  } catch {
155
170
  console.warn("Warning: failed to disable service (may already be stopped)");
156
171
  }
157
- unlinkSync(SERVICE_PATH);
172
+ unlinkSync(SYSTEM_SERVICE_PATH);
158
173
  if (existsSync(PROFILE_PATH)) unlinkSync(PROFILE_PATH);
159
174
  if (existsSync(WRAPPER_PATH)) unlinkSync(WRAPPER_PATH);
160
175
  try {
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getDaemonUrl,
4
+ getServiceMode,
5
+ modeLabel,
6
+ readDaemonConfig
7
+ } from "./chunk-25GGN3OV.js";
8
+ import "./chunk-5C5JWR2L.js";
9
+ import {
10
+ checkForUpdate
11
+ } from "./chunk-RT6Y7AR3.js";
12
+ import "./chunk-DP2DX4WV.js";
13
+ import "./chunk-K3NQKI34.js";
14
+
15
+ // src/commands/status.ts
16
+ async function run(_args) {
17
+ const mode = getServiceMode();
18
+ console.log(`Mode: ${modeLabel(mode)}`);
19
+ const { hostname, port, token } = readDaemonConfig();
20
+ const baseUrl = getDaemonUrl(hostname, port);
21
+ let running = false;
22
+ let version;
23
+ try {
24
+ const res = await fetch(`${baseUrl}/api/health`);
25
+ if (res.ok) {
26
+ const body = await res.json();
27
+ if (body.ok) {
28
+ running = true;
29
+ version = body.version;
30
+ }
31
+ }
32
+ } catch {
33
+ }
34
+ if (!running) {
35
+ console.log("Status: not running");
36
+ return;
37
+ }
38
+ console.log(`Status: running on ${hostname}:${port}`);
39
+ if (version) console.log(`Version: ${version}`);
40
+ const update = await checkForUpdate();
41
+ if (update.updateAvailable) {
42
+ console.log(`Update available: ${update.current} \u2192 ${update.latest}`);
43
+ }
44
+ const headers = {};
45
+ if (token) headers.Authorization = `Bearer ${token}`;
46
+ headers.Origin = baseUrl;
47
+ try {
48
+ const res = await fetch(`${baseUrl}/api/agents`, { headers });
49
+ if (res.ok) {
50
+ const agents = await res.json();
51
+ if (agents.length > 0) {
52
+ console.log(`
53
+ Agents (${agents.length}):`);
54
+ for (const agent of agents) {
55
+ const status = agent.running ? "running" : "stopped";
56
+ console.log(` ${agent.name}: ${status}`);
57
+ }
58
+ } else {
59
+ console.log("\nNo agents configured.");
60
+ }
61
+ }
62
+ } catch {
63
+ }
64
+ }
65
+ export {
66
+ run
67
+ };
@@ -2,7 +2,9 @@
2
2
  import {
3
3
  readGlobalConfig,
4
4
  run
5
- } from "./chunk-M5AEQLB3.js";
5
+ } from "./chunk-34GBEBQH.js";
6
+ import "./chunk-25GGN3OV.js";
7
+ import "./chunk-5C5JWR2L.js";
6
8
  import "./chunk-D424ZQGI.js";
7
9
  import "./chunk-DP2DX4WV.js";
8
10
  import "./chunk-K3NQKI34.js";
@@ -1,30 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- checkForUpdate
4
- } from "./chunk-RT6Y7AR3.js";
3
+ getServiceMode,
4
+ modeLabel,
5
+ pollHealth,
6
+ readDaemonConfig,
7
+ restartService
8
+ } from "./chunk-25GGN3OV.js";
5
9
  import {
10
+ exec,
6
11
  execInherit,
7
12
  resolveVoluteBin
8
13
  } from "./chunk-5C5JWR2L.js";
14
+ import {
15
+ checkForUpdate
16
+ } from "./chunk-RT6Y7AR3.js";
9
17
  import {
10
18
  voluteHome
11
19
  } from "./chunk-DP2DX4WV.js";
12
20
  import "./chunk-K3NQKI34.js";
13
21
 
14
22
  // src/commands/update.ts
15
- import { execFileSync } from "child_process";
16
23
  import { existsSync, readFileSync, unlinkSync } from "fs";
17
24
  import { resolve } from "path";
18
25
  async function run(_args) {
19
- try {
20
- execFileSync("systemctl", ["is-enabled", "--quiet", "volute"]);
21
- console.error("Volute is managed by a systemd service.");
22
- console.error("To update, run:");
23
- console.error(" sudo npm install -g volute@latest");
24
- console.error(" sudo systemctl restart volute");
25
- process.exit(1);
26
- } catch {
27
- }
28
26
  const result = await checkForUpdate();
29
27
  if (result.checkFailed) {
30
28
  console.error("Could not reach npm registry. Check your network connection and try again.");
@@ -38,6 +36,71 @@ async function run(_args) {
38
36
  }
39
37
  console.log(`
40
38
  Updating volute ${result.current} \u2192 ${result.latest}...`);
39
+ const mode = getServiceMode();
40
+ if (mode === "system") {
41
+ let npmPath = "/usr/bin/npm";
42
+ if (!existsSync(npmPath)) {
43
+ try {
44
+ npmPath = (await exec("which", ["npm"])).trim();
45
+ } catch {
46
+ console.error("Could not find npm. Install npm and try again.");
47
+ process.exit(1);
48
+ }
49
+ }
50
+ try {
51
+ await execInherit("sudo", [npmPath, "install", "-g", "volute@latest"]);
52
+ } catch (err) {
53
+ console.error(`
54
+ Update failed: ${err.message}`);
55
+ process.exit(1);
56
+ }
57
+ console.log("Restarting service...");
58
+ try {
59
+ await restartService(mode);
60
+ } catch (err) {
61
+ console.error(`Failed to restart: ${err instanceof Error ? err.message : err}`);
62
+ console.error("Try: sudo systemctl restart volute");
63
+ process.exit(1);
64
+ }
65
+ {
66
+ const { hostname, port } = readDaemonConfig();
67
+ if (await pollHealth(hostname, port)) {
68
+ console.log(`
69
+ Updated to volute v${result.latest}`);
70
+ } else {
71
+ console.error("Service restarted but daemon did not become healthy.");
72
+ process.exit(1);
73
+ }
74
+ }
75
+ return;
76
+ }
77
+ if (mode === "user-systemd" || mode === "user-launchd") {
78
+ try {
79
+ await execInherit("npm", ["install", "-g", "volute@latest"]);
80
+ } catch (err) {
81
+ console.error(`
82
+ Update failed: ${err.message}`);
83
+ process.exit(1);
84
+ }
85
+ console.log(`Restarting service (${modeLabel(mode)})...`);
86
+ try {
87
+ await restartService(mode);
88
+ } catch (err) {
89
+ console.error(`Failed to restart: ${err instanceof Error ? err.message : err}`);
90
+ process.exit(1);
91
+ }
92
+ {
93
+ const { hostname, port } = readDaemonConfig();
94
+ if (await pollHealth(hostname, port)) {
95
+ console.log(`
96
+ Updated to volute v${result.latest}`);
97
+ } else {
98
+ console.error("Service restarted but daemon did not become healthy.");
99
+ process.exit(1);
100
+ }
101
+ }
102
+ return;
103
+ }
41
104
  const home = voluteHome();
42
105
  const pidPath = resolve(home, "daemon.pid");
43
106
  const configPath = resolve(home, "daemon.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volute",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "CLI for creating and managing self-modifying AI agents powered by the Claude Agent SDK",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,7 +16,7 @@
16
16
  "anthropic"
17
17
  ],
18
18
  "engines": {
19
- "node": ">=22"
19
+ "node": ">=24"
20
20
  },
21
21
  "bin": {
22
22
  "volute": "dist/cli.js"
@@ -1,61 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- run
4
- } from "./chunk-M5AEQLB3.js";
5
- import {
6
- stopDaemon
7
- } from "./chunk-FYQGANL6.js";
8
- import "./chunk-D424ZQGI.js";
9
- import {
10
- daemonFetch
11
- } from "./chunk-STOEJOJO.js";
12
- import {
13
- voluteHome
14
- } from "./chunk-DP2DX4WV.js";
15
- import {
16
- getClient,
17
- urlOf
18
- } from "./chunk-4RQBJWQX.js";
19
- import "./chunk-K3NQKI34.js";
20
-
21
- // src/commands/daemon-restart.ts
22
- import { readFileSync } from "fs";
23
- import { resolve } from "path";
24
- async function run2(args) {
25
- const result = await stopDaemon();
26
- if (!result.stopped && result.reason === "systemd") {
27
- const client = getClient();
28
- await daemonFetch(urlOf(client.api.system.restart.$url()), { method: "POST" });
29
- const config = JSON.parse(readFileSync(resolve(voluteHome(), "daemon.json"), "utf-8"));
30
- let hostname = config.hostname || "localhost";
31
- if (hostname === "0.0.0.0") hostname = "127.0.0.1";
32
- if (hostname === "::") hostname = "[::1]";
33
- const url = new URL("http://localhost");
34
- url.hostname = hostname;
35
- url.port = String(config.port ?? 4200);
36
- const healthUrl = `${url.origin}/api/health`;
37
- const maxWait = 15e3;
38
- const start = Date.now();
39
- while (Date.now() - start < maxWait) {
40
- try {
41
- const res = await fetch(healthUrl);
42
- if (res.ok) {
43
- console.log("Daemon restarted.");
44
- return;
45
- }
46
- } catch {
47
- }
48
- await new Promise((r) => setTimeout(r, 500));
49
- }
50
- console.error("Daemon did not restart within 15s. Check logs.");
51
- process.exit(1);
52
- }
53
- if (!result.stopped && result.reason === "kill-failed") {
54
- console.error("Cannot restart: failed to stop the running daemon.");
55
- process.exit(1);
56
- }
57
- await run(args);
58
- }
59
- export {
60
- run2 as run
61
- };
@@ -1,63 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- daemonFetch
4
- } from "./chunk-STOEJOJO.js";
5
- import "./chunk-DP2DX4WV.js";
6
- import {
7
- getClient,
8
- urlOf
9
- } from "./chunk-4RQBJWQX.js";
10
- import "./chunk-K3NQKI34.js";
11
-
12
- // src/commands/status.ts
13
- async function run(args) {
14
- const name = args[0];
15
- const client = getClient();
16
- if (!name) {
17
- const res2 = await daemonFetch(urlOf(client.api.agents.$url()));
18
- if (!res2.ok) {
19
- const data = await res2.json();
20
- console.error(data.error ?? `Failed to get status: ${res2.status}`);
21
- process.exit(1);
22
- }
23
- const agents = await res2.json();
24
- if (agents.length === 0) {
25
- console.log("No agents registered. Create one with: volute agent create <name>");
26
- return;
27
- }
28
- const nameW = Math.max(4, ...agents.map((a) => a.name.length));
29
- const portW = Math.max(4, ...agents.map((a) => String(a.port).length));
30
- console.log(`${"NAME".padEnd(nameW)} ${"PORT".padEnd(portW)} STATUS CONNECTORS`);
31
- for (const agent2 of agents) {
32
- const connected = agent2.channels.filter((ch) => ch.status === "connected").map((ch) => ch.name);
33
- const connectors = connected.length > 0 ? connected.join(", ") : "-";
34
- console.log(
35
- `${agent2.name.padEnd(nameW)} ${String(agent2.port).padEnd(portW)} ${agent2.status.padEnd(8)} ${connectors}`
36
- );
37
- }
38
- return;
39
- }
40
- const res = await daemonFetch(urlOf(client.api.agents[":name"].$url({ param: { name } })));
41
- if (!res.ok) {
42
- const data = await res.json();
43
- console.error(data.error || `Failed to get status for ${name}`);
44
- process.exit(1);
45
- }
46
- const agent = await res.json();
47
- console.log(`Agent: ${agent.name}`);
48
- console.log(`Port: ${agent.port}`);
49
- console.log(`Status: ${agent.status}`);
50
- for (const ch of agent.channels) {
51
- console.log(`${ch.name}: ${ch.status}`);
52
- }
53
- if (agent.variants && agent.variants.length > 0) {
54
- console.log("");
55
- console.log("Variants:");
56
- for (const v of agent.variants) {
57
- console.log(` ${v.name} port=${v.port} ${v.status}`);
58
- }
59
- }
60
- }
61
- export {
62
- run
63
- };