@floh-solutions/pharos-cli 0.33.0 → 0.35.0

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,11 +1,12 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { realpath, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
3
4
  import { basename, isAbsolute, join } from "node:path";
4
5
  import { promisify } from "node:util";
5
6
  import { findApp, HOSTS, HOST_IDS, AGENT_IDS, MODES, isAgentId, isHostId, isMode, } from "../delegate/hosts.js";
6
7
  import { ARGUS_NEW_WORKER, ARGUS_REASONS, ARGUS_TEXT_LIMIT, ARGUS_WAIT_SECONDS, ArgusUnreachableError, askArgus, candidatesSupport, delegateCandidatesRequest, delegateRequest, delegateStatusRequest, fleetStatusRequest, isFailure, mapRefusal, parseCandidates, parseFleet, rankWorkers, readDelegation, readTimeoutMs, systemArgusDeps, unreachable, } from "../delegate/argus.js";
7
8
  import { asTypedInput, LAUNCH_SCRIPT, launchCommand, SEND_TO_TAB_SCRIPT } from "../delegate/quote.js";
8
- import { findSessions, rankSessions, systemProbes, } from "../delegate/sessions.js";
9
+ import { carriedSentence, findSessions, rankSessions, systemProbes, } from "../delegate/sessions.js";
9
10
  import { BRIDGE_EXTENSION_ID, bridgeAtLeast, bundledBridge, bundledVsixPath, CLAUDE_EXTENSION_ID, extensionsDirectory, installBridge, installedExtensions, SESSION_BRIDGE_VERSION, } from "../delegate/vsix.js";
10
11
  import { CliError, EXIT_FAILED, EXIT_USAGE, emit, emitText, usageError } from "../output.js";
11
12
  import { resolveOnPath } from "./doctor.js";
@@ -55,6 +56,17 @@ export const REASONS = [
55
56
  * whenever it gets to it rather than in lockstep with this release.
56
57
  */
57
58
  export const CAUSES = ["stale-window"];
59
+ /**
60
+ * `--host all`: ask every installed host, in one listing. `--list` only.
61
+ *
62
+ * Spelled as a `--host` value rather than as a missing `--host`, on purpose:
63
+ * `--host` stays required everywhere, and a pharos that predates this refuses
64
+ * `all` with a usage error naming the four it knows — which is exactly the
65
+ * signal an app built against this one needs to fall back to asking one host.
66
+ * A missing flag meaning "every host" would have been accepted by nobody and
67
+ * silently misread by nothing, which is worse.
68
+ */
69
+ export const ALL_HOSTS = "all";
58
70
  export const SYSTEM_DEPS = {
59
71
  findApp,
60
72
  resolveOnPath,
@@ -216,6 +228,9 @@ export function ghAccountOption(value) {
216
228
  return given;
217
229
  }
218
230
  export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
231
+ // Before the one-host parse, which would refuse the word — see ALL_HOSTS.
232
+ if ((options.host ?? "").trim() === ALL_HOSTS)
233
+ return runAcrossHosts(io, env, options, deps);
219
234
  const host = oneOf(options.host, "--host", HOST_IDS, isHostId);
220
235
  const agent = oneOf(options.agent, "--agent", AGENT_IDS, isAgentId);
221
236
  const mode = options.mode === undefined ? "terminal" : oneOf(options.mode, "--mode", MODES, isMode);
@@ -258,12 +273,7 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
258
273
  if (text === "" && !options.list && !asking) {
259
274
  throw usageError("Nothing to send. Pass the prompt with --text, --file or --stdin.");
260
275
  }
261
- const folder = (options.folder ?? "").trim();
262
- if (folder === "")
263
- throw usageError("--folder is required: the absolute path of the project folder.");
264
- if (!isAbsolute(folder)) {
265
- throw usageError(`--folder must be absolute, got ${JSON.stringify(folder)}.`, { folder });
266
- }
276
+ const folder = folderOption(options.folder);
267
277
  const base = { host, agent, mode, folder };
268
278
  /**
269
279
  * What `--gh-account` did on a host that cannot honour it: nothing, said out
@@ -304,17 +314,11 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
304
314
  return navarchStatus(io, env, status, base, deps, refuse, succeed);
305
315
  // Cheapest fact first: nothing else is worth asking about a folder that is
306
316
  // not there, and it is the one refusal that is entirely the caller's to fix.
307
- const info = await stat(folder).catch(() => null);
308
- if (info === null || !info.isDirectory()) {
309
- return refuse("folder-missing", `${folder} is not a directory on this machine. The linked folder for this project has moved `
310
- + "or was never here — pick it again in Settings ▸ Agents.");
311
- }
317
+ if (!(await isFolder(folder)))
318
+ return refuse("folder-missing", folderMissing(folder));
312
319
  const app = await deps.findApp(host, env);
313
- if (app === null) {
314
- return refuse("host-not-installed", `${HOSTS[host].name} is not installed — looked up by bundle id ${HOSTS[host].bundleId} through `
315
- + "Spotlight and at the usual paths under /Applications and ~/Applications."
316
- + (HOSTS[host].cask === undefined ? "" : ` \`brew install --cask ${HOSTS[host].cask}\` installs it.`));
317
- }
320
+ if (app === null)
321
+ return refuse("host-not-installed", hostNotInstalled(host));
318
322
  // **Navarch takes both verbs before the agent is even resolved**, and that
319
323
  // is not an oversight. Nothing here runs the agent: argusd spawns a worker
320
324
  // through Navarch, from the app's own login shell and its own PATH. A
@@ -366,25 +370,260 @@ async function listSessions(io, host, agent, mode, folder, agentPath, env, deps,
366
370
  if (agent === "claude" ? probePath === null : agentPath === null) {
367
371
  return refuse("agent-not-installed", agentNotInstalled(agent, env));
368
372
  }
369
- const { sessions, incomplete, detail: probeDetail } = await findSessions(agent, folder, deps.probes(env, probePath));
370
- const ranked = rankSessions(sessions, host);
371
- const rows = ranked.map((session, index) => toRow(session, index === 0));
373
+ const { sessions, incomplete, detail: probeDetail, carried } = await findSessions(agent, folder, deps.probes(env, probePath));
374
+ const rows = hostRows(sessions, host);
375
+ // The list is also where somebody chooses "Start a … Session", so a host
376
+ // that would start it as another login says so here, before the choice.
377
+ const detail = listSentence(rows, agent, host, folder, incomplete, probeDetail) + carriedNote(carried, host, env);
378
+ const listing = { ok: true, host, agent, mode, folder, sessions: rows, incomplete, detail };
379
+ if (!pretty)
380
+ return emit(io, listing);
381
+ return emitText(io, [detail, ...rows.map(prettyRow)].join("\n"));
382
+ }
383
+ /** One host's rows, in its pick order, `recommended` on the head — the ONE ranking. */
384
+ function hostRows(sessions, host) {
385
+ return rankSessions(sessions, host).map((session, index) => toRow(session, index === 0));
386
+ }
387
+ /** What a one-host listing says — also each pid host's entry in a `--host all` listing. */
388
+ function listSentence(rows, agent, host, folder, incomplete, probeDetail) {
372
389
  const agentName = nameOf(agent);
373
390
  const where = `${HOSTS[host].name} on ${folder}`;
374
391
  const core = rows.length === 0
375
392
  ? `No ${agentName} session is running in ${where}.`
376
393
  : `${rows.length} ${agentName} session${rows.length === 1 ? "" : "s"} in ${where}. A plain delegate `
377
394
  + `would use ${rows[0].name} (${rows[0].status}).`;
378
- const detail = incomplete
395
+ return incomplete
379
396
  ? `${core} Detection was incomplete (${probeDetail ?? "a probe failed"}), so this list may be short.`
380
397
  : core;
381
- const listing = { ok: true, host, agent, mode, folder, sessions: rows, incomplete, detail };
382
- if (!pretty)
398
+ }
399
+ /** One row of `--pretty`: the star on the row a plain delegate to its host would take. */
400
+ function prettyRow(row) {
401
+ return `${row.recommended ? "*" : " "} ${row.id} ${row.name} — ${row.status}, ${row.detail}`;
402
+ }
403
+ // MARK: - Every host at once (#1401)
404
+ /**
405
+ * `--list --host all` — the validation, the two refusals that are about the
406
+ * whole question, and the answer.
407
+ *
408
+ * **Only `--list`.** A send has to go somewhere, and "every host" is not a
409
+ * somewhere: a `--session` id is a pid or a worker uuid that exists in exactly
410
+ * one host, `--session new` needs the host to start in, and `--status` is a
411
+ * Navarch question. So each of those, with `all`, is a usage error naming what
412
+ * to pass instead — never a guess at which host was meant.
413
+ *
414
+ * **It refuses only where no host could be asked** — the folder is missing,
415
+ * nothing is installed, or every installed host refused for the same reason
416
+ * (`claude` is on no PATH and no VS Code carries the extension). Then the
417
+ * answer IS that reason, and it is the one a one-host call would have given.
418
+ * Anything short of that is `ok: true` with the rows there are, and each host
419
+ * that contributed none says why in `hosts` — a Navarch whose argusd is down is
420
+ * no reason to hide the Terminal session beside it.
421
+ */
422
+ async function runAcrossHosts(io, env, options, deps) {
423
+ const agent = oneOf(options.agent, "--agent", AGENT_IDS, isAgentId);
424
+ // Accepted for its shape and otherwise unread: a listing does not depend on
425
+ // the door a prompt would go through, and `extension` is a real value for
426
+ // the VS Code hosts among the ones asked.
427
+ const mode = options.mode === undefined ? "terminal" : oneOf(options.mode, "--mode", MODES, isMode);
428
+ if (!options.list || options.session !== undefined || options.status !== undefined) {
429
+ throw usageError(`--host ${ALL_HOSTS} asks every host what is running, so it goes with --list and nothing else. A send, `
430
+ + "a --session pick and a --status read each go to ONE host: pass the row's own `host` with its "
431
+ + "`--session` id, and the host a new session should start in otherwise.", { host: ALL_HOSTS });
432
+ }
433
+ // Shape only, as on every `--list`: a list starts nothing to bind it to.
434
+ ghAccountOption(options.ghAccount);
435
+ const folder = folderOption(options.folder);
436
+ const base = { host: ALL_HOSTS, agent, mode, folder };
437
+ const refuse = (reason, detail) => emitRefusal(io, options.pretty, { ok: false, reason, detail, ...base });
438
+ if (!(await isFolder(folder)))
439
+ return refuse("folder-missing", folderMissing(folder));
440
+ const answers = await listAcrossHosts(agent, folder, env, deps);
441
+ const asked = answers.filter((answer) => answer.listing.reason !== "host-not-installed");
442
+ if (asked.length === 0) {
443
+ return refuse("host-not-installed", `None of the hosts is installed — ${HOST_IDS.map((host) => HOSTS[host].name).join(", ")} were each `
444
+ + "looked up by bundle id through Spotlight and at the usual paths under /Applications and "
445
+ + "~/Applications.");
446
+ }
447
+ const reasons = new Set(asked.map((answer) => answer.listing.reason));
448
+ const only = asked[0].listing;
449
+ if (reasons.size === 1 && only.reason !== undefined)
450
+ return refuse(only.reason, only.detail);
451
+ const sessions = answers.flatMap((answer) => answer.rows);
452
+ const hosts = answers.map((answer) => answer.listing);
453
+ const source = hosts.find((entry) => entry.source !== undefined)?.source;
454
+ const listing = {
455
+ ok: true,
456
+ ...base,
457
+ sessions,
458
+ incomplete: hosts.some((entry) => entry.incomplete),
459
+ ...(source === undefined ? {} : { source }),
460
+ detail: acrossHostsDetail(hosts, agent, folder),
461
+ hosts,
462
+ };
463
+ if (!options.pretty)
383
464
  return emit(io, listing);
384
- return emitText(io, [
385
- detail,
386
- ...rows.map((row) => `${row.recommended ? "*" : " "} ${row.id} ${row.name} — ${row.status}, ${row.detail}`),
387
- ].join("\n"));
465
+ return emitText(io, [listing.detail, ...sessions.map(prettyRow)].join("\n"));
466
+ }
467
+ /**
468
+ * Every host's `--list`, in {@link HOST_IDS} order, each exactly as it would
469
+ * have answered alone — and never more expensive than it has to be.
470
+ *
471
+ * - **One detection for the three pid hosts.** `claude agents --json` and `ps`
472
+ * see the whole machine whichever host asked, and {@link rankSessions}
473
+ * filters by host afterwards — so asking once and ranking three times is the
474
+ * same answer as three calls, for a third of the processes.
475
+ * - **The Navarch read beside it, not after it.** It is a socket round trip
476
+ * that shares nothing with the detection, so the two run at once.
477
+ * - **The installed check first**, for all four at once: a host nobody has
478
+ * installed is not asked anything, as it is not on its own.
479
+ */
480
+ async function listAcrossHosts(agent, folder, env, deps) {
481
+ const [apps, agentPath] = await Promise.all([
482
+ Promise.all(HOST_IDS.map((host) => deps.findApp(host, env))),
483
+ deps.resolveOnPath(agent, env),
484
+ ]);
485
+ const installed = new Set(HOST_IDS.filter((_, index) => apps[index] !== null));
486
+ const pidHosts = HOST_IDS.filter((host) => host !== "navarch" && installed.has(host));
487
+ const [pid, navarch] = await Promise.all([
488
+ listPidHosts(agent, folder, agentPath, pidHosts, env, deps),
489
+ installed.has("navarch") ? listNavarchHost(agent, folder, env, deps) : null,
490
+ ]);
491
+ return HOST_IDS.map((host) => {
492
+ if (!installed.has(host))
493
+ return refusedHost(host, "host-not-installed", hostNotInstalled(host));
494
+ const answer = host === "navarch" ? navarch : pid.get(host);
495
+ // Unreachable: every installed host was handed to exactly one of the two.
496
+ return answer ?? refusedHost(host, "unsupported", `${HOSTS[host].name} was not asked.`);
497
+ });
498
+ }
499
+ /**
500
+ * Terminal and the two VS Codes, from one detection — each gated the way
501
+ * {@link listSessions} gates it alone.
502
+ *
503
+ * The gate is the agent: the list is `claude agents --json` (or `ps` for
504
+ * Codex) and a host whose own call would refuse `agent-not-installed` is
505
+ * reported so rather than listed. Claude off PATH is the one case where hosts
506
+ * differ: a VS Code whose Claude Code extension carries its own `claude` lists
507
+ * through that binary, and Terminal has nothing to fall back on. Whichever
508
+ * binary asks, the registries are the machine's — so the detection runs once,
509
+ * with the first `claude` there is.
510
+ */
511
+ async function listPidHosts(agent, folder, agentPath, hosts, env, deps) {
512
+ const answers = new Map();
513
+ const listable = [];
514
+ let probePath = agent === "claude" ? agentPath : null;
515
+ for (const host of hosts) {
516
+ if (agentPath !== null) {
517
+ listable.push(host);
518
+ continue;
519
+ }
520
+ if (agent === "claude" && HOSTS[host].vscode !== undefined) {
521
+ const extensions = await deps.installedExtensions(host, env);
522
+ const bundled = bundledClaude(extensions?.find((extension) => extension.id === CLAUDE_EXTENSION_ID));
523
+ if (bundled !== null) {
524
+ listable.push(host);
525
+ probePath ??= bundled;
526
+ continue;
527
+ }
528
+ }
529
+ answers.set(host, refusedHost(host, "agent-not-installed", agentNotInstalled(agent, env)));
530
+ }
531
+ if (listable.length === 0)
532
+ return answers;
533
+ const { sessions, incomplete, detail: probeDetail, carried } = await findSessions(agent, folder, deps.probes(env, probePath));
534
+ for (const host of listable) {
535
+ const rows = hostRows(sessions, host);
536
+ answers.set(host, {
537
+ rows,
538
+ listing: {
539
+ host,
540
+ sessions: rows.length,
541
+ incomplete,
542
+ detail: listSentence(rows, agent, host, folder, incomplete, probeDetail) + carriedNote(carried, host, env),
543
+ },
544
+ });
545
+ }
546
+ return answers;
547
+ }
548
+ /**
549
+ * Navarch's part: `delegate_candidates`, with the `fleet_status` fallback, as
550
+ * `--list --host navarch` reads it — and what would have been a refusal or a
551
+ * failure there folded into the entry instead.
552
+ *
553
+ * Three shapes, and they differ on `incomplete` for a reason:
554
+ *
555
+ * - **the socket refused** (`argusd-outdated`, `navarch-not-running`, …) — a
556
+ * definite answer that nothing here can be delivered to, so not incomplete;
557
+ * - **nothing listening** — `argusd-not-running`, and not incomplete either:
558
+ * argusd is what holds a worker's terminal, so with no argusd there is no
559
+ * worker to have missed;
560
+ * - **the conversation broke** — a park that outlived its deadline, a line
561
+ * that is not JSON. That is "could not tell", which is exactly what
562
+ * `incomplete` is for; alone it is the CLI's exit-1 failure.
563
+ */
564
+ async function listNavarchHost(agent, folder, env, deps) {
565
+ try {
566
+ const found = await navarchCandidates(deps.argus(env), agent, await canonicalFolder(folder));
567
+ if (isFailure(found))
568
+ return refusedHost("navarch", found.reason, found.detail);
569
+ const rows = rowsOf(found.workers, agent, found.target);
570
+ return {
571
+ rows,
572
+ listing: {
573
+ host: "navarch",
574
+ sessions: rows.length,
575
+ incomplete: false,
576
+ detail: navarchListDetail(rows, agent, folder, found.source),
577
+ source: found.source,
578
+ },
579
+ };
580
+ }
581
+ catch (error) {
582
+ if (error instanceof ArgusUnreachableError) {
583
+ const failure = unreachable(error);
584
+ return refusedHost("navarch", failure.reason, failure.detail);
585
+ }
586
+ return {
587
+ rows: [],
588
+ listing: {
589
+ host: "navarch",
590
+ sessions: 0,
591
+ incomplete: true,
592
+ detail: `The Argus socket did not answer, so Navarch's workers could not be listed: ${messageOf(error)}`,
593
+ },
594
+ };
595
+ }
596
+ }
597
+ /** A host that contributed nothing, with the reason its own `--list` would have refused with. */
598
+ function refusedHost(host, reason, detail) {
599
+ return { rows: [], listing: { host, sessions: 0, incomplete: false, reason, detail } };
600
+ }
601
+ /**
602
+ * The listing's own sentence: how many, where, and which hosts said nothing
603
+ * useful and why.
604
+ *
605
+ * A host that is **not installed** is left out of it — on most machines that
606
+ * is two of the four, it is not news, and `hosts` still carries it. A host that
607
+ * IS installed and could not be listed is named with its reason id, because
608
+ * that is a fix somebody may want to make; the full prose is on its entry.
609
+ */
610
+ function acrossHostsDetail(hosts, agent, folder) {
611
+ const agentName = nameOf(agent);
612
+ const total = hosts.reduce((sum, entry) => sum + entry.sessions, 0);
613
+ const counts = hosts
614
+ .filter((entry) => entry.sessions > 0)
615
+ .map((entry) => `${entry.sessions} in ${HOSTS[entry.host].name}`);
616
+ const head = total === 0
617
+ ? `No ${agentName} session is running on ${folder} in any host that could be asked.`
618
+ : `${total} ${agentName} session${total === 1 ? "" : "s"} on ${folder}: ${counts.join(", ")}. Each host's `
619
+ + "first row is the one a plain delegate to that host would use.";
620
+ const refused = hosts
621
+ .filter((entry) => entry.reason !== undefined && entry.reason !== "host-not-installed")
622
+ .map((entry) => `${HOSTS[entry.host].name} (${entry.reason})`);
623
+ const short = hosts.filter((entry) => entry.incomplete).map((entry) => HOSTS[entry.host].name);
624
+ return (head
625
+ + (refused.length === 0 ? "" : ` Not listed: ${refused.join(", ")}.`)
626
+ + (short.length === 0 ? "" : ` Detection was incomplete for ${short.join(", ")}, so the list may be short there.`));
388
627
  }
389
628
  /** The `claude` the Claude Code extension carries, at the path measured on 2.1.228. */
390
629
  function bundledClaude(claudeExt) {
@@ -394,18 +633,35 @@ function bundledClaude(claudeExt) {
394
633
  function toRow(session, recommended) {
395
634
  // `rankSessions` filtered on the host, so it is never null here.
396
635
  const host = session.host;
636
+ const where = host === "terminal"
637
+ ? session.tty === null ? "Terminal" : `Terminal tab ${basename(session.tty)}`
638
+ : HOSTS[host].name;
397
639
  return {
398
640
  id: String(session.pid),
399
641
  name: displayName(session),
400
642
  agent: session.agent,
401
643
  host,
402
644
  status: session.status ?? "unknown",
403
- detail: host === "terminal"
404
- ? session.tty === null ? "Terminal" : `Terminal tab ${basename(session.tty)}`
405
- : HOSTS[host].name,
645
+ // Still a LOCATOR: where the session is, and — when it is not the
646
+ // default which login it runs as. Never the status.
647
+ detail: session.profile === undefined ? where : `${where} · profile ${session.profile}`,
406
648
  recommended,
649
+ ...(session.profile === undefined ? {} : { profile: session.profile }),
407
650
  };
408
651
  }
652
+ /**
653
+ * The sentence for a host that would START a Claude session as another login,
654
+ * with its leading space — or nothing when it would not.
655
+ *
656
+ * Only ever about the host being asked: a note that VS Code carries a profile
657
+ * is noise on a Terminal delegation. Starting a session is deliberately
658
+ * unchanged (#1431 ▸ 3) — the host's own environment decides the account, as
659
+ * it always did — so this reports rather than corrects.
660
+ */
661
+ function carriedNote(carried, host, env) {
662
+ const mine = carried.find((entry) => entry.host === host);
663
+ return mine === undefined ? "" : ` ${carriedSentence(mine, env["HOME"] ?? homedir())}`;
664
+ }
409
665
  /**
410
666
  * What to call a session in a menu.
411
667
  *
@@ -666,12 +922,31 @@ function dryRunDetail(picked, pick, ranked, agent, folder, ghAccount, source) {
666
922
  /** The inventory, in the shape every host answers `--list` in. */
667
923
  function emitNavarchList(io, base, agent, found, pretty) {
668
924
  const rows = rowsOf(found.workers, agent, found.target);
925
+ const detail = navarchListDetail(rows, agent, base.folder, found.source);
926
+ // **`incomplete` is false and means it.** Every other host infers the list
927
+ // from `ps` and a probe that can fail; both reads here are the daemon's own
928
+ // register of what it is running, so a short answer is an answer and not a
929
+ // gap.
930
+ const listing = {
931
+ ok: true,
932
+ ...base,
933
+ sessions: rows,
934
+ incomplete: false,
935
+ source: found.source,
936
+ detail,
937
+ };
938
+ if (!pretty)
939
+ return emit(io, listing);
940
+ return emitText(io, [detail, ...rows.map(prettyRow)].join("\n"));
941
+ }
942
+ /** What a Navarch listing says — alone, and as Navarch's entry in a `--host all` one. */
943
+ function navarchListDetail(rows, agent, folder, source) {
669
944
  const agentName = nameOf(agent);
670
945
  const head = rows.length === 0
671
- ? `No ${agentName} worker is free in Navarch on ${base.folder}. A delegation would start one. `
946
+ ? `No ${agentName} worker is free in Navarch on ${folder}. A delegation would start one. `
672
947
  + "A worker holding a board mission, and an Argus Agent, are running agents that are "
673
948
  + "deliberately not candidates — so this can read empty with panes open on that folder."
674
- : `${rows.length} ${agentName} worker${rows.length === 1 ? "" : "s"} in Navarch on ${base.folder}. `
949
+ : `${rows.length} ${agentName} worker${rows.length === 1 ? "" : "s"} in Navarch on ${folder}. `
675
950
  + `A plain delegate would use ${(rows.find((row) => row.recommended) ?? rows[0]).name} `
676
951
  + `(${(rows.find((row) => row.recommended) ?? rows[0]).status}).`;
677
952
  // **Which side answered, in the list's own prose as well as in `source`.**
@@ -679,32 +954,14 @@ function emitNavarchList(io, base, agent, found, pretty) {
679
954
  // accept, the other is what this CLI predicts it would — and on the older
680
955
  // daemon the pick a caller makes from it cannot be honoured at all, which is
681
956
  // the thing a person reading the list needs to know BEFORE they pick.
682
- const provenance = found.source === "daemon"
957
+ const provenance = source === "daemon"
683
958
  ? " argusd answered this list itself (`delegate_candidates`), so a row here is a row it accepts:"
684
959
  + " `--session` takes any of these ids."
685
960
  : " This argusd predates `delegate_candidates` (argus #1402), so the list is this CLI's own"
686
961
  + " reading of `fleet_status` and a prediction of the daemon's find. `--session` and"
687
962
  + " `--gh-account` are refused `argusd-outdated` against it — reloading argusd is what lifts"
688
963
  + " that, and it ends every live worker session.";
689
- const detail = head + provenance;
690
- // **`incomplete` is false and means it.** Every other host infers the list
691
- // from `ps` and a probe that can fail; both reads here are the daemon's own
692
- // register of what it is running, so a short answer is an answer and not a
693
- // gap.
694
- const listing = {
695
- ok: true,
696
- ...base,
697
- sessions: rows,
698
- incomplete: false,
699
- source: found.source,
700
- detail,
701
- };
702
- if (!pretty)
703
- return emit(io, listing);
704
- return emitText(io, [
705
- detail,
706
- ...rows.map((row) => `${row.recommended ? "*" : " "} ${row.id} ${row.name} — ${row.status}, ${row.detail}`),
707
- ].join("\n"));
964
+ return head + provenance;
708
965
  }
709
966
  /**
710
967
  * The rows, with `recommended` on the one the daemon would take.
@@ -849,6 +1106,31 @@ function navarchText(text) {
849
1106
  }
850
1107
  return text;
851
1108
  }
1109
+ /** `--folder` → the path, or a usage error: it is required, and it must be absolute. */
1110
+ function folderOption(value) {
1111
+ const folder = (value ?? "").trim();
1112
+ if (folder === "")
1113
+ throw usageError("--folder is required: the absolute path of the project folder.");
1114
+ if (!isAbsolute(folder)) {
1115
+ throw usageError(`--folder must be absolute, got ${JSON.stringify(folder)}.`, { folder });
1116
+ }
1117
+ return folder;
1118
+ }
1119
+ async function isFolder(folder) {
1120
+ const info = await stat(folder).catch(() => null);
1121
+ return info !== null && info.isDirectory();
1122
+ }
1123
+ /** The `folder-missing` detail — one place, because the one-host and every-host paths both reach it. */
1124
+ function folderMissing(folder) {
1125
+ return (`${folder} is not a directory on this machine. The linked folder for this project has moved `
1126
+ + "or was never here — pick it again in Settings ▸ Agents.");
1127
+ }
1128
+ /** The `host-not-installed` detail — alone a refusal, and in `--host all` that host's entry. */
1129
+ function hostNotInstalled(host) {
1130
+ return (`${HOSTS[host].name} is not installed — looked up by bundle id ${HOSTS[host].bundleId} through `
1131
+ + "Spotlight and at the usual paths under /Applications and ~/Applications."
1132
+ + (HOSTS[host].cask === undefined ? "" : ` \`brew install --cask ${HOSTS[host].cask}\` installs it.`));
1133
+ }
852
1134
  /** `realpath`, falling back to the path as given when it cannot be resolved. */
853
1135
  async function canonicalFolder(folder) {
854
1136
  try {
@@ -1030,7 +1312,7 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
1030
1312
  // Detect against the bundled binary when that is the only `claude` there, so
1031
1313
  // the prediction still holds; the bridge does its own detection regardless.
1032
1314
  const probePath = agent === "claude" ? agentPath ?? bundledClaude(claudeExt) : null;
1033
- const { sessions, incomplete, detail: probeDetail } = await findSessions(agent, folder, deps.probes(env, probePath));
1315
+ const { sessions, incomplete, detail: probeDetail, carried } = await findSessions(agent, folder, deps.probes(env, probePath));
1034
1316
  const ranked = rankSessions(sessions, host);
1035
1317
  // **`--session <pid>` is pre-checked here AND honoured again by the bridge,
1036
1318
  // and the two are independent on purpose.** The URI is one-way: nothing comes
@@ -1138,9 +1420,15 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
1138
1420
  const uri = delegateUri(spec.vscode.scheme, { folder, agent, mode, text, session: pick });
1139
1421
  const agentName = nameOf(agent);
1140
1422
  const claudeExtInstalled = mode === "extension" && agent === "claude" ? claudeExt !== undefined : true;
1141
- // The bridge decides; this is the same detection it runs, so the prediction
1142
- // is honest and the wording says whose call it is. One tense throughout —
1423
+ // The bridge decides; this is the detection it runs, so the prediction is
1424
+ // honest and the wording says whose call it is. One tense throughout —
1143
1425
  // `would` in a dry run, plain past/present otherwise.
1426
+ //
1427
+ // One known gap in "the detection it runs" (#1436): this reads every Claude
1428
+ // registry on the machine, and the bridge reads only the one its editor
1429
+ // carries. They agree whenever a session's registry came from the editor —
1430
+ // which is the measured case — and disagree only when one terminal inside
1431
+ // it overrode `CLAUDE_CONFIG_DIR` itself.
1144
1432
  const hand = dryRun ? "Would hand this to" : "Handed to";
1145
1433
  const asked = pick?.kind !== "new"
1146
1434
  ? ""
@@ -1163,12 +1451,17 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
1163
1451
  // sentence as-is, and "Installed the Pharos bridge 0.3.0 into VS Code
1164
1452
  // Insiders first." is the fact somebody needs before the one about where the
1165
1453
  // prompt went.
1454
+ //
1455
+ // A session the bridge STARTS runs in one of this editor's terminals, so it
1456
+ // is the editor's environment that decides its login — said right after the
1457
+ // sentence about the start, and only when there is one.
1166
1458
  const detail = leadNote
1167
1459
  + (pick?.kind === "new"
1168
1460
  ? `${core}${asked}`
1169
1461
  : session === null && incomplete
1170
1462
  ? `${core} Detection was incomplete (${probeDetail ?? "a probe failed"}); a running session may exist that could not be found.`
1171
1463
  : core)
1464
+ + (session === null ? carriedNote(carried, host, env) : "")
1172
1465
  + tailNote;
1173
1466
  if (!dryRun) {
1174
1467
  try {
@@ -1201,8 +1494,14 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
1201
1494
  * `automation-denied` with the Settings pane named.
1202
1495
  */
1203
1496
  async function delegateToTerminal(agent, folder, text, agentPath, env, deps, dryRun, pick, refuse, succeed) {
1204
- const { sessions, incomplete, detail: probeDetail } = await findSessions(agent, folder, deps.probes(env, agent === "claude" ? agentPath : null));
1497
+ const { sessions, incomplete, detail: probeDetail, carried } = await findSessions(agent, folder, deps.probes(env, agent === "claude" ? agentPath : null));
1205
1498
  const ranked = rankSessions(sessions, "terminal");
1499
+ // A new window's shell is started by Terminal, so it inherits Terminal's
1500
+ // environment — and with it, the login a session started there runs as.
1501
+ // On macOS 26 this is always empty: Terminal is a platform binary and `ps
1502
+ // -E` is shown none of its environment (see `delegate/sessions.ts`), so it
1503
+ // cannot be told, and nothing is said rather than a guess.
1504
+ const startedAs = carriedNote(carried, "terminal", env);
1206
1505
  const rows = () => ranked.map((found, index) => toRow(found, index === 0));
1207
1506
  let session;
1208
1507
  if (pick === undefined) {
@@ -1281,12 +1580,12 @@ async function delegateToTerminal(agent, folder, text, agentPath, env, deps, dry
1281
1580
  session: null,
1282
1581
  detail: `Opened a new Terminal window in ${folder} running ${agentName} with the prompt, because the `
1283
1582
  + `${agentName} session attributed to Terminal (pid ${session.pid}) holds no tab on tty `
1284
- + `${session.tty} that could be typed into.`,
1583
+ + `${session.tty} that could be typed into.${startedAs}`,
1285
1584
  });
1286
1585
  }
1287
1586
  const opened = dryRun ? "Would open" : "Opened";
1288
1587
  const detail = `${opened} a new Terminal window in ${folder} running ${agentName} with the prompt.`
1289
- + `${askedNote}${elsewhereNote}${incompleteNote}`;
1588
+ + `${startedAs}${askedNote}${elsewhereNote}${incompleteNote}`;
1290
1589
  if (dryRun) {
1291
1590
  return succeed({ action: "launched", session: null, detail, sessions: rows() });
1292
1591
  }
@@ -1326,12 +1625,24 @@ function describeStatus(session) {
1326
1625
  const parts = [session.status ?? "status unknown", `pid ${session.pid}`];
1327
1626
  if (session.tty !== null)
1328
1627
  parts.push(session.tty);
1628
+ // A send is unchanged by where the session was registered — it is a pid and
1629
+ // a tty like any other — but the person should still see which login it
1630
+ // landed in when that is not the default.
1631
+ if (session.profile !== undefined)
1632
+ parts.push(`profile ${session.profile}`);
1329
1633
  return ` (${parts.join(", ")})`;
1330
1634
  }
1331
1635
  function report(session) {
1332
1636
  if (session === null)
1333
1637
  return null;
1334
- return { pid: session.pid, cwd: session.cwd, name: session.name, status: session.status, tty: session.tty };
1638
+ return {
1639
+ pid: session.pid,
1640
+ cwd: session.cwd,
1641
+ name: session.name,
1642
+ status: session.status,
1643
+ tty: session.tty,
1644
+ ...(session.profile === undefined ? {} : { profile: session.profile }),
1645
+ };
1335
1646
  }
1336
1647
  function emitOutcome(io, pretty, outcome) {
1337
1648
  return pretty ? emitText(io, outcome.detail) : emit(io, outcome);