@vellumai/cli 0.8.7 → 0.8.8-dev.202606060043.60454ad

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.
Files changed (49) hide show
  1. package/node_modules/@vellumai/local-mode/package.json +2 -1
  2. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +15 -0
  5. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  6. package/node_modules/@vellumai/local-mode/src/config.ts +15 -8
  7. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  8. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +42 -0
  9. package/node_modules/@vellumai/local-mode/src/hatch.ts +22 -4
  10. package/node_modules/@vellumai/local-mode/src/index.ts +26 -4
  11. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  12. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  13. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  14. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -7
  15. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  16. package/package.json +1 -1
  17. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  18. package/src/__tests__/clean.test.ts +179 -0
  19. package/src/__tests__/client-token.test.ts +87 -0
  20. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  21. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  22. package/src/__tests__/connect-import.test.ts +317 -0
  23. package/src/__tests__/devices.test.ts +272 -0
  24. package/src/__tests__/guardian-token.test.ts +126 -2
  25. package/src/__tests__/pair.test.ts +271 -0
  26. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  27. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  28. package/src/__tests__/unpair.test.ts +163 -0
  29. package/src/commands/client.ts +115 -26
  30. package/src/commands/connect/import.ts +217 -0
  31. package/src/commands/connect.ts +31 -0
  32. package/src/commands/devices.ts +247 -0
  33. package/src/commands/pair.ts +222 -0
  34. package/src/commands/ps.ts +16 -0
  35. package/src/commands/retire.ts +20 -47
  36. package/src/commands/sleep.ts +7 -0
  37. package/src/commands/tunnel.ts +46 -2
  38. package/src/commands/unpair.ts +118 -0
  39. package/src/commands/wake.ts +7 -0
  40. package/src/components/DefaultMainScreen.tsx +84 -13
  41. package/src/index.ts +16 -0
  42. package/src/lib/assistant-client.ts +58 -37
  43. package/src/lib/assistant-config.ts +12 -0
  44. package/src/lib/cloudflare-tunnel.ts +276 -0
  45. package/src/lib/confirm-action.ts +57 -0
  46. package/src/lib/docker.ts +25 -1
  47. package/src/lib/environments/resolve.ts +9 -30
  48. package/src/lib/guardian-token.ts +120 -4
  49. package/src/lib/local.ts +20 -6
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `vellum pair [assistant] [--label <name>]`
3
+ *
4
+ * Mint a device-scoped token for another machine and print a pairing bundle.
5
+ * Runs on the machine hosting the assistant: it calls the local gateway's
6
+ * loopback-only `POST /v1/pair` (cli interface) with a freshly generated
7
+ * deviceId, then prints the credentials to hand to a second device.
8
+ *
9
+ * Each invocation generates a NEW random deviceId, so each pairing is an
10
+ * independent, separately-revocable device (see `vellum unpair`, forthcoming).
11
+ */
12
+
13
+ import { nanoid } from "nanoid";
14
+
15
+ import { extractFlag } from "../lib/arg-utils.js";
16
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
17
+ import {
18
+ formatAssistantLookupError,
19
+ lookupAssistantByIdentifier,
20
+ resolveAssistant,
21
+ type AssistantEntry,
22
+ } from "../lib/assistant-config.js";
23
+ import {
24
+ CLI_INTERFACE_ID,
25
+ getClientRegistrationHeaders,
26
+ } from "../lib/client-identity.js";
27
+ import { GATEWAY_PORT } from "../lib/constants.js";
28
+ import { getLocalLanIPv4 } from "../lib/local.js";
29
+
30
+ function isLoopbackHost(url: string): boolean {
31
+ try {
32
+ const host = new URL(url).hostname.toLowerCase();
33
+ return host === "localhost" || host === "::1" || host.startsWith("127.");
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function printUsage(): void {
40
+ console.log(`vellum pair - Mint a device-scoped token for another machine
41
+
42
+ USAGE:
43
+ vellum pair [assistant] [options]
44
+
45
+ ARGUMENTS:
46
+ [assistant] Instance name (default: active assistant)
47
+
48
+ OPTIONS:
49
+ --url <url> Reachable gateway URL to advertise in the bundle
50
+ (default: the assistant's runtime URL, not loopback)
51
+ --label <name> Human label for this pairing (echoed in the output)
52
+ --json Output the raw bundle as JSON
53
+
54
+ EXAMPLES:
55
+ vellum pair
56
+ vellum pair "My Assistant" --label "phone"
57
+ vellum pair --url https://abc123.ngrok.app
58
+ vellum pair --json
59
+ `);
60
+ }
61
+
62
+ interface PairResponse {
63
+ token: string;
64
+ expiresAt: string;
65
+ guardianId: string;
66
+ assistantId: string;
67
+ // Present on the device-bound path: a long-lived refresh credential the
68
+ // imported client uses to renew its access token (ISO-8601 strings).
69
+ refreshToken?: string;
70
+ refreshTokenExpiresAt?: string;
71
+ refreshAfter?: string;
72
+ }
73
+
74
+ export async function pair(): Promise<void> {
75
+ const rawArgs = process.argv.slice(3);
76
+
77
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
78
+ printUsage();
79
+ return;
80
+ }
81
+
82
+ const jsonOutput = rawArgs.includes("--json");
83
+ let args = rawArgs.filter((a) => a !== "--json");
84
+
85
+ const [label, afterLabel] = extractFlag(args, "--label");
86
+ const [urlOverride, afterUrl] = extractFlag(afterLabel, "--url");
87
+ args = afterUrl;
88
+
89
+ // Resolve the target. An explicit argument is matched by display name OR id
90
+ // (with the standard ambiguity error); no argument falls back to the active
91
+ // assistant. Join positional tokens so multi-word display names work even
92
+ // unquoted (e.g. `vellum pair My Assistant`).
93
+ const assistantName = parseAssistantTargetArg(args);
94
+ let entry: AssistantEntry | null;
95
+ if (assistantName) {
96
+ const result = lookupAssistantByIdentifier(assistantName);
97
+ if (result.status !== "found") {
98
+ console.error(formatAssistantLookupError(assistantName, result));
99
+ process.exit(1);
100
+ }
101
+ entry = result.entry;
102
+ } else {
103
+ entry = resolveAssistant();
104
+ if (!entry) {
105
+ console.error("No assistant instance found. Run `vellum hatch` first.");
106
+ process.exit(1);
107
+ }
108
+ }
109
+
110
+ // Mint over loopback (localUrl avoids mDNS for same-machine calls), but
111
+ // advertise a REACHABLE url in the bundle — the loopback url would point the
112
+ // other machine at its own localhost. Prefer an explicit --url, then the
113
+ // runtime (LAN/tunnel) url.
114
+ const mintUrl = (
115
+ entry.localUrl ||
116
+ entry.runtimeUrl ||
117
+ `http://127.0.0.1:${GATEWAY_PORT}`
118
+ ).replace(/\/+$/, "");
119
+ const advertisedUrl = (urlOverride || entry.runtimeUrl || mintUrl).replace(
120
+ /\/+$/,
121
+ "",
122
+ );
123
+
124
+ // A local hatch's runtimeUrl is itself loopback (http://localhost:<port>),
125
+ // so without an explicit --url the bundle would point the other machine at
126
+ // its own localhost. Refuse to advertise a loopback URL unless the user
127
+ // explicitly passed one. (An explicit --url is trusted as-is.)
128
+ if (!urlOverride && isLoopbackHost(advertisedUrl)) {
129
+ const lan = getLocalLanIPv4();
130
+ // Use THIS assistant's gateway port (not the global default) — second
131
+ // local instances listen on a different port.
132
+ let port = String(GATEWAY_PORT);
133
+ try {
134
+ port = new URL(mintUrl).port || port;
135
+ } catch {
136
+ /* keep default */
137
+ }
138
+ const suggestion = lan
139
+ ? `http://${lan}:${port}`
140
+ : `http://<this-machine-ip>:${port}`;
141
+ console.error(
142
+ "Error: this assistant has no reachable gateway URL — its address is " +
143
+ `loopback (${advertisedUrl}), which the other machine can't connect to.`,
144
+ );
145
+ console.error(
146
+ `Re-run with a reachable URL, e.g.:\n vellum pair --url ${suggestion}`,
147
+ );
148
+ process.exit(1);
149
+ }
150
+
151
+ // Fresh per-pairing device identity — each `vellum pair` is independently
152
+ // revocable.
153
+ const deviceId = nanoid();
154
+
155
+ let response: Response;
156
+ try {
157
+ response = await fetch(`${mintUrl}/v1/pair`, {
158
+ method: "POST",
159
+ headers: {
160
+ "Content-Type": "application/json",
161
+ ...getClientRegistrationHeaders(CLI_INTERFACE_ID),
162
+ },
163
+ body: JSON.stringify({ deviceId, platform: "cli" }),
164
+ });
165
+ } catch (err) {
166
+ console.error(
167
+ `Error: could not reach the gateway at ${mintUrl} ` +
168
+ `(${err instanceof Error ? err.message : String(err)}).`,
169
+ );
170
+ console.error("Is the assistant running? Try `vellum wake`.");
171
+ process.exit(1);
172
+ }
173
+
174
+ if (!response.ok) {
175
+ const body = await response.text().catch(() => "");
176
+ console.error(
177
+ `Error: HTTP ${response.status}: ${body || response.statusText}`,
178
+ );
179
+ process.exit(1);
180
+ }
181
+
182
+ const result = (await response.json()) as PairResponse;
183
+
184
+ // Single-line, copy-pasteable blob for the consume side (`vellum connect
185
+ // import <blob>`, forthcoming).
186
+ const bundle = {
187
+ gatewayUrl: advertisedUrl,
188
+ assistantId: result.assistantId,
189
+ token: result.token,
190
+ deviceId,
191
+ // Carry the refresh credential through when the gateway issued one, so the
192
+ // imported client can renew without re-pairing. Omitted entirely for an
193
+ // access-only (older gateway) response so the bundle stays clean.
194
+ ...(result.refreshToken
195
+ ? {
196
+ refreshToken: result.refreshToken,
197
+ refreshTokenExpiresAt: result.refreshTokenExpiresAt,
198
+ refreshAfter: result.refreshAfter,
199
+ }
200
+ : {}),
201
+ };
202
+ const blob = Buffer.from(JSON.stringify(bundle)).toString("base64");
203
+
204
+ if (jsonOutput) {
205
+ console.log(
206
+ JSON.stringify({ ...bundle, expiresAt: result.expiresAt }, null, 2),
207
+ );
208
+ return;
209
+ }
210
+
211
+ const displayName = entry.name || entry.assistantName || entry.assistantId;
212
+ console.log(`Paired ${label ? `"${label}" ` : ""}with ${displayName}.`);
213
+ console.log("");
214
+ console.log(` Gateway: ${advertisedUrl}`);
215
+ console.log(` Assistant: ${result.assistantId}`);
216
+ console.log(` Expires: ${result.expiresAt}`);
217
+ console.log("");
218
+ console.log("Hand this to the other machine (keep it secret):");
219
+ console.log("");
220
+ console.log(` ${blob}`);
221
+ console.log("");
222
+ }
@@ -444,6 +444,22 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
444
444
  return;
445
445
  }
446
446
 
447
+ if (cloud === "paired") {
448
+ // A remote assistant paired from another machine: no local process to
449
+ // list — probe the remote gateway's health over the bearer token instead.
450
+ const token = loadGuardianToken(entry.assistantId)?.accessToken;
451
+ const health = await checkHealth(entry.runtimeUrl, token);
452
+ const rows: TableRow[] = [
453
+ {
454
+ name: "gateway",
455
+ status: withStatusEmoji(health.status),
456
+ info: entry.runtimeUrl + (health.detail ? ` | ${health.detail}` : ""),
457
+ },
458
+ ];
459
+ printTable(rows);
460
+ return;
461
+ }
462
+
447
463
  let output: string;
448
464
  try {
449
465
  if (cloud === "gcp") {
@@ -13,6 +13,10 @@ import {
13
13
  type AssistantEntry,
14
14
  } from "../lib/assistant-config.js";
15
15
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
16
+ import {
17
+ canPromptForConfirmation,
18
+ confirmAction,
19
+ } from "../lib/confirm-action.js";
16
20
  import { getConfigDir } from "../lib/environments/paths.js";
17
21
  import { getCurrentEnvironment } from "../lib/environments/resolve.js";
18
22
  import {
@@ -152,51 +156,6 @@ function printRetireTarget(entry: AssistantEntry, cloud: string): void {
152
156
  console.log("");
153
157
  }
154
158
 
155
- function canPromptForRetireConfirmation(): boolean {
156
- return (
157
- process.stdin.isTTY === true &&
158
- process.stdout.isTTY === true &&
159
- typeof process.stdin.setRawMode === "function"
160
- );
161
- }
162
-
163
- async function confirmRetireInteractive(): Promise<boolean> {
164
- const stdin = process.stdin;
165
- const stdout = process.stdout;
166
- const wasRaw = stdin.isRaw === true;
167
- const wasPaused = stdin.isPaused();
168
-
169
- stdout.write("Press Enter to retire, or Esc/q to cancel: ");
170
- stdin.setRawMode(true);
171
- stdin.resume();
172
-
173
- return await new Promise<boolean>((resolve) => {
174
- const cleanup = () => {
175
- stdin.off("data", onData);
176
- stdin.setRawMode(wasRaw);
177
- if (wasPaused) {
178
- stdin.pause();
179
- }
180
- stdout.write("\n");
181
- };
182
-
183
- const onData = (chunk: Buffer) => {
184
- const byte = chunk[0];
185
- if (byte === 13 || byte === 10) {
186
- cleanup();
187
- resolve(true);
188
- return;
189
- }
190
- if (byte === 27 || byte === 3 || byte === 113 || byte === 81) {
191
- cleanup();
192
- resolve(false);
193
- }
194
- };
195
-
196
- stdin.on("data", onData);
197
- });
198
- }
199
-
200
159
  /** Patch console methods to also append output to the given log file descriptor. */
201
160
  function teeConsoleToLogFile(fd: number | "ignore"): void {
202
161
  if (fd === "ignore") return;
@@ -290,10 +249,22 @@ async function retireInner(): Promise<void> {
290
249
  const assistantId = entry.assistantId;
291
250
  const source = parsed.source;
292
251
  const cloud = resolveCloud(entry);
252
+
253
+ if (cloud === "paired") {
254
+ // A remote assistant paired from another machine. Retiring tears the
255
+ // assistant down — that can only happen on its host machine, never from a
256
+ // paired machine, which holds nothing but a pairing record. (Removing that
257
+ // local record is `vellum unpair`'s job, not retire's.)
258
+ console.error(
259
+ `Error: '${assistantId}' is a remote assistant paired from another machine — it can't be retired from here. Retiring tears down the assistant, which can only be done on its host machine. To remove the local pairing record on this machine, run \`vellum unpair ${assistantId}\`.`,
260
+ );
261
+ process.exit(1);
262
+ }
263
+
293
264
  printRetireTarget(entry, cloud);
294
265
 
295
266
  if (!parsed.yes) {
296
- if (!canPromptForRetireConfirmation()) {
267
+ if (!canPromptForConfirmation()) {
297
268
  console.error(
298
269
  "Error: Refusing to retire without confirmation in a non-interactive terminal.",
299
270
  );
@@ -301,7 +272,9 @@ async function retireInner(): Promise<void> {
301
272
  process.exit(1);
302
273
  }
303
274
 
304
- const confirmed = await confirmRetireInteractive();
275
+ const confirmed = await confirmAction(
276
+ "Press Enter to retire, or Esc/q to cancel: ",
277
+ );
305
278
  if (!confirmed) {
306
279
  console.log("Retire cancelled.");
307
280
  process.exit(1);
@@ -82,6 +82,13 @@ export async function sleep(): Promise<void> {
82
82
  process.exit(1);
83
83
  }
84
84
 
85
+ if (entry.cloud === "paired") {
86
+ console.error(
87
+ `Error: '${entry.assistantId}' is a remote assistant paired from another machine — its lifecycle is managed on its host machine, not here. Use \`vellum client ${entry.assistantId}\` to chat with it.`,
88
+ );
89
+ process.exit(1);
90
+ }
91
+
85
92
  if (entry.cloud && entry.cloud !== "local") {
86
93
  console.error(
87
94
  `Error: 'vellum sleep' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
@@ -1,4 +1,7 @@
1
+ import { join } from "path";
2
+
1
3
  import { resolveAssistant } from "../lib/assistant-config";
4
+ import { runCloudflareTunnel } from "../lib/cloudflare-tunnel.js";
2
5
  import { runNgrokTunnel } from "../lib/ngrok";
3
6
 
4
7
  const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
@@ -21,17 +24,45 @@ function parseArgs(): TunnelArgs {
21
24
  if (arg === "--help" || arg === "-h") {
22
25
  console.log("Usage: vellum tunnel [<name>] [options]");
23
26
  console.log("");
24
- console.log("Create a tunnel for a locally hosted assistant.");
27
+ console.log(
28
+ "Expose a locally running assistant to the internet via a tunnel.",
29
+ );
30
+ console.log(
31
+ "The public URL is saved to the workspace config as the ingress base URL,",
32
+ );
33
+ console.log(
34
+ "enabling webhook integrations (Telegram, Twilio, etc.) to reach the assistant.",
35
+ );
25
36
  console.log("");
26
37
  console.log("Arguments:");
27
38
  console.log(
28
- " <name> Name of the assistant (defaults to latest)",
39
+ " <name> Name of the assistant (defaults to active or only local)",
29
40
  );
30
41
  console.log("");
31
42
  console.log("Options:");
32
43
  console.log(
33
44
  ` --provider <provider> Tunnel provider: ${VALID_PROVIDERS.join(", ")} (default: ${DEFAULT_PROVIDER})`,
34
45
  );
46
+ console.log("");
47
+ console.log("Providers:");
48
+ console.log(
49
+ " vellum Managed tunnel via Vellum Cloud (default; requires account)",
50
+ );
51
+ console.log(
52
+ " ngrok ngrok tunnel — install: brew install ngrok/ngrok/ngrok",
53
+ );
54
+ console.log(
55
+ " cloudflare Cloudflare quick tunnel — install: brew install cloudflare/cloudflare/cloudflared",
56
+ );
57
+ console.log(
58
+ " No Cloudflare account required for quick tunnels.",
59
+ );
60
+ console.log("");
61
+ console.log("Examples:");
62
+ console.log(" $ vellum tunnel");
63
+ console.log(" $ vellum tunnel --provider ngrok");
64
+ console.log(" $ vellum tunnel --provider cloudflare");
65
+ console.log(" $ vellum tunnel my-assistant --provider cloudflare");
35
66
  process.exit(0);
36
67
  } else if (arg === "--provider") {
37
68
  const next = args[i + 1];
@@ -78,5 +109,18 @@ export async function tunnel(): Promise<void> {
78
109
  return;
79
110
  }
80
111
 
112
+ if (provider === "cloudflare") {
113
+ const resources = entry.resources;
114
+ await runCloudflareTunnel(
115
+ resources
116
+ ? {
117
+ port: resources.gatewayPort,
118
+ workspaceDir: join(resources.instanceDir, ".vellum", "workspace"),
119
+ }
120
+ : {},
121
+ );
122
+ return;
123
+ }
124
+
81
125
  throw new Error(`Tunnel provider '${provider}' is not yet implemented.`);
82
126
  }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `vellum unpair <name> [--yes]`
3
+ *
4
+ * Forget a pairing imported from another machine via `vellum connect import`:
5
+ * remove its lockfile entry and stored guardian token from THIS machine. Only
6
+ * paired assistants (`cloud: "paired"`) can be unpaired — `vellum retire` owns
7
+ * local and managed assistants.
8
+ *
9
+ * This is client-side only: it forgets the connection here but does not revoke
10
+ * the device on the host. (Host-side revocation is `vellum devices`.)
11
+ */
12
+
13
+ import {
14
+ formatAssistantLookupError,
15
+ getAssistantDisplayName,
16
+ lookupAssistantByIdentifier,
17
+ removeAssistantEntry,
18
+ } from "../lib/assistant-config";
19
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
20
+ import {
21
+ canPromptForConfirmation,
22
+ confirmAction,
23
+ } from "../lib/confirm-action.js";
24
+ import { deleteGuardianToken } from "../lib/guardian-token";
25
+
26
+ function printUsage(): void {
27
+ console.log(`vellum unpair - Forget a paired assistant imported from another machine
28
+
29
+ USAGE:
30
+ vellum unpair <name> [--yes]
31
+
32
+ ARGUMENTS:
33
+ <name> Name or id of the paired assistant to forget
34
+
35
+ OPTIONS:
36
+ --yes Skip the interactive confirmation prompt (for automation)
37
+
38
+ Removes the local connection (lockfile entry + stored token). Only paired
39
+ assistants (imported via 'vellum connect import') can be unpaired; use
40
+ 'vellum retire' for local or managed assistants.
41
+
42
+ EXAMPLES:
43
+ vellum unpair paired-desk
44
+ vellum unpair "Desk Box"
45
+ vellum unpair paired-desk --yes
46
+ `);
47
+ }
48
+
49
+ export async function unpair(): Promise<void> {
50
+ const args = process.argv.slice(3);
51
+
52
+ if (args.includes("--help") || args.includes("-h")) {
53
+ printUsage();
54
+ return;
55
+ }
56
+
57
+ const yes = args.includes("--yes");
58
+ const name = parseAssistantTargetArg(args, []);
59
+ if (!name) {
60
+ console.error("Error: assistant name or id is required.");
61
+ printUsage();
62
+ process.exit(1);
63
+ }
64
+
65
+ const lookup = lookupAssistantByIdentifier(name);
66
+ if (lookup.status !== "found") {
67
+ console.error(formatAssistantLookupError(name, lookup));
68
+ process.exit(1);
69
+ }
70
+ const entry = lookup.entry;
71
+
72
+ if (entry.cloud !== "paired") {
73
+ console.error(
74
+ `Error: '${name}' is not a paired assistant. Use \`vellum retire\` to remove a local or managed assistant.`,
75
+ );
76
+ process.exit(1);
77
+ }
78
+
79
+ // Print the resolved identity before acting (cli/AGENTS.md).
80
+ const displayName = getAssistantDisplayName(entry);
81
+ console.log("Pairing to unpair:");
82
+ if (displayName !== entry.assistantId) {
83
+ console.log(` Name: ${displayName}`);
84
+ }
85
+ console.log(` ID: ${entry.assistantId}`);
86
+ if (entry.runtimeUrl) {
87
+ console.log(` Host: ${entry.runtimeUrl}`);
88
+ }
89
+ console.log("");
90
+
91
+ if (!yes) {
92
+ if (!canPromptForConfirmation()) {
93
+ console.error(
94
+ "Error: Refusing to unpair without confirmation in a non-interactive terminal.",
95
+ );
96
+ console.error("Re-run with --yes to confirm from automation.");
97
+ process.exit(1);
98
+ }
99
+ const confirmed = await confirmAction(
100
+ "Press Enter to unpair, or Esc/q to cancel: ",
101
+ );
102
+ if (!confirmed) {
103
+ console.log("Unpair cancelled.");
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ removeAssistantEntry(entry.assistantId);
109
+ deleteGuardianToken(entry.assistantId);
110
+
111
+ console.log(
112
+ `Unpaired '${name}' — removed the local connection (lockfile entry + token).`,
113
+ );
114
+ console.log("");
115
+ console.log(
116
+ "Note: this only forgets the connection on this machine. The assistant's host can fully revoke this device from its side.",
117
+ );
118
+ }
@@ -68,6 +68,13 @@ export async function wake(): Promise<void> {
68
68
  process.exit(1);
69
69
  }
70
70
 
71
+ if (entry.cloud === "paired") {
72
+ console.error(
73
+ `Error: '${entry.assistantId}' is a remote assistant paired from another machine — its lifecycle is managed on its host machine, not here. Use \`vellum client ${entry.assistantId}\` to chat with it.`,
74
+ );
75
+ process.exit(1);
76
+ }
77
+
71
78
  if (entry.cloud && entry.cloud !== "local") {
72
79
  console.error(
73
80
  `Error: 'vellum wake' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,