@vellumai/cli 0.6.6 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -14,10 +14,12 @@ import {
14
14
  import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
15
15
 
16
16
  import { removeAssistantEntry } from "../lib/assistant-config";
17
+ import { getClientRegistrationHeaders } from "../lib/client-identity";
17
18
  import { SPECIES_CONFIG, type Species } from "../lib/constants";
18
19
  import { callDoctorDaemon, type ChatLogEntry } from "../lib/doctor-client";
19
20
  import { checkHealth } from "../lib/health-check";
20
21
  import { appendHistory, loadHistory } from "../lib/input-history";
22
+ import { tuiLog } from "../lib/tui-log";
21
23
  import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
22
24
  import {
23
25
  getTerminalCapabilities,
@@ -50,9 +52,7 @@ export const SLASH_COMMANDS = [
50
52
  "/retire",
51
53
  ];
52
54
 
53
- const POLL_INTERVAL_MS = 3000;
54
55
  const SEND_TIMEOUT_MS = 5000;
55
- const RESPONSE_POLL_INTERVAL_MS = 1000;
56
56
 
57
57
  // ── Layout constants ──────────────────────────────────────
58
58
  const MAX_TOTAL_WIDTH = 72;
@@ -148,11 +148,6 @@ interface AddTrustRuleResponse {
148
148
  accepted: boolean;
149
149
  }
150
150
 
151
- interface PendingInteractionsResponse {
152
- pendingConfirmation: (PendingConfirmation & { requestId: string }) | null;
153
- pendingSecret: (PendingSecret & { requestId?: string }) | null;
154
- }
155
-
156
151
  type TrustDecision = "always_allow" | "always_deny";
157
152
 
158
153
  interface HealthResponse {
@@ -301,19 +296,127 @@ async function addTrustRule(
301
296
  );
302
297
  }
303
298
 
304
- async function pollPendingInteractions(
299
+ // ── SSE event types ─────────────────────────────────────────────
300
+ interface SseEvent {
301
+ type: string;
302
+ text?: string;
303
+ thinking?: string;
304
+ toolName?: string;
305
+ toolUseId?: string;
306
+ input?: Record<string, unknown>;
307
+ result?: string;
308
+ isError?: boolean;
309
+ content?: string;
310
+ chunk?: string;
311
+ message?: string;
312
+ conversationId?: string;
313
+ messageId?: string;
314
+ requestId?: string;
315
+ // confirmation_request fields
316
+ riskLevel?: string;
317
+ riskReason?: string;
318
+ executionTarget?: "sandbox" | "host";
319
+ allowlistOptions?: Array<{
320
+ label: string;
321
+ description: string;
322
+ pattern: string;
323
+ }>;
324
+ scopeOptions?: Array<{ label: string; scope: string }>;
325
+ persistentDecisionsAllowed?: boolean;
326
+ isContainerized?: boolean;
327
+ // secret_request fields
328
+ service?: string;
329
+ field?: string;
330
+ label?: string;
331
+ description?: string;
332
+ placeholder?: string;
333
+ purpose?: string;
334
+ allowOneTimeSend?: boolean;
335
+ allowedTools?: string[];
336
+ allowedDomains?: string[];
337
+ // message_complete fields
338
+ source?: "main" | "aux";
339
+ [key: string]: unknown;
340
+ }
341
+
342
+ /**
343
+ * Open an SSE stream to the assistant's /events endpoint.
344
+ * Yields unwrapped message payloads from `data:` lines, skipping
345
+ * heartbeat comments. The /events endpoint emits AssistantEvent
346
+ * envelopes (`{ id, assistantId, message: { type, ... } }`); this
347
+ * generator unwraps the envelope so callers switch on `.type` directly.
348
+ */
349
+ async function* streamEvents(
305
350
  baseUrl: string,
306
351
  assistantId: string,
352
+ conversationKey: string,
353
+ signal: AbortSignal,
307
354
  bearerToken?: string,
308
- ): Promise<PendingInteractionsResponse> {
309
- const params = new URLSearchParams({ conversationKey: assistantId });
310
- return runtimeRequest<PendingInteractionsResponse>(
311
- baseUrl,
312
- assistantId,
313
- `/pending-interactions?${params.toString()}`,
314
- undefined,
315
- bearerToken,
316
- );
355
+ ): AsyncGenerator<SseEvent> {
356
+ const params = new URLSearchParams({ conversationKey });
357
+ const url = `${baseUrl}/v1/assistants/${assistantId}/events?${params.toString()}`;
358
+ const clientHeaders = getClientRegistrationHeaders();
359
+ tuiLog.info("sse connect", { url, clientHeaders });
360
+ const response = await fetch(url, {
361
+ headers: {
362
+ Accept: "text/event-stream",
363
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
364
+ ...clientHeaders,
365
+ },
366
+ signal,
367
+ });
368
+
369
+ tuiLog.info("sse response", {
370
+ status: response.status,
371
+ statusText: response.statusText,
372
+ contentType: response.headers.get("content-type"),
373
+ });
374
+
375
+ if (!response.ok) {
376
+ const body = await response.text().catch(() => "");
377
+ tuiLog.error("sse connection failed", {
378
+ status: response.status,
379
+ body: body.slice(0, 500),
380
+ });
381
+ throw new Error(
382
+ `SSE connection failed (${response.status}): ${body || response.statusText}`,
383
+ );
384
+ }
385
+ if (!response.body) {
386
+ tuiLog.error("sse response has no body");
387
+ throw new Error("No response body from SSE endpoint");
388
+ }
389
+
390
+ const decoder = new TextDecoder();
391
+ let buffer = "";
392
+ for await (const chunk of response.body) {
393
+ buffer += decoder.decode(chunk as Uint8Array, { stream: true });
394
+ let boundary: number;
395
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
396
+ const frame = buffer.slice(0, boundary);
397
+ buffer = buffer.slice(boundary + 2);
398
+ if (!frame.trim() || frame.startsWith(":")) continue;
399
+ let data: string | undefined;
400
+ for (const line of frame.split("\n")) {
401
+ if (line.startsWith("data: ")) {
402
+ data = line.slice(6);
403
+ }
404
+ }
405
+ if (!data) continue;
406
+ try {
407
+ const envelope = JSON.parse(data) as {
408
+ message?: SseEvent;
409
+ [key: string]: unknown;
410
+ };
411
+ // Unwrap the AssistantEvent envelope
412
+ if (envelope.message && typeof envelope.message.type === "string") {
413
+ yield envelope.message;
414
+ }
415
+ } catch {
416
+ // skip malformed JSON
417
+ }
418
+ }
419
+ }
317
420
  }
318
421
 
319
422
  function formatConfirmationPreview(
@@ -508,6 +611,7 @@ export interface ToolCallInfo {
508
611
  input: Record<string, unknown>;
509
612
  result?: string;
510
613
  isError?: boolean;
614
+ toolUseId?: string;
511
615
  }
512
616
 
513
617
  export interface RuntimeMessage {
@@ -1307,7 +1411,9 @@ function ChatApp({
1307
1411
  const connectingRef = useRef(false);
1308
1412
  const seenMessageIdsRef = useRef(new Set<string>());
1309
1413
  const chatLogRef = useRef<ChatLogEntry[]>([]);
1310
- const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
1414
+ const sseAbortRef = useRef<AbortController | null>(null);
1415
+ const streamingTextRef = useRef("");
1416
+ const streamingToolCallsRef = useRef<ToolCallInfo[]>([]);
1311
1417
  const doctorSessionIdRef = useRef(randomUUID());
1312
1418
  const handleRef_ = useRef<ChatAppHandle | null>(null);
1313
1419
 
@@ -1535,9 +1641,9 @@ function ChatApp({
1535
1641
  }, []);
1536
1642
 
1537
1643
  const cleanup = useCallback(() => {
1538
- if (pollTimerRef.current) {
1539
- clearInterval(pollTimerRef.current);
1540
- pollTimerRef.current = null;
1644
+ if (sseAbortRef.current) {
1645
+ sseAbortRef.current.abort();
1646
+ sseAbortRef.current = null;
1541
1647
  }
1542
1648
  }, []);
1543
1649
 
@@ -1561,6 +1667,10 @@ function ChatApp({
1561
1667
 
1562
1668
  try {
1563
1669
  const health = await checkHealthRuntime(runtimeUrl);
1670
+ tuiLog.info("health check", {
1671
+ status: health.status,
1672
+ message: health.message,
1673
+ });
1564
1674
  h.hideSpinner();
1565
1675
  h.updateHealthStatus(health.status);
1566
1676
  if (health.status === "healthy" || health.status === "ok") {
@@ -1595,35 +1705,196 @@ function ChatApp({
1595
1705
  h.hideSpinner();
1596
1706
  }
1597
1707
 
1598
- pollTimerRef.current = setInterval(async () => {
1708
+ // Open SSE stream for real-time events
1709
+ const sseAc = new AbortController();
1710
+ sseAbortRef.current = sseAc;
1711
+
1712
+ // Process SSE events in the background
1713
+ (async () => {
1599
1714
  try {
1600
- const response = await pollMessages(
1715
+ for await (const event of streamEvents(
1601
1716
  runtimeUrl,
1602
1717
  assistantId,
1718
+ assistantId,
1719
+ sseAc.signal,
1603
1720
  bearerToken,
1604
- );
1605
- for (const msg of response.messages) {
1606
- if (!seenMessageIdsRef.current.has(msg.id)) {
1607
- seenMessageIdsRef.current.add(msg.id);
1608
- if (msg.role === "assistant") {
1609
- handleRef_.current?.addMessage(msg);
1721
+ )) {
1722
+ const hRef = handleRef_.current;
1723
+ if (!hRef) continue;
1724
+
1725
+ switch (event.type) {
1726
+ case "assistant_text_delta":
1727
+ streamingTextRef.current += event.text ?? "";
1728
+ break;
1729
+
1730
+ case "assistant_thinking_delta":
1731
+ // Thinking deltas are suppressed in the TUI for now
1732
+ break;
1733
+
1734
+ case "tool_use_start":
1735
+ if (event.toolName) {
1736
+ streamingToolCallsRef.current.push({
1737
+ name: event.toolName,
1738
+ input: event.input ?? {},
1739
+ toolUseId: event.toolUseId,
1740
+ });
1741
+ }
1742
+ break;
1743
+
1744
+ case "tool_result": {
1745
+ // Match by toolUseId first (robust for parallel/same-name calls),
1746
+ // fall back to name + missing result for backwards compat.
1747
+ const tc = event.toolUseId
1748
+ ? streamingToolCallsRef.current.find(
1749
+ (t) => t.toolUseId === event.toolUseId,
1750
+ )
1751
+ : streamingToolCallsRef.current.find(
1752
+ (t) =>
1753
+ t.name === event.toolName && t.result === undefined,
1754
+ );
1755
+ if (tc) {
1756
+ tc.result = event.result;
1757
+ tc.isError = event.isError;
1758
+ } else if (event.toolName) {
1759
+ streamingToolCallsRef.current.push({
1760
+ name: event.toolName,
1761
+ input: event.input ?? {},
1762
+ result: event.result,
1763
+ isError: event.isError,
1764
+ toolUseId: event.toolUseId,
1765
+ });
1766
+ }
1767
+ break;
1768
+ }
1769
+
1770
+ case "confirmation_request":
1771
+ hRef.hideSpinner();
1772
+ await handleConfirmationPrompt(
1773
+ runtimeUrl,
1774
+ assistantId,
1775
+ event.requestId ?? "",
1776
+ {
1777
+ toolName: event.toolName ?? "",
1778
+ toolUseId: event.toolUseId ?? "",
1779
+ input: event.input ?? {},
1780
+ riskLevel: event.riskLevel ?? "unknown",
1781
+ executionTarget: event.executionTarget,
1782
+ allowlistOptions: event.allowlistOptions?.map((o) => ({
1783
+ label: o.label,
1784
+ pattern: o.pattern,
1785
+ })),
1786
+ scopeOptions: event.scopeOptions,
1787
+ persistentDecisionsAllowed:
1788
+ event.persistentDecisionsAllowed,
1789
+ },
1790
+ hRef,
1791
+ bearerToken,
1792
+ );
1793
+ hRef.showSpinner("Working...");
1794
+ break;
1795
+
1796
+ case "secret_request":
1797
+ hRef.hideSpinner();
1798
+ await hRef.handleSecretPrompt(
1799
+ {
1800
+ requestId: event.requestId ?? "",
1801
+ service: event.service ?? "",
1802
+ field: event.field ?? "",
1803
+ label: event.label ?? "",
1804
+ description: event.description,
1805
+ placeholder: event.placeholder,
1806
+ purpose: event.purpose,
1807
+ allowOneTimeSend: event.allowOneTimeSend,
1808
+ },
1809
+ async (value, delivery) => {
1810
+ await runtimeRequest(
1811
+ runtimeUrl,
1812
+ assistantId,
1813
+ "/secret",
1814
+ {
1815
+ method: "POST",
1816
+ body: JSON.stringify({
1817
+ requestId: event.requestId,
1818
+ value,
1819
+ delivery,
1820
+ }),
1821
+ },
1822
+ bearerToken,
1823
+ );
1824
+ },
1825
+ );
1826
+ hRef.showSpinner("Working...");
1827
+ break;
1828
+
1829
+ case "message_complete": {
1830
+ // Only finalize main turns (ignore aux events like call transcripts)
1831
+ if (event.source === "aux") break;
1832
+
1833
+ const text = streamingTextRef.current;
1834
+ const toolCalls = [...streamingToolCallsRef.current];
1835
+ streamingTextRef.current = "";
1836
+ streamingToolCallsRef.current = [];
1837
+
1838
+ if (text || toolCalls.length > 0) {
1839
+ const msg: RuntimeMessage = {
1840
+ id: event.messageId ?? `sse-${Date.now()}`,
1841
+ role: "assistant",
1842
+ content: text,
1843
+ timestamp: new Date().toISOString(),
1844
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
1845
+ };
1846
+ seenMessageIdsRef.current.add(msg.id);
1847
+ hRef.addMessage(msg);
1848
+ chatLogRef.current.push({
1849
+ role: "assistant",
1850
+ content: text,
1851
+ });
1852
+ process.stdout.write("\x07");
1853
+ }
1854
+
1855
+ hRef.setBusy(false);
1856
+ hRef.hideSpinner();
1857
+ break;
1610
1858
  }
1859
+
1860
+ case "error":
1861
+ hRef.hideSpinner();
1862
+ hRef.showError(event.message ?? "Unknown error");
1863
+ hRef.setBusy(false);
1864
+ break;
1865
+
1866
+ default:
1867
+ // Ignore events we don't handle (activity state, traces, etc.)
1868
+ break;
1611
1869
  }
1612
1870
  }
1613
- } catch {
1614
- // Poll failure; continue silently
1871
+ } catch (sseErr) {
1872
+ // Stream ended only report if not intentionally aborted
1873
+ if (!sseAc.signal.aborted) {
1874
+ tuiLog.warn("sse stream disconnected", {
1875
+ error: String(sseErr),
1876
+ });
1877
+ handleRef_.current?.addStatus(
1878
+ "SSE stream disconnected — will reconnect on next message",
1879
+ "yellow",
1880
+ );
1881
+ handleRef_.current?.setBusy(false);
1882
+ handleRef_.current?.hideSpinner();
1883
+ connectedRef.current = false;
1884
+ }
1615
1885
  }
1616
- }, POLL_INTERVAL_MS);
1886
+ })();
1617
1887
 
1618
1888
  connectedRef.current = true;
1619
1889
  connectingRef.current = false;
1620
1890
  setConnectionState("connected");
1621
1891
  return true;
1622
1892
  } catch (err) {
1893
+ const msg = err instanceof Error ? err.message : String(err);
1894
+ tuiLog.error("connection failed", { error: msg });
1623
1895
  h.hideSpinner();
1624
1896
  connectingRef.current = false;
1625
1897
  h.updateHealthStatus("unreachable");
1626
- const msg = err instanceof Error ? err.message : String(err);
1627
1898
  setConnectionState("error");
1628
1899
  setConnectionError(msg);
1629
1900
  h.addStatus(
@@ -2049,135 +2320,38 @@ function ChatApp({
2049
2320
  h.showSpinner("Sending...");
2050
2321
  h.setBusy(true);
2051
2322
 
2052
- try {
2053
- const controller = new AbortController();
2054
- const timeoutId = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
2323
+ const controller = new AbortController();
2324
+ const timeoutId = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
2055
2325
 
2056
- try {
2057
- const sendResult = await sendMessage(
2058
- runtimeUrl,
2059
- assistantId,
2060
- trimmed,
2061
- controller.signal,
2062
- bearerToken,
2063
- );
2064
- clearTimeout(timeoutId);
2065
- if (!sendResult.accepted) {
2066
- h.setBusy(false);
2067
- h.hideSpinner();
2068
- h.showError("Message was not accepted by the assistant");
2069
- return;
2070
- }
2071
- } catch (sendErr) {
2072
- clearTimeout(timeoutId);
2326
+ try {
2327
+ const sendResult = await sendMessage(
2328
+ runtimeUrl,
2329
+ assistantId,
2330
+ trimmed,
2331
+ controller.signal,
2332
+ bearerToken,
2333
+ );
2334
+ clearTimeout(timeoutId);
2335
+ if (!sendResult.accepted) {
2073
2336
  h.setBusy(false);
2074
2337
  h.hideSpinner();
2075
- const errorMsg =
2076
- sendErr instanceof Error ? sendErr.message : String(sendErr);
2077
- h.showError(errorMsg);
2078
- chatLogRef.current.push({ role: "error", content: errorMsg });
2338
+ h.showError("Message was not accepted by the assistant");
2079
2339
  return;
2080
2340
  }
2081
-
2082
- h.showSpinner("Working...");
2083
-
2084
- while (true) {
2085
- await new Promise((resolve) =>
2086
- setTimeout(resolve, RESPONSE_POLL_INTERVAL_MS),
2087
- );
2088
-
2089
- // Check for pending confirmations/secrets
2090
- try {
2091
- const pending = await pollPendingInteractions(
2092
- runtimeUrl,
2093
- assistantId,
2094
- bearerToken,
2095
- );
2096
-
2097
- if (pending.pendingConfirmation) {
2098
- h.hideSpinner();
2099
- await handleConfirmationPrompt(
2100
- runtimeUrl,
2101
- assistantId,
2102
- pending.pendingConfirmation.requestId,
2103
- pending.pendingConfirmation,
2104
- h,
2105
- bearerToken,
2106
- );
2107
- h.showSpinner("Working...");
2108
- continue;
2109
- }
2110
-
2111
- if (pending.pendingSecret) {
2112
- const secretRequestId = pending.pendingSecret.requestId ?? "";
2113
- h.hideSpinner();
2114
- await h.handleSecretPrompt(
2115
- pending.pendingSecret,
2116
- async (value, delivery) => {
2117
- await runtimeRequest(
2118
- runtimeUrl,
2119
- assistantId,
2120
- "/secret",
2121
- {
2122
- method: "POST",
2123
- body: JSON.stringify({
2124
- requestId: secretRequestId,
2125
- value,
2126
- delivery,
2127
- }),
2128
- },
2129
- bearerToken,
2130
- );
2131
- },
2132
- );
2133
- h.showSpinner("Working...");
2134
- continue;
2135
- }
2136
- } catch {
2137
- // Pending interactions poll failure; fall through to message poll
2138
- }
2139
-
2140
- // Poll for new messages to detect completion
2141
- try {
2142
- const pollResult = await pollMessages(
2143
- runtimeUrl,
2144
- assistantId,
2145
- bearerToken,
2146
- );
2147
- for (const msg of pollResult.messages) {
2148
- if (!seenMessageIdsRef.current.has(msg.id)) {
2149
- seenMessageIdsRef.current.add(msg.id);
2150
- if (msg.role === "assistant") {
2151
- h.addMessage(msg);
2152
- chatLogRef.current.push({
2153
- role: "assistant",
2154
- content: msg.content,
2155
- });
2156
- process.stdout.write("\x07");
2157
- h.setBusy(false);
2158
- h.hideSpinner();
2159
- return;
2160
- }
2161
- }
2162
- }
2163
- } catch {
2164
- // Poll failure; retry
2165
- }
2166
- }
2167
- } catch (error) {
2341
+ } catch (sendErr) {
2342
+ clearTimeout(timeoutId);
2168
2343
  h.setBusy(false);
2169
2344
  h.hideSpinner();
2170
- const isTimeout = error instanceof Error && error.name === "AbortError";
2171
- if (isTimeout) {
2172
- const errorMsg = "Send timed out";
2173
- h.showError(errorMsg);
2174
- chatLogRef.current.push({ role: "error", content: errorMsg });
2175
- } else {
2176
- const errorMsg = `Failed to send: ${error instanceof Error ? error.message : error}`;
2177
- h.showError(errorMsg);
2178
- chatLogRef.current.push({ role: "error", content: errorMsg });
2179
- }
2345
+ const errorMsg =
2346
+ sendErr instanceof Error ? sendErr.message : String(sendErr);
2347
+ h.showError(errorMsg);
2348
+ chatLogRef.current.push({ role: "error", content: errorMsg });
2349
+ return;
2180
2350
  }
2351
+
2352
+ // Accumulators are reset by message_complete; no reset here to avoid
2353
+ // racing with SSE events that may arrive during the sendMessage await.
2354
+ h.showSpinner("Working...");
2181
2355
  },
2182
2356
  [
2183
2357
  runtimeUrl,
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import cliPkg from "../package.json";
4
4
  import { backup } from "./commands/backup";
5
5
  import { clean } from "./commands/clean";
6
6
  import { client } from "./commands/client";
7
+ import { env } from "./commands/env";
7
8
  import { events } from "./commands/events";
8
9
  import { exec } from "./commands/exec";
9
10
  import { hatch } from "./commands/hatch";
@@ -26,9 +27,7 @@ import { upgrade } from "./commands/upgrade";
26
27
  import { use } from "./commands/use";
27
28
  import { wake } from "./commands/wake";
28
29
  import {
29
- getActiveAssistant,
30
- findAssistantByName,
31
- loadLatestAssistant,
30
+ resolveAssistant,
32
31
  setActiveAssistant,
33
32
  } from "./lib/assistant-config";
34
33
  import { loadGuardianToken } from "./lib/guardian-token";
@@ -38,6 +37,7 @@ const commands = {
38
37
  backup,
39
38
  clean,
40
39
  client,
40
+ env,
41
41
  events,
42
42
  exec,
43
43
  hatch,
@@ -72,6 +72,7 @@ function printHelp(): void {
72
72
  console.log(" backup Export a backup of a running assistant");
73
73
  console.log(" clean Kill orphaned vellum processes");
74
74
  console.log(" client Connect to a hatched assistant");
75
+ console.log(" env Manage the default CLI environment");
75
76
  console.log(" events Stream events from a running assistant");
76
77
  console.log(" exec Execute a command inside an assistant's container");
77
78
  console.log(" hatch Create a new assistant instance");
@@ -126,10 +127,7 @@ function applyNoColorFlags(argv: string[]): void {
126
127
  * Otherwise return false so the caller can fall back to help text.
127
128
  */
128
129
  async function tryLaunchClient(): Promise<boolean> {
129
- const activeName = getActiveAssistant();
130
- const entry = activeName
131
- ? findAssistantByName(activeName)
132
- : loadLatestAssistant();
130
+ const entry = resolveAssistant();
133
131
 
134
132
  if (!entry) return false;
135
133