@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.
- package/README.md +65 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +16 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/delegate.d.ts +101 -3
- package/dist/commands/delegate.d.ts.map +1 -1
- package/dist/commands/delegate.js +370 -59
- package/dist/commands/delegate.js.map +1 -1
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +42 -5
- package/dist/commands/doctor.js.map +1 -1
- package/dist/delegate/hosts.d.ts +29 -0
- package/dist/delegate/hosts.d.ts.map +1 -1
- package/dist/delegate/hosts.js +23 -0
- package/dist/delegate/hosts.js.map +1 -1
- package/dist/delegate/sessions.d.ts +174 -1
- package/dist/delegate/sessions.d.ts.map +1 -1
- package/dist/delegate/sessions.js +282 -28
- package/dist/delegate/sessions.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
308
|
-
|
|
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",
|
|
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
|
|
371
|
-
|
|
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
|
-
|
|
395
|
+
return incomplete
|
|
379
396
|
? `${core} Detection was incomplete (${probeDetail ?? "a probe failed"}), so this list may be short.`
|
|
380
397
|
: core;
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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 ${
|
|
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 ${
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1142
|
-
//
|
|
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 {
|
|
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);
|