@vellumai/cli 0.4.42 → 0.4.44
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__/assistant-config.test.ts +282 -3
- package/src/__tests__/multi-local.test.ts +13 -21
- package/src/__tests__/sleep.test.ts +172 -0
- package/src/commands/client.ts +72 -10
- package/src/commands/hatch.ts +65 -14
- package/src/commands/ps.ts +25 -8
- package/src/commands/recover.ts +17 -8
- package/src/commands/retire.ts +14 -23
- package/src/commands/sleep.ts +88 -16
- package/src/commands/wake.ts +9 -7
- package/src/components/DefaultMainScreen.tsx +19 -85
- package/src/index.ts +0 -3
- package/src/lib/assistant-config.ts +154 -61
- package/src/lib/aws.ts +30 -1
- package/src/lib/docker.ts +321 -0
- package/src/lib/gcp.ts +53 -1
- package/src/lib/http-client.ts +114 -0
- package/src/lib/local.ts +117 -167
- package/src/lib/step-runner.ts +9 -1
- package/src/lib/xdg-log.ts +47 -3
- package/src/__tests__/skills-uninstall.test.ts +0 -203
- package/src/commands/skills.ts +0 -514
package/src/commands/client.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { hostname } from "os";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
findAssistantByName,
|
|
3
5
|
getActiveAssistant,
|
|
4
6
|
loadLatestAssistant,
|
|
5
7
|
} from "../lib/assistant-config";
|
|
6
8
|
import { GATEWAY_PORT, type Species } from "../lib/constants";
|
|
9
|
+
import { getLocalLanIPv4, getMacLocalHostname } from "../lib/local";
|
|
7
10
|
|
|
8
11
|
const ANSI = {
|
|
9
12
|
reset: "\x1b[0m",
|
|
@@ -46,7 +49,7 @@ function parseArgs(): ParsedArgs {
|
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
let entry: ReturnType<typeof findAssistantByName
|
|
52
|
+
let entry: ReturnType<typeof findAssistantByName> = null;
|
|
50
53
|
if (positionalName) {
|
|
51
54
|
entry = findAssistantByName(positionalName);
|
|
52
55
|
if (!entry) {
|
|
@@ -55,15 +58,30 @@ function parseArgs(): ParsedArgs {
|
|
|
55
58
|
);
|
|
56
59
|
process.exit(1);
|
|
57
60
|
}
|
|
58
|
-
} else if (process.env.RUNTIME_URL) {
|
|
59
|
-
// Explicit env var — skip assistant resolution, will use env values below
|
|
60
|
-
entry = loadLatestAssistant();
|
|
61
61
|
} else {
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
const hasExplicitUrl =
|
|
63
|
+
process.env.RUNTIME_URL ||
|
|
64
|
+
flagArgs.includes("--url") ||
|
|
65
|
+
flagArgs.includes("-u");
|
|
64
66
|
const active = getActiveAssistant();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
if (active) {
|
|
68
|
+
entry = findAssistantByName(active);
|
|
69
|
+
if (!entry && !hasExplicitUrl) {
|
|
70
|
+
console.error(
|
|
71
|
+
`Active assistant '${active}' not found in lockfile. Set an active assistant with 'vellum use <name>'.`,
|
|
72
|
+
);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!entry && hasExplicitUrl) {
|
|
77
|
+
// URL provided but active assistant missing or unset — use latest for remaining defaults
|
|
78
|
+
entry = loadLatestAssistant();
|
|
79
|
+
} else if (!entry) {
|
|
80
|
+
console.error(
|
|
81
|
+
"No active assistant set. Set one with 'vellum use <name>' or specify a name: 'vellum client <name>'.",
|
|
82
|
+
);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
let runtimeUrl =
|
|
@@ -90,7 +108,7 @@ function parseArgs(): ParsedArgs {
|
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
return {
|
|
93
|
-
runtimeUrl: runtimeUrl.replace(/\/+$/, ""),
|
|
111
|
+
runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
|
|
94
112
|
assistantId,
|
|
95
113
|
species,
|
|
96
114
|
bearerToken,
|
|
@@ -99,6 +117,50 @@ function parseArgs(): ParsedArgs {
|
|
|
99
117
|
};
|
|
100
118
|
}
|
|
101
119
|
|
|
120
|
+
/**
|
|
121
|
+
* If the hostname in `url` matches this machine's local DNS name, LAN IP, or
|
|
122
|
+
* raw hostname, replace it with 127.0.0.1 so the client avoids mDNS round-trips
|
|
123
|
+
* when talking to an assistant running on the same machine.
|
|
124
|
+
*/
|
|
125
|
+
function maybeSwapToLocalhost(url: string): string {
|
|
126
|
+
let parsed: URL;
|
|
127
|
+
try {
|
|
128
|
+
parsed = new URL(url);
|
|
129
|
+
} catch {
|
|
130
|
+
return url;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const urlHost = parsed.hostname.toLowerCase();
|
|
134
|
+
|
|
135
|
+
const localNames: string[] = [];
|
|
136
|
+
|
|
137
|
+
const host = hostname();
|
|
138
|
+
if (host) {
|
|
139
|
+
localNames.push(host.toLowerCase());
|
|
140
|
+
// Also consider the bare name without .local suffix
|
|
141
|
+
if (host.toLowerCase().endsWith(".local")) {
|
|
142
|
+
localNames.push(host.toLowerCase().slice(0, -".local".length));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const macHost = getMacLocalHostname();
|
|
147
|
+
if (macHost) {
|
|
148
|
+
localNames.push(macHost.toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const lanIp = getLocalLanIPv4();
|
|
152
|
+
if (lanIp) {
|
|
153
|
+
localNames.push(lanIp);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (localNames.includes(urlHost)) {
|
|
157
|
+
parsed.hostname = "127.0.0.1";
|
|
158
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return url;
|
|
162
|
+
}
|
|
163
|
+
|
|
102
164
|
function printUsage(): void {
|
|
103
165
|
console.log(`${ANSI.bold}vellum client${ANSI.reset} - Connect to a hatched assistant
|
|
104
166
|
|
|
@@ -106,7 +168,7 @@ ${ANSI.bold}USAGE:${ANSI.reset}
|
|
|
106
168
|
vellum client [name] [options]
|
|
107
169
|
|
|
108
170
|
${ANSI.bold}ARGUMENTS:${ANSI.reset}
|
|
109
|
-
[name] Instance name (default:
|
|
171
|
+
[name] Instance name (default: active)
|
|
110
172
|
|
|
111
173
|
${ANSI.bold}OPTIONS:${ANSI.reset}
|
|
112
174
|
-u, --url <url> Runtime URL
|
package/src/commands/hatch.ts
CHANGED
|
@@ -22,10 +22,10 @@ import cliPkg from "../../package.json";
|
|
|
22
22
|
import { buildOpenclawStartupScript } from "../adapters/openclaw";
|
|
23
23
|
import {
|
|
24
24
|
allocateLocalResources,
|
|
25
|
-
defaultLocalResources,
|
|
26
25
|
findAssistantByName,
|
|
27
26
|
loadAllAssistants,
|
|
28
27
|
saveAssistantEntry,
|
|
28
|
+
setActiveAssistant,
|
|
29
29
|
syncConfigToLockfile,
|
|
30
30
|
} from "../lib/assistant-config";
|
|
31
31
|
import type {
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
VALID_SPECIES,
|
|
40
40
|
} from "../lib/constants";
|
|
41
41
|
import type { RemoteHost, Species } from "../lib/constants";
|
|
42
|
+
import { hatchDocker } from "../lib/docker";
|
|
42
43
|
import { hatchGcp } from "../lib/gcp";
|
|
43
44
|
import type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
44
45
|
import {
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
import { isProcessAlive } from "../lib/process";
|
|
50
51
|
import { generateRandomSuffix } from "../lib/random-name";
|
|
51
52
|
import { validateAssistantName } from "../lib/retire-archive";
|
|
53
|
+
import { archiveLogFile, resetLogFile } from "../lib/xdg-log";
|
|
52
54
|
|
|
53
55
|
export type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
54
56
|
|
|
@@ -169,6 +171,7 @@ const DEFAULT_REMOTE: RemoteHost = "local";
|
|
|
169
171
|
interface HatchArgs {
|
|
170
172
|
species: Species;
|
|
171
173
|
detached: boolean;
|
|
174
|
+
keepAlive: boolean;
|
|
172
175
|
name: string | null;
|
|
173
176
|
remote: RemoteHost;
|
|
174
177
|
daemonOnly: boolean;
|
|
@@ -180,6 +183,7 @@ function parseArgs(): HatchArgs {
|
|
|
180
183
|
const args = process.argv.slice(3);
|
|
181
184
|
let species: Species = DEFAULT_SPECIES;
|
|
182
185
|
let detached = false;
|
|
186
|
+
let keepAlive = false;
|
|
183
187
|
let name: string | null = null;
|
|
184
188
|
let remote: RemoteHost = DEFAULT_REMOTE;
|
|
185
189
|
let daemonOnly = false;
|
|
@@ -212,6 +216,9 @@ function parseArgs(): HatchArgs {
|
|
|
212
216
|
console.log(
|
|
213
217
|
" --watch Run assistant and gateway in watch mode (hot reload on source changes)",
|
|
214
218
|
);
|
|
219
|
+
console.log(
|
|
220
|
+
" --keep-alive Stay alive after hatch, exit when gateway stops",
|
|
221
|
+
);
|
|
215
222
|
process.exit(0);
|
|
216
223
|
} else if (arg === "-d") {
|
|
217
224
|
detached = true;
|
|
@@ -221,6 +228,8 @@ function parseArgs(): HatchArgs {
|
|
|
221
228
|
restart = true;
|
|
222
229
|
} else if (arg === "--watch") {
|
|
223
230
|
watch = true;
|
|
231
|
+
} else if (arg === "--keep-alive") {
|
|
232
|
+
keepAlive = true;
|
|
224
233
|
} else if (arg === "--name") {
|
|
225
234
|
const next = args[i + 1];
|
|
226
235
|
if (!next || next.startsWith("-")) {
|
|
@@ -251,13 +260,13 @@ function parseArgs(): HatchArgs {
|
|
|
251
260
|
species = arg as Species;
|
|
252
261
|
} else {
|
|
253
262
|
console.error(
|
|
254
|
-
`Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
|
|
263
|
+
`Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --watch, --keep-alive, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
|
|
255
264
|
);
|
|
256
265
|
process.exit(1);
|
|
257
266
|
}
|
|
258
267
|
}
|
|
259
268
|
|
|
260
|
-
return { species, detached, name, remote, daemonOnly, restart, watch };
|
|
269
|
+
return { species, detached, keepAlive, name, remote, daemonOnly, restart, watch };
|
|
261
270
|
}
|
|
262
271
|
|
|
263
272
|
function formatElapsed(ms: number): string {
|
|
@@ -682,6 +691,7 @@ async function hatchLocal(
|
|
|
682
691
|
daemonOnly: boolean = false,
|
|
683
692
|
restart: boolean = false,
|
|
684
693
|
watch: boolean = false,
|
|
694
|
+
keepAlive: boolean = false,
|
|
685
695
|
): Promise<void> {
|
|
686
696
|
if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
|
|
687
697
|
console.error(
|
|
@@ -717,14 +727,13 @@ async function hatchLocal(
|
|
|
717
727
|
const existingEntry = findAssistantByName(instanceName);
|
|
718
728
|
if (existingEntry?.cloud === "local" && existingEntry.resources) {
|
|
719
729
|
resources = existingEntry.resources;
|
|
720
|
-
} else if (restart && existingEntry?.cloud === "local") {
|
|
721
|
-
// Legacy entry without resources — use default paths to match existing layout
|
|
722
|
-
resources = defaultLocalResources();
|
|
723
730
|
} else {
|
|
724
731
|
resources = await allocateLocalResources(instanceName);
|
|
725
732
|
}
|
|
726
733
|
|
|
727
|
-
const
|
|
734
|
+
const logsDir = join(resources.instanceDir, ".vellum", "workspace", "data", "logs");
|
|
735
|
+
archiveLogFile("hatch.log", logsDir);
|
|
736
|
+
resetLogFile("hatch.log");
|
|
728
737
|
|
|
729
738
|
console.log(`🥚 Hatching local assistant: ${instanceName}`);
|
|
730
739
|
console.log(` Species: ${species}`);
|
|
@@ -761,7 +770,6 @@ async function hatchLocal(
|
|
|
761
770
|
assistantId: instanceName,
|
|
762
771
|
runtimeUrl,
|
|
763
772
|
localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
|
|
764
|
-
baseDataDir,
|
|
765
773
|
bearerToken,
|
|
766
774
|
cloud: "local",
|
|
767
775
|
species,
|
|
@@ -770,6 +778,7 @@ async function hatchLocal(
|
|
|
770
778
|
};
|
|
771
779
|
if (!daemonOnly && !restart) {
|
|
772
780
|
saveAssistantEntry(localEntry);
|
|
781
|
+
setActiveAssistant(instanceName);
|
|
773
782
|
syncConfigToLockfile();
|
|
774
783
|
|
|
775
784
|
if (process.env.VELLUM_DESKTOP_APP) {
|
|
@@ -790,6 +799,46 @@ async function hatchLocal(
|
|
|
790
799
|
const localGatewayUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
791
800
|
await displayPairingQRCode(localGatewayUrl, bearerToken, runtimeUrl);
|
|
792
801
|
}
|
|
802
|
+
|
|
803
|
+
if (keepAlive) {
|
|
804
|
+
const gatewayHealthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
|
|
805
|
+
const POLL_INTERVAL_MS = 5000;
|
|
806
|
+
const MAX_FAILURES = 3;
|
|
807
|
+
let consecutiveFailures = 0;
|
|
808
|
+
|
|
809
|
+
const shutdown = async (): Promise<void> => {
|
|
810
|
+
console.log("\nShutting down local processes...");
|
|
811
|
+
await stopLocalProcesses(resources);
|
|
812
|
+
process.exit(0);
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
process.on("SIGTERM", () => void shutdown());
|
|
816
|
+
process.on("SIGINT", () => void shutdown());
|
|
817
|
+
|
|
818
|
+
// Poll the gateway health endpoint until it stops responding.
|
|
819
|
+
while (true) {
|
|
820
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
821
|
+
try {
|
|
822
|
+
const res = await fetch(gatewayHealthUrl, {
|
|
823
|
+
signal: AbortSignal.timeout(3000),
|
|
824
|
+
});
|
|
825
|
+
if (res.ok) {
|
|
826
|
+
consecutiveFailures = 0;
|
|
827
|
+
} else {
|
|
828
|
+
consecutiveFailures++;
|
|
829
|
+
}
|
|
830
|
+
} catch {
|
|
831
|
+
consecutiveFailures++;
|
|
832
|
+
}
|
|
833
|
+
if (consecutiveFailures >= MAX_FAILURES) {
|
|
834
|
+
console.log(
|
|
835
|
+
"\n⚠️ Gateway stopped responding — shutting down.",
|
|
836
|
+
);
|
|
837
|
+
await stopLocalProcesses(resources);
|
|
838
|
+
process.exit(1);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
793
842
|
}
|
|
794
843
|
|
|
795
844
|
function getCliVersion(): string {
|
|
@@ -800,7 +849,7 @@ export async function hatch(): Promise<void> {
|
|
|
800
849
|
const cliVersion = getCliVersion();
|
|
801
850
|
console.log(`@vellumai/cli v${cliVersion}`);
|
|
802
851
|
|
|
803
|
-
const { species, detached, name, remote, daemonOnly, restart, watch } =
|
|
852
|
+
const { species, detached, keepAlive, name, remote, daemonOnly, restart, watch } =
|
|
804
853
|
parseArgs();
|
|
805
854
|
|
|
806
855
|
if (restart && remote !== "local") {
|
|
@@ -810,13 +859,15 @@ export async function hatch(): Promise<void> {
|
|
|
810
859
|
process.exit(1);
|
|
811
860
|
}
|
|
812
861
|
|
|
813
|
-
if (watch && remote !== "local") {
|
|
814
|
-
console.error(
|
|
862
|
+
if (watch && remote !== "local" && remote !== "docker") {
|
|
863
|
+
console.error(
|
|
864
|
+
"Error: --watch is only supported for local and docker hatch targets.",
|
|
865
|
+
);
|
|
815
866
|
process.exit(1);
|
|
816
867
|
}
|
|
817
868
|
|
|
818
869
|
if (remote === "local") {
|
|
819
|
-
await hatchLocal(species, name, daemonOnly, restart, watch);
|
|
870
|
+
await hatchLocal(species, name, daemonOnly, restart, watch, keepAlive);
|
|
820
871
|
return;
|
|
821
872
|
}
|
|
822
873
|
|
|
@@ -831,8 +882,8 @@ export async function hatch(): Promise<void> {
|
|
|
831
882
|
}
|
|
832
883
|
|
|
833
884
|
if (remote === "docker") {
|
|
834
|
-
|
|
835
|
-
|
|
885
|
+
await hatchDocker(species, detached, name, watch);
|
|
886
|
+
return;
|
|
836
887
|
}
|
|
837
888
|
|
|
838
889
|
console.error(`Error: Remote host '${remote}' is not yet supported.`);
|
package/src/commands/ps.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { homedir } from "os";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
-
defaultLocalResources,
|
|
7
6
|
findAssistantByName,
|
|
8
7
|
getActiveAssistant,
|
|
9
8
|
loadAllAssistants,
|
|
@@ -178,45 +177,65 @@ interface DetectedProcess {
|
|
|
178
177
|
pid: string | null;
|
|
179
178
|
port: number;
|
|
180
179
|
running: boolean;
|
|
180
|
+
watch: boolean;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function isWatchMode(pid: string): Promise<boolean> {
|
|
184
|
+
try {
|
|
185
|
+
const args = await execOutput("ps", ["-p", pid, "-o", "args="]);
|
|
186
|
+
return args.includes("--watch");
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
async function detectProcess(spec: ProcessSpec): Promise<DetectedProcess> {
|
|
184
193
|
// Tier 1: pgrep by process title
|
|
185
194
|
const pids = await pgrepExact(spec.pgrepName);
|
|
186
195
|
if (pids.length > 0) {
|
|
187
|
-
|
|
196
|
+
const watch = await isWatchMode(pids[0]);
|
|
197
|
+
return { name: spec.name, pid: pids[0], port: spec.port, running: true, watch };
|
|
188
198
|
}
|
|
189
199
|
|
|
190
200
|
// Tier 2: TCP port probe (skip for processes without a port)
|
|
191
201
|
const listening = spec.port > 0 && (await probePort(spec.port));
|
|
192
202
|
if (listening) {
|
|
193
203
|
const filePid = readPidFile(spec.pidFile);
|
|
204
|
+
const watch = filePid ? await isWatchMode(filePid) : false;
|
|
194
205
|
return {
|
|
195
206
|
name: spec.name,
|
|
196
207
|
pid: filePid,
|
|
197
208
|
port: spec.port,
|
|
198
209
|
running: true,
|
|
210
|
+
watch,
|
|
199
211
|
};
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
// Tier 3: PID file fallback
|
|
203
215
|
const filePid = readPidFile(spec.pidFile);
|
|
204
216
|
if (filePid && isProcessAlive(filePid)) {
|
|
205
|
-
|
|
217
|
+
const watch = await isWatchMode(filePid);
|
|
218
|
+
return { name: spec.name, pid: filePid, port: spec.port, running: true, watch };
|
|
206
219
|
}
|
|
207
220
|
|
|
208
|
-
return { name: spec.name, pid: null, port: spec.port, running: false };
|
|
221
|
+
return { name: spec.name, pid: null, port: spec.port, running: false, watch: false };
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
function formatDetectionInfo(proc: DetectedProcess): string {
|
|
212
225
|
const parts: string[] = [];
|
|
213
226
|
if (proc.pid) parts.push(`PID ${proc.pid}`);
|
|
214
227
|
if (proc.port > 0) parts.push(`port ${proc.port}`);
|
|
228
|
+
if (proc.watch) parts.push("watch");
|
|
215
229
|
return parts.join(" | ");
|
|
216
230
|
}
|
|
217
231
|
|
|
218
232
|
async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
|
|
219
|
-
|
|
233
|
+
if (!entry.resources) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Local assistant '${entry.assistantId}' is missing resource configuration. Re-hatch to fix.`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const resources = entry.resources;
|
|
220
239
|
const vellumDir = join(resources.instanceDir, ".vellum");
|
|
221
240
|
|
|
222
241
|
const specs: ProcessSpec[] = [
|
|
@@ -409,9 +428,7 @@ async function listAllAssistants(): Promise<void> {
|
|
|
409
428
|
// process isn't running, the assistant is sleeping — skip the
|
|
410
429
|
// network health check to avoid a misleading "unreachable" status.
|
|
411
430
|
let health: { status: string; detail: string | null };
|
|
412
|
-
const resources =
|
|
413
|
-
a.resources ??
|
|
414
|
-
(a.cloud === "local" ? defaultLocalResources() : undefined);
|
|
431
|
+
const resources = a.resources;
|
|
415
432
|
if (a.cloud === "local" && resources) {
|
|
416
433
|
const pid = readPidFile(resources.pidFile);
|
|
417
434
|
const alive = pid !== null && isProcessAlive(pid);
|
package/src/commands/recover.ts
CHANGED
|
@@ -37,7 +37,17 @@ export async function recover(): Promise<void> {
|
|
|
37
37
|
process.exit(1);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// 2.
|
|
40
|
+
// 2. Read and validate metadata before any side effects
|
|
41
|
+
const entry: AssistantEntry = JSON.parse(readFileSync(metadataPath, "utf-8"));
|
|
42
|
+
if (!entry.resources) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Retired assistant '${name}' is missing resource configuration. ` +
|
|
45
|
+
`Fix the archive metadata at ${metadataPath} and retry, ` +
|
|
46
|
+
`or run 'vellum hatch' to re-provision with proper resource allocation.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Check ~/.vellum doesn't already exist
|
|
41
51
|
const vellumDir = join(homedir(), ".vellum");
|
|
42
52
|
if (existsSync(vellumDir)) {
|
|
43
53
|
console.error(
|
|
@@ -46,21 +56,20 @@ export async function recover(): Promise<void> {
|
|
|
46
56
|
process.exit(1);
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
//
|
|
59
|
+
// 4. Extract archive
|
|
50
60
|
await exec("tar", ["xzf", archivePath, "-C", homedir()]);
|
|
51
61
|
|
|
52
|
-
//
|
|
53
|
-
const entry: AssistantEntry = JSON.parse(readFileSync(metadataPath, "utf-8"));
|
|
62
|
+
// 5. Restore lockfile entry
|
|
54
63
|
saveAssistantEntry(entry);
|
|
55
64
|
|
|
56
|
-
//
|
|
65
|
+
// 6. Clean up archive
|
|
57
66
|
unlinkSync(archivePath);
|
|
58
67
|
unlinkSync(metadataPath);
|
|
59
68
|
|
|
60
|
-
//
|
|
61
|
-
await startLocalDaemon();
|
|
69
|
+
// 7. Start daemon + gateway (same as wake)
|
|
70
|
+
await startLocalDaemon(false, entry.resources);
|
|
62
71
|
if (!process.env.VELLUM_DESKTOP_APP) {
|
|
63
|
-
await startGateway();
|
|
72
|
+
await startGateway(undefined, false, entry.resources);
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
console.log(`✅ Recovered assistant '${name}'.`);
|
package/src/commands/retire.ts
CHANGED
|
@@ -4,13 +4,13 @@ import { homedir } from "os";
|
|
|
4
4
|
import { basename, dirname, join } from "path";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
-
defaultLocalResources,
|
|
8
7
|
findAssistantByName,
|
|
9
8
|
loadAllAssistants,
|
|
10
9
|
removeAssistantEntry,
|
|
11
10
|
} from "../lib/assistant-config";
|
|
12
11
|
import type { AssistantEntry } from "../lib/assistant-config";
|
|
13
12
|
import { retireInstance as retireAwsInstance } from "../lib/aws";
|
|
13
|
+
import { retireDocker } from "../lib/docker";
|
|
14
14
|
import { retireInstance as retireGcpInstance } from "../lib/gcp";
|
|
15
15
|
import {
|
|
16
16
|
stopOrphanedDaemonProcesses,
|
|
@@ -45,22 +45,20 @@ function extractHostFromUrl(url: string): string {
|
|
|
45
45
|
async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
|
|
46
46
|
console.log("\u{1F5D1}\ufe0f Stopping local assistant...\n");
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
if (!entry.resources) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Local assistant '${name}' is missing resource configuration. Re-hatch to fix.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const resources = entry.resources;
|
|
54
|
+
const vellumDir = join(resources.instanceDir, ".vellum");
|
|
53
55
|
|
|
54
56
|
// 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
57
|
const otherSharesDir = loadAllAssistants().some((other) => {
|
|
59
58
|
if (other.cloud !== "local") return false;
|
|
60
59
|
if (other.assistantId === name) return false;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
join((other.resources ?? defaultLocalResources()).instanceDir, ".vellum");
|
|
60
|
+
if (!other.resources) return false;
|
|
61
|
+
const otherVellumDir = join(other.resources.instanceDir, ".vellum");
|
|
64
62
|
return otherVellumDir === vellumDir;
|
|
65
63
|
});
|
|
66
64
|
|
|
@@ -72,17 +70,8 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
|
|
|
72
70
|
return;
|
|
73
71
|
}
|
|
74
72
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const daemonPidFile = legacyDir
|
|
78
|
-
? join(legacyDir, "vellum.pid")
|
|
79
|
-
: resources.pidFile;
|
|
80
|
-
const socketFile = legacyDir
|
|
81
|
-
? join(legacyDir, "vellum.sock")
|
|
82
|
-
: resources.socketPath;
|
|
83
|
-
const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon", [
|
|
84
|
-
socketFile,
|
|
85
|
-
]);
|
|
73
|
+
const daemonPidFile = resources.pidFile;
|
|
74
|
+
const daemonStopped = await stopProcessByPidFile(daemonPidFile, "daemon");
|
|
86
75
|
|
|
87
76
|
// Stop gateway via PID file — use a longer timeout because the gateway has a
|
|
88
77
|
// configurable drain window (GATEWAY_SHUTDOWN_DRAIN_MS, default 5s) before it exits.
|
|
@@ -286,6 +275,8 @@ async function retireInner(): Promise<void> {
|
|
|
286
275
|
process.exit(1);
|
|
287
276
|
}
|
|
288
277
|
await retireAwsInstance(name, region, source);
|
|
278
|
+
} else if (cloud === "docker") {
|
|
279
|
+
await retireDocker(name);
|
|
289
280
|
} else if (cloud === "local") {
|
|
290
281
|
await retireLocal(name, entry);
|
|
291
282
|
} else if (cloud === "custom") {
|
package/src/commands/sleep.ts
CHANGED
|
@@ -1,15 +1,50 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
1
2
|
import { join } from "path";
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { resolveTargetAssistant } from "../lib/assistant-config.js";
|
|
5
|
+
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
6
|
+
import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
|
|
7
|
+
|
|
8
|
+
const ACTIVE_CALL_LEASES_FILE = "active-call-leases.json";
|
|
9
|
+
|
|
10
|
+
type ActiveCallLease = {
|
|
11
|
+
callSessionId: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function getAssistantRootDir(entry: AssistantEntry): string {
|
|
15
|
+
if (!entry.resources) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Local assistant '${entry.assistantId}' is missing resource configuration. Re-hatch to fix.`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return join(entry.resources.instanceDir, ".vellum");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readActiveCallLeases(vellumDir: string): ActiveCallLease[] {
|
|
24
|
+
const path = join(vellumDir, ACTIVE_CALL_LEASES_FILE);
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as {
|
|
30
|
+
version?: number;
|
|
31
|
+
leases?: Array<{ callSessionId?: unknown }>;
|
|
32
|
+
};
|
|
33
|
+
if (raw.version !== 1 || !Array.isArray(raw.leases)) {
|
|
34
|
+
throw new Error(`Invalid active call lease file at ${path}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return raw.leases.filter(
|
|
38
|
+
(lease): lease is ActiveCallLease =>
|
|
39
|
+
typeof lease?.callSessionId === "string" &&
|
|
40
|
+
lease.callSessionId.length > 0,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
8
43
|
|
|
9
44
|
export async function sleep(): Promise<void> {
|
|
10
45
|
const args = process.argv.slice(3);
|
|
11
46
|
if (args.includes("--help") || args.includes("-h")) {
|
|
12
|
-
console.log("Usage: vellum sleep [<name>]");
|
|
47
|
+
console.log("Usage: vellum sleep [<name>] [--force]");
|
|
13
48
|
console.log("");
|
|
14
49
|
console.log("Stop the assistant and gateway processes.");
|
|
15
50
|
console.log("");
|
|
@@ -17,9 +52,15 @@ export async function sleep(): Promise<void> {
|
|
|
17
52
|
console.log(
|
|
18
53
|
" <name> Name of the assistant to stop (default: active or only local)",
|
|
19
54
|
);
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log("Options:");
|
|
57
|
+
console.log(
|
|
58
|
+
" --force Stop the assistant even if a phone call keepalive lease is active",
|
|
59
|
+
);
|
|
20
60
|
process.exit(0);
|
|
21
61
|
}
|
|
22
62
|
|
|
63
|
+
const force = args.includes("--force");
|
|
23
64
|
const nameArg = args.find((a) => !a.startsWith("-"));
|
|
24
65
|
const entry = resolveTargetAssistant(nameArg);
|
|
25
66
|
|
|
@@ -30,18 +71,49 @@ export async function sleep(): Promise<void> {
|
|
|
30
71
|
process.exit(1);
|
|
31
72
|
}
|
|
32
73
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
74
|
+
if (!entry.resources) {
|
|
75
|
+
console.error(
|
|
76
|
+
`Error: Local assistant '${entry.assistantId}' is missing resource configuration. Re-hatch to fix.`,
|
|
77
|
+
);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const resources = entry.resources;
|
|
81
|
+
const assistantPidFile = resources.pidFile;
|
|
82
|
+
const vellumDir = getAssistantRootDir(entry);
|
|
38
83
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
39
84
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
85
|
+
if (!force) {
|
|
86
|
+
const assistantAlive = isProcessAlive(assistantPidFile).alive;
|
|
87
|
+
if (assistantAlive) {
|
|
88
|
+
try {
|
|
89
|
+
const activeCallLeases = readActiveCallLeases(vellumDir);
|
|
90
|
+
if (activeCallLeases.length > 0) {
|
|
91
|
+
const activeIds = activeCallLeases.map(
|
|
92
|
+
(lease) => lease.callSessionId,
|
|
93
|
+
);
|
|
94
|
+
console.error(
|
|
95
|
+
`Error: assistant is staying awake for active phone calls (${activeIds.join(
|
|
96
|
+
", ",
|
|
97
|
+
)}). Use 'vellum sleep --force' to stop it anyway.`,
|
|
98
|
+
);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(
|
|
103
|
+
`Error: ${
|
|
104
|
+
err instanceof Error ? err.message : String(err)
|
|
105
|
+
}. Use 'vellum sleep --force' to override if you want to stop the assistant anyway.`,
|
|
106
|
+
);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const assistantStopped = await stopProcessByPidFile(
|
|
113
|
+
assistantPidFile,
|
|
114
|
+
"assistant",
|
|
115
|
+
);
|
|
116
|
+
if (!assistantStopped) {
|
|
45
117
|
console.log("Assistant is not running.");
|
|
46
118
|
} else {
|
|
47
119
|
console.log("Assistant stopped.");
|