@vellumai/cli 0.8.0 → 0.8.2

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[];
@@ -333,6 +330,8 @@ interface SseEvent {
333
330
  allowedDomains?: string[];
334
331
  // message_complete fields
335
332
  source?: "main" | "aux";
333
+ // sync_changed fields
334
+ tags?: string[];
336
335
  [key: string]: unknown;
337
336
  }
338
337
 
@@ -666,6 +665,21 @@ function truncateValue(value: unknown, maxLen: number): string {
666
665
  return serialized;
667
666
  }
668
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
+
669
683
  interface ToolCallDisplayProps {
670
684
  tc: ToolCallInfo;
671
685
  }
@@ -732,10 +746,6 @@ function HelpDisplay(): ReactElement {
732
746
  {" /btw <question> "}
733
747
  <Text dimColor>Ask a side question while the assistant is working</Text>
734
748
  </Text>
735
- <Text>
736
- {" /doctor [question] "}
737
- <Text dimColor>Run diagnostics on the remote instance via SSH</Text>
738
- </Text>
739
749
  <Text>
740
750
  {" /retire "}
741
751
  <Text dimColor>Retire the remote instance and exit</Text>
@@ -859,28 +869,33 @@ function stripAnsi(str: string): string {
859
869
  interface DefaultMainScreenProps {
860
870
  runtimeUrl: string;
861
871
  assistantId: string;
872
+ assistantName?: string;
862
873
  species: Species;
863
874
  healthStatus?: string;
864
875
  }
865
876
 
866
877
  interface StyledLine {
867
878
  text: string;
868
- style: "heading" | "dim" | "normal";
879
+ style: "heading" | "dim" | "normal" | "eyebrow" | "title" | "art";
869
880
  }
870
881
 
871
882
  function CompactHeader({
883
+ assistantName,
872
884
  species,
873
885
  healthStatus,
874
886
  totalWidth,
875
887
  }: {
888
+ assistantName?: string;
876
889
  species: Species;
877
890
  healthStatus?: string;
878
891
  totalWidth: number;
879
892
  }): ReactElement {
880
- const config = SPECIES_CONFIG[species];
881
893
  const accentColor = species === "openclaw" ? "red" : "magenta";
882
894
  const status = healthStatus ?? "checking...";
883
- 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)} `;
884
899
  const prefix = "── Vellum";
885
900
  const suffix = "──";
886
901
  const fillLen = Math.max(
@@ -902,13 +917,13 @@ function CompactHeader({
902
917
  function DefaultMainScreen({
903
918
  runtimeUrl,
904
919
  assistantId,
920
+ assistantName,
905
921
  species,
906
922
  healthStatus,
907
923
  }: DefaultMainScreenProps): ReactElement {
908
924
  const cwd = process.cwd();
909
925
  const dirName = basename(cwd);
910
926
  const config = SPECIES_CONFIG[species];
911
- const art = config.art;
912
927
  const accentColor = species === "openclaw" ? "red" : "magenta";
913
928
  const caps = getTerminalCapabilities();
914
929
  const headerPrefix = caps.unicodeSupported
@@ -924,6 +939,7 @@ function DefaultMainScreen({
924
939
  if (isCompact) {
925
940
  return (
926
941
  <CompactHeader
942
+ assistantName={assistantName}
927
943
  species={species}
928
944
  healthStatus={healthStatus}
929
945
  totalWidth={totalWidth}
@@ -931,16 +947,21 @@ function DefaultMainScreen({
931
947
  );
932
948
  }
933
949
 
950
+ const art = config.art;
934
951
  const rightPanelWidth = Math.max(1, totalWidth - LEFT_PANEL_WIDTH);
935
952
 
936
- const leftLines = [
937
- " ",
938
- " Meet your Assistant!",
939
- " ",
940
- ...art.map((l) => ` ${stripAnsi(l)}`),
941
- " ",
942
- ` ${runtimeUrl}`,
943
- ` ~/${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" },
944
965
  ];
945
966
 
946
967
  const rightLines: StyledLine[] = [
@@ -948,7 +969,7 @@ function DefaultMainScreen({
948
969
  { text: "Tips for getting started", style: "heading" },
949
970
  ...TIPS.map((t) => ({ text: t, style: "normal" as const })),
950
971
  { text: " ", style: "normal" },
951
- { text: "Assistant", style: "heading" },
972
+ { text: "Assistant ID", style: "heading" },
952
973
  { text: assistantId, style: "dim" },
953
974
  { text: "Species", style: "heading" },
954
975
  {
@@ -970,29 +991,37 @@ function DefaultMainScreen({
970
991
  <Box flexDirection="row">
971
992
  <Box flexDirection="column" width={LEFT_PANEL_WIDTH}>
972
993
  {Array.from({ length: maxLines }, (_, i) => {
973
- const line = leftLines[i] ?? " ";
974
- 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") {
975
1004
  return (
976
1005
  <Text key={i} bold>
977
- {line}
1006
+ {item.text}
978
1007
  </Text>
979
1008
  );
980
1009
  }
981
- if (i > 2 && i <= 2 + art.length) {
1010
+ if (item.style === "art") {
982
1011
  return (
983
1012
  <Text key={i} color={caps.isDumb ? undefined : accentColor}>
984
- {line}
1013
+ {item.text}
985
1014
  </Text>
986
1015
  );
987
1016
  }
988
- if (i > 2 + art.length) {
1017
+ if (item.style === "dim") {
989
1018
  return (
990
1019
  <Text key={i} dimColor>
991
- {line}
1020
+ {item.text}
992
1021
  </Text>
993
1022
  );
994
1023
  }
995
- return <Text key={i}>{line}</Text>;
1024
+ return <Text key={i}>{item.text}</Text>;
996
1025
  })}
997
1026
  </Box>
998
1027
  <Box flexDirection="column" width={rightPanelWidth}>
@@ -1113,8 +1142,7 @@ function calculateHeaderHeight(
1113
1142
  if ((terminalColumns ?? DEFAULT_TERMINAL_COLUMNS) < COMPACT_THRESHOLD) {
1114
1143
  return COMPACT_HEADER_HEIGHT;
1115
1144
  }
1116
- const config = SPECIES_CONFIG[species];
1117
- const artLength = config.art.length;
1145
+ const artLength = SPECIES_CONFIG[species].art.length;
1118
1146
  const leftLineCount = LEFT_HEADER_LINES + artLength + LEFT_FOOTER_LINES;
1119
1147
  const maxLines = Math.max(leftLineCount, RIGHT_PANEL_LINE_COUNT);
1120
1148
  return maxLines + HEADER_CHROME_LINES;
@@ -1126,12 +1154,11 @@ export function render(
1126
1154
  runtimeUrl: string,
1127
1155
  assistantId: string,
1128
1156
  species: Species,
1157
+ assistantName?: string,
1129
1158
  ): number {
1130
1159
  const terminalColumns = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
1131
1160
  const isCompact = terminalColumns < COMPACT_THRESHOLD;
1132
-
1133
- const config = SPECIES_CONFIG[species];
1134
- const art = config.art;
1161
+ const art = SPECIES_CONFIG[species].art;
1135
1162
 
1136
1163
  const leftLineCount = LEFT_HEADER_LINES + art.length + LEFT_FOOTER_LINES;
1137
1164
  const maxLines = Math.max(leftLineCount, RIGHT_PANEL_LINE_COUNT);
@@ -1140,6 +1167,7 @@ export function render(
1140
1167
  <DefaultMainScreen
1141
1168
  runtimeUrl={runtimeUrl}
1142
1169
  assistantId={assistantId}
1170
+ assistantName={assistantName}
1143
1171
  species={species}
1144
1172
  />,
1145
1173
  { exitOnCtrlC: false },
@@ -1358,6 +1386,7 @@ export interface ChatAppHandle {
1358
1386
  interface ChatAppProps {
1359
1387
  runtimeUrl: string;
1360
1388
  assistantId: string;
1389
+ assistantName?: string;
1361
1390
  species: Species;
1362
1391
  /** Pre-built auth headers (e.g. { Authorization: "Bearer ..." } for local,
1363
1392
  * { "X-Session-Token": "...", "Vellum-Organization-Id": "..." } for platform). */
@@ -1371,6 +1400,7 @@ interface ChatAppProps {
1371
1400
  function ChatApp({
1372
1401
  runtimeUrl,
1373
1402
  assistantId,
1403
+ assistantName,
1374
1404
  species,
1375
1405
  auth,
1376
1406
  project,
@@ -1403,11 +1433,9 @@ function ChatApp({
1403
1433
  const connectedRef = useRef(false);
1404
1434
  const connectingRef = useRef(false);
1405
1435
  const seenMessageIdsRef = useRef(new Set<string>());
1406
- const chatLogRef = useRef<ChatLogEntry[]>([]);
1407
1436
  const sseAbortRef = useRef<AbortController | null>(null);
1408
1437
  const streamingTextRef = useRef("");
1409
1438
  const streamingToolCallsRef = useRef<ToolCallInfo[]>([]);
1410
- const doctorSessionIdRef = useRef(randomUUID());
1411
1439
  const handleRef_ = useRef<ChatAppHandle | null>(null);
1412
1440
 
1413
1441
  const { stdout } = useStdout();
@@ -1838,10 +1866,6 @@ function ChatApp({
1838
1866
  };
1839
1867
  seenMessageIdsRef.current.add(msg.id);
1840
1868
  hRef.addMessage(msg);
1841
- chatLogRef.current.push({
1842
- role: "assistant",
1843
- content: text,
1844
- });
1845
1869
  process.stdout.write("\x07");
1846
1870
  }
1847
1871
 
@@ -1856,6 +1880,11 @@ function ChatApp({
1856
1880
  hRef.setBusy(false);
1857
1881
  break;
1858
1882
 
1883
+ case "sync_changed":
1884
+ // The interactive CLI does not currently keep any sync-tagged
1885
+ // caches, so generic invalidations are intentionally ignored.
1886
+ break;
1887
+
1859
1888
  default:
1860
1889
  // Ignore events we don't handle (activity state, traces, etc.)
1861
1890
  break;
@@ -2005,79 +2034,6 @@ function ChatApp({
2005
2034
  return;
2006
2035
  }
2007
2036
 
2008
- if (trimmed === "/doctor" || trimmed.startsWith("/doctor ")) {
2009
- if (!project || !zone) {
2010
- h.showError(
2011
- "No instance info available. Connect to a hatched instance first.",
2012
- );
2013
- return;
2014
- }
2015
- const userPrompt = trimmed.slice("/doctor".length).trim() || undefined;
2016
- const recentChatContext = chatLogRef.current.slice(-20);
2017
-
2018
- chatLogRef.current.push({ role: "user", content: trimmed });
2019
-
2020
- if (userPrompt) {
2021
- const doctorUserMsg: RuntimeMessage = {
2022
- id: "local-user-" + Date.now(),
2023
- role: "user",
2024
- content: userPrompt,
2025
- timestamp: new Date().toISOString(),
2026
- label: "You (to Doctor):",
2027
- };
2028
- h.addMessage(doctorUserMsg);
2029
- }
2030
-
2031
- h.showSpinner(`Analyzing ${assistantId}...`);
2032
-
2033
- try {
2034
- const result = await callDoctorDaemon(
2035
- assistantId,
2036
- project,
2037
- zone,
2038
- userPrompt,
2039
- (event) => {
2040
- switch (event.phase) {
2041
- case "invoking_prompt":
2042
- handleRef_.current?.showSpinner(
2043
- `Analyzing ${assistantId}...`,
2044
- );
2045
- break;
2046
- case "calling_tool":
2047
- handleRef_.current?.showSpinner(
2048
- `Running ${event.toolName ?? "tool"} on ${assistantId}...`,
2049
- );
2050
- break;
2051
- case "processing_tool_result":
2052
- handleRef_.current?.showSpinner(
2053
- `Reviewing diagnostics for ${assistantId}...`,
2054
- );
2055
- break;
2056
- }
2057
- },
2058
- doctorSessionIdRef.current,
2059
- recentChatContext,
2060
- );
2061
- h.hideSpinner();
2062
- if (result.recommendation) {
2063
- h.addStatus(`Recommendation:\n${result.recommendation}`);
2064
- chatLogRef.current.push({
2065
- role: "assistant",
2066
- content: result.recommendation,
2067
- });
2068
- } else if (result.error) {
2069
- h.showError(result.error);
2070
- chatLogRef.current.push({ role: "error", content: result.error });
2071
- }
2072
- } catch (err) {
2073
- h.hideSpinner();
2074
- const errorMsg = `Doctor assistant unreachable: ${err instanceof Error ? err.message : err}`;
2075
- h.showError(errorMsg);
2076
- chatLogRef.current.push({ role: "error", content: errorMsg });
2077
- }
2078
- return;
2079
- }
2080
-
2081
2037
  // If a connection attempt is already in progress, don't silently drop input
2082
2038
  if (connectingRef.current) {
2083
2039
  h.addStatus(
@@ -2194,7 +2150,6 @@ function ChatApp({
2194
2150
  );
2195
2151
  clearTimeout(timeoutId);
2196
2152
  if (sendResult.accepted) {
2197
- chatLogRef.current.push({ role: "user", content: trimmed });
2198
2153
  h.addStatus(
2199
2154
  "Message queued — will be processed after current response",
2200
2155
  "gray",
@@ -2226,7 +2181,6 @@ function ChatApp({
2226
2181
  return;
2227
2182
  }
2228
2183
 
2229
- chatLogRef.current.push({ role: "user", content: trimmed });
2230
2184
  seenMessageIdsRef.current.add("pending-user-" + Date.now());
2231
2185
 
2232
2186
  h.showSpinner("Sending...");
@@ -2257,7 +2211,6 @@ function ChatApp({
2257
2211
  const errorMsg =
2258
2212
  sendErr instanceof Error ? sendErr.message : String(sendErr);
2259
2213
  h.showError(errorMsg);
2260
- chatLogRef.current.push({ role: "error", content: errorMsg });
2261
2214
  return;
2262
2215
  }
2263
2216
 
@@ -2265,15 +2218,7 @@ function ChatApp({
2265
2218
  // racing with SSE events that may arrive during the sendMessage await.
2266
2219
  h.showSpinner("Working...");
2267
2220
  },
2268
- [
2269
- runtimeUrl,
2270
- assistantId,
2271
- auth,
2272
- project,
2273
- zone,
2274
- cleanup,
2275
- ensureConnected,
2276
- ],
2221
+ [runtimeUrl, assistantId, auth, project, zone, cleanup, ensureConnected],
2277
2222
  );
2278
2223
 
2279
2224
  const handleSubmit = useCallback(
@@ -2463,6 +2408,7 @@ function ChatApp({
2463
2408
  <DefaultMainScreen
2464
2409
  runtimeUrl={runtimeUrl}
2465
2410
  assistantId={assistantId}
2411
+ assistantName={assistantName}
2466
2412
  species={species}
2467
2413
  healthStatus={healthStatus}
2468
2414
  />
@@ -2582,7 +2528,12 @@ export function renderChatApp(
2582
2528
  assistantId: string,
2583
2529
  species: Species,
2584
2530
  onExit: () => void,
2585
- 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
+ },
2586
2537
  ): ChatAppInstance {
2587
2538
  let chatHandle: ChatAppHandle | null = null;
2588
2539
 
@@ -2590,6 +2541,7 @@ export function renderChatApp(
2590
2541
  <ChatApp
2591
2542
  runtimeUrl={runtimeUrl}
2592
2543
  assistantId={assistantId}
2544
+ assistantName={options?.assistantName}
2593
2545
  species={species}
2594
2546
  auth={options?.auth}
2595
2547
  project={options?.project}
@@ -128,6 +128,17 @@ describe("buildServiceRunArgs — gateway", () => {
128
128
  buildGatewayArgs().some((arg) => arg.startsWith("VELAY_BASE_URL=")),
129
129
  ).toBe(false);
130
130
  });
131
+
132
+ test("forces gateway to run as uid 0 so it can connect to the assistant's root-owned IPC socket (mirrors K8s securityContext.runAsUser=0)", () => {
133
+ const args = buildGatewayArgs();
134
+ const userIdx = args.indexOf("--user");
135
+ expect(userIdx).toBeGreaterThan(-1);
136
+ expect(args[userIdx + 1]).toBe("0");
137
+ });
138
+
139
+ test("assistant container does NOT get a --user override (image USER root wins)", () => {
140
+ expect(buildAssistantArgs().includes("--user")).toBe(false);
141
+ });
131
142
  });
132
143
 
133
144
  describe("VELLUM_AVATAR_DEVICE passthrough", () => {
@@ -18,6 +18,8 @@ import {
18
18
  getMultiInstanceDir,
19
19
  } from "./environments/paths.js";
20
20
  import { getCurrentEnvironment } from "./environments/resolve.js";
21
+ import { SEEDS } from "./environments/seeds.js";
22
+ import type { EnvironmentDefinition } from "./environments/types.js";
21
23
  import { probePort } from "./port-probe.js";
22
24
 
23
25
  /**
@@ -65,6 +67,10 @@ export interface ContainerInfo {
65
67
 
66
68
  export interface AssistantEntry {
67
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;
68
74
  runtimeUrl: string;
69
75
  /** Loopback URL for same-machine health checks (e.g. `http://127.0.0.1:7831`).
70
76
  * Avoids mDNS resolution issues when the machine checks its own gateway. */
@@ -327,6 +333,69 @@ export function loadAllAssistants(): AssistantEntry[] {
327
333
  return readAssistants();
328
334
  }
329
335
 
336
+ /**
337
+ * Read the first existing lockfile for an explicitly-provided environment,
338
+ * without applying legacy migrations. This is the cross-env read path used by
339
+ * {@link loadAllAssistantsAcrossEnvs}: it deliberately bypasses
340
+ * {@link readLockfile} (which always resolves the *current* env) so callers
341
+ * can enumerate state from every env without flipping `process.env` or the
342
+ * persisted default. Migrations are skipped because we never want to write
343
+ * to another env's lockfile from the current env's process.
344
+ */
345
+ function readLockfileForEnv(env: EnvironmentDefinition): LockfileData {
346
+ for (const lockfilePath of getLockfilePaths(env)) {
347
+ if (!existsSync(lockfilePath)) continue;
348
+ try {
349
+ const raw = readFileSync(lockfilePath, "utf-8");
350
+ const parsed = JSON.parse(raw) as unknown;
351
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
352
+ return parsed as LockfileData;
353
+ }
354
+ } catch {
355
+ // Malformed; try next candidate
356
+ }
357
+ }
358
+ return {};
359
+ }
360
+
361
+ /**
362
+ * Load assistant entries from every known environment's lockfile.
363
+ *
364
+ * Each {@link SEEDS} entry has its own on-host data layout (config dir,
365
+ * lockfile path, data dir). A running assistant from `dev` is invisible to
366
+ * `loadAllAssistants()` when the current env is `local`, but its host
367
+ * processes (daemon/gateway/qdrant) still show up in `ps ax`. The orphan
368
+ * detector and `vellum clean` need the union of all envs' entries to avoid
369
+ * misclassifying — or worse, killing — another env's running services.
370
+ *
371
+ * Optional `envs` override is provided for testability so call sites can
372
+ * inject a curated env list with `lockfileDirOverride` set, without having
373
+ * to manipulate the global SEEDS table or process.env.
374
+ */
375
+ export function loadAllAssistantsAcrossEnvs(
376
+ envs?: EnvironmentDefinition[],
377
+ ): AssistantEntry[] {
378
+ const envList = envs ?? Object.values(SEEDS).map((env) => ({ ...env }));
379
+ const all: AssistantEntry[] = [];
380
+ for (const env of envList) {
381
+ const data = readLockfileForEnv(env);
382
+ const entries = data.assistants;
383
+ if (!Array.isArray(entries)) continue;
384
+ for (const raw of entries) {
385
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
386
+ const entry = raw as AssistantEntry;
387
+ if (
388
+ typeof entry.assistantId !== "string" ||
389
+ typeof entry.runtimeUrl !== "string"
390
+ ) {
391
+ continue;
392
+ }
393
+ all.push(entry);
394
+ }
395
+ }
396
+ return all;
397
+ }
398
+
330
399
  export function getActiveAssistant(): string | null {
331
400
  const data = readLockfile();
332
401
  return data.activeAssistant ?? null;
@@ -507,5 +576,3 @@ export function getLockfilePlatformBaseUrl(): string | undefined {
507
576
  if (typeof url === "string" && url.trim()) return url.trim();
508
577
  return undefined;
509
578
  }
510
-
511
-
@@ -12,6 +12,7 @@ import { homedir } from "os";
12
12
  import { join } from "path";
13
13
 
14
14
  export const CLI_INTERFACE_ID = "cli";
15
+ export const WEB_INTERFACE_ID = "web";
15
16
 
16
17
  let cached: string | null = null;
17
18
 
@@ -110,6 +110,21 @@ export function getStateDir(env: EnvironmentDefinition): string {
110
110
  return join(xdgDataHome(), `vellum-${env.name}`);
111
111
  }
112
112
 
113
+ /**
114
+ * Path to the interactive CLI's input history file.
115
+ *
116
+ * Follows the XDG Base Directory spec: history files are state data
117
+ * (persistent across runs but not portable / user-owned content), so they
118
+ * belong under `$XDG_STATE_HOME`, mirroring `bash`, `zsh`, `psql`, and `gh`.
119
+ * Defaults to `~/.local/state/vellum/input-history`.
120
+ *
121
+ * Not environment-scoped: terminal input history is per-user, not per-assistant,
122
+ * so dev and prod CLIs share the same history file.
123
+ */
124
+ export function getInputHistoryPath(): string {
125
+ return join(xdgStateHome(), "vellum", "input-history");
126
+ }
127
+
113
128
  /**
114
129
  * Named port constants derived from `DEFAULT_PORTS`.
115
130
  * These are the ports the assistant and gateway services bind to *inside*
@@ -127,3 +142,9 @@ function xdgDataHome(): string {
127
142
  function xdgConfigHome(): string {
128
143
  return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
129
144
  }
145
+
146
+ function xdgStateHome(): string {
147
+ return (
148
+ process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state")
149
+ );
150
+ }
@@ -1,16 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { homedir } from "os";
3
- import { dirname, join } from "path";
2
+ import { dirname } from "path";
4
3
 
5
- const MAX_ENTRIES = 1000;
4
+ import { getInputHistoryPath } from "./environments/paths.js";
6
5
 
7
- function historyPath(): string {
8
- return join(homedir(), ".vellum", "input-history");
9
- }
6
+ const MAX_ENTRIES = 1000;
10
7
 
11
8
  export function loadHistory(): string[] {
12
9
  try {
13
- const path = historyPath();
10
+ const path = getInputHistoryPath();
14
11
  if (!existsSync(path)) return [];
15
12
  const content = readFileSync(path, "utf-8");
16
13
  return content
@@ -26,7 +23,7 @@ export function appendHistory(entry: string): void {
26
23
  const trimmed = entry.trim();
27
24
  if (!trimmed || trimmed.startsWith("/")) return;
28
25
  try {
29
- const path = historyPath();
26
+ const path = getInputHistoryPath();
30
27
  const dir = dirname(path);
31
28
  if (!existsSync(dir)) {
32
29
  mkdirSync(dir, { recursive: true });