@vellumai/cli 0.4.1 → 0.4.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,13 +14,23 @@ success() { printf "${GREEN}${BOLD}==>${RESET} ${BOLD}%s${RESET}\n" "$1"; }
14
14
  error() { printf "${RED}error:${RESET} %s\n" "$1" >&2; }
15
15
 
16
16
  ensure_git() {
17
- if command -v git >/dev/null 2>&1; then
17
+ # On macOS, /usr/bin/git is a shim that triggers an "Install Command Line
18
+ # Developer Tools" popup instead of running git. Check that git actually
19
+ # works, not just that the binary exists.
20
+ if command -v git >/dev/null 2>&1 && git --version >/dev/null 2>&1; then
18
21
  success "git already installed ($(git --version))"
19
22
  return
20
23
  fi
21
24
 
22
25
  info "Installing git..."
23
- if command -v apt-get >/dev/null 2>&1; then
26
+ if [ "$(uname -s)" = "Darwin" ]; then
27
+ if command -v brew >/dev/null 2>&1; then
28
+ brew install git
29
+ else
30
+ error "git is required. Install Homebrew (https://brew.sh) then run: brew install git"
31
+ exit 1
32
+ fi
33
+ elif command -v apt-get >/dev/null 2>&1; then
24
34
  sudo apt-get update -qq && sudo apt-get install -y -qq git
25
35
  elif command -v yum >/dev/null 2>&1; then
26
36
  sudo yum install -y git
@@ -31,7 +41,11 @@ ensure_git() {
31
41
  exit 1
32
42
  fi
33
43
 
34
- if ! command -v git >/dev/null 2>&1; then
44
+ # Clear bash's command hash so it finds the newly installed git binary
45
+ # instead of the cached path to the macOS /usr/bin/git shim.
46
+ hash -r 2>/dev/null || true
47
+
48
+ if ! git --version >/dev/null 2>&1; then
35
49
  error "git installation failed. Please install manually."
36
50
  exit 1
37
51
  fi
@@ -79,6 +93,98 @@ ensure_bun() {
79
93
  success "bun installed ($(bun --version))"
80
94
  }
81
95
 
96
+ # Ensure ~/.bun/bin is in the user's shell profile so bun and vellum are
97
+ # available in new terminal sessions. The bun installer sometimes skips
98
+ # this (e.g. when stdin is piped via curl | bash).
99
+ configure_shell_profile() {
100
+ local bun_line='export BUN_INSTALL="$HOME/.bun"'
101
+ local path_line='export PATH="$BUN_INSTALL/bin:$PATH"'
102
+ local snippet
103
+ snippet=$(printf '\n# bun\n%s\n%s\n' "$bun_line" "$path_line")
104
+
105
+ local profiles=()
106
+ local shell_name="${SHELL:-}"
107
+
108
+ if [[ "$shell_name" == */zsh ]]; then
109
+ profiles+=("$HOME/.zshrc")
110
+ elif [[ "$shell_name" == */bash ]]; then
111
+ # Write to both .bashrc (non-login shells, e.g. new terminal on Linux)
112
+ # and .bash_profile (login shells, e.g. macOS Terminal.app)
113
+ profiles+=("$HOME/.bashrc")
114
+ [ -f "$HOME/.bash_profile" ] && profiles+=("$HOME/.bash_profile")
115
+ else
116
+ # Unknown shell — try both
117
+ profiles+=("$HOME/.bashrc")
118
+ [ -f "$HOME/.zshrc" ] && profiles+=("$HOME/.zshrc")
119
+ fi
120
+
121
+ for profile in "${profiles[@]}"; do
122
+ if [ -f "$profile" ] && grep -q 'BUN_INSTALL' "$profile" 2>/dev/null; then
123
+ continue
124
+ fi
125
+ printf '%s\n' "$snippet" >> "$profile"
126
+ success "Added bun to PATH in $profile"
127
+ done
128
+ }
129
+
130
+ # Create a symlink so `vellum` is available without ~/.bun/bin in PATH.
131
+ # Tries /usr/local/bin first (works on most systems), falls back to
132
+ # ~/.local/bin (user-writable, no sudo needed).
133
+ # This is best-effort — failure must not abort the install script.
134
+ symlink_vellum() {
135
+ local vellum_bin="$HOME/.bun/bin/vellum"
136
+ if [ ! -f "$vellum_bin" ]; then
137
+ return 0
138
+ fi
139
+
140
+ # Skip if vellum is already resolvable outside of ~/.bun/bin
141
+ local resolved
142
+ resolved=$(command -v vellum 2>/dev/null || true)
143
+ if [ -n "$resolved" ] && [ "$resolved" != "$vellum_bin" ]; then
144
+ return 0
145
+ fi
146
+
147
+ # Try /usr/local/bin (may need sudo on some systems)
148
+ if [ -d "/usr/local/bin" ] && [ -w "/usr/local/bin" ]; then
149
+ if ln -sf "$vellum_bin" /usr/local/bin/vellum 2>/dev/null; then
150
+ success "Symlinked /usr/local/bin/vellum → $vellum_bin"
151
+ return 0
152
+ fi
153
+ fi
154
+
155
+ # Fallback: ~/.local/bin
156
+ local local_bin="$HOME/.local/bin"
157
+ mkdir -p "$local_bin" 2>/dev/null || true
158
+ if ln -sf "$vellum_bin" "$local_bin/vellum" 2>/dev/null; then
159
+ success "Symlinked $local_bin/vellum → $vellum_bin"
160
+ # Ensure ~/.local/bin is in PATH in shell profile
161
+ for profile in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"; do
162
+ if [ -f "$profile" ] && ! grep -q "$local_bin" "$profile" 2>/dev/null; then
163
+ printf '\nexport PATH="%s:$PATH"\n' "$local_bin" >> "$profile"
164
+ fi
165
+ done
166
+ return 0
167
+ fi
168
+
169
+ return 0
170
+ }
171
+
172
+ # Write a small sourceable env file to ~/.config/vellum/env so callers can
173
+ # pick up PATH changes without restarting their shell:
174
+ # curl -fsSL https://assistant.vellum.ai/install.sh | bash && . ~/.config/vellum/env
175
+ write_env_file() {
176
+ local env_dir="${XDG_CONFIG_HOME:-$HOME/.config}/vellum"
177
+ local env_file="$env_dir/env"
178
+ mkdir -p "$env_dir"
179
+ cat > "$env_file" <<'ENVEOF'
180
+ export BUN_INSTALL="$HOME/.bun"
181
+ case ":$PATH:" in
182
+ *":$BUN_INSTALL/bin:"*) ;;
183
+ *) export PATH="$BUN_INSTALL/bin:$PATH" ;;
184
+ esac
185
+ ENVEOF
186
+ }
187
+
82
188
  install_vellum() {
83
189
  if command -v vellum >/dev/null 2>&1; then
84
190
  info "Updating vellum to latest..."
@@ -103,7 +209,20 @@ main() {
103
209
 
104
210
  ensure_git
105
211
  ensure_bun
212
+ configure_shell_profile
106
213
  install_vellum
214
+ symlink_vellum
215
+
216
+ # Write a sourceable env file so the quickstart one-liner can pick up
217
+ # PATH changes in the caller's shell:
218
+ # curl ... | bash && . ~/.config/vellum/env
219
+ write_env_file
220
+
221
+ # Source the shell profile so vellum hatch runs with the correct PATH
222
+ # in this session (the profile changes only take effect in new shells
223
+ # otherwise).
224
+ export BUN_INSTALL="$HOME/.bun"
225
+ export PATH="$BUN_INSTALL/bin:$PATH"
107
226
 
108
227
  info "Running vellum hatch..."
109
228
  printf "\n"
@@ -13,6 +13,7 @@ import { loadAllAssistants, saveAssistantEntry, syncConfigToLockfile } from "../
13
13
  import type { AssistantEntry } from "../lib/assistant-config";
14
14
  import { hatchAws } from "../lib/aws";
15
15
  import {
16
+ GATEWAY_PORT,
16
17
  SPECIES_CONFIG,
17
18
  VALID_REMOTE_HOSTS,
18
19
  VALID_SPECIES,
@@ -21,6 +22,7 @@ import type { RemoteHost, Species } from "../lib/constants";
21
22
  import { hatchGcp } from "../lib/gcp";
22
23
  import type { PollResult, WatchHatchingResult } from "../lib/gcp";
23
24
  import { startLocalDaemon, startGateway, stopLocalProcesses } from "../lib/local";
25
+ import { probePort } from "../lib/port-probe";
24
26
  import { isProcessAlive } from "../lib/process";
25
27
  import { generateRandomSuffix } from "../lib/random-name";
26
28
  import { validateAssistantName } from "../lib/retire-archive";
@@ -155,7 +157,22 @@ function parseArgs(): HatchArgs {
155
157
 
156
158
  for (let i = 0; i < args.length; i++) {
157
159
  const arg = args[i];
158
- if (arg === "-d") {
160
+ if (arg === "--help" || arg === "-h") {
161
+ console.log("Usage: vellum hatch [species] [options]");
162
+ console.log("");
163
+ console.log("Create a new assistant instance.");
164
+ console.log("");
165
+ console.log("Species:");
166
+ console.log(" vellum Default assistant (default)");
167
+ console.log(" openclaw OpenClaw adapter");
168
+ console.log("");
169
+ console.log("Options:");
170
+ console.log(" -d Run in detached mode");
171
+ console.log(" --name <name> Custom instance name");
172
+ console.log(" --remote <host> Remote host (local, gcp, aws, custom)");
173
+ console.log(" --daemon-only Start daemon only, skip gateway");
174
+ process.exit(0);
175
+ } else if (arg === "-d") {
159
176
  detached = true;
160
177
  } else if (arg === "--daemon-only") {
161
178
  daemonOnly = true;
@@ -496,6 +513,8 @@ async function displayPairingQRCode(runtimeUrl: string, bearerToken: string | un
496
513
  });
497
514
 
498
515
  if (!registerRes.ok) {
516
+ const body = await registerRes.text().catch(() => "");
517
+ console.warn(`⚠ Could not register pairing request: ${registerRes.status} ${registerRes.statusText}${body ? ` — ${body}` : ""}. Run \`vellum pair\` to try again.\n`);
499
518
  return;
500
519
  }
501
520
 
@@ -519,8 +538,10 @@ async function displayPairingQRCode(runtimeUrl: string, bearerToken: string | un
519
538
  console.log(qrString);
520
539
  console.log("This pairing request expires in 5 minutes.");
521
540
  console.log("Run `vellum pair` to generate a new one.\n");
522
- } catch {
541
+ } catch (err) {
523
542
  // Non-fatal — pairing is optional
543
+ const reason = err instanceof Error ? err.message : String(err);
544
+ console.warn(`⚠ Could not generate pairing QR code: ${reason}. Run \`vellum pair\` to try again.\n`);
524
545
  }
525
546
  }
526
547
 
@@ -540,6 +561,32 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
540
561
  console.log("🧹 Cleaning up stale local processes (no lock file entry)...\n");
541
562
  await stopLocalProcesses();
542
563
  }
564
+
565
+ // Verify required ports are available before starting any services.
566
+ // Only check when no local assistants exist — if there are existing local
567
+ // assistants, their daemon/gateway/qdrant legitimately own these ports.
568
+ const RUNTIME_HTTP_PORT = Number(process.env.RUNTIME_HTTP_PORT) || 7821;
569
+ const QDRANT_PORT = 6333;
570
+ const requiredPorts = [
571
+ { name: "daemon", port: RUNTIME_HTTP_PORT },
572
+ { name: "gateway", port: GATEWAY_PORT },
573
+ { name: "qdrant", port: QDRANT_PORT },
574
+ ];
575
+ const conflicts: string[] = [];
576
+ await Promise.all(
577
+ requiredPorts.map(async ({ name, port }) => {
578
+ if (await probePort(port)) {
579
+ conflicts.push(` - Port ${port} (${name}) is already in use`);
580
+ }
581
+ }),
582
+ );
583
+ if (conflicts.length > 0) {
584
+ throw new Error(
585
+ `Cannot hatch — required ports are already in use:\n${conflicts.join("\n")}\n\n` +
586
+ "Stop the conflicting processes or use environment variables to configure alternative ports " +
587
+ "(RUNTIME_HTTP_PORT, GATEWAY_PORT).",
588
+ );
589
+ }
543
590
  }
544
591
 
545
592
  const baseDataDir = join(process.env.BASE_DATA_DIR?.trim() || (process.env.HOME ?? userInfo().homedir), ".vellum");
@@ -7,6 +7,17 @@ import {
7
7
 
8
8
  export async function login(): Promise<void> {
9
9
  const args = process.argv.slice(3);
10
+
11
+ if (args.includes("--help") || args.includes("-h")) {
12
+ console.log("Usage: vellum login --token <session-token>");
13
+ console.log("");
14
+ console.log("Log in to the Vellum platform.");
15
+ console.log("");
16
+ console.log("Options:");
17
+ console.log(" --token <token> Session token from the Vellum platform");
18
+ process.exit(0);
19
+ }
20
+
10
21
  let token: string | null = null;
11
22
 
12
23
  for (let i = 0; i < args.length; i++) {
@@ -43,11 +54,27 @@ export async function login(): Promise<void> {
43
54
  }
44
55
 
45
56
  export async function logout(): Promise<void> {
57
+ const args = process.argv.slice(3);
58
+ if (args.includes("--help") || args.includes("-h")) {
59
+ console.log("Usage: vellum logout");
60
+ console.log("");
61
+ console.log("Log out of the Vellum platform and remove the stored session token.");
62
+ process.exit(0);
63
+ }
64
+
46
65
  clearPlatformToken();
47
66
  console.log("Logged out. Platform token removed.");
48
67
  }
49
68
 
50
69
  export async function whoami(): Promise<void> {
70
+ const args = process.argv.slice(3);
71
+ if (args.includes("--help") || args.includes("-h")) {
72
+ console.log("Usage: vellum whoami");
73
+ console.log("");
74
+ console.log("Show the currently logged-in Vellum platform user.");
75
+ process.exit(0);
76
+ }
77
+
51
78
  const token = readPlatformToken();
52
79
  if (!token) {
53
80
  console.error("Not logged in. Run `vellum login --token <token>` first.");
@@ -72,6 +72,14 @@ async function pollForApproval(
72
72
 
73
73
  export async function pair(): Promise<void> {
74
74
  const args = process.argv.slice(3);
75
+
76
+ if (args.includes("--help") || args.includes("-h")) {
77
+ console.log("Usage: vellum pair <path-to-qrcode.png>");
78
+ console.log("");
79
+ console.log("Pair with a remote assistant by scanning the QR code PNG generated during setup.");
80
+ process.exit(0);
81
+ }
82
+
75
83
  const qrCodePath = args[0] || process.env.VELLUM_CUSTOM_QR_CODE_PATH;
76
84
 
77
85
  if (!qrCodePath) {
@@ -409,6 +409,17 @@ async function listAllAssistants(): Promise<void> {
409
409
  // ── Entry point ─────────────────────────────────────────────────
410
410
 
411
411
  export async function ps(): Promise<void> {
412
+ const args = process.argv.slice(3);
413
+ if (args.includes("--help") || args.includes("-h")) {
414
+ console.log("Usage: vellum ps [<name>]");
415
+ console.log("");
416
+ console.log("List all assistants, or show processes for a specific assistant.");
417
+ console.log("");
418
+ console.log("Arguments:");
419
+ console.log(" <name> Show processes for the named assistant");
420
+ process.exit(0);
421
+ }
422
+
412
423
  const assistantId = process.argv[3];
413
424
 
414
425
  if (!assistantId) {
@@ -9,6 +9,17 @@ import { getArchivePath, getMetadataPath } from "../lib/retire-archive";
9
9
  import { exec } from "../lib/step-runner";
10
10
 
11
11
  export async function recover(): Promise<void> {
12
+ const args = process.argv.slice(3);
13
+ if (args.includes("--help") || args.includes("-h")) {
14
+ console.log("Usage: vellum recover <name>");
15
+ console.log("");
16
+ console.log("Restore a previously retired local assistant from its archive.");
17
+ console.log("");
18
+ console.log("Arguments:");
19
+ console.log(" <name> Name of the retired assistant to recover");
20
+ process.exit(0);
21
+ }
22
+
12
23
  const name = process.argv[3];
13
24
  if (!name) {
14
25
  console.error("Usage: vellum recover <name>");
@@ -121,6 +121,20 @@ function parseSource(): string | undefined {
121
121
  }
122
122
 
123
123
  export async function retire(): Promise<void> {
124
+ const args = process.argv.slice(3);
125
+ if (args.includes("--help") || args.includes("-h")) {
126
+ console.log("Usage: vellum retire <name> [--source <source>]");
127
+ console.log("");
128
+ console.log("Delete an assistant instance and archive its data.");
129
+ console.log("");
130
+ console.log("Arguments:");
131
+ console.log(" <name> Name of the assistant to retire");
132
+ console.log("");
133
+ console.log("Options:");
134
+ console.log(" --source <source> Source identifier for the retirement");
135
+ process.exit(0);
136
+ }
137
+
124
138
  const name = process.argv[3];
125
139
 
126
140
  if (!name) {
@@ -5,6 +5,14 @@ import { loadAllAssistants } from "../lib/assistant-config";
5
5
  import { stopProcessByPidFile } from "../lib/process";
6
6
 
7
7
  export async function sleep(): Promise<void> {
8
+ const args = process.argv.slice(3);
9
+ if (args.includes("--help") || args.includes("-h")) {
10
+ console.log("Usage: vellum sleep");
11
+ console.log("");
12
+ console.log("Stop the daemon and gateway processes.");
13
+ process.exit(0);
14
+ }
15
+
8
16
  const assistants = loadAllAssistants();
9
17
  const hasLocal = assistants.some((a) => a.cloud === "local");
10
18
  if (!hasLocal) {
@@ -33,6 +33,17 @@ function extractHostFromUrl(url: string): string {
33
33
  }
34
34
 
35
35
  export async function ssh(): Promise<void> {
36
+ const args = process.argv.slice(3);
37
+ if (args.includes("--help") || args.includes("-h")) {
38
+ console.log("Usage: vellum ssh [<name>]");
39
+ console.log("");
40
+ console.log("SSH into a remote assistant instance.");
41
+ console.log("");
42
+ console.log("Arguments:");
43
+ console.log(" <name> Name of the assistant to connect to (defaults to latest)");
44
+ process.exit(0);
45
+ }
46
+
36
47
  const name = process.argv[3];
37
48
  const entry = name ? findAssistantByName(name) : loadLatestAssistant();
38
49
 
@@ -7,6 +7,14 @@ import { isProcessAlive } from "../lib/process";
7
7
  import { startLocalDaemon, startGateway } from "../lib/local";
8
8
 
9
9
  export async function wake(): Promise<void> {
10
+ const args = process.argv.slice(3);
11
+ if (args.includes("--help") || args.includes("-h")) {
12
+ console.log("Usage: vellum wake");
13
+ console.log("");
14
+ console.log("Start the daemon and gateway processes.");
15
+ process.exit(0);
16
+ }
17
+
10
18
  const assistants = loadAllAssistants();
11
19
  const hasLocal = assistants.some((a) => a.cloud === "local");
12
20
  if (!hasLocal) {
@@ -32,6 +32,51 @@ const POLL_INTERVAL_MS = 3000;
32
32
  const SEND_TIMEOUT_MS = 5000;
33
33
  const RESPONSE_POLL_INTERVAL_MS = 1000;
34
34
 
35
+ // ── Layout constants ──────────────────────────────────────
36
+ const MAX_TOTAL_WIDTH = 72;
37
+ const DEFAULT_TERMINAL_COLUMNS = 80;
38
+ const DEFAULT_TERMINAL_ROWS = 24;
39
+ const LEFT_PANEL_WIDTH = 36;
40
+
41
+ const HEADER_PREFIX = "── Vellum ";
42
+
43
+ // Left panel structure: HEADER lines + art + FOOTER lines
44
+ const LEFT_HEADER_LINES = 3; // spacer + heading + spacer
45
+ const LEFT_FOOTER_LINES = 3; // spacer + runtimeUrl + dirName
46
+
47
+ // Right panel structure
48
+ const TIPS = ["Send a message to start chatting", "Use /help to see available commands"];
49
+ const RIGHT_PANEL_INFO_SECTIONS = 3; // Assistant, Species, Status — each with heading + value
50
+ const RIGHT_PANEL_SPACERS = 2; // top spacer + spacer between tips and info
51
+ const RIGHT_PANEL_TIPS_HEADING = 1;
52
+ const RIGHT_PANEL_LINE_COUNT =
53
+ RIGHT_PANEL_SPACERS + RIGHT_PANEL_TIPS_HEADING + TIPS.length + RIGHT_PANEL_INFO_SECTIONS * 2;
54
+
55
+ // Header chrome (borders around panel content)
56
+ const HEADER_TOP_BORDER_LINES = 1; // "── Vellum ───..." line
57
+ const HEADER_BOTTOM_BORDER_LINES = 2; // bottom rule + blank line
58
+ const HEADER_CHROME_LINES = HEADER_TOP_BORDER_LINES + HEADER_BOTTOM_BORDER_LINES;
59
+
60
+ // Selection / Secret windows
61
+ const DIALOG_WINDOW_WIDTH = 60;
62
+ const DIALOG_TITLE_CHROME = 5; // "┌─ " (3) + " " (1) + "┐" (1)
63
+ const DIALOG_BORDER_CORNERS = 2; // └ and ┘
64
+ const SELECTION_OPTION_CHROME = 6; // "│ " (2) + marker (1) + " " (1) + padding‐adjust + "│" (1)
65
+ const SECRET_CONTENT_CHROME = 4; // "│ " (2) + padding + "│" (1) + adjustment
66
+
67
+ // Chat area heights
68
+ const TOOLTIP_HEIGHT = 3;
69
+ const INPUT_AREA_HEIGHT = 4; // separator + input row + separator + hint
70
+ const SELECTION_CHROME_LINES = 3; // title bar + bottom border + spacing
71
+ const SECRET_INPUT_HEIGHT = 5; // title bar + content row + bottom border + tooltip chrome
72
+ const SPINNER_HEIGHT = 1;
73
+ const MIN_FEED_ROWS = 3;
74
+
75
+ // Feed item height estimation
76
+ const TOOL_CALL_CHROME_LINES = 2; // header (┌) + footer (└)
77
+ const MESSAGE_SPACING = 1;
78
+ const HELP_DISPLAY_HEIGHT = 6;
79
+
35
80
  interface ListMessagesResponse {
36
81
  messages: RuntimeMessage[];
37
82
  nextCursor?: string;
@@ -598,12 +643,10 @@ function DefaultMainScreen({
598
643
  const accentColor = species === "openclaw" ? "red" : "magenta";
599
644
 
600
645
  const { stdout } = useStdout();
601
- const terminalColumns = stdout.columns || 80;
602
- const totalWidth = Math.min(72, terminalColumns);
646
+ const terminalColumns = stdout.columns || DEFAULT_TERMINAL_COLUMNS;
647
+ const totalWidth = Math.min(MAX_TOTAL_WIDTH, terminalColumns);
603
648
  const rightPanelWidth = Math.max(1, totalWidth - LEFT_PANEL_WIDTH);
604
649
 
605
- const tips = ["Send a message to start chatting", "Use /help to see available commands"];
606
-
607
650
  const leftLines = [
608
651
  " ",
609
652
  " Meet your Assistant!",
@@ -617,7 +660,7 @@ function DefaultMainScreen({
617
660
  const rightLines: StyledLine[] = [
618
661
  { text: " ", style: "normal" },
619
662
  { text: "Tips for getting started", style: "heading" },
620
- ...tips.map((t) => ({ text: t, style: "normal" as const })),
663
+ ...TIPS.map((t) => ({ text: t, style: "normal" as const })),
621
664
  { text: " ", style: "normal" },
622
665
  { text: "Assistant", style: "heading" },
623
666
  { text: assistantId, style: "dim" },
@@ -631,7 +674,7 @@ function DefaultMainScreen({
631
674
 
632
675
  return (
633
676
  <Box flexDirection="column" width={totalWidth}>
634
- <Text dimColor>{"── Vellum " + "─".repeat(Math.max(0, totalWidth - 10))}</Text>
677
+ <Text dimColor>{HEADER_PREFIX + "─".repeat(Math.max(0, totalWidth - HEADER_PREFIX.length))}</Text>
635
678
  <Box flexDirection="row">
636
679
  <Box flexDirection="column" width={LEFT_PANEL_WIDTH}>
637
680
  {Array.from({ length: maxLines }, (_, i) => {
@@ -684,15 +727,10 @@ function DefaultMainScreen({
684
727
  </Box>
685
728
  <Text dimColor>{"─".repeat(totalWidth)}</Text>
686
729
  <Text> </Text>
687
- <Tooltip text="Type ? or /help for available commands" delay={1000}>
688
- <Text dimColor> ? for shortcuts</Text>
689
- </Tooltip>
690
- <Text> </Text>
691
730
  </Box>
692
731
  );
693
732
  }
694
733
 
695
- const LEFT_PANEL_WIDTH = 36;
696
734
 
697
735
  export interface SelectionRequest {
698
736
  title: string;
@@ -737,13 +775,21 @@ function estimateItemHeight(item: FeedItem, terminalColumns: number): number {
737
775
  for (const tc of item.toolCalls) {
738
776
  const paramCount =
739
777
  typeof tc.input === "object" && tc.input ? Object.keys(tc.input).length : 0;
740
- lines += 2 + paramCount + (tc.result !== undefined ? 1 : 0);
778
+ lines += TOOL_CALL_CHROME_LINES + paramCount + (tc.result !== undefined ? 1 : 0);
741
779
  }
742
780
  }
743
- return lines + 1;
781
+ return lines + MESSAGE_SPACING;
744
782
  }
745
783
  if (item.type === "help") {
746
- return 6;
784
+ return HELP_DISPLAY_HEIGHT;
785
+ }
786
+ if (item.type === "status" || item.type === "error") {
787
+ const cols = Math.max(1, terminalColumns);
788
+ let lines = 0;
789
+ for (const line of item.text.split("\n")) {
790
+ lines += Math.max(1, Math.ceil(line.length / cols));
791
+ }
792
+ return lines;
747
793
  }
748
794
  return 1;
749
795
  }
@@ -751,10 +797,9 @@ function estimateItemHeight(item: FeedItem, terminalColumns: number): number {
751
797
  function calculateHeaderHeight(species: Species): number {
752
798
  const config = SPECIES_CONFIG[species];
753
799
  const artLength = config.art.length;
754
- const leftLineCount = 3 + artLength + 3;
755
- const rightLineCount = 11;
756
- const maxLines = Math.max(leftLineCount, rightLineCount);
757
- return maxLines + 5;
800
+ const leftLineCount = LEFT_HEADER_LINES + artLength + LEFT_FOOTER_LINES;
801
+ const maxLines = Math.max(leftLineCount, RIGHT_PANEL_LINE_COUNT);
802
+ return maxLines + HEADER_CHROME_LINES;
758
803
  }
759
804
 
760
805
  const SCROLL_STEP = 5;
@@ -763,9 +808,8 @@ export function render(runtimeUrl: string, assistantId: string, species: Species
763
808
  const config = SPECIES_CONFIG[species];
764
809
  const art = config.art;
765
810
 
766
- const leftLineCount = 3 + art.length + 3;
767
- const rightLineCount = 11;
768
- const maxLines = Math.max(leftLineCount, rightLineCount);
811
+ const leftLineCount = LEFT_HEADER_LINES + art.length + LEFT_FOOTER_LINES;
812
+ const maxLines = Math.max(leftLineCount, RIGHT_PANEL_LINE_COUNT);
769
813
 
770
814
  const { unmount } = inkRender(
771
815
  <DefaultMainScreen runtimeUrl={runtimeUrl} assistantId={assistantId} species={species} />,
@@ -773,7 +817,7 @@ export function render(runtimeUrl: string, assistantId: string, species: Species
773
817
  );
774
818
  unmount();
775
819
 
776
- const statusCanvasLine = rightLineCount + 1;
820
+ const statusCanvasLine = RIGHT_PANEL_LINE_COUNT + HEADER_TOP_BORDER_LINES;
777
821
  const statusCol = LEFT_PANEL_WIDTH + 1;
778
822
  checkHealth(runtimeUrl)
779
823
  .then((health) => {
@@ -784,7 +828,7 @@ export function render(runtimeUrl: string, assistantId: string, species: Species
784
828
  })
785
829
  .catch(() => {});
786
830
 
787
- return 1 + maxLines + 4;
831
+ return maxLines + HEADER_CHROME_LINES;
788
832
  }
789
833
 
790
834
  interface SelectionWindowProps {
@@ -825,15 +869,14 @@ function SelectionWindow({
825
869
  },
826
870
  );
827
871
 
828
- const windowWidth = 60;
829
- const borderH = "\u2500".repeat(Math.max(0, windowWidth - title.length - 5));
872
+ const borderH = "\u2500".repeat(Math.max(0, DIALOG_WINDOW_WIDTH - title.length - DIALOG_TITLE_CHROME));
830
873
 
831
874
  return (
832
- <Box flexDirection="column" width={windowWidth}>
875
+ <Box flexDirection="column" width={DIALOG_WINDOW_WIDTH}>
833
876
  <Text>{"\u250C\u2500 " + title + " " + borderH + "\u2510"}</Text>
834
877
  {options.map((option, i) => {
835
878
  const marker = i === selectedIndex ? "\u276F" : " ";
836
- const padding = " ".repeat(Math.max(0, windowWidth - option.length - 6));
879
+ const padding = " ".repeat(Math.max(0, DIALOG_WINDOW_WIDTH - option.length - SELECTION_OPTION_CHROME));
837
880
  return (
838
881
  <Text key={i}>
839
882
  {"\u2502 "}
@@ -844,7 +887,7 @@ function SelectionWindow({
844
887
  </Text>
845
888
  );
846
889
  })}
847
- <Text>{"\u2514" + "\u2500".repeat(windowWidth - 2) + "\u2518"}</Text>
890
+ <Text>{"\u2514" + "\u2500".repeat(DIALOG_WINDOW_WIDTH - DIALOG_BORDER_CORNERS) + "\u2518"}</Text>
848
891
  <Tooltip text="\u2191/\u2193 navigate Enter select Esc cancel" delay={1000} />
849
892
  </Box>
850
893
  );
@@ -888,15 +931,14 @@ function SecretInputWindow({
888
931
  },
889
932
  );
890
933
 
891
- const windowWidth = 60;
892
- const borderH = "\u2500".repeat(Math.max(0, windowWidth - label.length - 5));
934
+ const borderH = "\u2500".repeat(Math.max(0, DIALOG_WINDOW_WIDTH - label.length - DIALOG_TITLE_CHROME));
893
935
  const masked = "\u2022".repeat(value.length);
894
936
  const displayText = value.length > 0 ? masked : (placeholder ?? "Enter secret...");
895
937
  const displayColor = value.length > 0 ? undefined : "gray";
896
- const contentPad = " ".repeat(Math.max(0, windowWidth - displayText.length - 4));
938
+ const contentPad = " ".repeat(Math.max(0, DIALOG_WINDOW_WIDTH - displayText.length - SECRET_CONTENT_CHROME));
897
939
 
898
940
  return (
899
- <Box flexDirection="column" width={windowWidth}>
941
+ <Box flexDirection="column" width={DIALOG_WINDOW_WIDTH}>
900
942
  <Text>{"\u250C\u2500 " + label + " " + borderH + "\u2510"}</Text>
901
943
  <Text>
902
944
  {"\u2502 "}
@@ -904,7 +946,7 @@ function SecretInputWindow({
904
946
  {contentPad}
905
947
  {"\u2502"}
906
948
  </Text>
907
- <Text>{"\u2514" + "\u2500".repeat(windowWidth - 2) + "\u2518"}</Text>
949
+ <Text>{"\u2514" + "\u2500".repeat(DIALOG_WINDOW_WIDTH - DIALOG_BORDER_CORNERS) + "\u2518"}</Text>
908
950
  <Tooltip text="Enter submit Esc cancel" delay={1000} />
909
951
  </Box>
910
952
  );
@@ -985,20 +1027,18 @@ function ChatApp({
985
1027
  const handleRef_ = useRef<ChatAppHandle | null>(null);
986
1028
 
987
1029
  const { stdout } = useStdout();
988
- const terminalRows = stdout.rows || 24;
989
- const terminalColumns = stdout.columns || 80;
1030
+ const terminalRows = stdout.rows || DEFAULT_TERMINAL_ROWS;
1031
+ const terminalColumns = stdout.columns || DEFAULT_TERMINAL_COLUMNS;
990
1032
  const headerHeight = calculateHeaderHeight(species);
991
- const tooltipBubbleHeight = 3;
992
- const showTooltip = inputFocused && inputValue.length === 0;
993
- const tooltipHeight = showTooltip ? tooltipBubbleHeight : 0;
1033
+
994
1034
  const bottomHeight = selection
995
- ? selection.options.length + 3 + tooltipBubbleHeight
1035
+ ? selection.options.length + SELECTION_CHROME_LINES + TOOLTIP_HEIGHT
996
1036
  : secretInput
997
- ? 5 + tooltipBubbleHeight
1037
+ ? SECRET_INPUT_HEIGHT + TOOLTIP_HEIGHT
998
1038
  : spinnerText
999
- ? 4
1000
- : 3 + tooltipHeight;
1001
- const availableRows = Math.max(3, terminalRows - headerHeight - bottomHeight);
1039
+ ? SPINNER_HEIGHT + INPUT_AREA_HEIGHT
1040
+ : INPUT_AREA_HEIGHT;
1041
+ const availableRows = Math.max(MIN_FEED_ROWS, terminalRows - headerHeight - bottomHeight);
1002
1042
 
1003
1043
  const addMessage = useCallback((msg: RuntimeMessage) => {
1004
1044
  setFeed((prev) => [...prev, msg]);
@@ -1823,12 +1863,6 @@ function ChatApp({
1823
1863
 
1824
1864
  {!selection && !secretInput ? (
1825
1865
  <Box flexDirection="column">
1826
- <Tooltip
1827
- text="Type a message or /help for commands"
1828
- visible={inputFocused && inputValue.length === 0}
1829
- position="above"
1830
- delay={1000}
1831
- />
1832
1866
  <Text dimColor>{"\u2500".repeat(terminalColumns)}</Text>
1833
1867
  <Box paddingLeft={1}>
1834
1868
  <Text color="green" bold>
@@ -1843,6 +1877,7 @@ function ChatApp({
1843
1877
  />
1844
1878
  </Box>
1845
1879
  <Text dimColor>{"\u2500".repeat(terminalColumns)}</Text>
1880
+ <Text dimColor> ? for shortcuts</Text>
1846
1881
  </Box>
1847
1882
  ) : null}
1848
1883
  </Box>
package/src/index.ts CHANGED
@@ -31,8 +31,8 @@ const commands = {
31
31
  email,
32
32
  hatch,
33
33
  login,
34
- pair,
35
34
  logout,
35
+ pair,
36
36
  ps,
37
37
  recover,
38
38
  retire,
@@ -90,9 +90,9 @@ async function main() {
90
90
  console.log(" contacts Manage the contact graph");
91
91
  console.log(" email Email operations (status, create inbox)");
92
92
  console.log(" hatch Create a new assistant instance");
93
- console.log(" pair Pair with a remote assistant via QR code");
94
93
  console.log(" login Log in to the Vellum platform");
95
94
  console.log(" logout Log out of the Vellum platform");
95
+ console.log(" pair Pair with a remote assistant via QR code");
96
96
  console.log(" ps List assistants (or processes for a specific assistant)");
97
97
  console.log(" recover Restore a previously retired local assistant");
98
98
  console.log(" retire Delete an assistant instance");
package/src/lib/local.ts CHANGED
@@ -557,8 +557,35 @@ export async function startGateway(assistantId?: string): Promise<string> {
557
557
  writeFileSync(join(vellumDir, "gateway.pid"), String(gateway.pid), "utf-8");
558
558
  }
559
559
 
560
+ const gatewayUrl = publicUrl || `http://localhost:${GATEWAY_PORT}`;
561
+
562
+ // Wait for the gateway to be responsive before returning. Without this,
563
+ // callers (e.g. displayPairingQRCode) may try to connect before the HTTP
564
+ // server is listening and get connection-refused errors.
565
+ const start = Date.now();
566
+ const timeoutMs = 30000;
567
+ let ready = false;
568
+ while (Date.now() - start < timeoutMs) {
569
+ try {
570
+ const res = await fetch(`http://localhost:${GATEWAY_PORT}/healthz`, {
571
+ signal: AbortSignal.timeout(2000),
572
+ });
573
+ if (res.ok) {
574
+ ready = true;
575
+ break;
576
+ }
577
+ } catch {
578
+ // Gateway not ready yet
579
+ }
580
+ await new Promise((r) => setTimeout(r, 250));
581
+ }
582
+
583
+ if (!ready) {
584
+ console.warn("⚠ Gateway started but health check did not respond within 30s");
585
+ }
586
+
560
587
  console.log("✅ Gateway started\n");
561
- return publicUrl || `http://localhost:${GATEWAY_PORT}`;
588
+ return gatewayUrl;
562
589
  }
563
590
 
564
591
  /**