@vellumai/cli 0.4.37 → 0.4.40

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.
@@ -4,7 +4,9 @@ import { homedir } from "os";
4
4
  import { basename, dirname, join } from "path";
5
5
 
6
6
  import {
7
+ defaultLocalResources,
7
8
  findAssistantByName,
9
+ loadAllAssistants,
8
10
  removeAssistantEntry,
9
11
  } from "../lib/assistant-config";
10
12
  import type { AssistantEntry } from "../lib/assistant-config";
@@ -40,18 +42,44 @@ function extractHostFromUrl(url: string): string {
40
42
  }
41
43
  }
42
44
 
43
- function getBaseDir(): string {
44
- return process.env.BASE_DATA_DIR?.trim() || homedir();
45
- }
46
-
47
45
  async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
48
46
  console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n");
49
47
 
50
- const vellumDir = join(getBaseDir(), ".vellum");
48
+ // Use entry resources when available; for legacy entries, derive paths
49
+ // from baseDataDir (which may differ from homedir if BASE_DATA_DIR was set).
50
+ const resources = entry.resources ?? defaultLocalResources();
51
+ const legacyDir = entry.baseDataDir;
52
+ const vellumDir = legacyDir ?? join(resources.instanceDir, ".vellum");
53
+
54
+ // Check whether another local assistant shares the same data directory.
55
+ // Legacy entries without `resources` all resolve to ~/.vellum/ — if we
56
+ // blindly kill processes and archive the directory, we'd destroy the
57
+ // other assistant's running daemon and data.
58
+ const otherSharesDir = loadAllAssistants().some((other) => {
59
+ if (other.cloud !== "local") return false;
60
+ if (other.assistantId === name) return false;
61
+ const otherVellumDir =
62
+ other.baseDataDir ??
63
+ join((other.resources ?? defaultLocalResources()).instanceDir, ".vellum");
64
+ return otherVellumDir === vellumDir;
65
+ });
51
66
 
52
- // Stop daemon via PID file
53
- const daemonPidFile = join(vellumDir, "vellum.pid");
54
- const socketFile = join(vellumDir, "vellum.sock");
67
+ if (otherSharesDir) {
68
+ console.log(
69
+ ` Skipping process stop and archive — another local assistant shares ${vellumDir}.`,
70
+ );
71
+ console.log("\u2705 Local instance retired (config entry removed only).");
72
+ return;
73
+ }
74
+
75
+ // Stop daemon via PID file — prefer resources paths, but for legacy entries
76
+ // with a custom baseDataDir, derive from that directory instead.
77
+ const daemonPidFile = legacyDir
78
+ ? join(legacyDir, "vellum.pid")
79
+ : resources.pidFile;
80
+ const socketFile = legacyDir
81
+ ? join(legacyDir, "vellum.sock")
82
+ : resources.socketPath;
55
83
  const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [
56
84
  socketFile,
57
85
  ]);
@@ -67,14 +95,22 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
67
95
  await stopOrphanedDaemonProcesses();
68
96
  }
69
97
 
98
+ // For named instances (instanceDir differs from homedir), archive and
99
+ // remove the entire instance directory. For the default instance
100
+ // (instanceDir is homedir), archive only the .vellum subdirectory.
101
+ const isNamedInstance = resources.instanceDir !== homedir();
102
+ const dirToArchive = isNamedInstance ? resources.instanceDir : vellumDir;
103
+
70
104
  // Move the data directory out of the way so the path is immediately available
71
105
  // for the next hatch, then kick off the tar archive in the background.
72
106
  const archivePath = getArchivePath(name);
73
107
  const metadataPath = getMetadataPath(name);
74
108
  const stagingDir = `${archivePath}.staging`;
75
109
 
76
- if (!existsSync(vellumDir)) {
77
- console.log(` No data directory at ${vellumDir} — nothing to archive.`);
110
+ if (!existsSync(dirToArchive)) {
111
+ console.log(
112
+ ` No data directory at ${dirToArchive} — nothing to archive.`,
113
+ );
78
114
  console.log("\u2705 Local instance retired.");
79
115
  return;
80
116
  }
@@ -83,10 +119,10 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
83
119
  mkdirSync(dirname(stagingDir), { recursive: true });
84
120
 
85
121
  try {
86
- renameSync(vellumDir, stagingDir);
122
+ renameSync(dirToArchive, stagingDir);
87
123
  } catch (err) {
88
124
  console.warn(
89
- `⚠️ Failed to move ${vellumDir}: ${err instanceof Error ? err.message : err}`,
125
+ `⚠️ Failed to move ${dirToArchive}: ${err instanceof Error ? err.message : err}`,
90
126
  );
91
127
  console.warn("Skipping archive.");
92
128
  console.log("\u2705 Local instance retired.");
@@ -1,20 +1,40 @@
1
- import { homedir } from "os";
2
1
  import { join } from "path";
3
2
 
3
+ import {
4
+ defaultLocalResources,
5
+ resolveTargetAssistant,
6
+ } from "../lib/assistant-config.js";
4
7
  import { stopProcessByPidFile } from "../lib/process";
5
8
 
6
9
  export async function sleep(): Promise<void> {
7
10
  const args = process.argv.slice(3);
8
11
  if (args.includes("--help") || args.includes("-h")) {
9
- console.log("Usage: vellum sleep");
12
+ console.log("Usage: vellum sleep [<name>]");
10
13
  console.log("");
11
14
  console.log("Stop the assistant and gateway processes.");
15
+ console.log("");
16
+ console.log("Arguments:");
17
+ console.log(
18
+ " <name> Name of the assistant to stop (default: active or only local)",
19
+ );
12
20
  process.exit(0);
13
21
  }
14
22
 
15
- const vellumDir = join(homedir(), ".vellum");
16
- const daemonPidFile = join(vellumDir, "vellum.pid");
17
- const socketFile = join(vellumDir, "vellum.sock");
23
+ const nameArg = args.find((a) => !a.startsWith("-"));
24
+ const entry = resolveTargetAssistant(nameArg);
25
+
26
+ if (entry.cloud && entry.cloud !== "local") {
27
+ console.error(
28
+ `Error: 'vellum sleep' only works with local assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
29
+ );
30
+ process.exit(1);
31
+ }
32
+
33
+ const resources = entry.resources ?? defaultLocalResources();
34
+
35
+ const daemonPidFile = resources.pidFile;
36
+ const socketFile = resources.socketPath;
37
+ const vellumDir = join(resources.instanceDir, ".vellum");
18
38
  const gatewayPidFile = join(vellumDir, "gateway.pid");
19
39
 
20
40
  // Stop daemon
@@ -40,5 +60,4 @@ export async function sleep(): Promise<void> {
40
60
  } else {
41
61
  console.log("Gateway stopped.");
42
62
  }
43
-
44
63
  }
@@ -0,0 +1,44 @@
1
+ import {
2
+ findAssistantByName,
3
+ getActiveAssistant,
4
+ setActiveAssistant,
5
+ } from "../lib/assistant-config.js";
6
+
7
+ export async function use(): Promise<void> {
8
+ const args = process.argv.slice(3);
9
+
10
+ if (args.includes("--help") || args.includes("-h")) {
11
+ console.log("Usage: vellum use [<name>]");
12
+ console.log("");
13
+ console.log("Set the active assistant for commands.");
14
+ console.log("");
15
+ console.log("Arguments:");
16
+ console.log(" <name> Name of the assistant to make active");
17
+ console.log("");
18
+ console.log(
19
+ "When called without a name, prints the current active assistant.",
20
+ );
21
+ process.exit(0);
22
+ }
23
+
24
+ const name = args.find((a) => !a.startsWith("-"));
25
+
26
+ if (!name) {
27
+ const active = getActiveAssistant();
28
+ if (active) {
29
+ console.log(`Active assistant: ${active}`);
30
+ } else {
31
+ console.log("No active assistant set.");
32
+ }
33
+ return;
34
+ }
35
+
36
+ const entry = findAssistantByName(name);
37
+ if (!entry) {
38
+ console.error(`No assistant found with name '${name}'.`);
39
+ process.exit(1);
40
+ }
41
+
42
+ setActiveAssistant(name);
43
+ console.log(`Active assistant set to '${name}'.`);
44
+ }
@@ -1,18 +1,25 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
- import { homedir } from "os";
3
2
  import { join } from "path";
4
3
 
5
- import { loadAllAssistants } from "../lib/assistant-config";
4
+ import {
5
+ defaultLocalResources,
6
+ resolveTargetAssistant,
7
+ } from "../lib/assistant-config.js";
6
8
  import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
7
9
  import { startLocalDaemon, startGateway } from "../lib/local";
8
10
 
9
11
  export async function wake(): Promise<void> {
10
12
  const args = process.argv.slice(3);
11
13
  if (args.includes("--help") || args.includes("-h")) {
12
- console.log("Usage: vellum wake [options]");
14
+ console.log("Usage: vellum wake [<name>] [options]");
13
15
  console.log("");
14
16
  console.log("Start the assistant and gateway processes.");
15
17
  console.log("");
18
+ console.log("Arguments:");
19
+ console.log(
20
+ " <name> Name of the assistant to start (default: active or only local)",
21
+ );
22
+ console.log("");
16
23
  console.log("Options:");
17
24
  console.log(
18
25
  " --watch Run assistant and gateway in watch mode (hot reload on source changes)",
@@ -21,19 +28,20 @@ export async function wake(): Promise<void> {
21
28
  }
22
29
 
23
30
  const watch = args.includes("--watch");
31
+ const nameArg = args.find((a) => !a.startsWith("-"));
32
+ const entry = resolveTargetAssistant(nameArg);
24
33
 
25
- const assistants = loadAllAssistants();
26
- const hasLocal = assistants.some((a) => a.cloud === "local");
27
- if (!hasLocal) {
34
+ if (entry.cloud && entry.cloud !== "local") {
28
35
  console.error(
29
- "Error: No local assistant found in lock file. Run 'vellum hatch local' first.",
36
+ `Error: 'vellum wake' only works with local assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
30
37
  );
31
38
  process.exit(1);
32
39
  }
33
40
 
34
- const vellumDir = join(homedir(), ".vellum");
35
- const pidFile = join(vellumDir, "vellum.pid");
36
- const socketFile = join(vellumDir, "vellum.sock");
41
+ const resources = entry.resources ?? defaultLocalResources();
42
+
43
+ const pidFile = resources.pidFile;
44
+ const socketFile = resources.socketPath;
37
45
 
38
46
  // Check if daemon is already running
39
47
  let daemonRunning = false;
@@ -61,11 +69,12 @@ export async function wake(): Promise<void> {
61
69
  }
62
70
 
63
71
  if (!daemonRunning) {
64
- await startLocalDaemon(watch);
72
+ await startLocalDaemon(watch, resources);
65
73
  }
66
74
 
67
- // Start gateway (non-desktop only)
68
- if (!process.env.VELLUM_DESKTOP_APP) {
75
+ // Start gateway
76
+ {
77
+ const vellumDir = join(resources.instanceDir, ".vellum");
69
78
  const gatewayPidFile = join(vellumDir, "gateway.pid");
70
79
  const { alive, pid } = isProcessAlive(gatewayPidFile);
71
80
  if (alive) {
@@ -75,14 +84,14 @@ export async function wake(): Promise<void> {
75
84
  `Gateway running (pid ${pid}) — restarting in watch mode...`,
76
85
  );
77
86
  await stopProcessByPidFile(gatewayPidFile, "gateway");
78
- await startGateway(undefined, watch);
87
+ await startGateway(undefined, watch, resources);
79
88
  } else {
80
89
  console.log(`Gateway already running (pid ${pid}).`);
81
90
  }
82
91
  } else {
83
- await startGateway(undefined, watch);
92
+ await startGateway(undefined, watch, resources);
84
93
  }
85
94
  }
86
95
 
87
- console.log("Wake complete.");
96
+ console.log("Wake complete.");
88
97
  }
package/src/index.ts CHANGED
@@ -1,11 +1,5 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { existsSync } from "node:fs";
4
- import { createRequire } from "node:module";
5
- import { dirname, join } from "node:path";
6
- import { spawn } from "node:child_process";
7
- import { fileURLToPath } from "node:url";
8
-
9
3
  import cliPkg from "../package.json";
10
4
  import { client } from "./commands/client";
11
5
  import { hatch } from "./commands/hatch";
@@ -18,6 +12,7 @@ import { skills } from "./commands/skills";
18
12
  import { sleep } from "./commands/sleep";
19
13
  import { ssh } from "./commands/ssh";
20
14
  import { tunnel } from "./commands/tunnel";
15
+ import { use } from "./commands/use";
21
16
  import { wake } from "./commands/wake";
22
17
 
23
18
  const commands = {
@@ -33,37 +28,13 @@ const commands = {
33
28
  sleep,
34
29
  ssh,
35
30
  tunnel,
31
+ use,
36
32
  wake,
37
33
  whoami,
38
34
  } as const;
39
35
 
40
36
  type CommandName = keyof typeof commands;
41
37
 
42
- function resolveAssistantEntry(): string | undefined {
43
- // When installed globally, resolve from node_modules
44
- try {
45
- const require = createRequire(import.meta.url);
46
- const assistantPkgPath =
47
- require.resolve("@vellumai/assistant/package.json");
48
- return join(dirname(assistantPkgPath), "src", "index.ts");
49
- } catch {
50
- // For local development, resolve from sibling directory
51
- const __dirname = dirname(fileURLToPath(import.meta.url));
52
- const localPath = join(
53
- __dirname,
54
- "..",
55
- "..",
56
- "assistant",
57
- "src",
58
- "index.ts",
59
- );
60
- if (existsSync(localPath)) {
61
- return localPath;
62
- }
63
- }
64
- return undefined;
65
- }
66
-
67
38
  async function main() {
68
39
  const args = process.argv.slice(2);
69
40
  const commandName = args[0];
@@ -77,11 +48,7 @@ async function main() {
77
48
  console.log("Usage: vellum <command> [options]");
78
49
  console.log("");
79
50
  console.log("Commands:");
80
- console.log(" autonomy View and configure autonomy tiers");
81
51
  console.log(" client Connect to a hatched assistant");
82
- console.log(" config Manage configuration");
83
- console.log(" contacts Manage assistant contacts");
84
- console.log(" email Email operations (provider-agnostic)");
85
52
  console.log(" hatch Create a new assistant instance");
86
53
  console.log(" login Log in to the Vellum platform");
87
54
  console.log(" logout Log out of the Vellum platform");
@@ -95,6 +62,7 @@ async function main() {
95
62
  console.log(" sleep Stop the assistant process");
96
63
  console.log(" ssh SSH into a remote assistant instance");
97
64
  console.log(" tunnel Create a tunnel for a locally hosted assistant");
65
+ console.log(" use Set the active assistant for commands");
98
66
  console.log(" wake Start the assistant and gateway");
99
67
  console.log(" whoami Show current logged-in user");
100
68
  process.exit(0);
@@ -103,20 +71,8 @@ async function main() {
103
71
  const command = commands[commandName as CommandName];
104
72
 
105
73
  if (!command) {
106
- const assistantEntry = resolveAssistantEntry();
107
- if (assistantEntry) {
108
- const child = spawn("bun", ["run", assistantEntry, ...args], {
109
- stdio: "inherit",
110
- });
111
- child.on("exit", (code) => {
112
- process.exit(code ?? 1);
113
- });
114
- } else {
115
- console.error(`Unknown command: ${commandName}`);
116
- console.error("Install the full stack with: bun install -g vellum");
117
- process.exit(1);
118
- }
119
- return;
74
+ console.error(`Unknown command: ${commandName}`);
75
+ process.exit(1);
120
76
  }
121
77
 
122
78
  try {
@@ -1,10 +1,49 @@
1
- import { existsSync, readFileSync, writeFileSync } from "fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
+ import {
6
+ DEFAULT_DAEMON_PORT,
7
+ DEFAULT_GATEWAY_PORT,
8
+ DEFAULT_QDRANT_PORT,
9
+ } from "./constants.js";
10
+ import { probePort } from "./port-probe.js";
11
+
12
+ /**
13
+ * Per-instance resource paths and ports. Each local assistant instance gets
14
+ * its own directory tree, ports, and socket so multiple instances can run
15
+ * side-by-side without conflicts.
16
+ */
17
+ export interface LocalInstanceResources {
18
+ /**
19
+ * Instance-specific data root. The first local assistant uses `~` (workspace
20
+ * at `~/.vellum`); subsequent assistants use
21
+ * `~/.local/share/vellum/assistants/<name>/` (workspace at
22
+ * `~/.local/share/vellum/assistants/<name>/.vellum`).
23
+ * The daemon's `.vellum/` directory lives inside it. Equivalent to
24
+ * `AssistantEntry.baseDataDir` minus the trailing `/.vellum` suffix —
25
+ * `baseDataDir` is kept on the flat entry for legacy lockfile compat.
26
+ */
27
+ instanceDir: string;
28
+ /** HTTP port for the daemon runtime server */
29
+ daemonPort: number;
30
+ /** HTTP port for the gateway */
31
+ gatewayPort: number;
32
+ /** HTTP port for the Qdrant vector store */
33
+ qdrantPort: number;
34
+ /** Absolute path to the Unix domain socket for IPC */
35
+ socketPath: string;
36
+ /** Absolute path to the daemon PID file */
37
+ pidFile: string;
38
+ }
39
+
5
40
  export interface AssistantEntry {
6
41
  assistantId: string;
7
42
  runtimeUrl: string;
43
+ /** Loopback URL for same-machine health checks (e.g. `http://127.0.0.1:7831`).
44
+ * Avoids mDNS resolution issues when the machine checks its own gateway. */
45
+ localUrl?: string;
46
+ /** @deprecated Use `resources.instanceDir` for multi-instance entries. Legacy equivalent of `join(instanceDir, ".vellum")`. */
8
47
  baseDataDir?: string;
9
48
  bearerToken?: string;
10
49
  cloud: string;
@@ -16,10 +55,13 @@ export interface AssistantEntry {
16
55
  sshUser?: string;
17
56
  zone?: string;
18
57
  hatchedAt?: string;
58
+ /** Per-instance resource config. Present for local entries in multi-instance setups. */
59
+ resources?: LocalInstanceResources;
19
60
  }
20
61
 
21
62
  interface LockfileData {
22
63
  assistants?: AssistantEntry[];
64
+ activeAssistant?: string;
23
65
  platformBaseUrl?: string;
24
66
  [key: string]: unknown;
25
67
  }
@@ -91,14 +133,70 @@ export function findAssistantByName(name: string): AssistantEntry | null {
91
133
  }
92
134
 
93
135
  export function removeAssistantEntry(assistantId: string): void {
94
- const entries = readAssistants();
95
- writeAssistants(entries.filter((e) => e.assistantId !== assistantId));
136
+ const data = readLockfile();
137
+ const entries = (data.assistants ?? []).filter(
138
+ (e: AssistantEntry) => e.assistantId !== assistantId,
139
+ );
140
+ data.assistants = entries;
141
+ // Clear active assistant if it matches the removed entry
142
+ if (data.activeAssistant === assistantId) {
143
+ delete data.activeAssistant;
144
+ }
145
+ writeLockfile(data);
96
146
  }
97
147
 
98
148
  export function loadAllAssistants(): AssistantEntry[] {
99
149
  return readAssistants();
100
150
  }
101
151
 
152
+ export function getActiveAssistant(): string | null {
153
+ const data = readLockfile();
154
+ return data.activeAssistant ?? null;
155
+ }
156
+
157
+ export function setActiveAssistant(assistantId: string): void {
158
+ const data = readLockfile();
159
+ data.activeAssistant = assistantId;
160
+ writeLockfile(data);
161
+ }
162
+
163
+ /**
164
+ * Resolve which assistant to target for a command. Priority:
165
+ * 1. Explicit name argument
166
+ * 2. Active assistant set via `vellum use`
167
+ * 3. Sole local assistant (when exactly one exists)
168
+ */
169
+ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
170
+ if (nameArg) {
171
+ const entry = findAssistantByName(nameArg);
172
+ if (!entry) {
173
+ console.error(`No assistant found with name '${nameArg}'.`);
174
+ process.exit(1);
175
+ }
176
+ return entry;
177
+ }
178
+
179
+ const active = getActiveAssistant();
180
+ if (active) {
181
+ const entry = findAssistantByName(active);
182
+ if (entry) return entry;
183
+ // Active assistant no longer exists in lockfile — fall through
184
+ }
185
+
186
+ const all = readAssistants();
187
+ const locals = all.filter((e) => e.cloud === "local");
188
+ if (locals.length === 1) return locals[0];
189
+
190
+ if (locals.length === 0) {
191
+ console.error("No local assistant found. Run 'vellum hatch local' first.");
192
+ } else {
193
+ console.error(
194
+ `Multiple assistants found. Set an active assistant with 'vellum use <name>'.`,
195
+ );
196
+ }
197
+ process.exit(1);
198
+ }
199
+
102
200
  export function saveAssistantEntry(entry: AssistantEntry): void {
103
201
  const entries = readAssistants().filter(
104
202
  (e) => e.assistantId !== entry.assistantId,
@@ -107,6 +205,131 @@ export function saveAssistantEntry(entry: AssistantEntry): void {
107
205
  writeAssistants(entries);
108
206
  }
109
207
 
208
+ /**
209
+ * Scan upward from `basePort` to find an available port. A port is considered
210
+ * available when `probePort()` returns false (nothing listening). Scans up to
211
+ * 100 ports above the base before giving up.
212
+ */
213
+ async function findAvailablePort(
214
+ basePort: number,
215
+ excludedPorts: number[] = [],
216
+ ): Promise<number> {
217
+ const maxOffset = 100;
218
+ for (let offset = 0; offset < maxOffset; offset++) {
219
+ const port = basePort + offset;
220
+ if (excludedPorts.includes(port)) continue;
221
+ const inUse = await probePort(port);
222
+ if (!inUse) return port;
223
+ }
224
+ throw new Error(
225
+ `Could not find an available port scanning from ${basePort} to ${basePort + maxOffset - 1}`,
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Allocate an isolated set of resources for a named local instance.
231
+ * The first local assistant gets `instanceDir = ~` with default ports (same as
232
+ * legacy single-instance layout). Subsequent assistants are placed under
233
+ * `~/.local/share/vellum/assistants/<name>/` with scanned ports.
234
+ */
235
+ export async function allocateLocalResources(
236
+ instanceName: string,
237
+ ): Promise<LocalInstanceResources> {
238
+ // First local assistant gets the home directory — identical to legacy layout.
239
+ const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
240
+ if (existingLocals.length === 0) {
241
+ return defaultLocalResources();
242
+ }
243
+
244
+ const instanceDir = join(
245
+ homedir(),
246
+ ".local",
247
+ "share",
248
+ "vellum",
249
+ "assistants",
250
+ instanceName,
251
+ );
252
+ mkdirSync(instanceDir, { recursive: true });
253
+
254
+ // Collect ports already assigned to other local instances in the lockfile.
255
+ // Even if those instances are stopped, we must avoid reusing their ports
256
+ // to prevent binding collisions when both are woken.
257
+ const reservedPorts: number[] = [];
258
+ for (const entry of loadAllAssistants()) {
259
+ if (entry.cloud !== "local") continue;
260
+ if (entry.resources) {
261
+ reservedPorts.push(
262
+ entry.resources.daemonPort,
263
+ entry.resources.gatewayPort,
264
+ entry.resources.qdrantPort,
265
+ );
266
+ } else {
267
+ // Legacy entries without resources use the default ports
268
+ reservedPorts.push(
269
+ DEFAULT_DAEMON_PORT,
270
+ DEFAULT_GATEWAY_PORT,
271
+ DEFAULT_QDRANT_PORT,
272
+ );
273
+ }
274
+ }
275
+
276
+ // Allocate ports sequentially to avoid overlapping ranges assigning the
277
+ // same port to multiple services (e.g. daemon 7821-7920 overlaps gateway 7830-7929).
278
+ const daemonPort = await findAvailablePort(
279
+ DEFAULT_DAEMON_PORT,
280
+ reservedPorts,
281
+ );
282
+ const gatewayPort = await findAvailablePort(DEFAULT_GATEWAY_PORT, [
283
+ ...reservedPorts,
284
+ daemonPort,
285
+ ]);
286
+ const qdrantPort = await findAvailablePort(DEFAULT_QDRANT_PORT, [
287
+ ...reservedPorts,
288
+ daemonPort,
289
+ gatewayPort,
290
+ ]);
291
+
292
+ return {
293
+ instanceDir,
294
+ daemonPort,
295
+ gatewayPort,
296
+ qdrantPort,
297
+ socketPath: join(instanceDir, ".vellum", "vellum.sock"),
298
+ pidFile: join(instanceDir, ".vellum", "vellum.pid"),
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Return default resources representing the legacy single-instance layout.
304
+ * Used to normalize existing lockfile entries so callers can treat all local
305
+ * entries uniformly.
306
+ */
307
+ export function defaultLocalResources(): LocalInstanceResources {
308
+ const vellumDir = join(homedir(), ".vellum");
309
+ return {
310
+ instanceDir: homedir(),
311
+ daemonPort: DEFAULT_DAEMON_PORT,
312
+ gatewayPort: DEFAULT_GATEWAY_PORT,
313
+ qdrantPort: DEFAULT_QDRANT_PORT,
314
+ socketPath: join(vellumDir, "vellum.sock"),
315
+ pidFile: join(vellumDir, "vellum.pid"),
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Normalize existing lockfile entries so local entries include resource fields.
321
+ * Remote entries are left untouched. Returns a new array (does not mutate input).
322
+ */
323
+ export function normalizeExistingEntryResources(
324
+ entries: AssistantEntry[],
325
+ ): AssistantEntry[] {
326
+ return entries.map((entry) => {
327
+ if (entry.cloud !== "local") return entry;
328
+ if (entry.resources) return entry;
329
+ return { ...entry, resources: defaultLocalResources() };
330
+ });
331
+ }
332
+
110
333
  /**
111
334
  * Read the assistant config file and sync client-relevant values to the
112
335
  * lockfile. This lets external tools (e.g. vel) discover the platform URL
@@ -2,6 +2,12 @@ export const FIREWALL_TAG = "vellum-assistant";
2
2
  export const GATEWAY_PORT = process.env.GATEWAY_PORT
3
3
  ? Number(process.env.GATEWAY_PORT)
4
4
  : 7830;
5
+
6
+ /** Default ports used as scan start points for multi-instance allocation. */
7
+ export const DEFAULT_DAEMON_PORT = 7821;
8
+ export const DEFAULT_GATEWAY_PORT = 7830;
9
+ export const DEFAULT_QDRANT_PORT = 6333;
10
+
5
11
  export const VALID_REMOTE_HOSTS = ["local", "gcp", "aws", "custom"] as const;
6
12
  export type RemoteHost = (typeof VALID_REMOTE_HOSTS)[number];
7
13
  export const VALID_SPECIES = ["openclaw", "vellum"] as const;