@vellumai/cli 0.4.37 → 0.4.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.37",
3
+ "version": "0.4.40",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,275 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Create a temp directory that acts as a fake home, so allocateLocalResources()
7
+ // and defaultLocalResources() never touch the real ~/.vellum directory.
8
+ const testDir = mkdtempSync(join(tmpdir(), "cli-multi-local-test-"));
9
+ process.env.BASE_DATA_DIR = testDir;
10
+
11
+ // Mock homedir() to return testDir — this isolates allocateLocalResources()
12
+ // which uses homedir() directly for instance directory creation.
13
+ const realOs = await import("node:os");
14
+ mock.module("node:os", () => ({
15
+ ...realOs,
16
+ homedir: () => testDir,
17
+ }));
18
+ // Also mock the bare "os" specifier since assistant-config.ts uses `from "os"`
19
+ mock.module("os", () => ({
20
+ ...realOs,
21
+ homedir: () => testDir,
22
+ }));
23
+
24
+ // Mock probePort so we control which ports appear in-use without touching the network
25
+ const probePortMock = mock<(port: number, host?: string) => Promise<boolean>>(
26
+ () => Promise.resolve(false),
27
+ );
28
+ mock.module("../lib/port-probe.js", () => ({
29
+ probePort: probePortMock,
30
+ }));
31
+
32
+ import {
33
+ allocateLocalResources,
34
+ defaultLocalResources,
35
+ resolveTargetAssistant,
36
+ setActiveAssistant,
37
+ getActiveAssistant,
38
+ removeAssistantEntry,
39
+ saveAssistantEntry,
40
+ type AssistantEntry,
41
+ } from "../lib/assistant-config.js";
42
+ import { DEFAULT_DAEMON_PORT } from "../lib/constants.js";
43
+
44
+ afterAll(() => {
45
+ rmSync(testDir, { recursive: true, force: true });
46
+ delete process.env.BASE_DATA_DIR;
47
+ });
48
+
49
+ function writeLockfile(data: unknown): void {
50
+ writeFileSync(
51
+ join(testDir, ".vellum.lock.json"),
52
+ JSON.stringify(data, null, 2),
53
+ );
54
+ }
55
+
56
+ function readLockfileRaw(): Record<string, unknown> {
57
+ return JSON.parse(
58
+ readFileSync(join(testDir, ".vellum.lock.json"), "utf-8"),
59
+ ) as Record<string, unknown>;
60
+ }
61
+
62
+ const makeEntry = (
63
+ id: string,
64
+ cloud = "local",
65
+ extra?: Partial<AssistantEntry>,
66
+ ): AssistantEntry => ({
67
+ assistantId: id,
68
+ runtimeUrl: `http://localhost:${DEFAULT_DAEMON_PORT}`,
69
+ cloud,
70
+ ...extra,
71
+ });
72
+
73
+ function resetLockfile(): void {
74
+ try {
75
+ rmSync(join(testDir, ".vellum.lock.json"));
76
+ } catch {
77
+ // file may not exist
78
+ }
79
+ try {
80
+ rmSync(join(testDir, ".vellum.lockfile.json"));
81
+ } catch {
82
+ // file may not exist
83
+ }
84
+ }
85
+
86
+ describe("multi-local", () => {
87
+ beforeEach(() => {
88
+ resetLockfile();
89
+ probePortMock.mockReset();
90
+ probePortMock.mockImplementation(() => Promise.resolve(false));
91
+ });
92
+
93
+ describe("allocateLocalResources() produces non-conflicting ports", () => {
94
+ test("two instances get distinct ports and dirs when first instance ports are occupied", async () => {
95
+ // After the first allocation grabs its ports, simulate those ports
96
+ // being in-use so the second allocation must pick different ones.
97
+ const a = await allocateLocalResources("instance-a");
98
+ const occupiedPorts = new Set([
99
+ a.daemonPort,
100
+ a.gatewayPort,
101
+ a.qdrantPort,
102
+ ]);
103
+ probePortMock.mockImplementation((port: number) =>
104
+ Promise.resolve(occupiedPorts.has(port)),
105
+ );
106
+
107
+ const b = await allocateLocalResources("instance-b");
108
+
109
+ // All six ports must be unique across both instances
110
+ const allPorts = [
111
+ a.daemonPort,
112
+ a.gatewayPort,
113
+ a.qdrantPort,
114
+ b.daemonPort,
115
+ b.gatewayPort,
116
+ b.qdrantPort,
117
+ ];
118
+ expect(new Set(allPorts).size).toBe(6);
119
+
120
+ // Instance dirs must be distinct
121
+ expect(a.instanceDir).not.toBe(b.instanceDir);
122
+ expect(a.instanceDir).toContain("instance-a");
123
+ expect(b.instanceDir).toContain("instance-b");
124
+ });
125
+
126
+ test("skips ports that probePort reports as in-use", async () => {
127
+ // Simulate the default ports being occupied
128
+ const portsInUse = new Set([
129
+ DEFAULT_DAEMON_PORT,
130
+ DEFAULT_DAEMON_PORT + 1,
131
+ ]);
132
+ probePortMock.mockImplementation((port: number) =>
133
+ Promise.resolve(portsInUse.has(port)),
134
+ );
135
+
136
+ const res = await allocateLocalResources("probe-test");
137
+ expect(res.daemonPort).toBeGreaterThan(DEFAULT_DAEMON_PORT + 1);
138
+ expect(portsInUse.has(res.daemonPort)).toBe(false);
139
+ });
140
+ });
141
+
142
+ describe("defaultLocalResources() returns legacy paths", () => {
143
+ test("instanceDir is homedir", () => {
144
+ const res = defaultLocalResources();
145
+ expect(res.instanceDir).toBe(testDir);
146
+ });
147
+
148
+ test("daemonPort is DEFAULT_DAEMON_PORT", () => {
149
+ const res = defaultLocalResources();
150
+ expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
151
+ });
152
+ });
153
+
154
+ describe("resolveTargetAssistant() priority chain", () => {
155
+ test("explicit name returns that entry", () => {
156
+ writeLockfile({
157
+ assistants: [makeEntry("alpha"), makeEntry("beta")],
158
+ });
159
+ const result = resolveTargetAssistant("beta");
160
+ expect(result.assistantId).toBe("beta");
161
+ });
162
+
163
+ test("active assistant set returns the active entry", () => {
164
+ writeLockfile({
165
+ assistants: [makeEntry("alpha"), makeEntry("beta")],
166
+ activeAssistant: "alpha",
167
+ });
168
+ const result = resolveTargetAssistant();
169
+ expect(result.assistantId).toBe("alpha");
170
+ });
171
+
172
+ test("sole local assistant returns it", () => {
173
+ writeLockfile({
174
+ assistants: [makeEntry("only-one")],
175
+ });
176
+ const result = resolveTargetAssistant();
177
+ expect(result.assistantId).toBe("only-one");
178
+ });
179
+
180
+ test("multiple local assistants and no active throws with guidance", () => {
181
+ writeLockfile({
182
+ assistants: [makeEntry("x"), makeEntry("y")],
183
+ });
184
+ // resolveTargetAssistant calls process.exit(1) on ambiguity
185
+ const mockExit = mock(() => {
186
+ throw new Error("process.exit called");
187
+ });
188
+ const origExit = process.exit;
189
+ process.exit = mockExit as unknown as typeof process.exit;
190
+ try {
191
+ expect(() => resolveTargetAssistant()).toThrow("process.exit called");
192
+ expect(mockExit).toHaveBeenCalledWith(1);
193
+ } finally {
194
+ process.exit = origExit;
195
+ }
196
+ });
197
+
198
+ test("no local assistants throws", () => {
199
+ writeLockfile({ assistants: [] });
200
+ const mockExit = mock(() => {
201
+ throw new Error("process.exit called");
202
+ });
203
+ const origExit = process.exit;
204
+ process.exit = mockExit as unknown as typeof process.exit;
205
+ try {
206
+ expect(() => resolveTargetAssistant()).toThrow("process.exit called");
207
+ expect(mockExit).toHaveBeenCalledWith(1);
208
+ } finally {
209
+ process.exit = origExit;
210
+ }
211
+ });
212
+ });
213
+
214
+ describe("setActiveAssistant() / getActiveAssistant() round-trip", () => {
215
+ test("set active, read it back", () => {
216
+ writeLockfile({ assistants: [makeEntry("my-assistant")] });
217
+ setActiveAssistant("my-assistant");
218
+ expect(getActiveAssistant()).toBe("my-assistant");
219
+ });
220
+
221
+ test("lockfile is updated on disk", () => {
222
+ writeLockfile({ assistants: [makeEntry("disk-check")] });
223
+ setActiveAssistant("disk-check");
224
+ const raw = readLockfileRaw();
225
+ expect(raw.activeAssistant).toBe("disk-check");
226
+ });
227
+ });
228
+
229
+ describe("removeAssistantEntry() clears matching activeAssistant", () => {
230
+ test("set active to foo, remove foo, verify active is null", () => {
231
+ writeLockfile({
232
+ assistants: [makeEntry("foo"), makeEntry("bar")],
233
+ activeAssistant: "foo",
234
+ });
235
+ removeAssistantEntry("foo");
236
+ expect(getActiveAssistant()).toBeNull();
237
+ });
238
+
239
+ test("set active to foo, remove bar, verify active is still foo", () => {
240
+ writeLockfile({
241
+ assistants: [makeEntry("foo"), makeEntry("bar")],
242
+ activeAssistant: "foo",
243
+ });
244
+ removeAssistantEntry("bar");
245
+ expect(getActiveAssistant()).toBe("foo");
246
+ });
247
+ });
248
+
249
+ describe("remote non-regression", () => {
250
+ test("resolveTargetAssistant works with remote entries", () => {
251
+ writeLockfile({
252
+ assistants: [
253
+ makeEntry("my-remote", "gcp", {
254
+ runtimeUrl: "http://10.0.0.1:7821",
255
+ }),
256
+ ],
257
+ activeAssistant: "my-remote",
258
+ });
259
+ const result = resolveTargetAssistant();
260
+ expect(result.assistantId).toBe("my-remote");
261
+ expect(result.cloud).toBe("gcp");
262
+ });
263
+
264
+ test("remote entries don't get resources applied", () => {
265
+ const remoteEntry = makeEntry("cloud-box", "aws", {
266
+ runtimeUrl: "http://10.0.0.2:7821",
267
+ });
268
+ writeLockfile({ assistants: [remoteEntry] });
269
+ // Save and reload to verify resources are not injected
270
+ saveAssistantEntry(remoteEntry);
271
+ const result = resolveTargetAssistant("cloud-box");
272
+ expect(result.resources).toBeUndefined();
273
+ });
274
+ });
275
+ });
@@ -49,7 +49,9 @@ beforeEach(() => {
49
49
  afterEach(() => {
50
50
  process.argv = originalArgv;
51
51
  process.env.BASE_DATA_DIR = originalBaseDataDir;
52
- process.exitCode = originalExitCode;
52
+ // Bun treats `process.exitCode = undefined` as a no-op, so explicitly
53
+ // reset to 0 when the original value was not set.
54
+ process.exitCode = originalExitCode ?? 0;
53
55
  rmSync(tempDir, { recursive: true, force: true });
54
56
  });
55
57
 
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  findAssistantByName,
3
+ getActiveAssistant,
3
4
  loadLatestAssistant,
4
5
  } from "../lib/assistant-config";
5
6
  import { GATEWAY_PORT, type Species } from "../lib/constants";
@@ -45,16 +46,31 @@ function parseArgs(): ParsedArgs {
45
46
  }
46
47
  }
47
48
 
48
- const entry = positionalName
49
- ? findAssistantByName(positionalName)
50
- : loadLatestAssistant();
51
- if (positionalName && !entry) {
52
- console.error(`No assistant instance found with name '${positionalName}'.`);
53
- process.exit(1);
49
+ let entry: ReturnType<typeof findAssistantByName>;
50
+ if (positionalName) {
51
+ entry = findAssistantByName(positionalName);
52
+ if (!entry) {
53
+ console.error(
54
+ `No assistant instance found with name '${positionalName}'.`,
55
+ );
56
+ process.exit(1);
57
+ }
58
+ } else if (process.env.RUNTIME_URL) {
59
+ // Explicit env var — skip assistant resolution, will use env values below
60
+ entry = loadLatestAssistant();
61
+ } else {
62
+ // Respect active assistant when set, otherwise fall back to latest
63
+ // for backward compatibility with remote-only setups.
64
+ const active = getActiveAssistant();
65
+ const activeEntry = active ? findAssistantByName(active) : null;
66
+ entry = activeEntry ?? loadLatestAssistant();
54
67
  }
55
68
 
56
69
  let runtimeUrl =
57
- process.env.RUNTIME_URL || entry?.runtimeUrl || FALLBACK_RUNTIME_URL;
70
+ process.env.RUNTIME_URL ||
71
+ entry?.localUrl ||
72
+ entry?.runtimeUrl ||
73
+ FALLBACK_RUNTIME_URL;
58
74
  let assistantId =
59
75
  process.env.ASSISTANT_ID || entry?.assistantId || FALLBACK_ASSISTANT_ID;
60
76
  const bearerToken =
@@ -21,14 +21,19 @@ import cliPkg from "../../package.json";
21
21
 
22
22
  import { buildOpenclawStartupScript } from "../adapters/openclaw";
23
23
  import {
24
+ allocateLocalResources,
25
+ defaultLocalResources,
26
+ findAssistantByName,
24
27
  loadAllAssistants,
25
28
  saveAssistantEntry,
26
29
  syncConfigToLockfile,
27
30
  } from "../lib/assistant-config";
28
- import type { AssistantEntry } from "../lib/assistant-config";
31
+ import type {
32
+ AssistantEntry,
33
+ LocalInstanceResources,
34
+ } from "../lib/assistant-config";
29
35
  import { hatchAws } from "../lib/aws";
30
36
  import {
31
- GATEWAY_PORT,
32
37
  SPECIES_CONFIG,
33
38
  VALID_REMOTE_HOSTS,
34
39
  VALID_SPECIES,
@@ -41,7 +46,6 @@ import {
41
46
  startGateway,
42
47
  stopLocalProcesses,
43
48
  } from "../lib/local";
44
- import { probePort } from "../lib/port-probe";
45
49
  import { isProcessAlive } from "../lib/process";
46
50
  import { generateRandomSuffix } from "../lib/random-name";
47
51
  import { validateAssistantName } from "../lib/retire-archive";
@@ -583,6 +587,8 @@ async function waitForDaemonReady(
583
587
  async function displayPairingQRCode(
584
588
  runtimeUrl: string,
585
589
  bearerToken: string | undefined,
590
+ /** External gateway URL for the QR payload. When omitted, runtimeUrl is used. */
591
+ externalGatewayUrl?: string,
586
592
  ): Promise<void> {
587
593
  try {
588
594
  const pairingRequestId = randomUUID();
@@ -609,7 +615,7 @@ async function displayPairingQRCode(
609
615
  body: JSON.stringify({
610
616
  pairingRequestId,
611
617
  pairingSecret,
612
- gatewayUrl: runtimeUrl,
618
+ gatewayUrl: externalGatewayUrl ?? runtimeUrl,
613
619
  }),
614
620
  });
615
621
 
@@ -628,7 +634,7 @@ async function displayPairingQRCode(
628
634
  type: "vellum-daemon",
629
635
  v: 4,
630
636
  id: hostId,
631
- g: runtimeUrl,
637
+ g: externalGatewayUrl ?? runtimeUrl,
632
638
  pairingRequestId,
633
639
  pairingSecret,
634
640
  });
@@ -703,64 +709,49 @@ async function hatchLocal(
703
709
  );
704
710
  await stopLocalProcesses();
705
711
  }
712
+ }
706
713
 
707
- // Verify required ports are available before starting any services.
708
- // Only check when no local assistants exist if there are existing local
709
- // assistants, their daemon/gateway/qdrant legitimately own these ports.
710
- const RUNTIME_HTTP_PORT = Number(process.env.RUNTIME_HTTP_PORT) || 7821;
711
- const QDRANT_PORT = 6333;
712
- const requiredPorts = [
713
- { name: "daemon", port: RUNTIME_HTTP_PORT },
714
- { name: "gateway", port: GATEWAY_PORT },
715
- { name: "qdrant", port: QDRANT_PORT },
716
- ];
717
- const conflicts: string[] = [];
718
- await Promise.all(
719
- requiredPorts.map(async ({ name, port }) => {
720
- if (await probePort(port)) {
721
- conflicts.push(` - Port ${port} (${name}) is already in use`);
722
- }
723
- }),
724
- );
725
- if (conflicts.length > 0) {
726
- throw new Error(
727
- `Cannot hatch — required ports are already in use:\n${conflicts.join("\n")}\n\n` +
728
- "Stop the conflicting processes or use environment variables to configure alternative ports " +
729
- "(RUNTIME_HTTP_PORT, GATEWAY_PORT).",
730
- );
731
- }
714
+ // Reuse existing resources if re-hatching with --name that matches a known
715
+ // local assistant, otherwise allocate fresh per-instance ports and directories.
716
+ let resources: LocalInstanceResources;
717
+ const existingEntry = findAssistantByName(instanceName);
718
+ if (existingEntry?.cloud === "local" && existingEntry.resources) {
719
+ resources = existingEntry.resources;
720
+ } else if (restart && existingEntry?.cloud === "local") {
721
+ // Legacy entry without resources — use default paths to match existing layout
722
+ resources = defaultLocalResources();
723
+ } else {
724
+ resources = await allocateLocalResources(instanceName);
732
725
  }
733
726
 
734
- const baseDataDir = join(
735
- process.env.BASE_DATA_DIR?.trim() ||
736
- (process.env.HOME ?? userInfo().homedir),
737
- ".vellum",
738
- );
727
+ const baseDataDir = join(resources.instanceDir, ".vellum");
739
728
 
740
729
  console.log(`🥚 Hatching local assistant: ${instanceName}`);
741
730
  console.log(` Species: ${species}`);
742
731
  console.log("");
743
732
 
744
- await startLocalDaemon(watch);
733
+ await startLocalDaemon(watch, resources);
745
734
 
746
735
  let runtimeUrl: string;
747
736
  try {
748
- runtimeUrl = await startGateway(instanceName, watch);
737
+ runtimeUrl = await startGateway(instanceName, watch, resources);
749
738
  } catch (error) {
750
739
  // Gateway failed — stop the daemon we just started so we don't leave
751
740
  // orphaned processes with no lock file entry.
752
741
  console.error(
753
742
  `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
754
743
  );
755
- await stopLocalProcesses();
744
+ await stopLocalProcesses(resources);
756
745
  throw error;
757
746
  }
758
747
 
759
748
  // Read the bearer token (JWT) written by the daemon so the CLI can
760
- // authenticate with the gateway.
749
+ // with the gateway (which requires auth by default). The daemon writes under
750
+ // getRootDir() which resolves to <instanceDir>/.vellum/.
761
751
  let bearerToken: string | undefined;
762
752
  try {
763
- const token = readFileSync(join(baseDataDir, "http-token"), "utf-8").trim();
753
+ const tokenPath = join(resources.instanceDir, ".vellum", "http-token");
754
+ const token = readFileSync(tokenPath, "utf-8").trim();
764
755
  if (token) bearerToken = token;
765
756
  } catch {
766
757
  // Token file may not exist if daemon started without HTTP server
@@ -769,11 +760,13 @@ async function hatchLocal(
769
760
  const localEntry: AssistantEntry = {
770
761
  assistantId: instanceName,
771
762
  runtimeUrl,
763
+ localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
772
764
  baseDataDir,
773
765
  bearerToken,
774
766
  cloud: "local",
775
767
  species,
776
768
  hatchedAt: new Date().toISOString(),
769
+ resources,
777
770
  };
778
771
  if (!daemonOnly && !restart) {
779
772
  saveAssistantEntry(localEntry);
@@ -791,8 +784,11 @@ async function hatchLocal(
791
784
  console.log(` Runtime: ${runtimeUrl}`);
792
785
  console.log("");
793
786
 
794
- // Generate and display pairing QR code
795
- await displayPairingQRCode(runtimeUrl, bearerToken);
787
+ // Use loopback for HTTP calls (health check + pairing register) since
788
+ // mDNS hostnames may not resolve on the local machine, but keep the
789
+ // external runtimeUrl in the QR payload so iOS devices can reach it.
790
+ const localGatewayUrl = `http://127.0.0.1:${resources.gatewayPort}`;
791
+ await displayPairingQRCode(localGatewayUrl, bearerToken, runtimeUrl);
796
792
  }
797
793
  }
798
794
 
@@ -3,20 +3,18 @@ import { homedir } from "os";
3
3
  import { join } from "path";
4
4
 
5
5
  import {
6
+ defaultLocalResources,
6
7
  findAssistantByName,
8
+ getActiveAssistant,
7
9
  loadAllAssistants,
8
10
  type AssistantEntry,
9
11
  } from "../lib/assistant-config";
10
- import { GATEWAY_PORT } from "../lib/constants";
11
12
  import { checkHealth } from "../lib/health-check";
12
13
  import { pgrepExact } from "../lib/pgrep";
13
14
  import { probePort } from "../lib/port-probe";
14
15
  import { withStatusEmoji } from "../lib/status-emoji";
15
16
  import { execOutput } from "../lib/step-runner";
16
17
 
17
- const RUNTIME_HTTP_PORT = Number(process.env.RUNTIME_HTTP_PORT) || 7821;
18
- const QDRANT_PORT = 6333;
19
-
20
18
  // ── Table formatting helpers ────────────────────────────────────
21
19
 
22
20
  interface TableRow {
@@ -218,25 +216,26 @@ function formatDetectionInfo(proc: DetectedProcess): string {
218
216
  }
219
217
 
220
218
  async function getLocalProcesses(entry: AssistantEntry): Promise<TableRow[]> {
221
- const vellumDir = entry.baseDataDir ?? join(homedir(), ".vellum");
219
+ const resources = entry.resources ?? defaultLocalResources();
220
+ const vellumDir = join(resources.instanceDir, ".vellum");
222
221
 
223
222
  const specs: ProcessSpec[] = [
224
223
  {
225
224
  name: "assistant",
226
225
  pgrepName: "vellum-daemon",
227
- port: RUNTIME_HTTP_PORT,
228
- pidFile: join(vellumDir, "vellum.pid"),
226
+ port: resources.daemonPort,
227
+ pidFile: resources.pidFile,
229
228
  },
230
229
  {
231
230
  name: "qdrant",
232
231
  pgrepName: "qdrant",
233
- port: QDRANT_PORT,
232
+ port: resources.qdrantPort,
234
233
  pidFile: join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"),
235
234
  },
236
235
  {
237
236
  name: "gateway",
238
237
  pgrepName: "vellum-gateway",
239
- port: GATEWAY_PORT,
238
+ port: resources.gatewayPort,
240
239
  pidFile: join(vellumDir, "gateway.pid"),
241
240
  },
242
241
  {
@@ -355,6 +354,7 @@ async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
355
354
 
356
355
  async function listAllAssistants(): Promise<void> {
357
356
  const assistants = loadAllAssistants();
357
+ const activeId = getActiveAssistant();
358
358
 
359
359
  if (assistants.length === 0) {
360
360
  console.log("No assistants found.");
@@ -381,9 +381,10 @@ async function listAllAssistants(): Promise<void> {
381
381
  const infoParts = [a.runtimeUrl];
382
382
  if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
383
383
  if (a.species) infoParts.push(`species: ${a.species}`);
384
+ const prefix = a.assistantId === activeId ? "* " : " ";
384
385
 
385
386
  return {
386
- name: a.assistantId,
387
+ name: prefix + a.assistantId,
387
388
  status: withStatusEmoji("checking..."),
388
389
  info: infoParts.join(" | "),
389
390
  };
@@ -403,15 +404,34 @@ async function listAllAssistants(): Promise<void> {
403
404
 
404
405
  await Promise.all(
405
406
  assistants.map(async (a, rowIndex) => {
406
- const health = await checkHealth(a.runtimeUrl, a.bearerToken);
407
+ // For local assistants, check if the daemon process is alive before
408
+ // hitting the health endpoint. If the PID file is missing or the
409
+ // process isn't running, the assistant is sleeping — skip the
410
+ // network health check to avoid a misleading "unreachable" status.
411
+ let health: { status: string; detail: string | null };
412
+ const resources =
413
+ a.resources ??
414
+ (a.cloud === "local" ? defaultLocalResources() : undefined);
415
+ if (a.cloud === "local" && resources) {
416
+ const pid = readPidFile(resources.pidFile);
417
+ const alive = pid !== null && isProcessAlive(pid);
418
+ if (!alive) {
419
+ health = { status: "sleeping", detail: null };
420
+ } else {
421
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
422
+ }
423
+ } else {
424
+ health = await checkHealth(a.localUrl ?? a.runtimeUrl, a.bearerToken);
425
+ }
407
426
 
408
427
  const infoParts = [a.runtimeUrl];
409
428
  if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
410
429
  if (a.species) infoParts.push(`species: ${a.species}`);
411
430
  if (health.detail) infoParts.push(health.detail);
412
431
 
432
+ const prefix = a.assistantId === activeId ? "* " : " ";
413
433
  const updatedRow: TableRow = {
414
- name: a.assistantId,
434
+ name: prefix + a.assistantId,
415
435
  status: withStatusEmoji(health.status),
416
436
  info: infoParts.join(" | "),
417
437
  };