@vellumai/cli 0.1.8 → 0.1.9
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 +3 -1
- package/package.json +1 -2
- package/src/commands/hatch.ts +297 -66
- package/src/commands/retire.ts +61 -23
- package/src/commands/sleep.ts +63 -0
- package/src/index.ts +3 -0
- package/src/lib/assistant-config.ts +1 -0
- package/src/lib/aws.ts +1 -0
- package/src/lib/gcp.ts +1 -0
- package/src/lib/interfaces-seed.ts +15 -8
- package/src/lib/openclaw-runtime-server.ts +11 -3
- package/scripts/bump.ts +0 -36
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ vellum-cli hatch [species] [options]
|
|
|
39
39
|
|
|
40
40
|
#### Remote Targets
|
|
41
41
|
|
|
42
|
-
- **`local`** -- Starts
|
|
42
|
+
- **`local`** -- Starts the local daemon and local gateway. Gateway source resolution order is: `VELLUM_GATEWAY_DIR` override, repo source tree, then installed `@vellumai/vellum-gateway` package.
|
|
43
43
|
- **`gcp`** -- Creates a GCP Compute Engine VM (`e2-standard-4`: 4 vCPUs, 16 GB) with a startup script that bootstraps the assistant. Requires `gcloud` authentication and `GCP_PROJECT` / `GCP_DEFAULT_ZONE` environment variables.
|
|
44
44
|
- **`aws`** -- Provisions an AWS instance.
|
|
45
45
|
- **`custom`** -- Provisions on an arbitrary SSH host. Set `VELLUM_CUSTOM_HOST` (e.g. `user@hostname`) to specify the target.
|
|
@@ -52,6 +52,8 @@ vellum-cli hatch [species] [options]
|
|
|
52
52
|
| `GCP_PROJECT` | `gcp` | GCP project ID. Falls back to the active `gcloud` project. |
|
|
53
53
|
| `GCP_DEFAULT_ZONE` | `gcp` | GCP zone for the compute instance. |
|
|
54
54
|
| `VELLUM_CUSTOM_HOST` | `custom` | SSH host in `user@hostname` format. |
|
|
55
|
+
| `VELLUM_GATEWAY_DIR` | `local` | Optional absolute path to a local gateway source directory to run instead of the packaged gateway. |
|
|
56
|
+
| `INGRESS_PUBLIC_BASE_URL` | `local` | Optional fallback public ingress URL when `ingress.publicBaseUrl` is not set in workspace config. |
|
|
55
57
|
|
|
56
58
|
#### Examples
|
|
57
59
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "CLI tools for vellum-assistant",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"vellum-cli": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"bump": "bun run scripts/bump.ts",
|
|
11
10
|
"lint": "eslint",
|
|
12
11
|
"typecheck": "bunx tsc --noEmit"
|
|
13
12
|
},
|
package/src/commands/hatch.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { randomBytes } from "crypto";
|
|
3
|
-
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
4
4
|
import { createRequire } from "module";
|
|
5
|
-
import { tmpdir, userInfo } from "os";
|
|
5
|
+
import { tmpdir, userInfo, homedir } from "os";
|
|
6
6
|
import { dirname, join } from "path";
|
|
7
7
|
|
|
8
8
|
import { buildOpenclawStartupScript } from "../adapters/openclaw";
|
|
@@ -26,8 +26,18 @@ import { exec, execOutput } from "../lib/step-runner";
|
|
|
26
26
|
const _require = createRequire(import.meta.url);
|
|
27
27
|
|
|
28
28
|
const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
|
|
29
|
-
const INSTALL_SCRIPT_PATH = join(import.meta.dir, "..", "adapters", "install.sh");
|
|
30
29
|
const MACHINE_TYPE = "e2-standard-4"; // 4 vCPUs, 16 GB memory
|
|
30
|
+
|
|
31
|
+
// Resolve the install script path. In source tree, use the file directly.
|
|
32
|
+
// In compiled binary ($bunfs), the file may not be available.
|
|
33
|
+
async function resolveInstallScriptPath(): Promise<string | null> {
|
|
34
|
+
const sourcePath = join(import.meta.dir, "..", "adapters", "install.sh");
|
|
35
|
+
if (existsSync(sourcePath)) {
|
|
36
|
+
return sourcePath;
|
|
37
|
+
}
|
|
38
|
+
console.warn("⚠️ Install script not found at", sourcePath, "(expected in compiled binary)");
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
31
41
|
const HATCH_TIMEOUT_MS: Record<Species, number> = {
|
|
32
42
|
vellum: 2 * 60 * 1000,
|
|
33
43
|
openclaw: 10 * 60 * 1000,
|
|
@@ -85,6 +95,7 @@ export async function buildStartupScript(
|
|
|
85
95
|
sshUser: string,
|
|
86
96
|
anthropicApiKey: string,
|
|
87
97
|
instanceName: string,
|
|
98
|
+
cloud: RemoteHost,
|
|
88
99
|
): Promise<string> {
|
|
89
100
|
const platformUrl = process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? "https://assistant.vellum.ai";
|
|
90
101
|
const timestampRedirect = buildTimestampRedirect();
|
|
@@ -102,7 +113,7 @@ export async function buildStartupScript(
|
|
|
102
113
|
);
|
|
103
114
|
}
|
|
104
115
|
|
|
105
|
-
const interfacesSeed = buildInterfacesSeed();
|
|
116
|
+
const interfacesSeed = await buildInterfacesSeed();
|
|
106
117
|
|
|
107
118
|
return `#!/bin/bash
|
|
108
119
|
set -e
|
|
@@ -115,6 +126,7 @@ ANTHROPIC_API_KEY=${anthropicApiKey}
|
|
|
115
126
|
GATEWAY_RUNTIME_PROXY_ENABLED=true
|
|
116
127
|
RUNTIME_PROXY_BEARER_TOKEN=${bearerToken}
|
|
117
128
|
VELLUM_ASSISTANT_NAME=${instanceName}
|
|
129
|
+
VELLUM_CLOUD=${cloud}
|
|
118
130
|
${interfacesSeed}
|
|
119
131
|
mkdir -p "\$HOME/.vellum"
|
|
120
132
|
cat > "\$HOME/.vellum/.env" << DOTENV_EOF
|
|
@@ -123,6 +135,7 @@ GATEWAY_RUNTIME_PROXY_ENABLED=\$GATEWAY_RUNTIME_PROXY_ENABLED
|
|
|
123
135
|
RUNTIME_PROXY_BEARER_TOKEN=\$RUNTIME_PROXY_BEARER_TOKEN
|
|
124
136
|
INTERFACES_SEED_DIR=\$INTERFACES_SEED_DIR
|
|
125
137
|
RUNTIME_HTTP_PORT=7821
|
|
138
|
+
VELLUM_CLOUD=\$VELLUM_CLOUD
|
|
126
139
|
DOTENV_EOF
|
|
127
140
|
|
|
128
141
|
mkdir -p "\$HOME/.vellum/workspace"
|
|
@@ -154,6 +167,7 @@ interface HatchArgs {
|
|
|
154
167
|
detached: boolean;
|
|
155
168
|
name: string | null;
|
|
156
169
|
remote: RemoteHost;
|
|
170
|
+
daemonOnly: boolean;
|
|
157
171
|
}
|
|
158
172
|
|
|
159
173
|
function parseArgs(): HatchArgs {
|
|
@@ -162,11 +176,14 @@ function parseArgs(): HatchArgs {
|
|
|
162
176
|
let detached = false;
|
|
163
177
|
let name: string | null = null;
|
|
164
178
|
let remote: RemoteHost = DEFAULT_REMOTE;
|
|
179
|
+
let daemonOnly = false;
|
|
165
180
|
|
|
166
181
|
for (let i = 0; i < args.length; i++) {
|
|
167
182
|
const arg = args[i];
|
|
168
183
|
if (arg === "-d") {
|
|
169
184
|
detached = true;
|
|
185
|
+
} else if (arg === "--daemon-only") {
|
|
186
|
+
daemonOnly = true;
|
|
170
187
|
} else if (arg === "--name") {
|
|
171
188
|
const next = args[i + 1];
|
|
172
189
|
if (!next || next.startsWith("-")) {
|
|
@@ -189,13 +206,13 @@ function parseArgs(): HatchArgs {
|
|
|
189
206
|
species = arg as Species;
|
|
190
207
|
} else {
|
|
191
208
|
console.error(
|
|
192
|
-
`Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
|
|
209
|
+
`Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
|
|
193
210
|
);
|
|
194
211
|
process.exit(1);
|
|
195
212
|
}
|
|
196
213
|
}
|
|
197
214
|
|
|
198
|
-
return { species, detached, name, remote };
|
|
215
|
+
return { species, detached, name, remote, daemonOnly };
|
|
199
216
|
}
|
|
200
217
|
|
|
201
218
|
export interface PollResult {
|
|
@@ -304,14 +321,16 @@ async function recoverFromCurlFailure(
|
|
|
304
321
|
sshUser: string,
|
|
305
322
|
account?: string,
|
|
306
323
|
): Promise<void> {
|
|
307
|
-
|
|
308
|
-
|
|
324
|
+
const installScriptPath = await resolveInstallScriptPath();
|
|
325
|
+
if (!installScriptPath) {
|
|
326
|
+
console.warn("⚠️ Skipping install script upload (not available in compiled binary)");
|
|
327
|
+
return;
|
|
309
328
|
}
|
|
310
329
|
|
|
311
330
|
const scpArgs = [
|
|
312
331
|
"compute",
|
|
313
332
|
"scp",
|
|
314
|
-
|
|
333
|
+
installScriptPath,
|
|
315
334
|
`${instanceName}:${INSTALL_SCRIPT_REMOTE_PATH}`,
|
|
316
335
|
`--zone=${zone}`,
|
|
317
336
|
`--project=${project}`,
|
|
@@ -511,6 +530,7 @@ async function hatchGcp(
|
|
|
511
530
|
sshUser,
|
|
512
531
|
anthropicApiKey,
|
|
513
532
|
instanceName,
|
|
533
|
+
"gcp",
|
|
514
534
|
);
|
|
515
535
|
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
516
536
|
writeFileSync(startupScriptPath, startupScript);
|
|
@@ -689,19 +709,25 @@ async function hatchCustom(
|
|
|
689
709
|
sshUser,
|
|
690
710
|
anthropicApiKey,
|
|
691
711
|
instanceName,
|
|
712
|
+
"custom",
|
|
692
713
|
);
|
|
693
714
|
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
694
715
|
writeFileSync(startupScriptPath, startupScript);
|
|
695
716
|
|
|
696
717
|
try {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
"
|
|
700
|
-
"
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
718
|
+
const installScriptPath = await resolveInstallScriptPath();
|
|
719
|
+
if (installScriptPath) {
|
|
720
|
+
console.log("📋 Uploading install script to instance...");
|
|
721
|
+
await exec("scp", [
|
|
722
|
+
"-o", "StrictHostKeyChecking=no",
|
|
723
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
724
|
+
"-o", "LogLevel=ERROR",
|
|
725
|
+
installScriptPath,
|
|
726
|
+
`${host}:${INSTALL_SCRIPT_REMOTE_PATH}`,
|
|
727
|
+
]);
|
|
728
|
+
} else {
|
|
729
|
+
console.warn("⚠️ Skipping install script upload (not available in compiled binary)");
|
|
730
|
+
}
|
|
705
731
|
|
|
706
732
|
console.log("📋 Uploading startup script to instance...");
|
|
707
733
|
const remoteStartupPath = `/tmp/${instanceName}-startup.sh`;
|
|
@@ -754,23 +780,118 @@ async function hatchCustom(
|
|
|
754
780
|
}
|
|
755
781
|
}
|
|
756
782
|
|
|
783
|
+
function isGatewaySourceDir(dir: string): boolean {
|
|
784
|
+
return existsSync(join(dir, "package.json")) && existsSync(join(dir, "src", "index.ts"));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function findGatewaySourceFromCwd(): string | undefined {
|
|
788
|
+
let current = process.cwd();
|
|
789
|
+
while (true) {
|
|
790
|
+
if (isGatewaySourceDir(current)) {
|
|
791
|
+
return current;
|
|
792
|
+
}
|
|
793
|
+
const nestedCandidate = join(current, "gateway");
|
|
794
|
+
if (isGatewaySourceDir(nestedCandidate)) {
|
|
795
|
+
return nestedCandidate;
|
|
796
|
+
}
|
|
797
|
+
const parent = dirname(current);
|
|
798
|
+
if (parent === current) {
|
|
799
|
+
return undefined;
|
|
800
|
+
}
|
|
801
|
+
current = parent;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
757
805
|
function resolveGatewayDir(): string {
|
|
806
|
+
const override = process.env.VELLUM_GATEWAY_DIR?.trim();
|
|
807
|
+
if (override) {
|
|
808
|
+
if (!isGatewaySourceDir(override)) {
|
|
809
|
+
throw new Error(
|
|
810
|
+
`VELLUM_GATEWAY_DIR is set to "${override}", but it is not a valid gateway source directory.`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
return override;
|
|
814
|
+
}
|
|
815
|
+
|
|
758
816
|
const sourceDir = join(import.meta.dir, "..", "..", "..", "gateway");
|
|
759
|
-
if (
|
|
817
|
+
if (isGatewaySourceDir(sourceDir)) {
|
|
760
818
|
return sourceDir;
|
|
761
819
|
}
|
|
762
820
|
|
|
821
|
+
const cwdSourceDir = findGatewaySourceFromCwd();
|
|
822
|
+
if (cwdSourceDir) {
|
|
823
|
+
return cwdSourceDir;
|
|
824
|
+
}
|
|
825
|
+
|
|
763
826
|
try {
|
|
764
827
|
const pkgPath = _require.resolve("@vellumai/vellum-gateway/package.json");
|
|
765
828
|
return dirname(pkgPath);
|
|
766
829
|
} catch {
|
|
767
830
|
throw new Error(
|
|
768
|
-
"Gateway not found. Ensure @vellumai/vellum-gateway is installed
|
|
831
|
+
"Gateway not found. Ensure @vellumai/vellum-gateway is installed, run from the source tree, or set VELLUM_GATEWAY_DIR.",
|
|
769
832
|
);
|
|
770
833
|
}
|
|
771
834
|
}
|
|
772
835
|
|
|
773
|
-
|
|
836
|
+
function normalizeIngressUrl(value: unknown): string | undefined {
|
|
837
|
+
if (typeof value !== "string") return undefined;
|
|
838
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
839
|
+
return normalized || undefined;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function readWorkspaceIngressPublicBaseUrl(): string | undefined {
|
|
843
|
+
const baseDataDir = process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? homedir());
|
|
844
|
+
const workspaceConfigPath = join(baseDataDir, ".vellum", "workspace", "config.json");
|
|
845
|
+
try {
|
|
846
|
+
const raw = JSON.parse(readFileSync(workspaceConfigPath, "utf-8")) as Record<string, unknown>;
|
|
847
|
+
const ingress = raw.ingress as Record<string, unknown> | undefined;
|
|
848
|
+
return normalizeIngressUrl(ingress?.publicBaseUrl);
|
|
849
|
+
} catch {
|
|
850
|
+
return undefined;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function discoverPublicUrl(): Promise<string | undefined> {
|
|
855
|
+
const cloud = process.env.VELLUM_CLOUD;
|
|
856
|
+
if (!cloud || cloud === "local") {
|
|
857
|
+
return `http://localhost:${GATEWAY_PORT}`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
let externalIp: string | undefined;
|
|
861
|
+
try {
|
|
862
|
+
if (cloud === "gcp") {
|
|
863
|
+
const resp = await fetch(
|
|
864
|
+
"http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
|
|
865
|
+
{ headers: { "Metadata-Flavor": "Google" } },
|
|
866
|
+
);
|
|
867
|
+
if (resp.ok) externalIp = (await resp.text()).trim();
|
|
868
|
+
} else if (cloud === "aws") {
|
|
869
|
+
// Use IMDSv2 (token-based) for compatibility with HttpTokens=required
|
|
870
|
+
const tokenResp = await fetch(
|
|
871
|
+
"http://169.254.169.254/latest/api/token",
|
|
872
|
+
{ method: "PUT", headers: { "X-aws-ec2-metadata-token-ttl-seconds": "30" } },
|
|
873
|
+
);
|
|
874
|
+
if (tokenResp.ok) {
|
|
875
|
+
const token = await tokenResp.text();
|
|
876
|
+
const ipResp = await fetch(
|
|
877
|
+
"http://169.254.169.254/latest/meta-data/public-ipv4",
|
|
878
|
+
{ headers: { "X-aws-ec2-metadata-token": token } },
|
|
879
|
+
);
|
|
880
|
+
if (ipResp.ok) externalIp = (await ipResp.text()).trim();
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
} catch {
|
|
884
|
+
// metadata service not reachable
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (externalIp) {
|
|
888
|
+
console.log(` Discovered external IP: ${externalIp}`);
|
|
889
|
+
return `http://${externalIp}:${GATEWAY_PORT}`;
|
|
890
|
+
}
|
|
891
|
+
return undefined;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
|
|
774
895
|
const instanceName =
|
|
775
896
|
name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
|
|
776
897
|
|
|
@@ -778,33 +899,110 @@ async function hatchLocal(species: Species, name: string | null): Promise<void>
|
|
|
778
899
|
console.log(` Species: ${species}`);
|
|
779
900
|
console.log("");
|
|
780
901
|
|
|
781
|
-
console.log("🔨 Starting local daemon...");
|
|
782
|
-
|
|
783
902
|
if (process.env.VELLUM_DESKTOP_APP) {
|
|
903
|
+
// When running inside the desktop app, the CLI owns the daemon lifecycle.
|
|
904
|
+
// Find the vellum-daemon binary adjacent to the CLI binary.
|
|
784
905
|
const daemonBinary = join(dirname(process.execPath), "vellum-daemon");
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
const
|
|
793
|
-
const
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
906
|
+
if (!existsSync(daemonBinary)) {
|
|
907
|
+
throw new Error(
|
|
908
|
+
`vellum-daemon binary not found at ${daemonBinary}.\n` +
|
|
909
|
+
" Ensure the daemon binary is bundled alongside the CLI in the app bundle.",
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
914
|
+
const pidFile = join(vellumDir, "vellum.pid");
|
|
915
|
+
const socketFile = join(vellumDir, "vellum.sock");
|
|
916
|
+
|
|
917
|
+
// If a daemon is already running, skip spawning a new one.
|
|
918
|
+
// This prevents cascading kill→restart cycles when multiple callers
|
|
919
|
+
// invoke hatch() concurrently (setupDaemonClient + ensureDaemonConnected).
|
|
920
|
+
let daemonAlive = false;
|
|
921
|
+
if (existsSync(pidFile)) {
|
|
922
|
+
try {
|
|
923
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
924
|
+
if (!isNaN(pid)) {
|
|
925
|
+
try {
|
|
926
|
+
process.kill(pid, 0); // Check if alive
|
|
927
|
+
daemonAlive = true;
|
|
928
|
+
console.log(` Daemon already running (pid ${pid})\n`);
|
|
929
|
+
} catch {
|
|
930
|
+
// Process doesn't exist, clean up stale PID file
|
|
931
|
+
try { unlinkSync(pidFile); } catch {}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
} catch {}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (!daemonAlive) {
|
|
938
|
+
// Remove stale socket so we can detect the fresh one
|
|
939
|
+
try { unlinkSync(socketFile); } catch {}
|
|
940
|
+
|
|
941
|
+
console.log("🔨 Starting daemon...");
|
|
942
|
+
|
|
943
|
+
// Ensure ~/.vellum/ exists for PID/socket files
|
|
944
|
+
mkdirSync(vellumDir, { recursive: true });
|
|
945
|
+
|
|
946
|
+
// Build a minimal environment for the daemon. When launched from the
|
|
947
|
+
// macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
|
|
948
|
+
// __CFBundleIdentifier, CLAUDE_CODE_ENTRYPOINT, etc.) that can cause
|
|
949
|
+
// the daemon to take 50+ seconds to start instead of ~1s.
|
|
950
|
+
const daemonEnv: Record<string, string> = {
|
|
951
|
+
HOME: process.env.HOME || homedir(),
|
|
952
|
+
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
953
|
+
VELLUM_DAEMON_TCP_ENABLED: "1",
|
|
954
|
+
};
|
|
955
|
+
// Forward optional config env vars the daemon may need
|
|
956
|
+
for (const key of [
|
|
957
|
+
"ANTHROPIC_API_KEY",
|
|
958
|
+
"BASE_DATA_DIR",
|
|
959
|
+
"VELLUM_DAEMON_TCP_PORT",
|
|
960
|
+
"VELLUM_DAEMON_TCP_HOST",
|
|
961
|
+
"VELLUM_DAEMON_SOCKET",
|
|
962
|
+
"VELLUM_DEBUG",
|
|
963
|
+
"SENTRY_DSN",
|
|
964
|
+
"TMPDIR",
|
|
965
|
+
"USER",
|
|
966
|
+
"LANG",
|
|
967
|
+
]) {
|
|
968
|
+
if (process.env[key]) {
|
|
969
|
+
daemonEnv[key] = process.env[key]!;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const child = spawn(daemonBinary, [], {
|
|
974
|
+
detached: true,
|
|
975
|
+
stdio: "ignore",
|
|
976
|
+
env: daemonEnv,
|
|
977
|
+
});
|
|
978
|
+
child.unref();
|
|
979
|
+
|
|
980
|
+
// Write PID file immediately so the health monitor can find the process
|
|
981
|
+
// and concurrent hatch() calls see it as alive.
|
|
982
|
+
if (child.pid) {
|
|
983
|
+
writeFileSync(pidFile, String(child.pid), "utf-8");
|
|
800
984
|
}
|
|
801
|
-
await new Promise((r) => setTimeout(r, pollInterval));
|
|
802
|
-
waited += pollInterval;
|
|
803
985
|
}
|
|
804
|
-
|
|
805
|
-
|
|
986
|
+
|
|
987
|
+
// Wait for socket at ~/.vellum/vellum.sock (up to 15s)
|
|
988
|
+
if (!existsSync(socketFile)) {
|
|
989
|
+
const maxWait = 15000;
|
|
990
|
+
const start = Date.now();
|
|
991
|
+
while (Date.now() - start < maxWait) {
|
|
992
|
+
if (existsSync(socketFile)) {
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (existsSync(socketFile)) {
|
|
999
|
+
console.log(" Daemon socket ready\n");
|
|
1000
|
+
} else {
|
|
1001
|
+
console.log(" ⚠️ Daemon socket did not appear within 15s — continuing anyway\n");
|
|
806
1002
|
}
|
|
807
1003
|
} else {
|
|
1004
|
+
console.log("🔨 Starting local daemon...");
|
|
1005
|
+
|
|
808
1006
|
// Source tree layout: cli/src/commands/ -> ../../.. -> repo root -> assistant/src/index.ts
|
|
809
1007
|
const sourceTreeIndex = join(import.meta.dir, "..", "..", "..", "assistant", "src", "index.ts");
|
|
810
1008
|
// bunx layout: @vellumai/cli/src/commands/ -> ../../../.. -> node_modules/ -> vellum/src/index.ts
|
|
@@ -848,44 +1046,77 @@ async function hatchLocal(species: Species, name: string | null): Promise<void>
|
|
|
848
1046
|
});
|
|
849
1047
|
}
|
|
850
1048
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1049
|
+
// The desktop app communicates with the daemon directly via Unix socket,
|
|
1050
|
+
// so the HTTP gateway is only needed for non-desktop (CLI) usage.
|
|
1051
|
+
let runtimeUrl: string;
|
|
1052
|
+
|
|
1053
|
+
if (process.env.VELLUM_DESKTOP_APP) {
|
|
1054
|
+
// No gateway needed — the macOS app uses DaemonClient over the Unix socket.
|
|
1055
|
+
runtimeUrl = "local";
|
|
1056
|
+
} else {
|
|
1057
|
+
const publicUrl = await discoverPublicUrl();
|
|
1058
|
+
if (publicUrl) {
|
|
1059
|
+
console.log(` Public URL: ${publicUrl}`);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
console.log("🌐 Starting gateway...");
|
|
1063
|
+
const gatewayDir = resolveGatewayDir();
|
|
1064
|
+
const gatewayEnv: Record<string, string> = {
|
|
1065
|
+
...process.env as Record<string, string>,
|
|
859
1066
|
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
860
1067
|
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1068
|
+
};
|
|
1069
|
+
const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
|
|
1070
|
+
const ingressPublicBaseUrl =
|
|
1071
|
+
workspaceIngressPublicBaseUrl
|
|
1072
|
+
?? normalizeIngressUrl(process.env.INGRESS_PUBLIC_BASE_URL);
|
|
1073
|
+
if (ingressPublicBaseUrl) {
|
|
1074
|
+
gatewayEnv.INGRESS_PUBLIC_BASE_URL = ingressPublicBaseUrl;
|
|
1075
|
+
console.log(` Ingress URL: ${ingressPublicBaseUrl}`);
|
|
1076
|
+
if (!workspaceIngressPublicBaseUrl) {
|
|
1077
|
+
console.log(" (using INGRESS_PUBLIC_BASE_URL env fallback)");
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
if (publicUrl) gatewayEnv.GATEWAY_PUBLIC_URL = publicUrl;
|
|
865
1081
|
|
|
866
|
-
|
|
1082
|
+
const gateway = spawn("bun", ["run", "src/index.ts"], {
|
|
1083
|
+
cwd: gatewayDir,
|
|
1084
|
+
detached: true,
|
|
1085
|
+
stdio: "ignore",
|
|
1086
|
+
env: gatewayEnv,
|
|
1087
|
+
});
|
|
1088
|
+
gateway.unref();
|
|
1089
|
+
console.log("✅ Gateway started\n");
|
|
1090
|
+
runtimeUrl = publicUrl || `http://localhost:${GATEWAY_PORT}`;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const baseDataDir = join(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? userInfo().homedir), ".vellum");
|
|
867
1094
|
const localEntry: AssistantEntry = {
|
|
868
1095
|
assistantId: instanceName,
|
|
869
1096
|
runtimeUrl,
|
|
1097
|
+
baseDataDir,
|
|
870
1098
|
cloud: "local",
|
|
871
1099
|
species,
|
|
872
1100
|
hatchedAt: new Date().toISOString(),
|
|
873
1101
|
};
|
|
874
|
-
|
|
1102
|
+
if (!daemonOnly) {
|
|
1103
|
+
saveAssistantEntry(localEntry);
|
|
875
1104
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1105
|
+
console.log("");
|
|
1106
|
+
console.log(`✅ Local assistant hatched!`);
|
|
1107
|
+
console.log("");
|
|
1108
|
+
console.log("Instance details:");
|
|
1109
|
+
console.log(` Name: ${instanceName}`);
|
|
1110
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
1111
|
+
console.log("");
|
|
1112
|
+
}
|
|
883
1113
|
}
|
|
884
1114
|
|
|
885
1115
|
function getCliVersion(): string {
|
|
886
1116
|
try {
|
|
887
|
-
|
|
888
|
-
const
|
|
1117
|
+
// Use createRequire for JSON import — works in both Bun dev and compiled binary.
|
|
1118
|
+
const require = createRequire(import.meta.url);
|
|
1119
|
+
const pkg = require("../../package.json") as { version?: string };
|
|
889
1120
|
return pkg.version ?? "unknown";
|
|
890
1121
|
} catch {
|
|
891
1122
|
return "unknown";
|
|
@@ -896,10 +1127,10 @@ export async function hatch(): Promise<void> {
|
|
|
896
1127
|
const cliVersion = getCliVersion();
|
|
897
1128
|
console.log(`@vellumai/cli v${cliVersion}`);
|
|
898
1129
|
|
|
899
|
-
const { species, detached, name, remote } = parseArgs();
|
|
1130
|
+
const { species, detached, name, remote, daemonOnly } = parseArgs();
|
|
900
1131
|
|
|
901
1132
|
if (remote === "local") {
|
|
902
|
-
await hatchLocal(species, name);
|
|
1133
|
+
await hatchLocal(species, name, daemonOnly);
|
|
903
1134
|
return;
|
|
904
1135
|
}
|
|
905
1136
|
|
package/src/commands/retire.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import { rmSync } from "fs";
|
|
2
|
+
import { existsSync, readFileSync, rmSync, unlinkSync } from "fs";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
|
|
@@ -34,30 +34,68 @@ function extractHostFromUrl(url: string): string {
|
|
|
34
34
|
async function retireLocal(): Promise<void> {
|
|
35
35
|
console.log("\u{1F5D1}\ufe0f Stopping local daemon...\n");
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
37
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
38
|
+
const isDesktopApp = !!process.env.VELLUM_DESKTOP_APP;
|
|
39
|
+
|
|
40
|
+
// Stop daemon via PID file (works for both desktop app and standalone)
|
|
41
|
+
const pidFile = join(vellumDir, "vellum.pid");
|
|
42
|
+
const socketFile = join(vellumDir, "vellum.sock");
|
|
43
|
+
|
|
44
|
+
if (existsSync(pidFile)) {
|
|
45
|
+
try {
|
|
46
|
+
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
47
|
+
if (!isNaN(pid)) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0); // Check if alive
|
|
50
|
+
process.kill(pid, "SIGTERM");
|
|
51
|
+
const deadline = Date.now() + 2000;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
56
|
+
} catch {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
process.kill(pid, "SIGKILL");
|
|
63
|
+
} catch {}
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
try { unlinkSync(pidFile); } catch {}
|
|
68
|
+
try { unlinkSync(socketFile); } catch {}
|
|
69
|
+
}
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
if (!isDesktopApp) {
|
|
72
|
+
// Non-desktop: also stop daemon via bunx (fallback) and kill gateway
|
|
73
|
+
try {
|
|
74
|
+
const child = spawn("bunx", ["vellum", "daemon", "stop"], {
|
|
75
|
+
stdio: "inherit",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await new Promise<void>((resolve) => {
|
|
79
|
+
child.on("close", () => resolve());
|
|
80
|
+
child.on("error", () => resolve());
|
|
81
|
+
});
|
|
82
|
+
} catch {}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const killGateway = spawn("pkill", ["-f", "gateway/src/index.ts"], {
|
|
86
|
+
stdio: "ignore",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await new Promise<void>((resolve) => {
|
|
90
|
+
killGateway.on("close", () => resolve());
|
|
91
|
+
killGateway.on("error", () => resolve());
|
|
92
|
+
});
|
|
93
|
+
} catch {}
|
|
94
|
+
|
|
95
|
+
// Only delete ~/.vellum in non-desktop mode
|
|
96
|
+
rmSync(vellumDir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
58
98
|
|
|
59
|
-
const vellumDir = join(homedir(), ".vellum");
|
|
60
|
-
rmSync(vellumDir, { recursive: true, force: true });
|
|
61
99
|
console.log("\u2705 Local instance retired.");
|
|
62
100
|
}
|
|
63
101
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
export async function sleep(): Promise<void> {
|
|
6
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
7
|
+
const pidFile = join(vellumDir, "vellum.pid");
|
|
8
|
+
const socketFile = join(vellumDir, "vellum.sock");
|
|
9
|
+
|
|
10
|
+
if (!existsSync(pidFile)) {
|
|
11
|
+
console.log("No daemon PID file found — nothing to stop.");
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const pidStr = readFileSync(pidFile, "utf-8").trim();
|
|
16
|
+
const pid = parseInt(pidStr, 10);
|
|
17
|
+
|
|
18
|
+
if (isNaN(pid)) {
|
|
19
|
+
console.log("Invalid PID file contents — cleaning up.");
|
|
20
|
+
try { unlinkSync(pidFile); } catch {}
|
|
21
|
+
try { unlinkSync(socketFile); } catch {}
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Check if process is alive
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
} catch {
|
|
29
|
+
console.log(`Daemon process ${pid} is not running — cleaning up stale files.`);
|
|
30
|
+
try { unlinkSync(pidFile); } catch {}
|
|
31
|
+
try { unlinkSync(socketFile); } catch {}
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log(`Stopping daemon (pid ${pid})...`);
|
|
36
|
+
process.kill(pid, "SIGTERM");
|
|
37
|
+
|
|
38
|
+
// Wait up to 2s for graceful exit
|
|
39
|
+
const deadline = Date.now() + 2000;
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
44
|
+
} catch {
|
|
45
|
+
break; // Process exited
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Force kill if still alive
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
console.log("Daemon did not exit after SIGTERM, sending SIGKILL...");
|
|
53
|
+
process.kill(pid, "SIGKILL");
|
|
54
|
+
} catch {
|
|
55
|
+
// Already dead
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Clean up PID and socket files
|
|
59
|
+
try { unlinkSync(pidFile); } catch {}
|
|
60
|
+
try { unlinkSync(socketFile); } catch {}
|
|
61
|
+
|
|
62
|
+
console.log("Daemon stopped.");
|
|
63
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { hatch } from "./commands/hatch";
|
|
4
4
|
import { retire } from "./commands/retire";
|
|
5
|
+
import { sleep } from "./commands/sleep";
|
|
5
6
|
|
|
6
7
|
const commands = {
|
|
7
8
|
hatch,
|
|
8
9
|
retire,
|
|
10
|
+
sleep,
|
|
9
11
|
} as const;
|
|
10
12
|
|
|
11
13
|
type CommandName = keyof typeof commands;
|
|
@@ -20,6 +22,7 @@ async function main() {
|
|
|
20
22
|
console.log("Commands:");
|
|
21
23
|
console.log(" hatch Create a new assistant instance");
|
|
22
24
|
console.log(" retire Delete an assistant instance");
|
|
25
|
+
console.log(" sleep Stop the daemon process");
|
|
23
26
|
process.exit(0);
|
|
24
27
|
}
|
|
25
28
|
|
package/src/lib/aws.ts
CHANGED
package/src/lib/gcp.ts
CHANGED
|
@@ -1,21 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
// Read source files using Bun.file() with string concatenation (not join())
|
|
2
|
+
// so Bun's bundler can statically analyze the paths and embed the files
|
|
3
|
+
// in the compiled binary ($bunfs). Files must also be passed via --embed
|
|
4
|
+
// in the bun build --compile invocation.
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
const defaultMainScreenSource = await Bun.file(join(import.meta.dir, "..", "components", "DefaultMainScreen.tsx")).text();
|
|
5
|
-
|
|
6
|
-
function inlineLocalImports(source: string): string {
|
|
6
|
+
function inlineLocalImports(source: string, constantsSource: string): string {
|
|
7
7
|
return source
|
|
8
8
|
.replace(/import\s*\{[^}]*\}\s*from\s*["'][^"']*\/constants["'];?\s*\n/, constantsSource + "\n")
|
|
9
9
|
.replace(/import\s*\{[^}]*\}\s*from\s*["']path["'];?\s*\n/, "");
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function buildInterfacesSeed(): string {
|
|
13
|
-
|
|
12
|
+
export async function buildInterfacesSeed(): Promise<string> {
|
|
13
|
+
try {
|
|
14
|
+
const constantsSource = await Bun.file(import.meta.dir + "/constants.ts").text();
|
|
15
|
+
const defaultMainScreenSource = await Bun.file(import.meta.dir + "/../components/DefaultMainScreen.tsx").text();
|
|
16
|
+
const mainWindowSource = inlineLocalImports(defaultMainScreenSource, constantsSource);
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
return `
|
|
16
19
|
INTERFACES_SEED_DIR="/tmp/interfaces-seed"
|
|
17
20
|
mkdir -p "\$INTERFACES_SEED_DIR/tui"
|
|
18
21
|
cat > "\$INTERFACES_SEED_DIR/tui/main-window.tsx" << 'INTERFACES_SEED_EOF'
|
|
19
22
|
${mainWindowSource}INTERFACES_SEED_EOF
|
|
20
23
|
`;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.warn("⚠️ Could not embed interfaces seed files (expected in compiled binary without --embed):", (err as Error).message);
|
|
26
|
+
return "# interfaces-seed: skipped (source files not available in compiled binary)";
|
|
27
|
+
}
|
|
21
28
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
// Read source file using Bun.file() with string concatenation (not join())
|
|
2
|
+
// so Bun's bundler can statically analyze the path and embed the file
|
|
3
|
+
// in the compiled binary ($bunfs). The file must also be passed via --embed
|
|
4
|
+
// in the bun build --compile invocation.
|
|
2
5
|
|
|
3
6
|
export async function buildOpenclawRuntimeServer(): Promise<string> {
|
|
4
|
-
|
|
7
|
+
try {
|
|
8
|
+
const serverSource = await Bun.file(import.meta.dir + "/../adapters/openclaw-http-server.ts").text();
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
return `
|
|
7
11
|
cat > /opt/openclaw-runtime-server.ts << 'RUNTIME_SERVER_EOF'
|
|
8
12
|
${serverSource}
|
|
9
13
|
RUNTIME_SERVER_EOF
|
|
@@ -12,4 +16,8 @@ mkdir -p "\$HOME/.vellum"
|
|
|
12
16
|
nohup bun run /opt/openclaw-runtime-server.ts >> "\$HOME/.vellum/http-gateway.log" 2>&1 &
|
|
13
17
|
echo "OpenClaw runtime server started (PID: \$!)"
|
|
14
18
|
`;
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.warn("⚠️ Could not embed openclaw runtime server (expected in compiled binary without --embed):", (err as Error).message);
|
|
21
|
+
return "# openclaw-runtime-server: skipped (source files not available in compiled binary)";
|
|
22
|
+
}
|
|
15
23
|
}
|
package/scripts/bump.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { resolve } from "path";
|
|
2
|
-
|
|
3
|
-
const CLI_DIR = resolve(import.meta.dirname, "..");
|
|
4
|
-
const ASSISTANT_DIR = resolve(CLI_DIR, "../assistant");
|
|
5
|
-
|
|
6
|
-
const cliPkgPath = resolve(CLI_DIR, "package.json");
|
|
7
|
-
const assistantPkgPath = resolve(ASSISTANT_DIR, "package.json");
|
|
8
|
-
const assistantLockPath = resolve(ASSISTANT_DIR, "bun.lock");
|
|
9
|
-
|
|
10
|
-
function bumpPatch(version: string): string {
|
|
11
|
-
const parts = version.split(".");
|
|
12
|
-
parts[2] = String(Number(parts[2]) + 1);
|
|
13
|
-
return parts.join(".");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const cliPkg = await Bun.file(cliPkgPath).json();
|
|
17
|
-
const oldVersion: string = cliPkg.version;
|
|
18
|
-
const newVersion = bumpPatch(oldVersion);
|
|
19
|
-
cliPkg.version = newVersion;
|
|
20
|
-
await Bun.write(cliPkgPath, JSON.stringify(cliPkg, null, 2) + "\n");
|
|
21
|
-
|
|
22
|
-
const assistantPkg = await Bun.file(assistantPkgPath).json();
|
|
23
|
-
assistantPkg.dependencies["@vellumai/cli"] = newVersion;
|
|
24
|
-
assistantPkg.version = bumpPatch(assistantPkg.version);
|
|
25
|
-
await Bun.write(assistantPkgPath, JSON.stringify(assistantPkg, null, 2) + "\n");
|
|
26
|
-
|
|
27
|
-
let lockContent = await Bun.file(assistantLockPath).text();
|
|
28
|
-
lockContent = lockContent.replace(
|
|
29
|
-
/"@vellumai\/cli": "[^"]*"/g,
|
|
30
|
-
`"@vellumai/cli": "${newVersion}"`
|
|
31
|
-
);
|
|
32
|
-
lockContent = lockContent.replace(
|
|
33
|
-
/@vellumai\/cli@\d+\.\d+\.\d+/g,
|
|
34
|
-
`@vellumai/cli@${newVersion}`
|
|
35
|
-
);
|
|
36
|
-
await Bun.write(assistantLockPath, lockContent);
|