@vellumai/cli 0.8.1 → 0.8.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.
@@ -1,5 +1,4 @@
1
1
  import { spawn } from "child_process";
2
- import { randomUUID } from "crypto";
3
2
  import { basename } from "path";
4
3
  import {
5
4
  useCallback,
@@ -14,7 +13,6 @@ import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
14
13
  import { removeAssistantEntry } from "../lib/assistant-config";
15
14
 
16
15
  import { SPECIES_CONFIG, type Species } from "../lib/constants";
17
- import { callDoctorDaemon, type ChatLogEntry } from "../lib/doctor-client";
18
16
  import { checkHealth } from "../lib/health-check";
19
17
  import { appendHistory, loadHistory } from "../lib/input-history";
20
18
  import { tuiLog } from "../lib/tui-log";
@@ -41,7 +39,6 @@ export const ANSI = {
41
39
  export const SLASH_COMMANDS = [
42
40
  "/btw",
43
41
  "/clear",
44
- "/doctor",
45
42
  "/exit",
46
43
  "/help",
47
44
  "/q",
@@ -62,7 +59,7 @@ const HEADER_PREFIX_UNICODE = "── Vellum ";
62
59
  const HEADER_PREFIX_ASCII = "-- Vellum ";
63
60
 
64
61
  // Left panel structure: HEADER lines + art + FOOTER lines
65
- const LEFT_HEADER_LINES = 3; // spacer + heading + spacer
62
+ const LEFT_HEADER_LINES = 4; // spacer + eyebrow + title + spacer
66
63
  const LEFT_FOOTER_LINES = 3; // spacer + runtimeUrl + dirName
67
64
 
68
65
  // Right panel structure
@@ -70,7 +67,7 @@ const TIPS = [
70
67
  "Send a message to start chatting",
71
68
  "Use /help to see available commands",
72
69
  ];
73
- const RIGHT_PANEL_INFO_SECTIONS = 3; // Assistant, Species, Status — each with heading + value
70
+ const RIGHT_PANEL_INFO_SECTIONS = 3; // Assistant ID, Species, Status — each with heading + value
74
71
  const RIGHT_PANEL_SPACERS = 2; // top spacer + spacer between tips and info
75
72
  const RIGHT_PANEL_TIPS_HEADING = 1;
76
73
  const RIGHT_PANEL_LINE_COUNT =
@@ -103,7 +100,7 @@ const MIN_FEED_ROWS = 3;
103
100
  // Feed item height estimation
104
101
  const TOOL_CALL_CHROME_LINES = 2; // header (┌) + footer (└)
105
102
  const MESSAGE_SPACING = 1;
106
- const HELP_DISPLAY_HEIGHT = 8;
103
+ const HELP_DISPLAY_HEIGHT = 7;
107
104
 
108
105
  interface ListMessagesResponse {
109
106
  messages: RuntimeMessage[];
@@ -668,6 +665,21 @@ function truncateValue(value: unknown, maxLen: number): string {
668
665
  return serialized;
669
666
  }
670
667
 
668
+ function formatHeaderTitle(assistantName?: string): string {
669
+ const rawTitle = assistantName?.trim() || "Meet your Assistant!";
670
+ const title = rawTitle.replace(/\s+/g, " ");
671
+ const maxTitleLength = LEFT_PANEL_WIDTH - 2;
672
+ const displayTitle =
673
+ title.length > maxTitleLength
674
+ ? title.slice(0, maxTitleLength - 3) + "..."
675
+ : title;
676
+ return ` ${displayTitle}`;
677
+ }
678
+
679
+ function formatHeaderEyebrow(): string {
680
+ return " Assistant";
681
+ }
682
+
671
683
  interface ToolCallDisplayProps {
672
684
  tc: ToolCallInfo;
673
685
  }
@@ -734,10 +746,6 @@ function HelpDisplay(): ReactElement {
734
746
  {" /btw <question> "}
735
747
  <Text dimColor>Ask a side question while the assistant is working</Text>
736
748
  </Text>
737
- <Text>
738
- {" /doctor [question] "}
739
- <Text dimColor>Run diagnostics on the remote instance via SSH</Text>
740
- </Text>
741
749
  <Text>
742
750
  {" /retire "}
743
751
  <Text dimColor>Retire the remote instance and exit</Text>
@@ -861,28 +869,33 @@ function stripAnsi(str: string): string {
861
869
  interface DefaultMainScreenProps {
862
870
  runtimeUrl: string;
863
871
  assistantId: string;
872
+ assistantName?: string;
864
873
  species: Species;
865
874
  healthStatus?: string;
866
875
  }
867
876
 
868
877
  interface StyledLine {
869
878
  text: string;
870
- style: "heading" | "dim" | "normal";
879
+ style: "heading" | "dim" | "normal" | "eyebrow" | "title" | "art";
871
880
  }
872
881
 
873
882
  function CompactHeader({
883
+ assistantName,
874
884
  species,
875
885
  healthStatus,
876
886
  totalWidth,
877
887
  }: {
888
+ assistantName?: string;
878
889
  species: Species;
879
890
  healthStatus?: string;
880
891
  totalWidth: number;
881
892
  }): ReactElement {
882
- const config = SPECIES_CONFIG[species];
883
893
  const accentColor = species === "openclaw" ? "red" : "magenta";
884
894
  const status = healthStatus ?? "checking...";
885
- const label = ` ${config.hatchedEmoji} ${species} ${statusEmoji(status)} `;
895
+ const identity = assistantName?.trim() || species;
896
+ const compactIdentity =
897
+ identity.length > 18 ? `${identity.slice(0, 15)}...` : identity;
898
+ const label = ` ${compactIdentity} ${statusEmoji(status)} `;
886
899
  const prefix = "── Vellum";
887
900
  const suffix = "──";
888
901
  const fillLen = Math.max(
@@ -904,13 +917,13 @@ function CompactHeader({
904
917
  function DefaultMainScreen({
905
918
  runtimeUrl,
906
919
  assistantId,
920
+ assistantName,
907
921
  species,
908
922
  healthStatus,
909
923
  }: DefaultMainScreenProps): ReactElement {
910
924
  const cwd = process.cwd();
911
925
  const dirName = basename(cwd);
912
926
  const config = SPECIES_CONFIG[species];
913
- const art = config.art;
914
927
  const accentColor = species === "openclaw" ? "red" : "magenta";
915
928
  const caps = getTerminalCapabilities();
916
929
  const headerPrefix = caps.unicodeSupported
@@ -926,6 +939,7 @@ function DefaultMainScreen({
926
939
  if (isCompact) {
927
940
  return (
928
941
  <CompactHeader
942
+ assistantName={assistantName}
929
943
  species={species}
930
944
  healthStatus={healthStatus}
931
945
  totalWidth={totalWidth}
@@ -933,16 +947,21 @@ function DefaultMainScreen({
933
947
  );
934
948
  }
935
949
 
950
+ const art = config.art;
936
951
  const rightPanelWidth = Math.max(1, totalWidth - LEFT_PANEL_WIDTH);
937
952
 
938
- const leftLines = [
939
- " ",
940
- " Meet your Assistant!",
941
- " ",
942
- ...art.map((l) => ` ${stripAnsi(l)}`),
943
- " ",
944
- ` ${runtimeUrl}`,
945
- ` ~/${dirName}`,
953
+ const leftLines: StyledLine[] = [
954
+ { text: " ", style: "normal" },
955
+ { text: formatHeaderEyebrow(), style: "eyebrow" },
956
+ { text: formatHeaderTitle(assistantName), style: "title" },
957
+ { text: " ", style: "normal" },
958
+ ...art.map((line) => ({
959
+ text: ` ${stripAnsi(line)}`,
960
+ style: "art" as const,
961
+ })),
962
+ { text: " ", style: "normal" },
963
+ { text: ` ${runtimeUrl}`, style: "dim" },
964
+ { text: ` ~/${dirName}`, style: "dim" },
946
965
  ];
947
966
 
948
967
  const rightLines: StyledLine[] = [
@@ -950,7 +969,7 @@ function DefaultMainScreen({
950
969
  { text: "Tips for getting started", style: "heading" },
951
970
  ...TIPS.map((t) => ({ text: t, style: "normal" as const })),
952
971
  { text: " ", style: "normal" },
953
- { text: "Assistant", style: "heading" },
972
+ { text: "Assistant ID", style: "heading" },
954
973
  { text: assistantId, style: "dim" },
955
974
  { text: "Species", style: "heading" },
956
975
  {
@@ -972,29 +991,37 @@ function DefaultMainScreen({
972
991
  <Box flexDirection="row">
973
992
  <Box flexDirection="column" width={LEFT_PANEL_WIDTH}>
974
993
  {Array.from({ length: maxLines }, (_, i) => {
975
- const line = leftLines[i] ?? " ";
976
- if (i === 1) {
994
+ const item = leftLines[i];
995
+ if (!item) return <Text key={i}> </Text>;
996
+ if (item.style === "eyebrow") {
997
+ return (
998
+ <Text key={i} color={caps.isDumb ? undefined : accentColor}>
999
+ {item.text}
1000
+ </Text>
1001
+ );
1002
+ }
1003
+ if (item.style === "title") {
977
1004
  return (
978
1005
  <Text key={i} bold>
979
- {line}
1006
+ {item.text}
980
1007
  </Text>
981
1008
  );
982
1009
  }
983
- if (i > 2 && i <= 2 + art.length) {
1010
+ if (item.style === "art") {
984
1011
  return (
985
1012
  <Text key={i} color={caps.isDumb ? undefined : accentColor}>
986
- {line}
1013
+ {item.text}
987
1014
  </Text>
988
1015
  );
989
1016
  }
990
- if (i > 2 + art.length) {
1017
+ if (item.style === "dim") {
991
1018
  return (
992
1019
  <Text key={i} dimColor>
993
- {line}
1020
+ {item.text}
994
1021
  </Text>
995
1022
  );
996
1023
  }
997
- return <Text key={i}>{line}</Text>;
1024
+ return <Text key={i}>{item.text}</Text>;
998
1025
  })}
999
1026
  </Box>
1000
1027
  <Box flexDirection="column" width={rightPanelWidth}>
@@ -1115,8 +1142,7 @@ function calculateHeaderHeight(
1115
1142
  if ((terminalColumns ?? DEFAULT_TERMINAL_COLUMNS) < COMPACT_THRESHOLD) {
1116
1143
  return COMPACT_HEADER_HEIGHT;
1117
1144
  }
1118
- const config = SPECIES_CONFIG[species];
1119
- const artLength = config.art.length;
1145
+ const artLength = SPECIES_CONFIG[species].art.length;
1120
1146
  const leftLineCount = LEFT_HEADER_LINES + artLength + LEFT_FOOTER_LINES;
1121
1147
  const maxLines = Math.max(leftLineCount, RIGHT_PANEL_LINE_COUNT);
1122
1148
  return maxLines + HEADER_CHROME_LINES;
@@ -1128,12 +1154,11 @@ export function render(
1128
1154
  runtimeUrl: string,
1129
1155
  assistantId: string,
1130
1156
  species: Species,
1157
+ assistantName?: string,
1131
1158
  ): number {
1132
1159
  const terminalColumns = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
1133
1160
  const isCompact = terminalColumns < COMPACT_THRESHOLD;
1134
-
1135
- const config = SPECIES_CONFIG[species];
1136
- const art = config.art;
1161
+ const art = SPECIES_CONFIG[species].art;
1137
1162
 
1138
1163
  const leftLineCount = LEFT_HEADER_LINES + art.length + LEFT_FOOTER_LINES;
1139
1164
  const maxLines = Math.max(leftLineCount, RIGHT_PANEL_LINE_COUNT);
@@ -1142,6 +1167,7 @@ export function render(
1142
1167
  <DefaultMainScreen
1143
1168
  runtimeUrl={runtimeUrl}
1144
1169
  assistantId={assistantId}
1170
+ assistantName={assistantName}
1145
1171
  species={species}
1146
1172
  />,
1147
1173
  { exitOnCtrlC: false },
@@ -1360,6 +1386,7 @@ export interface ChatAppHandle {
1360
1386
  interface ChatAppProps {
1361
1387
  runtimeUrl: string;
1362
1388
  assistantId: string;
1389
+ assistantName?: string;
1363
1390
  species: Species;
1364
1391
  /** Pre-built auth headers (e.g. { Authorization: "Bearer ..." } for local,
1365
1392
  * { "X-Session-Token": "...", "Vellum-Organization-Id": "..." } for platform). */
@@ -1373,6 +1400,7 @@ interface ChatAppProps {
1373
1400
  function ChatApp({
1374
1401
  runtimeUrl,
1375
1402
  assistantId,
1403
+ assistantName,
1376
1404
  species,
1377
1405
  auth,
1378
1406
  project,
@@ -1405,11 +1433,9 @@ function ChatApp({
1405
1433
  const connectedRef = useRef(false);
1406
1434
  const connectingRef = useRef(false);
1407
1435
  const seenMessageIdsRef = useRef(new Set<string>());
1408
- const chatLogRef = useRef<ChatLogEntry[]>([]);
1409
1436
  const sseAbortRef = useRef<AbortController | null>(null);
1410
1437
  const streamingTextRef = useRef("");
1411
1438
  const streamingToolCallsRef = useRef<ToolCallInfo[]>([]);
1412
- const doctorSessionIdRef = useRef(randomUUID());
1413
1439
  const handleRef_ = useRef<ChatAppHandle | null>(null);
1414
1440
 
1415
1441
  const { stdout } = useStdout();
@@ -1840,10 +1866,6 @@ function ChatApp({
1840
1866
  };
1841
1867
  seenMessageIdsRef.current.add(msg.id);
1842
1868
  hRef.addMessage(msg);
1843
- chatLogRef.current.push({
1844
- role: "assistant",
1845
- content: text,
1846
- });
1847
1869
  process.stdout.write("\x07");
1848
1870
  }
1849
1871
 
@@ -2012,79 +2034,6 @@ function ChatApp({
2012
2034
  return;
2013
2035
  }
2014
2036
 
2015
- if (trimmed === "/doctor" || trimmed.startsWith("/doctor ")) {
2016
- if (!project || !zone) {
2017
- h.showError(
2018
- "No instance info available. Connect to a hatched instance first.",
2019
- );
2020
- return;
2021
- }
2022
- const userPrompt = trimmed.slice("/doctor".length).trim() || undefined;
2023
- const recentChatContext = chatLogRef.current.slice(-20);
2024
-
2025
- chatLogRef.current.push({ role: "user", content: trimmed });
2026
-
2027
- if (userPrompt) {
2028
- const doctorUserMsg: RuntimeMessage = {
2029
- id: "local-user-" + Date.now(),
2030
- role: "user",
2031
- content: userPrompt,
2032
- timestamp: new Date().toISOString(),
2033
- label: "You (to Doctor):",
2034
- };
2035
- h.addMessage(doctorUserMsg);
2036
- }
2037
-
2038
- h.showSpinner(`Analyzing ${assistantId}...`);
2039
-
2040
- try {
2041
- const result = await callDoctorDaemon(
2042
- assistantId,
2043
- project,
2044
- zone,
2045
- userPrompt,
2046
- (event) => {
2047
- switch (event.phase) {
2048
- case "invoking_prompt":
2049
- handleRef_.current?.showSpinner(
2050
- `Analyzing ${assistantId}...`,
2051
- );
2052
- break;
2053
- case "calling_tool":
2054
- handleRef_.current?.showSpinner(
2055
- `Running ${event.toolName ?? "tool"} on ${assistantId}...`,
2056
- );
2057
- break;
2058
- case "processing_tool_result":
2059
- handleRef_.current?.showSpinner(
2060
- `Reviewing diagnostics for ${assistantId}...`,
2061
- );
2062
- break;
2063
- }
2064
- },
2065
- doctorSessionIdRef.current,
2066
- recentChatContext,
2067
- );
2068
- h.hideSpinner();
2069
- if (result.recommendation) {
2070
- h.addStatus(`Recommendation:\n${result.recommendation}`);
2071
- chatLogRef.current.push({
2072
- role: "assistant",
2073
- content: result.recommendation,
2074
- });
2075
- } else if (result.error) {
2076
- h.showError(result.error);
2077
- chatLogRef.current.push({ role: "error", content: result.error });
2078
- }
2079
- } catch (err) {
2080
- h.hideSpinner();
2081
- const errorMsg = `Doctor assistant unreachable: ${err instanceof Error ? err.message : err}`;
2082
- h.showError(errorMsg);
2083
- chatLogRef.current.push({ role: "error", content: errorMsg });
2084
- }
2085
- return;
2086
- }
2087
-
2088
2037
  // If a connection attempt is already in progress, don't silently drop input
2089
2038
  if (connectingRef.current) {
2090
2039
  h.addStatus(
@@ -2201,7 +2150,6 @@ function ChatApp({
2201
2150
  );
2202
2151
  clearTimeout(timeoutId);
2203
2152
  if (sendResult.accepted) {
2204
- chatLogRef.current.push({ role: "user", content: trimmed });
2205
2153
  h.addStatus(
2206
2154
  "Message queued — will be processed after current response",
2207
2155
  "gray",
@@ -2233,7 +2181,6 @@ function ChatApp({
2233
2181
  return;
2234
2182
  }
2235
2183
 
2236
- chatLogRef.current.push({ role: "user", content: trimmed });
2237
2184
  seenMessageIdsRef.current.add("pending-user-" + Date.now());
2238
2185
 
2239
2186
  h.showSpinner("Sending...");
@@ -2264,7 +2211,6 @@ function ChatApp({
2264
2211
  const errorMsg =
2265
2212
  sendErr instanceof Error ? sendErr.message : String(sendErr);
2266
2213
  h.showError(errorMsg);
2267
- chatLogRef.current.push({ role: "error", content: errorMsg });
2268
2214
  return;
2269
2215
  }
2270
2216
 
@@ -2462,6 +2408,7 @@ function ChatApp({
2462
2408
  <DefaultMainScreen
2463
2409
  runtimeUrl={runtimeUrl}
2464
2410
  assistantId={assistantId}
2411
+ assistantName={assistantName}
2465
2412
  species={species}
2466
2413
  healthStatus={healthStatus}
2467
2414
  />
@@ -2581,7 +2528,12 @@ export function renderChatApp(
2581
2528
  assistantId: string,
2582
2529
  species: Species,
2583
2530
  onExit: () => void,
2584
- options?: { auth?: Record<string, string>; project?: string; zone?: string },
2531
+ options?: {
2532
+ auth?: Record<string, string>;
2533
+ project?: string;
2534
+ zone?: string;
2535
+ assistantName?: string;
2536
+ },
2585
2537
  ): ChatAppInstance {
2586
2538
  let chatHandle: ChatAppHandle | null = null;
2587
2539
 
@@ -2589,6 +2541,7 @@ export function renderChatApp(
2589
2541
  <ChatApp
2590
2542
  runtimeUrl={runtimeUrl}
2591
2543
  assistantId={assistantId}
2544
+ assistantName={options?.assistantName}
2592
2545
  species={species}
2593
2546
  auth={options?.auth}
2594
2547
  project={options?.project}
@@ -4,6 +4,8 @@ import {
4
4
  AVATAR_DEVICE_ENV_VAR,
5
5
  dockerResourceNames,
6
6
  resolveAvatarDevicePath,
7
+ resolveDockerHatchMode,
8
+ resolveDockerProviderCredentialSetupAction,
7
9
  type ServiceName,
8
10
  } from "../docker.js";
9
11
  import { buildServiceRunArgs } from "../statefulset.js";
@@ -103,6 +105,51 @@ describe("buildServiceRunArgs — assistant", () => {
103
105
  });
104
106
  });
105
107
 
108
+ describe("resolveDockerProviderCredentialSetupAction", () => {
109
+ test("defers provider setup in detached mode", () => {
110
+ expect(
111
+ resolveDockerProviderCredentialSetupAction({
112
+ provider: "anthropic",
113
+ detached: true,
114
+ }),
115
+ ).toBe("defer");
116
+ });
117
+
118
+ test("reports missing guardian token only when a lease was expected", () => {
119
+ expect(
120
+ resolveDockerProviderCredentialSetupAction({
121
+ provider: "anthropic",
122
+ detached: false,
123
+ }),
124
+ ).toBe("missing-token");
125
+ });
126
+
127
+ test("configures provider setup when a guardian token is available", () => {
128
+ expect(
129
+ resolveDockerProviderCredentialSetupAction({
130
+ provider: "anthropic",
131
+ guardianAccessToken: "guardian-token",
132
+ detached: false,
133
+ }),
134
+ ).toBe("configure");
135
+ });
136
+
137
+ test("skips provider setup for internal hatches and detached keyless hatches", () => {
138
+ expect(
139
+ resolveDockerProviderCredentialSetupAction({
140
+ provider: undefined,
141
+ detached: false,
142
+ }),
143
+ ).toBe("skip");
144
+ expect(
145
+ resolveDockerProviderCredentialSetupAction({
146
+ provider: null,
147
+ detached: true,
148
+ }),
149
+ ).toBe("skip");
150
+ });
151
+ });
152
+
106
153
  describe("buildServiceRunArgs — gateway", () => {
107
154
  const savedVelayBaseUrl = process.env.VELAY_BASE_URL;
108
155
 
@@ -171,3 +218,62 @@ describe("VELLUM_AVATAR_DEVICE passthrough", () => {
171
218
  );
172
219
  });
173
220
  });
221
+
222
+ describe("resolveDockerHatchMode", () => {
223
+ test("defaults to pulling published images when no source flag is set", () => {
224
+ expect(
225
+ resolveDockerHatchMode({
226
+ watch: false,
227
+ buildFromSource: false,
228
+ fullSourceTreeAvailable: true,
229
+ }),
230
+ ).toEqual({ build: false, watcher: false, fellBackToPull: false });
231
+ });
232
+
233
+ test("--source <path> builds without enabling the file watcher", () => {
234
+ expect(
235
+ resolveDockerHatchMode({
236
+ watch: false,
237
+ buildFromSource: true,
238
+ fullSourceTreeAvailable: true,
239
+ }),
240
+ ).toEqual({ build: true, watcher: false, fellBackToPull: false });
241
+ });
242
+
243
+ test("--watch builds and enables the file watcher", () => {
244
+ expect(
245
+ resolveDockerHatchMode({
246
+ watch: true,
247
+ buildFromSource: false,
248
+ fullSourceTreeAvailable: true,
249
+ }),
250
+ ).toEqual({ build: true, watcher: true, fellBackToPull: false });
251
+ });
252
+
253
+ test("--watch + --source <path> still enables the watcher (watch wins)", () => {
254
+ expect(
255
+ resolveDockerHatchMode({
256
+ watch: true,
257
+ buildFromSource: true,
258
+ fullSourceTreeAvailable: true,
259
+ }),
260
+ ).toEqual({ build: true, watcher: true, fellBackToPull: false });
261
+ });
262
+
263
+ test("falls back to pull when source flag is set but source tree is missing", () => {
264
+ expect(
265
+ resolveDockerHatchMode({
266
+ watch: false,
267
+ buildFromSource: true,
268
+ fullSourceTreeAvailable: false,
269
+ }),
270
+ ).toEqual({ build: false, watcher: false, fellBackToPull: true });
271
+ expect(
272
+ resolveDockerHatchMode({
273
+ watch: true,
274
+ buildFromSource: false,
275
+ fullSourceTreeAvailable: false,
276
+ }),
277
+ ).toEqual({ build: false, watcher: false, fellBackToPull: true });
278
+ });
279
+ });
@@ -67,6 +67,10 @@ export interface ContainerInfo {
67
67
 
68
68
  export interface AssistantEntry {
69
69
  assistantId: string;
70
+ /** Platform-provided display name, when available. */
71
+ name?: string;
72
+ /** Older lockfile key for the display name, if present. */
73
+ assistantName?: string;
70
74
  runtimeUrl: string;
71
75
  /** Loopback URL for same-machine health checks (e.g. `http://127.0.0.1:7831`).
72
76
  * Avoids mDNS resolution issues when the machine checks its own gateway. */
@@ -85,6 +89,8 @@ export interface AssistantEntry {
85
89
  resources?: LocalInstanceResources;
86
90
  /** PID of the file watcher process for docker instances hatched with --watch. */
87
91
  watcherPid?: number;
92
+ /** Local bootstrap secret used to lease guardian tokens for Docker assistants after detached hatch. */
93
+ guardianBootstrapSecret?: string;
88
94
  /** Docker image metadata for rollback. Only present for docker topology entries. */
89
95
  containerInfo?: ContainerInfo;
90
96
  /** Docker image metadata from before the last upgrade. Enables rollback to the prior version. */
@@ -572,5 +578,3 @@ export function getLockfilePlatformBaseUrl(): string | undefined {
572
578
  if (typeof url === "string" && url.trim()) return url.trim();
573
579
  return undefined;
574
580
  }
575
-
576
-
@@ -32,6 +32,24 @@ export function buildNestedConfig(
32
32
  return config;
33
33
  }
34
34
 
35
+ /**
36
+ * Ensure hatch always provides enough initial LLM config for the assistant to
37
+ * detect a fresh off-platform hatch and seed BYOK profiles.
38
+ */
39
+ export function buildHatchConfigValues(
40
+ configValues: Record<string, string>,
41
+ provider: string | null | undefined,
42
+ ): Record<string, string> {
43
+ if (!provider || configValues["llm.default.provider"]) {
44
+ return configValues;
45
+ }
46
+
47
+ return {
48
+ ...configValues,
49
+ "llm.default.provider": provider,
50
+ };
51
+ }
52
+
35
53
  /**
36
54
  * Write arbitrary key-value pairs to a temporary JSON file and return its
37
55
  * path. The caller passes this path to the daemon via the