@vellumai/cli 0.4.2 → 0.4.4

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.2",
3
+ "version": "0.4.4",
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
@@ -155,6 +169,22 @@ symlink_vellum() {
155
169
  return 0
156
170
  }
157
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
+
158
188
  install_vellum() {
159
189
  if command -v vellum >/dev/null 2>&1; then
160
190
  info "Updating vellum to latest..."
@@ -183,6 +213,11 @@ main() {
183
213
  install_vellum
184
214
  symlink_vellum
185
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
+
186
221
  # Source the shell profile so vellum hatch runs with the correct PATH
187
222
  # in this session (the profile changes only take effect in new shells
188
223
  # otherwise).
@@ -145,6 +145,7 @@ interface HatchArgs {
145
145
  name: string | null;
146
146
  remote: RemoteHost;
147
147
  daemonOnly: boolean;
148
+ restart: boolean;
148
149
  }
149
150
 
150
151
  function parseArgs(): HatchArgs {
@@ -154,6 +155,7 @@ function parseArgs(): HatchArgs {
154
155
  let name: string | null = null;
155
156
  let remote: RemoteHost = DEFAULT_REMOTE;
156
157
  let daemonOnly = false;
158
+ let restart = false;
157
159
 
158
160
  for (let i = 0; i < args.length; i++) {
159
161
  const arg = args[i];
@@ -171,11 +173,14 @@ function parseArgs(): HatchArgs {
171
173
  console.log(" --name <name> Custom instance name");
172
174
  console.log(" --remote <host> Remote host (local, gcp, aws, custom)");
173
175
  console.log(" --daemon-only Start daemon only, skip gateway");
176
+ console.log(" --restart Restart processes without onboarding side effects");
174
177
  process.exit(0);
175
178
  } else if (arg === "-d") {
176
179
  detached = true;
177
180
  } else if (arg === "--daemon-only") {
178
181
  daemonOnly = true;
182
+ } else if (arg === "--restart") {
183
+ restart = true;
179
184
  } else if (arg === "--name") {
180
185
  const next = args[i + 1];
181
186
  if (!next || next.startsWith("-")) {
@@ -204,13 +209,13 @@ function parseArgs(): HatchArgs {
204
209
  species = arg as Species;
205
210
  } else {
206
211
  console.error(
207
- `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
212
+ `Error: Unknown argument '${arg}'. Valid options: ${VALID_SPECIES.join(", ")}, -d, --daemon-only, --restart, --name <name>, --remote <${VALID_REMOTE_HOSTS.join("|")}>`,
208
213
  );
209
214
  process.exit(1);
210
215
  }
211
216
  }
212
217
 
213
- return { species, detached, name, remote, daemonOnly };
218
+ return { species, detached, name, remote, daemonOnly, restart };
214
219
  }
215
220
 
216
221
  function formatElapsed(ms: number): string {
@@ -545,7 +550,12 @@ async function displayPairingQRCode(runtimeUrl: string, bearerToken: string | un
545
550
  }
546
551
  }
547
552
 
548
- async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false): Promise<void> {
553
+ async function hatchLocal(species: Species, name: string | null, daemonOnly: boolean = false, restart: boolean = false): Promise<void> {
554
+ if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
555
+ console.error("Error: Cannot restart without a known assistant ID. Provide --name or ensure VELLUM_ASSISTANT_NAME is set.");
556
+ process.exit(1);
557
+ }
558
+
549
559
  const instanceName =
550
560
  name ?? process.env.VELLUM_ASSISTANT_NAME ?? `${species}-${generateRandomSuffix()}`;
551
561
 
@@ -627,7 +637,7 @@ async function hatchLocal(species: Species, name: string | null, daemonOnly: boo
627
637
  species,
628
638
  hatchedAt: new Date().toISOString(),
629
639
  };
630
- if (!daemonOnly) {
640
+ if (!daemonOnly && !restart) {
631
641
  saveAssistantEntry(localEntry);
632
642
  syncConfigToLockfile();
633
643
 
@@ -656,10 +666,15 @@ export async function hatch(): Promise<void> {
656
666
  const cliVersion = getCliVersion();
657
667
  console.log(`@vellumai/cli v${cliVersion}`);
658
668
 
659
- const { species, detached, name, remote, daemonOnly } = parseArgs();
669
+ const { species, detached, name, remote, daemonOnly, restart } = parseArgs();
670
+
671
+ if (restart && remote !== "local") {
672
+ console.error("Error: --restart is only supported for local hatch targets.");
673
+ process.exit(1);
674
+ }
660
675
 
661
676
  if (remote === "local") {
662
- await hatchLocal(species, name, daemonOnly);
677
+ await hatchLocal(species, name, daemonOnly, restart);
663
678
  return;
664
679
  }
665
680
 
@@ -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>
@@ -99,7 +99,7 @@ export function loadAllAssistants(): AssistantEntry[] {
99
99
  }
100
100
 
101
101
  export function saveAssistantEntry(entry: AssistantEntry): void {
102
- const entries = readAssistants();
102
+ const entries = readAssistants().filter((e) => e.assistantId !== entry.assistantId);
103
103
  entries.unshift(entry);
104
104
  writeAssistants(entries);
105
105
  }
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
  /**