@vellumai/cli 0.4.37 → 0.4.41
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/package.json +1 -1
- package/src/__tests__/multi-local.test.ts +275 -0
- package/src/__tests__/skills-uninstall.test.ts +3 -1
- package/src/commands/client.ts +23 -7
- package/src/commands/hatch.ts +38 -42
- package/src/commands/ps.ts +32 -12
- package/src/commands/retire.ts +48 -12
- package/src/commands/sleep.ts +25 -6
- package/src/commands/use.ts +44 -0
- package/src/commands/wake.ts +25 -16
- package/src/index.ts +5 -49
- package/src/lib/assistant-config.ts +226 -3
- package/src/lib/constants.ts +6 -0
- package/src/lib/local.ts +187 -49
- package/src/lib/status-emoji.ts +3 -0
package/src/commands/retire.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
77
|
-
console.log(
|
|
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(
|
|
122
|
+
renameSync(dirToArchive, stagingDir);
|
|
87
123
|
} catch (err) {
|
|
88
124
|
console.warn(
|
|
89
|
-
`⚠️ Failed to move ${
|
|
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.");
|
package/src/commands/sleep.ts
CHANGED
|
@@ -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
|
|
16
|
-
const
|
|
17
|
-
|
|
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
|
+
}
|
package/src/commands/wake.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
26
|
-
const hasLocal = assistants.some((a) => a.cloud === "local");
|
|
27
|
-
if (!hasLocal) {
|
|
34
|
+
if (entry.cloud && entry.cloud !== "local") {
|
|
28
35
|
console.error(
|
|
29
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
const
|
|
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
|
|
68
|
-
|
|
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("
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
95
|
-
|
|
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
|
package/src/lib/constants.ts
CHANGED
|
@@ -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;
|