@vellumai/cli 0.8.6 → 0.8.7

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 (43) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +21 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  13. package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
  14. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
  15. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  16. package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
  17. package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
  18. package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
  19. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  20. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  21. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  22. package/package.json +12 -1
  23. package/src/__tests__/env-drift.test.ts +32 -44
  24. package/src/__tests__/flags.test.ts +248 -0
  25. package/src/__tests__/multi-local.test.ts +1 -1
  26. package/src/__tests__/orphan-detection.test.ts +8 -6
  27. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  28. package/src/commands/client.ts +413 -2
  29. package/src/commands/env.ts +1 -1
  30. package/src/commands/flags.ts +89 -17
  31. package/src/components/DefaultMainScreen.tsx +16 -1
  32. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  33. package/src/lib/assistant-config.ts +3 -3
  34. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  35. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  36. package/src/lib/environments/paths.ts +1 -1
  37. package/src/lib/environments/resolve.ts +2 -5
  38. package/src/lib/guardian-token.ts +12 -5
  39. package/src/lib/hatch-local.ts +73 -33
  40. package/src/lib/lifecycle-reporter.ts +31 -0
  41. package/src/lib/retire-local.ts +28 -14
  42. package/src/lib/segments-to-plain-text.ts +35 -0
  43. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -1,3 +1,4 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { existsSync } from "node:fs";
2
3
  import { hostname } from "node:os";
3
4
  import path from "node:path";
@@ -23,10 +24,27 @@ import {
23
24
  WEB_INTERFACE_ID,
24
25
  getClientRegistrationHeaders,
25
26
  } from "../lib/client-identity";
27
+ import {
28
+ getLockfileData,
29
+ upsertLockfileAssistant,
30
+ replacePlatformAssistants,
31
+ runHatch,
32
+ runRetire,
33
+ getGuardianAccessToken,
34
+ parseGatewayUrl,
35
+ readAllowedGatewayPorts,
36
+ isLoopbackAddr,
37
+ resolveDevCliInvocation,
38
+ resolveLockfilePaths,
39
+ resolveConfigDir,
40
+ type CliInvocation,
41
+ } from "@vellumai/local-mode";
26
42
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
27
43
  import {
28
44
  fetchOrganizationId,
29
45
  fetchPlatformAssistants,
46
+ getPlatformUrl,
47
+ getWebUrl,
30
48
  readPlatformToken,
31
49
  } from "../lib/platform-client";
32
50
  import { tuiLog } from "../lib/tui-log";
@@ -311,7 +329,282 @@ function findWebDistDir(): string | null {
311
329
  return null;
312
330
  }
313
331
 
332
+ /**
333
+ * Locate the apps/web source directory for running the Vite dev server.
334
+ * Only works from a source checkout (not npm-installed).
335
+ */
336
+ function findWebSourceDir(): string | null {
337
+ let dir = import.meta.dir;
338
+ for (let depth = 0; depth < 8; depth++) {
339
+ const candidate = path.join(dir, "apps", "web", "vite.config.ts");
340
+ if (existsSync(candidate)) {
341
+ return path.dirname(candidate);
342
+ }
343
+ const parent = path.dirname(dir);
344
+ if (parent === dir) break;
345
+ dir = parent;
346
+ }
347
+ return null;
348
+ }
349
+
350
+ const LOCKFILE_PATTERN = /^(?:\/assistant)?\/__local\/lockfile$/;
351
+ const HATCH_PATTERN = /^(?:\/assistant)?\/__local\/hatch$/;
352
+ const RETIRE_PATTERN = /^(?:\/assistant)?\/__local\/retire$/;
353
+ const GUARDIAN_TOKEN_PATTERN =
354
+ /^(?:\/assistant)?\/__local\/guardian-token\/([^/]+)$/;
355
+
356
+ function getEnvRecord(): Record<string, string> {
357
+ const result: Record<string, string> = {};
358
+ for (const [k, v] of Object.entries(process.env)) {
359
+ if (v !== undefined) result[k] = v;
360
+ }
361
+ return result;
362
+ }
363
+
364
+ const _localEnv = getEnvRecord();
365
+ const _lockfilePaths = resolveLockfilePaths(_localEnv);
366
+ const _configDir = resolveConfigDir(_localEnv);
367
+ const _baseDir = getBaseDir();
368
+
369
+ async function handleLocalEndpoints(
370
+ req: Request,
371
+ url: URL,
372
+ server: { requestIP(req: Request): { address: string } | null },
373
+ ): Promise<Response | null> {
374
+ const { pathname } = url;
375
+ const lockfilePaths = _lockfilePaths;
376
+ const configDir = _configDir;
377
+
378
+ // Check if this is a __local or __gateway route before enforcing loopback.
379
+ const isLocalRoute =
380
+ LOCKFILE_PATTERN.test(pathname) ||
381
+ HATCH_PATTERN.test(pathname) ||
382
+ RETIRE_PATTERN.test(pathname) ||
383
+ GUARDIAN_TOKEN_PATTERN.test(pathname) ||
384
+ parseGatewayUrl(pathname).match;
385
+
386
+ if (!isLocalRoute) return null;
387
+
388
+ // All __local and __gateway endpoints are restricted to loopback clients.
389
+ const peer = server.requestIP(req)?.address ?? "";
390
+ if (!isLoopbackAddr(peer)) {
391
+ return Response.json({ error: "Forbidden" }, { status: 403 });
392
+ }
393
+
394
+ // Lockfile
395
+ if (LOCKFILE_PATTERN.test(pathname)) {
396
+ if (req.method === "GET") {
397
+ const result = getLockfileData(lockfilePaths);
398
+ if (result.ok) {
399
+ return Response.json(result.data);
400
+ }
401
+ return new Response(null, { status: result.status });
402
+ }
403
+ if (req.method === "POST") {
404
+ let body: Record<string, unknown>;
405
+ try {
406
+ body = (await req.json()) as Record<string, unknown>;
407
+ } catch {
408
+ return Response.json(
409
+ { ok: false, error: "Invalid JSON body" },
410
+ { status: 400 },
411
+ );
412
+ }
413
+ let result;
414
+ if (body.syncPlatform && Array.isArray(body.platformAssistants)) {
415
+ result = replacePlatformAssistants(
416
+ lockfilePaths,
417
+ body.platformAssistants as Array<Record<string, unknown>>,
418
+ );
419
+ } else {
420
+ result = upsertLockfileAssistant(
421
+ lockfilePaths,
422
+ body.assistant as Record<string, unknown>,
423
+ body.activeAssistant as string | undefined,
424
+ );
425
+ }
426
+ return Response.json(result, { status: result.ok ? 200 : result.status });
427
+ }
428
+ return new Response(null, { status: 405 });
429
+ }
430
+
431
+ // Hatch
432
+ if (HATCH_PATTERN.test(pathname)) {
433
+ if (req.method !== "POST") return new Response(null, { status: 405 });
434
+
435
+ let species = "vellum";
436
+ const contentType = req.headers.get("content-type") ?? "";
437
+ if (contentType.includes("json")) {
438
+ try {
439
+ const body = (await req.json()) as { species?: string };
440
+ if (body.species) species = body.species;
441
+ } catch {
442
+ return Response.json(
443
+ { ok: false, error: "Invalid JSON body" },
444
+ { status: 400 },
445
+ );
446
+ }
447
+ }
448
+
449
+ let invocation: CliInvocation;
450
+ try {
451
+ invocation = resolveDevCliInvocation(_baseDir);
452
+ } catch (err) {
453
+ return Response.json(
454
+ { ok: false, error: err instanceof Error ? err.message : String(err) },
455
+ { status: 500 },
456
+ );
457
+ }
458
+
459
+ const result = await runHatch(invocation, species);
460
+ if (result.ok) {
461
+ return Response.json({ ok: true, assistantId: result.assistantId });
462
+ }
463
+ return Response.json(
464
+ { ok: false, error: result.error },
465
+ { status: result.status },
466
+ );
467
+ }
468
+
469
+ // Retire
470
+ if (RETIRE_PATTERN.test(pathname)) {
471
+ if (req.method !== "POST") return new Response(null, { status: 405 });
472
+
473
+ let assistantId: string | undefined;
474
+ try {
475
+ const body = (await req.json()) as { assistantId?: string };
476
+ assistantId = body.assistantId;
477
+ } catch {
478
+ return Response.json(
479
+ { ok: false, error: "Invalid JSON body" },
480
+ { status: 400 },
481
+ );
482
+ }
483
+
484
+ if (!assistantId) {
485
+ return Response.json(
486
+ { ok: false, error: "Missing assistantId" },
487
+ { status: 400 },
488
+ );
489
+ }
490
+
491
+ let invocation: CliInvocation;
492
+ try {
493
+ invocation = resolveDevCliInvocation(_baseDir);
494
+ } catch (err) {
495
+ return Response.json(
496
+ { ok: false, error: err instanceof Error ? err.message : String(err) },
497
+ { status: 500 },
498
+ );
499
+ }
500
+
501
+ const result = await runRetire(invocation, assistantId);
502
+ if (result.ok) {
503
+ return Response.json({ ok: true });
504
+ }
505
+ return Response.json(
506
+ { ok: false, error: result.error },
507
+ { status: result.status },
508
+ );
509
+ }
510
+
511
+ // Guardian token
512
+ const guardianMatch = pathname.match(GUARDIAN_TOKEN_PATTERN);
513
+ if (guardianMatch) {
514
+ if (req.method !== "GET") return new Response(null, { status: 405 });
515
+
516
+ const assistantId = decodeURIComponent(guardianMatch[1]!);
517
+
518
+ let invocation: CliInvocation;
519
+ try {
520
+ invocation = resolveDevCliInvocation(_baseDir);
521
+ } catch (err) {
522
+ return Response.json(
523
+ { error: err instanceof Error ? err.message : String(err) },
524
+ { status: 500 },
525
+ );
526
+ }
527
+
528
+ const result = await getGuardianAccessToken(
529
+ assistantId,
530
+ configDir,
531
+ invocation,
532
+ true,
533
+ _localEnv,
534
+ );
535
+ if (result.ok) {
536
+ return Response.json({ accessToken: result.accessToken });
537
+ }
538
+ return Response.json({ error: result.error }, { status: result.status });
539
+ }
540
+
541
+ // Gateway proxy
542
+ const gatewayResult = parseGatewayUrl(pathname);
543
+ if (gatewayResult.match) {
544
+ if (!gatewayResult.valid) {
545
+ return new Response("Port must be between 1024 and 65535", {
546
+ status: 400,
547
+ });
548
+ }
549
+ const { target: gatewayTarget } = gatewayResult;
550
+ const allowedPorts = readAllowedGatewayPorts(lockfilePaths);
551
+ if (!allowedPorts.has(gatewayTarget.port)) {
552
+ return new Response("Gateway port is not active in lockfile", {
553
+ status: 403,
554
+ });
555
+ }
556
+ const targetUrl = `http://127.0.0.1:${gatewayTarget.port}${gatewayTarget.path}${url.search}`;
557
+ const headers = new Headers(req.headers);
558
+ headers.set("host", `127.0.0.1:${gatewayTarget.port}`);
559
+
560
+ try {
561
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
562
+ const proxyRes = await fetch(targetUrl, {
563
+ method: req.method,
564
+ headers,
565
+ body: hasBody ? req.body : undefined,
566
+ redirect: "manual",
567
+ });
568
+ const resHeaders = new Headers(proxyRes.headers);
569
+ resHeaders.delete("transfer-encoding");
570
+ return new Response(proxyRes.body, {
571
+ status: proxyRes.status,
572
+ statusText: proxyRes.statusText,
573
+ headers: resHeaders,
574
+ });
575
+ } catch {
576
+ return new Response("Gateway proxy error", { status: 502 });
577
+ }
578
+ }
579
+
580
+ return null;
581
+ }
582
+
583
+ function getBaseDir(): string {
584
+ let dir = import.meta.dir;
585
+ for (let depth = 0; depth < 8; depth++) {
586
+ if (existsSync(path.join(dir, "cli", "src", "index.ts"))) {
587
+ return dir;
588
+ }
589
+ const pkgPath = path.join(dir, "package.json");
590
+ if (existsSync(pkgPath)) {
591
+ return dir;
592
+ }
593
+ const parent = path.dirname(dir);
594
+ if (parent === dir) break;
595
+ dir = parent;
596
+ }
597
+ return path.resolve(import.meta.dir, "..", "..", "..");
598
+ }
599
+
314
600
  async function runWebInterface(): Promise<void> {
601
+ // Prefer Vite dev server in source checkouts for full local-mode support
602
+ // (HMR, __local endpoints, gateway proxy).
603
+ const webSourceDir = findWebSourceDir();
604
+ if (webSourceDir) {
605
+ return runViteDevServer(webSourceDir);
606
+ }
607
+
315
608
  const distDir = findWebDistDir();
316
609
  if (!distDir) {
317
610
  console.error(
@@ -323,7 +616,14 @@ async function runWebInterface(): Promise<void> {
323
616
  process.exit(1);
324
617
  }
325
618
 
326
- const indexHtml = await Bun.file(path.join(distDir, "index.html")).text();
619
+ const rawIndexHtml = await Bun.file(path.join(distDir, "index.html")).text();
620
+ const platformUrl = getPlatformUrl();
621
+ const webUrl = getWebUrl();
622
+ const configJson = JSON.stringify({ webUrl, platformUrl });
623
+ const indexHtml = rawIndexHtml.replace(
624
+ "</head>",
625
+ `<script>window.__VELLUM_CONFIG__=${configJson}</script></head>`,
626
+ );
327
627
 
328
628
  const server = Bun.serve({
329
629
  port: 3000,
@@ -332,10 +632,82 @@ async function runWebInterface(): Promise<void> {
332
632
  const url = new URL(req.url);
333
633
  const { pathname } = url;
334
634
 
335
- if (pathname === "/") {
635
+ if (pathname === "/" || pathname === "/assistant") {
336
636
  return Response.redirect(SPA_BASE, 302);
337
637
  }
338
638
 
639
+ // Loopback auth: the platform redirects here after login with
640
+ // ?state=...&session_token=... — forward into the SPA.
641
+ if (pathname === "/callback") {
642
+ return Response.redirect(
643
+ `/account/platform-callback${url.search}`,
644
+ 302,
645
+ );
646
+ }
647
+
648
+ // Expose environment config to the SPA.
649
+ if (pathname === "/assistant/__config" || pathname === "/__config") {
650
+ return new Response(configJson, {
651
+ headers: { "Content-Type": "application/json" },
652
+ });
653
+ }
654
+
655
+ // __local endpoints for local-mode (lockfile, hatch, retire, guardian-token, gateway-proxy).
656
+ const localResponse = await handleLocalEndpoints(req, url, server);
657
+ if (localResponse) return localResponse;
658
+
659
+ // Reverse-proxy platform API requests.
660
+ if (
661
+ pathname.startsWith("/v1/") ||
662
+ pathname.startsWith("/_allauth/") ||
663
+ pathname.startsWith("/accounts/")
664
+ ) {
665
+ const target = new URL(pathname + url.search, platformUrl);
666
+ const headers = new Headers(req.headers);
667
+ headers.set("Host", new URL(platformUrl).host);
668
+ headers.delete("Origin");
669
+ headers.delete("Referer");
670
+
671
+ // Forward the session token — the loopback flow stores it in
672
+ // the browser cookie jar for localhost, but the platform backend
673
+ // expects it on its own domain. Set both the Cookie (for Django
674
+ // session middleware / allauth) and X-Session-Token (for DRF
675
+ // views that accept header-based auth).
676
+ const sessionToken = /sessionid=([^;]+)/.exec(
677
+ req.headers.get("Cookie") ?? "",
678
+ )?.[1];
679
+ if (sessionToken) {
680
+ headers.set(
681
+ "Cookie",
682
+ `sessionid=${sessionToken}; __Secure-sessionid=${sessionToken}`,
683
+ );
684
+ headers.set("X-Session-Token", sessionToken);
685
+ }
686
+
687
+ try {
688
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
689
+ const body = hasBody ? await req.arrayBuffer() : undefined;
690
+ const proxyRes = await fetch(target.toString(), {
691
+ method: req.method,
692
+ headers,
693
+ body,
694
+ redirect: "manual",
695
+ });
696
+ const resHeaders = new Headers(proxyRes.headers);
697
+ resHeaders.delete("transfer-encoding");
698
+ return new Response(proxyRes.body, {
699
+ status: proxyRes.status,
700
+ statusText: proxyRes.statusText,
701
+ headers: resHeaders,
702
+ });
703
+ } catch (err) {
704
+ return new Response(
705
+ JSON.stringify({ error: `Platform proxy error: ${err}` }),
706
+ { status: 502, headers: { "Content-Type": "application/json" } },
707
+ );
708
+ }
709
+ }
710
+
339
711
  if (pathname.startsWith(SPA_BASE)) {
340
712
  const relPath = pathname.slice(SPA_BASE.length);
341
713
  if (relPath) {
@@ -350,6 +722,13 @@ async function runWebInterface(): Promise<void> {
350
722
  });
351
723
  }
352
724
 
725
+ // SPA fallback for /account/* routes (login, callback, etc.)
726
+ if (pathname.startsWith("/account/")) {
727
+ return new Response(indexHtml, {
728
+ headers: { "Content-Type": "text/html; charset=utf-8" },
729
+ });
730
+ }
731
+
353
732
  return new Response("Not Found", { status: 404 });
354
733
  },
355
734
  });
@@ -368,6 +747,38 @@ async function runWebInterface(): Promise<void> {
368
747
  await new Promise(() => {});
369
748
  }
370
749
 
750
+ async function runViteDevServer(webSourceDir: string): Promise<void> {
751
+ const platformUrl = getPlatformUrl();
752
+
753
+ const child = spawn("bun", ["run", "dev"], {
754
+ cwd: webSourceDir,
755
+ stdio: "inherit",
756
+ env: {
757
+ ...process.env,
758
+ VITE_PLATFORM_MODE: "false",
759
+ API_PROXY_TARGET: platformUrl,
760
+ VELLUM_WEB_URL: getWebUrl(),
761
+ VELLUM_PLATFORM_URL: platformUrl,
762
+ PORT: "3000",
763
+ },
764
+ });
765
+
766
+ const shutdown = (): void => {
767
+ child.kill();
768
+ process.exit(0);
769
+ };
770
+ process.on("SIGINT", shutdown);
771
+ process.on("SIGTERM", shutdown);
772
+
773
+ await new Promise<void>((_, reject) => {
774
+ child.on("exit", (code) => {
775
+ if (code !== 0) {
776
+ reject(new Error(`Vite dev server exited with code ${code}`));
777
+ }
778
+ });
779
+ });
780
+ }
781
+
371
782
  export async function client(): Promise<void> {
372
783
  const {
373
784
  runtimeUrl,
@@ -1,4 +1,4 @@
1
- import { SEEDS } from "../lib/environments/seeds.js";
1
+ import { SEEDS } from "@vellumai/environments";
2
2
  import {
3
3
  clearDefaultEnvironment,
4
4
  readDefaultEnvironment,
@@ -1,4 +1,8 @@
1
1
  import { AssistantClient } from "../lib/assistant-client.js";
2
+ import {
3
+ formatAssistantLookupError,
4
+ lookupAssistantByIdentifier,
5
+ } from "../lib/assistant-config.js";
2
6
 
3
7
  type FeatureFlagEntry = {
4
8
  key: string;
@@ -17,7 +21,12 @@ function pad(s: string, w: number): string {
17
21
  }
18
22
 
19
23
  function printFlagTable(flags: FeatureFlagEntry[]): void {
20
- const headers = { key: "KEY", enabled: "ENABLED", default: "DEFAULT", label: "LABEL" };
24
+ const headers = {
25
+ key: "KEY",
26
+ enabled: "ENABLED",
27
+ default: "DEFAULT",
28
+ label: "LABEL",
29
+ };
21
30
 
22
31
  const rows = flags
23
32
  .slice()
@@ -55,7 +64,9 @@ function printHelp(): void {
55
64
  console.log("Usage: vellum flags [subcommand] [options]");
56
65
  console.log("");
57
66
  console.log("Show and toggle feature flags for the active assistant.");
58
- console.log("Reads from the gateway's merged flag state (persisted overrides > remote > defaults).");
67
+ console.log(
68
+ "Reads from the gateway's merged flag state (persisted overrides > remote > defaults).",
69
+ );
59
70
  console.log("");
60
71
  console.log("Subcommands:");
61
72
  console.log(" (none) List all feature flags in a table");
@@ -63,20 +74,53 @@ function printHelp(): void {
63
74
  console.log(" set <key> <bool> Set a flag override to true or false");
64
75
  console.log("");
65
76
  console.log("Options:");
77
+ console.log(
78
+ " --assistant <name> Target a specific assistant (display name or ID)",
79
+ );
80
+ console.log(
81
+ " instead of the active one. Useful for scripted",
82
+ );
83
+ console.log(
84
+ " flows like eval harnesses that must not mutate",
85
+ );
86
+ console.log(" the user's active-assistant pointer.");
66
87
  console.log(" --help, -h Show this help");
67
88
  console.log("");
68
89
  console.log("Examples:");
69
- console.log(" $ vellum flags # list all flags");
70
- console.log(" $ vellum flags get query-complexity-routing # inspect one flag");
71
- console.log(" $ vellum flags set voice-mode true # enable a flag");
90
+ console.log(
91
+ " $ vellum flags # list flags for active assistant",
92
+ );
93
+ console.log(
94
+ " $ vellum flags get query-complexity-routing # inspect one flag",
95
+ );
96
+ console.log(
97
+ " $ vellum flags set voice-mode true # enable a flag",
98
+ );
99
+ console.log(
100
+ " $ vellum flags set external-plugins true --assistant eval-1 # target by name/id",
101
+ );
72
102
  }
73
103
 
74
- function createClient(): AssistantClient {
104
+ function createClient(assistantName?: string): AssistantClient {
105
+ // When `--assistant <name>` is provided, resolve the display name or
106
+ // explicit ID through the standard lookup helper (see cli/AGENTS.md
107
+ // "Assistant targeting convention"). Exact ID wins over display-name
108
+ // matches; ambiguous names fail loudly.
109
+ let assistantId: string | undefined;
110
+ if (assistantName) {
111
+ const result = lookupAssistantByIdentifier(assistantName);
112
+ if (result.status !== "found") {
113
+ throw new Error(formatAssistantLookupError(assistantName, result));
114
+ }
115
+ assistantId = result.entry.assistantId;
116
+ }
75
117
  try {
76
- return new AssistantClient();
118
+ return new AssistantClient(assistantId ? { assistantId } : undefined);
77
119
  } catch {
78
120
  throw new Error(
79
- "No assistant found. Hatch one with 'vellum hatch' first.",
121
+ assistantName
122
+ ? `No assistant found matching '${assistantName}'.`
123
+ : "No assistant found. Hatch one with 'vellum hatch' first.",
80
124
  );
81
125
  }
82
126
  }
@@ -93,8 +137,8 @@ function rethrowFetchError(err: unknown): never {
93
137
  throw err;
94
138
  }
95
139
 
96
- async function listFlags(): Promise<void> {
97
- const client = createClient();
140
+ async function listFlags(assistantName?: string): Promise<void> {
141
+ const client = createClient(assistantName);
98
142
  let res: Response;
99
143
  try {
100
144
  res = await client.get("/feature-flags");
@@ -113,8 +157,8 @@ async function listFlags(): Promise<void> {
113
157
  printFlagTable(data.flags);
114
158
  }
115
159
 
116
- async function getFlag(key: string): Promise<void> {
117
- const client = createClient();
160
+ async function getFlag(key: string, assistantName?: string): Promise<void> {
161
+ const client = createClient(assistantName);
118
162
  let res: Response;
119
163
  try {
120
164
  res = await client.get("/feature-flags");
@@ -136,8 +180,12 @@ async function getFlag(key: string): Promise<void> {
136
180
  console.log(`Description: ${flag.description || "(none)"}`);
137
181
  }
138
182
 
139
- async function setFlag(key: string, value: boolean): Promise<void> {
140
- const client = createClient();
183
+ async function setFlag(
184
+ key: string,
185
+ value: boolean,
186
+ assistantName?: string,
187
+ ): Promise<void> {
188
+ const client = createClient(assistantName);
141
189
  let res: Response;
142
190
  try {
143
191
  res = await client.patch(`/feature-flags/${key}`, { enabled: value });
@@ -151,6 +199,28 @@ async function setFlag(key: string, value: boolean): Promise<void> {
151
199
  console.log(`Flag "${key}" set to ${value}`);
152
200
  }
153
201
 
202
+ /**
203
+ * Strip `--assistant <name>` from argv and return the captured value.
204
+ *
205
+ * Mutates the input array so positional parsing downstream sees a clean
206
+ * shape (subcommand + key + value). Returns `undefined` if the flag is
207
+ * absent. Error-reports a missing value so the user gets a clear message
208
+ * rather than the flag being silently swallowed as a positional.
209
+ */
210
+ function extractAssistantFlag(args: string[]): string | undefined {
211
+ for (let i = 0; i < args.length; i++) {
212
+ if (args[i] !== "--assistant") continue;
213
+ const value = args[i + 1];
214
+ if (!value || value.startsWith("-")) {
215
+ console.error("Missing value for --assistant <name>");
216
+ process.exit(1);
217
+ }
218
+ args.splice(i, 2);
219
+ return value;
220
+ }
221
+ return undefined;
222
+ }
223
+
154
224
  export async function flags(): Promise<void> {
155
225
  const args = process.argv.slice(3);
156
226
 
@@ -159,10 +229,12 @@ export async function flags(): Promise<void> {
159
229
  return;
160
230
  }
161
231
 
232
+ const assistantName = extractAssistantFlag(args);
233
+
162
234
  const subcommand = args[0];
163
235
 
164
236
  if (!subcommand) {
165
- await listFlags();
237
+ await listFlags(assistantName);
166
238
  return;
167
239
  }
168
240
 
@@ -172,7 +244,7 @@ export async function flags(): Promise<void> {
172
244
  console.error("Usage: vellum flags get <key>");
173
245
  process.exit(1);
174
246
  }
175
- await getFlag(key);
247
+ await getFlag(key, assistantName);
176
248
  return;
177
249
  }
178
250
 
@@ -187,7 +259,7 @@ export async function flags(): Promise<void> {
187
259
  console.error(`Invalid value "${rawValue}". Must be "true" or "false".`);
188
260
  process.exit(1);
189
261
  }
190
- await setFlag(key, rawValue === "true");
262
+ await setFlag(key, rawValue === "true", assistantName);
191
263
  return;
192
264
  }
193
265
 
@@ -13,6 +13,7 @@ import { SPECIES_CONFIG, type Species } from "../lib/constants";
13
13
  import { checkHealth } from "../lib/health-check";
14
14
  import { appendHistory, loadHistory } from "../lib/input-history";
15
15
  import { tuiLog } from "../lib/tui-log";
16
+ import { segmentsToPlainText } from "../lib/segments-to-plain-text";
16
17
  import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
17
18
  import {
18
19
  getTerminalCapabilities,
@@ -230,13 +231,20 @@ async function pollMessages(
230
231
  auth?: Record<string, string>,
231
232
  ): Promise<ListMessagesResponse> {
232
233
  const params = new URLSearchParams({ conversationKey: assistantId });
233
- return runtimeRequest<ListMessagesResponse>(
234
+ const response = await runtimeRequest<ListMessagesResponse>(
234
235
  baseUrl,
235
236
  assistantId,
236
237
  `/messages?${params.toString()}`,
237
238
  undefined,
238
239
  auth,
239
240
  );
241
+ return {
242
+ ...response,
243
+ messages: response.messages.map((msg) => ({
244
+ ...msg,
245
+ content: segmentsToPlainText(msg.textSegments),
246
+ })),
247
+ };
240
248
  }
241
249
 
242
250
  async function sendMessage(
@@ -626,6 +634,13 @@ export interface RuntimeMessage {
626
634
  id: string;
627
635
  role: "user" | "assistant";
628
636
  content: string;
637
+ /**
638
+ * Ordered text segments from the daemon's history payload, split at
639
+ * tool_use/surface boundaries. The flat `content` body is derived from
640
+ * these (see `segmentsToPlainText`); the daemon no longer sends a
641
+ * redundant flattened `content` field on the wire.
642
+ */
643
+ textSegments?: string[];
629
644
  timestamp: string;
630
645
  toolCalls?: ToolCallInfo[];
631
646
  label?: string;