@floh-solutions/pharos-cli 0.32.0 → 0.34.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.
Files changed (42) hide show
  1. package/README.md +154 -32
  2. package/dist/capabilities.d.ts +27 -0
  3. package/dist/capabilities.d.ts.map +1 -1
  4. package/dist/capabilities.js +144 -41
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +62 -14
  8. package/dist/cli.js.map +1 -1
  9. package/dist/commands/delegate.d.ts +151 -6
  10. package/dist/commands/delegate.d.ts.map +1 -1
  11. package/dist/commands/delegate.js +575 -147
  12. package/dist/commands/delegate.js.map +1 -1
  13. package/dist/commands/doctor.d.ts +41 -0
  14. package/dist/commands/doctor.d.ts.map +1 -1
  15. package/dist/commands/doctor.js +345 -44
  16. package/dist/commands/doctor.js.map +1 -1
  17. package/dist/commands/pr.d.ts +56 -0
  18. package/dist/commands/pr.d.ts.map +1 -0
  19. package/dist/commands/pr.js +202 -0
  20. package/dist/commands/pr.js.map +1 -0
  21. package/dist/commands/setup.js +21 -3
  22. package/dist/commands/setup.js.map +1 -1
  23. package/dist/delegate/argus.d.ts +111 -2
  24. package/dist/delegate/argus.d.ts.map +1 -1
  25. package/dist/delegate/argus.js +99 -1
  26. package/dist/delegate/argus.js.map +1 -1
  27. package/dist/delegate/hosts.d.ts +29 -0
  28. package/dist/delegate/hosts.d.ts.map +1 -1
  29. package/dist/delegate/hosts.js +23 -0
  30. package/dist/delegate/hosts.js.map +1 -1
  31. package/dist/delegate/sessions.d.ts +174 -1
  32. package/dist/delegate/sessions.d.ts.map +1 -1
  33. package/dist/delegate/sessions.js +282 -28
  34. package/dist/delegate/sessions.js.map +1 -1
  35. package/dist/delegate/vsix.d.ts +26 -0
  36. package/dist/delegate/vsix.d.ts.map +1 -1
  37. package/dist/delegate/vsix.js +22 -5
  38. package/dist/delegate/vsix.js.map +1 -1
  39. package/package.json +3 -3
  40. package/skill/SKILL.md +32 -9
  41. package/vscode/pharos-bridge.json +1 -1
  42. package/vscode/pharos-bridge.vsix +0 -0
@@ -1,12 +1,13 @@
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
- import { ARGUS_REASONS, ARGUS_TEXT_LIMIT, ARGUS_WAIT_SECONDS, ArgusUnreachableError, askArgus, delegateRequest, delegateStatusRequest, fleetStatusRequest, isFailure, parseFleet, PROBE_REQUEST, rankWorkers, readDelegation, readTimeoutMs, servesDelegate, systemArgusDeps, unreachable, } from "../delegate/argus.js";
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 { BRIDGE_EXTENSION_ID, bridgeAtLeast, CLAUDE_EXTENSION_ID, extensionsDirectory, installedExtensions, SESSION_BRIDGE_VERSION, } from "../delegate/vsix.js";
9
+ import { carriedSentence, findSessions, rankSessions, systemProbes, } from "../delegate/sessions.js";
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";
12
13
  const run = promisify(execFile);
@@ -21,25 +22,47 @@ export const REASONS = [
21
22
  "automation-denied",
22
23
  "folder-missing",
23
24
  "unsupported",
24
- /** `--session <id>` named a session that is no longer a target on this folder. */
25
- "session-gone",
26
25
  /**
27
- * `--session` named a Navarch worker that is not the one the daemon's own
28
- * find would pick, or asked for a new one — neither of which the `delegate`
29
- * op can express: it takes no target worker (argus #1402). The id is real
30
- * and the session is alive, which is why this is not `session-gone`.
26
+ * `--session <id>` named a session that is no longer a target on this folder.
27
+ *
28
+ * On Navarch it is also what the daemon's `worker-not-eligible` becomes: the
29
+ * row you picked is not one this machine will deliver to, whether it exited,
30
+ * took a mission, or was never a candidate. Same fact, same fix — read the
31
+ * list again and pick from it.
31
32
  */
32
- "session-not-targetable",
33
+ "session-gone",
34
+ // **`session-not-targetable` was here and is gone** (#1412). It said the
35
+ // `delegate` op could not express a target worker at all; argus #1402 added
36
+ // `worker`, so no call can produce it any more. A daemon that predates that
37
+ // field is `argusd-outdated`, which is the truth and carries the fix (reload
38
+ // the daemon) — where this one carried none.
39
+ //
33
40
  // …and the Argus socket's own, each naming a different fix. See
34
41
  // `delegate/argus.js` — they are defined there because that is where the
35
42
  // daemon's reasons are mapped onto them.
36
43
  ...ARGUS_REASONS,
37
44
  ];
45
+ /**
46
+ * A second, narrower axis on a refusal: WHICH shape of a reason this is.
47
+ *
48
+ * One value so far, and it exists because one `bridge-not-installed` now has a
49
+ * different fix from the others. The app renders that reason with an Install
50
+ * button; `stale-window` means the install has already happened and what is
51
+ * needed is a window reload, so the app can swap the button for a reload hint
52
+ * (#1409, #1413) without reading the prose.
53
+ *
54
+ * **Optional, and absent on every refusal that does not need it.** A decoder
55
+ * that has never seen it keeps working — which is what lets the app adopt it
56
+ * whenever it gets to it rather than in lockstep with this release.
57
+ */
58
+ export const CAUSES = ["stale-window"];
38
59
  export const SYSTEM_DEPS = {
39
60
  findApp,
40
61
  resolveOnPath,
41
62
  probes: systemProbes,
42
63
  installedExtensions,
64
+ bundledBridge,
65
+ installBridge: (host, env) => installBridge({ env, hosts: [host] }),
43
66
  osascript: async (source, args) => {
44
67
  // `--` ends option parsing, so a prompt that begins with a dash is an
45
68
  // argument and not a flag. Every variable value travels as argv; nothing
@@ -101,6 +124,19 @@ function tabPick(pick) {
101
124
  return pick;
102
125
  throw new CliError(`--session ${pick.id} is a Navarch worker id, and this route delivers by process id.`, "internal", EXIT_FAILED);
103
126
  }
127
+ /**
128
+ * The mirror of {@link tabPick}: {@link sessionPick} parses a navarch `--session`
129
+ * as a uuid or `new` and nothing else, so a pid cannot reach that route.
130
+ *
131
+ * A guard rather than a cast for the same reason as its twin — the day the
132
+ * parse changes, this says so here instead of sending a process id to argusd
133
+ * as a worker uuid and getting `bad-worker` back about a value nobody typed.
134
+ */
135
+ function workerPick(pick) {
136
+ if (pick === undefined || pick.kind !== "pid")
137
+ return pick;
138
+ throw new CliError(`--session ${pick.pid} is a process id, and Navarch delivers by worker uuid.`, "internal", EXIT_FAILED);
139
+ }
104
140
  /** The reserved id. `--list` never prints it, so it can never shadow a real session. */
105
141
  export const NEW_SESSION = "new";
106
142
  /**
@@ -147,6 +183,39 @@ export function sessionPick(value, host) {
147
183
  }
148
184
  return { kind: "pid", pid: Number(given) };
149
185
  }
186
+ /**
187
+ * `--gh-account <login>` → the value to send, or a usage error.
188
+ *
189
+ * **A usage error and never a refusal**, the same split `--session` makes: a
190
+ * refusal is a fact about this machine that the app renders with a fix beside
191
+ * it, and "you passed an empty string" is not a fact about the machine. The
192
+ * one refusal this flag can produce is `gh-account-unknown`, and it comes off
193
+ * the socket — the daemon holds the accounts, so only the daemon can say an
194
+ * account is not one of them.
195
+ *
196
+ * **A login-shaped token: no whitespace.** `gh` logins have none, and neither
197
+ * does any spelling this CLI derives — it resolves a project folder's origin
198
+ * against `repos.json` and gets a login. Navarch will ALSO match a human's
199
+ * account *label*, and a label may well contain a space; such a label cannot
200
+ * be named through this flag, and the login always can. That is the trade, and
201
+ * it is the right way round: a value with a space in it reaching argusd would
202
+ * be one quoting mistake away from arriving as two.
203
+ */
204
+ export function ghAccountOption(value) {
205
+ if (value === undefined)
206
+ return undefined;
207
+ const given = value.trim();
208
+ if (given === "") {
209
+ throw usageError("--gh-account needs a GitHub login, or the name of an account bound in Navarch ▸ Accounts. "
210
+ + "Leave the flag off to let the session run as whoever this machine is logged in as.");
211
+ }
212
+ if (/\s/.test(given) || CONTROL.test(given)) {
213
+ throw usageError(`--gh-account takes one login-shaped token and ${JSON.stringify(value)} is not one: a GitHub `
214
+ + "login has no spaces. (Navarch also matches an account's label, which may have one — pass "
215
+ + "the login instead; it always resolves.)", { ghAccount: value });
216
+ }
217
+ return given;
218
+ }
150
219
  export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
151
220
  const host = oneOf(options.host, "--host", HOST_IDS, isHostId);
152
221
  const agent = oneOf(options.agent, "--agent", AGENT_IDS, isAgentId);
@@ -174,6 +243,15 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
174
243
  throw usageError("--status needs the `request` id the delegation answered with.");
175
244
  }
176
245
  const pick = sessionPick(options.session, host);
246
+ // **Accepted everywhere, honoured only by Navarch.** It is validated for
247
+ // shape on every host so a typo is caught where it was typed; what happens
248
+ // to it after that is the route's, and for the three hosts that cannot bind
249
+ // an identity it is a sentence on the outcome rather than a refusal. Only
250
+ // Navarch STARTS a session on the caller's behalf — a Terminal window and a
251
+ // VS Code terminal both inherit whatever `gh` this machine is logged in as,
252
+ // and there is nothing this verb could do about that short of writing to
253
+ // somebody's keyring.
254
+ const ghAccount = ghAccountOption(options.ghAccount);
177
255
  // **`--list` needs no prompt**, and demanding one would make the app build a
178
256
  // message it is only going to throw away to refresh a menu. Nor does
179
257
  // `--status`, which is a read about a delegation whose text was sent already.
@@ -188,17 +266,33 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
188
266
  throw usageError(`--folder must be absolute, got ${JSON.stringify(folder)}.`, { folder });
189
267
  }
190
268
  const base = { host, agent, mode, folder };
191
- const refuse = (reason, detail, delegation) => emitRefusal(io, options.pretty, {
269
+ /**
270
+ * What `--gh-account` did on a host that cannot honour it: nothing, said out
271
+ * loud. Appended in ONE place rather than by each route, because a route
272
+ * that forgot it would be the silent case this sentence exists to prevent —
273
+ * and the app renders `detail` verbatim, so a value that travelled and did
274
+ * nothing has to be in it. `--list` and `--status` are not sends and do not
275
+ * get it; they never reach {@link succeed} with anything to say about a
276
+ * session this call started.
277
+ */
278
+ const accountNote = ghAccount === undefined || host === "navarch"
279
+ ? ""
280
+ : ` The GitHub account “${ghAccount}” was not applied: only Navarch starts a session on your `
281
+ + `behalf and can bind one to it. A ${HOSTS[host].name} session runs as whichever account `
282
+ + "`gh` on this machine is logged in as.";
283
+ const refuse = (reason, detail, extra = {}) => emitRefusal(io, options.pretty, {
192
284
  ok: false,
193
285
  reason,
286
+ ...(extra.cause === undefined ? {} : { cause: extra.cause }),
194
287
  detail,
195
288
  ...base,
196
- ...(delegation === undefined ? {} : { delegation }),
289
+ ...(extra.delegation === undefined ? {} : { delegation: extra.delegation }),
197
290
  });
198
291
  const succeed = (outcome) => emitOutcome(io, options.pretty, {
199
292
  ok: true,
200
293
  ...base,
201
294
  ...outcome,
295
+ detail: outcome.detail + accountNote,
202
296
  ...(options.dryRun ? { dryRun: true } : {}),
203
297
  });
204
298
  // **`--status` asks the daemon, and nothing else.** It deliberately skips
@@ -229,7 +323,7 @@ export async function runDelegate(io, env, options, deps = SYSTEM_DEPS) {
229
323
  // though it were a fact about the machine — and a GUI-launched Pharos has
230
324
  // roughly `/usr/bin:/bin`, so it would refuse on nearly every real call.
231
325
  if (host === "navarch") {
232
- return delegateToNavarch(io, env, agent, folder, text, base, options, pick, deps, refuse, succeed);
326
+ return delegateToNavarch(io, env, agent, folder, text, base, options, workerPick(pick), ghAccount, deps, refuse, succeed);
233
327
  }
234
328
  // **May be null, and that is not always a refusal.** The Terminal route runs
235
329
  // the agent by this path, so it must exist there; the VS Code extension route
@@ -273,7 +367,7 @@ async function listSessions(io, host, agent, mode, folder, agentPath, env, deps,
273
367
  if (agent === "claude" ? probePath === null : agentPath === null) {
274
368
  return refuse("agent-not-installed", agentNotInstalled(agent, env));
275
369
  }
276
- const { sessions, incomplete, detail: probeDetail } = await findSessions(agent, folder, deps.probes(env, probePath));
370
+ const { sessions, incomplete, detail: probeDetail, carried } = await findSessions(agent, folder, deps.probes(env, probePath));
277
371
  const ranked = rankSessions(sessions, host);
278
372
  const rows = ranked.map((session, index) => toRow(session, index === 0));
279
373
  const agentName = nameOf(agent);
@@ -282,9 +376,12 @@ async function listSessions(io, host, agent, mode, folder, agentPath, env, deps,
282
376
  ? `No ${agentName} session is running in ${where}.`
283
377
  : `${rows.length} ${agentName} session${rows.length === 1 ? "" : "s"} in ${where}. A plain delegate `
284
378
  + `would use ${rows[0].name} (${rows[0].status}).`;
285
- const detail = incomplete
379
+ // The list is also where somebody chooses "Start a … Session", so a host
380
+ // that would start it as another login says so here, before the choice.
381
+ const detail = (incomplete
286
382
  ? `${core} Detection was incomplete (${probeDetail ?? "a probe failed"}), so this list may be short.`
287
- : core;
383
+ : core)
384
+ + carriedNote(carried, host, env);
288
385
  const listing = { ok: true, host, agent, mode, folder, sessions: rows, incomplete, detail };
289
386
  if (!pretty)
290
387
  return emit(io, listing);
@@ -301,18 +398,35 @@ function bundledClaude(claudeExt) {
301
398
  function toRow(session, recommended) {
302
399
  // `rankSessions` filtered on the host, so it is never null here.
303
400
  const host = session.host;
401
+ const where = host === "terminal"
402
+ ? session.tty === null ? "Terminal" : `Terminal tab ${basename(session.tty)}`
403
+ : HOSTS[host].name;
304
404
  return {
305
405
  id: String(session.pid),
306
406
  name: displayName(session),
307
407
  agent: session.agent,
308
408
  host,
309
409
  status: session.status ?? "unknown",
310
- detail: host === "terminal"
311
- ? session.tty === null ? "Terminal" : `Terminal tab ${basename(session.tty)}`
312
- : HOSTS[host].name,
410
+ // Still a LOCATOR: where the session is, and — when it is not the
411
+ // default which login it runs as. Never the status.
412
+ detail: session.profile === undefined ? where : `${where} · profile ${session.profile}`,
313
413
  recommended,
414
+ ...(session.profile === undefined ? {} : { profile: session.profile }),
314
415
  };
315
416
  }
417
+ /**
418
+ * The sentence for a host that would START a Claude session as another login,
419
+ * with its leading space — or nothing when it would not.
420
+ *
421
+ * Only ever about the host being asked: a note that VS Code carries a profile
422
+ * is noise on a Terminal delegation. Starting a session is deliberately
423
+ * unchanged (#1431 ▸ 3) — the host's own environment decides the account, as
424
+ * it always did — so this reports rather than corrects.
425
+ */
426
+ function carriedNote(carried, host, env) {
427
+ const mine = carried.find((entry) => entry.host === host);
428
+ return mine === undefined ? "" : ` ${carriedSentence(mine, env["HOME"] ?? homedir())}`;
429
+ }
316
430
  /**
317
431
  * What to call a session in a menu.
318
432
  *
@@ -333,23 +447,29 @@ function nameOf(agent) {
333
447
  /**
334
448
  * `--host navarch` — the send, the inventory and the dry run.
335
449
  *
336
- * All three go through one function because all three need the same two facts,
337
- * and the order they are established in is the whole contract:
338
- *
339
- * 1. **Does this argusd serve the verb?** `fleet_status` answers on every
340
- * generation of the daemon measured against one built from the commit
341
- * before `delegate` so a `--list` that skipped this would happily
342
- * enumerate workers that a send is about to refuse to touch. The probe is a
343
- * `delegate_status` read about an id that cannot exist: free, ungated,
344
- * side-effect-free. See {@link PROBE_REQUEST}.
345
- * 2. **Who is on the folder**, which is a PREDICTION and never the find
346
- * itself. The daemon does the find that counts, against a snapshot that may
347
- * have moved since this read.
348
- *
349
- * A plain send needs neither and asks for neither: one round trip, and the
350
- * daemon's own answer says where it landed.
450
+ * All three go through one function because all three rest on the same read,
451
+ * and which of them needs it is the whole contract:
452
+ *
453
+ * 1. **`delegate_candidates`** the find, asked rather than performed
454
+ * (argus #1402). It answers two questions at once, and both matter here:
455
+ * *who is on this folder*, as the daemon's own ranked list rather than as
456
+ * this CLI's prediction of it; and *does this daemon read a target at
457
+ * all*, since the op landed in the same change as `worker` and
458
+ * `gh_account`. A daemon that answers `unknown-op` serves neither, and it
459
+ * would IGNORE both rather than refuse them so `--session` and
460
+ * `--gh-account` are refused `argusd-outdated` there with nothing sent,
461
+ * while `--list` falls back to `fleet_status` and says so in `source`.
462
+ * 2. …and nothing else. **A plain send asks for none of it**: one round trip,
463
+ * the daemon does the find that counts, and its own answer says where the
464
+ * prompt landed.
465
+ *
466
+ * A pick is deliberately **not** re-checked against that list before a real
467
+ * send. The daemon validates `worker` against the very predicate the list came
468
+ * from — one rule, three callers — and its refusal names which of six reasons
469
+ * it was. A second copy of the rule here could only disagree with it, and the
470
+ * disagreeing copy is the one nobody is looking at.
351
471
  */
352
- async function delegateToNavarch(io, env, agent, folder, text, base, options, pick, deps, refuse, succeed) {
472
+ async function delegateToNavarch(io, env, agent, folder, text, base, options, pick, ghAccount, deps, refuse, succeed) {
353
473
  const kind = agent;
354
474
  const argus = deps.argus(env);
355
475
  // The daemon's own compare resolves `~`, `.`/`..` and the three symlinked
@@ -357,39 +477,64 @@ async function delegateToNavarch(io, env, agent, folder, text, base, options, pi
357
477
  // somebody made would miss every worker on it, so it is canonicalised here,
358
478
  // exactly as the Terminal route canonicalises before matching a cwd.
359
479
  const directory = await canonicalFolder(folder);
480
+ /** Does this call carry a field argus #1402 added? Both are ignored by an older daemon. */
481
+ const aims = pick !== undefined || ghAccount !== undefined;
360
482
  try {
361
- // `--session new`, and any pick that is not the worker the daemon would
362
- // choose, are refused BEFORE anything is sent — see {@link notTargetable}.
363
- const listing = options.list || options.dryRun || pick !== undefined
364
- ? await navarchFleet(argus, kind, directory)
483
+ const found = options.list || options.dryRun || aims
484
+ ? await navarchCandidates(argus, kind, directory)
365
485
  : null;
366
- if (listing !== null && isFailure(listing))
367
- return refuseFailure(refuse, listing);
368
- const ranked = listing ?? [];
486
+ if (found !== null && isFailure(found))
487
+ return refuseFailure(refuse, found);
488
+ const ranked = found?.workers ?? [];
489
+ const target = found?.target ?? null;
490
+ // `found!` below, three times and for one reason: `--list`, `--dry-run` and
491
+ // a call that `aims` are exactly the three that set it, so inside each of
492
+ // them the read has happened. A plain send is the only path where it is
493
+ // null, and it returns before any of them.
369
494
  if (options.list)
370
- return emitNavarchList(io, base, agent, ranked, options.pretty);
371
- if (pick !== undefined) {
372
- const gap = notTargetable(pick, ranked, agent);
373
- if (gap !== null)
374
- return refuse("session-not-targetable", gap);
495
+ return emitNavarchList(io, base, agent, found, options.pretty);
496
+ // **The version gate, and it is the reason a pick costs a round trip.**
497
+ // `delegate` ignores a key it does not know, so on a daemon that predates
498
+ // #1402 a `worker` is discarded and the find runs anyway — `sent`, about a
499
+ // pane the person did not choose — and a `gh_account` is discarded too,
500
+ // leaving a fresh worker running as whoever `gh` last logged in as. Both
501
+ // are exactly what these flags exist to prevent, and neither is visible in
502
+ // the reply, so the only place to catch it is here, before the send.
503
+ if (aims && found !== null && found.source === "fleet_status") {
504
+ return refuse("argusd-outdated", targetUnsupported(pick, ghAccount));
375
505
  }
376
506
  const oneLine = navarchText(text);
377
- const target = ranked[0] ?? null;
378
507
  if (options.dryRun) {
508
+ const picked = pick === undefined
509
+ ? headOf(ranked, target)
510
+ : pick.kind === "new"
511
+ ? null
512
+ : (ranked.find((worker) => worker.id === pick.id) ?? null);
513
+ // The one refusal a dry run can predict exactly, and only because the
514
+ // gate above has already run: a named worker means `aims`, so the list
515
+ // in hand is `delegate_candidates`' own — the very set `delegate`
516
+ // validates `worker` against. A uuid missing from it is a uuid the send
517
+ // would refuse `worker-not-eligible`.
518
+ if (pick?.kind === "worker" && picked === null) {
519
+ return refuse("session-gone", wouldBeGone(pick.id, ranked, agent));
520
+ }
379
521
  return succeed({
380
- action: target === null ? "launched" : "sent",
522
+ action: picked === null ? "launched" : "sent",
381
523
  session: null,
382
- sessions: ranked.map((worker, index) => workerRow(worker, agent, index === 0)),
383
- detail: target === null
384
- ? `Would ask Navarch to start a ${nameOf(agent)} worker in ${folder} and type the prompt `
385
- + "into it. No worker of that kind is free on this folder — a worker holding a board "
386
- + "mission and an Argus Agent are both deliberately not candidates."
387
- : `Would ask Navarch to type the prompt into “${target.name}” (${statusWord(target.status)}). `
388
- + "The find is argusd's rather than this CLI's, so this is the worker it would pick from "
389
- + "the snapshot as it stands now.",
524
+ sessions: rowsOf(ranked, agent, target),
525
+ source: found.source,
526
+ detail: dryRunDetail(picked, pick, ranked, agent, folder, ghAccount, found.source),
390
527
  });
391
528
  }
392
- const reply = await askArgus(argus, delegateRequest({ directory, kind, text: oneLine }), readTimeoutMs(ARGUS_WAIT_SECONDS));
529
+ const reply = await askArgus(argus, delegateRequest({
530
+ directory,
531
+ kind,
532
+ text: oneLine,
533
+ ...(pick === undefined
534
+ ? {}
535
+ : { worker: pick.kind === "new" ? ARGUS_NEW_WORKER : pick.id }),
536
+ ...(ghAccount === undefined ? {} : { ghAccount }),
537
+ }), readTimeoutMs(ARGUS_WAIT_SECONDS));
393
538
  const outcome = readDelegation(reply);
394
539
  if (isFailure(outcome))
395
540
  return refuseFailure(refuse, outcome);
@@ -430,75 +575,151 @@ async function navarchStatus(io, env, request, base, deps, refuse, succeed) {
430
575
  }
431
576
  }
432
577
  /**
433
- * The probe, then the fleet, then the find ranked the way the daemon ranks
434
- * it. The candidates on that folder with `[0]` first, or the refusal that
435
- * stopped it.
578
+ * One `delegate_candidates` read, with the fallback an un-reloaded daemon
579
+ * needs or the refusal that stopped it.
580
+ *
581
+ * Three daemons answer this, and `candidatesSupport` tells them apart by the
582
+ * `reason` KEY rather than by prose:
583
+ *
584
+ * - **it served the read** → the rows are the daemon's own, ranked by the
585
+ * daemon's own rule, and every #1402 field is honoured;
586
+ * - **`unknown-op`** → it serves `delegate` and predates #1402. `fleet_status`
587
+ * answers on every generation of argusd, so the list is still worth having —
588
+ * as this CLI's prediction, marked `fleet_status`;
589
+ * - **no `reason` at all** → it predates `delegate` itself, which
590
+ * {@link mapRefusal} already turns into `argusd-outdated`. Listing workers a
591
+ * send is about to refuse to touch would be worse than saying so.
592
+ *
593
+ * `bad-directory` and `bad-kind` are mapped like any other refusal: the read
594
+ * validates them exactly as `delegate` does, so an empty list stays an answer
595
+ * ("nothing eligible there") and a folder that is not there stays a typo.
436
596
  */
437
- async function navarchFleet(argus, kind, directory) {
438
- const probe = await askArgus(argus, delegateStatusRequest(PROBE_REQUEST, 0), readTimeoutMs(0));
439
- if (!servesDelegate(probe)) {
440
- // `servesDelegate` is false only for a refusal, and a refusal with no
441
- // `reason` is a pre-delegate daemon whatever its prose says — which
442
- // `readDelegation` has already turned into `argusd-outdated`.
443
- const failure = readDelegation(probe);
444
- if (isFailure(failure))
445
- return failure;
597
+ async function navarchCandidates(argus, kind, directory) {
598
+ const reply = await askArgus(argus, delegateCandidatesRequest(directory, kind), readTimeoutMs(0));
599
+ const support = candidatesSupport(reply);
600
+ if (support !== "no-op") {
601
+ if (!reply.ok)
602
+ return mapRefusal(reply);
603
+ const { workers, target } = parseCandidates(reply);
604
+ return { workers, target, source: "daemon" };
446
605
  }
447
606
  const fleet = await askArgus(argus, fleetStatusRequest(), readTimeoutMs(0));
448
- if (!fleet.ok) {
449
- const failure = readDelegation(fleet);
450
- if (isFailure(failure))
451
- return failure;
452
- }
453
- return fleet.ok ? rankWorkers(parseFleet(fleet), kind, directory) : [];
607
+ if (!fleet.ok)
608
+ return mapRefusal(fleet);
609
+ const workers = rankWorkers(parseFleet(fleet), kind, directory);
610
+ return { workers, target: workers[0]?.id ?? null, source: "fleet_status" };
611
+ }
612
+ /**
613
+ * `--session` or `--gh-account` against an argusd that predates argus #1402 —
614
+ * the refusal, and it is `argusd-outdated` because a reload is the fix.
615
+ *
616
+ * **The danger is that it would not refuse.** `delegate` reads the keys it
617
+ * knows and ignores the rest, so a target it has never heard of is discarded
618
+ * and the find runs anyway. The caller gets `sent`, naming a worker the person
619
+ * did not pick — or a fresh worker running as whoever `gh` was last logged in
620
+ * as. Nothing in the reply distinguishes that from having been obeyed, which
621
+ * is why the check is here and not in a reading of the answer.
622
+ */
623
+ function targetUnsupported(pick, ghAccount) {
624
+ const asked = [
625
+ pick === undefined ? "" : pick.kind === "new" ? "`--session new`" : `\`--session ${pick.id}\``,
626
+ ghAccount === undefined ? "" : `\`--gh-account ${ghAccount}\``,
627
+ ].filter((part) => part !== "");
628
+ return (`${asked.join(" and ")} ${asked.length > 1 ? "name things" : "names something"} the argusd running `
629
+ + "on this machine cannot be told. It serves the delegate verb — a plain hand-off works — but the "
630
+ + "target worker and the GitHub account arrived with argus #1402, and this daemon predates it: it "
631
+ + "does not serve `delegate_candidates` either. It would not refuse them, it would IGNORE them: do "
632
+ + "its own find and report `sent` about a worker you did not choose, or start one running as "
633
+ + "whichever account `gh` was last logged in as. So nothing was sent. Reload argusd — deliberately, "
634
+ + "because a reload ends every live worker session — or delegate without these flags, which works "
635
+ + "on the daemon you have.");
454
636
  }
455
637
  /**
456
- * Why `--session` cannot be honoured here yet, or null when it can.
457
- *
458
- * **The `delegate` op takes no target worker.** The find lives entirely in the
459
- * daemon (`BoardStore.delegateTarget`) and Navarch types into whatever it
460
- * named, so the only pick this CLI can promise is the one the daemon would
461
- * have made anywayverified against an isolated daemon, where two eligible
462
- * workers on one folder always drew the lower uuid. Answering `sent` about any
463
- * other is answering about a pane the person did not choose, which is the one
464
- * failure this verb is built around not producing.
465
- *
466
- * It is NOT `session-gone`: that id is a real, live worker, and telling
467
- * somebody their session had died would send them looking for a corpse. argus
468
- * #1402 adds the target field; when it lands this refusal goes and nothing
469
- * else here changes.
638
+ * A `--dry-run --session <uuid>` whose worker is not on the daemon's own list.
639
+ *
640
+ * Predicted rather than performed, and exact where the list came from the
641
+ * daemon: `delegate_candidates` lists from the very predicate `delegate`
642
+ * validates `worker` against, so a uuid missing from it is a uuid the send
643
+ * would refuse `worker-not-eligible`which is this verb's `session-gone`.
470
644
  */
471
- function notTargetable(pick, ranked, agent) {
472
- const head = ranked[0] ?? null;
473
- if (pick.kind === "worker" && head !== null && head.id === pick.id)
474
- return null;
475
- const what = pick.kind === "new"
476
- ? "Starting a fresh worker beside the ones already on this folder"
477
- : `Delivering to worker ${pick.kind === "worker" ? pick.id : ""}`;
478
- const instead = head === null
479
- ? `there is no ${nameOf(agent)} worker free on this folder, so a delegation starts one — which is `
480
- + "what `--session new` asks for, and already what would happen without it"
481
- : `a delegation on this folder goes to “${head.name}” (${head.id}), because argusd does the find `
482
- + "itself and this verb cannot override it";
483
- return (`${what} is not something the Argus \`delegate\` op can express yet: it takes a folder and a kind, `
484
- + `never a worker. Right now ${instead}. Nothing was sent. Board todo #1402 in the argus repo adds `
485
- + "the target field; until it lands, delegate without `--session` to use the worker `--list` marks "
486
- + "`recommended`.");
645
+ function wouldBeGone(id, ranked, agent) {
646
+ const now = ranked.length === 0
647
+ ? `No ${nameOf(agent)} worker is free in Navarch on this folder`
648
+ : `What it would accept: ${ranked.map((worker) => `${worker.name} (${worker.id})`).join(", ")}`;
649
+ return (`Worker ${id} is not one argusd would deliver to on this folder — it has exited, taken a board `
650
+ + "mission, is an Argus Agent, or was never there. That is the daemon's own answer to its own "
651
+ + `find, so a real run would be refused \`session-gone\` for exactly this. ${now}. Nothing was `
652
+ + "sent and nothing would be started: a chosen session is a choice. Re-read the inventory with "
653
+ + "`pharos delegate --list --host navarch`, or pass `--session new` to start a fresh worker on "
654
+ + "purpose.");
655
+ }
656
+ /** What a dry run would do, in the dry run's own tense. */
657
+ function dryRunDetail(picked, pick, ranked, agent, folder, ghAccount, source) {
658
+ const core = picked !== null
659
+ ? `Would ask Navarch to type the prompt into “${picked.name}” (${statusWord(picked.status)}).`
660
+ + (pick === undefined
661
+ ? source === "daemon"
662
+ ? " That is the worker argusd itself names as the target for this folder."
663
+ : " The find is argusd's rather than this CLI's, so this is the worker it would pick from"
664
+ + " the snapshot as it stands now."
665
+ : " It was named with `--session`, and argusd validates that pick against the same list it"
666
+ + " came from.")
667
+ : pick?.kind === "new"
668
+ ? `Would ask Navarch to start a FRESH ${nameOf(agent)} worker in ${folder}`
669
+ + (ranked.length === 0
670
+ ? ", which is what would have happened anyway: none is free on this folder."
671
+ : `, leaving the ${ranked.length === 1 ? "one" : String(ranked.length)} already there alone.`)
672
+ : `Would ask Navarch to start a ${nameOf(agent)} worker in ${folder} and type the prompt into `
673
+ + "it. No worker of that kind is free on this folder — a worker holding a board mission and "
674
+ + "an Argus Agent are both deliberately not candidates.";
675
+ // The account is only ever bound to a worker that is STARTED, so a dry run
676
+ // that predicts a send has to say the value would travel and do nothing —
677
+ // the same sentence argusd appends to the real answer.
678
+ const account = ghAccount === undefined
679
+ ? ""
680
+ : picked === null
681
+ ? ` It would run as the GitHub account “${ghAccount}”, and argusd refuses `
682
+ + "`gh-account-unknown` rather than substituting one if it does not know that name."
683
+ : ` The GitHub account “${ghAccount}” would not be applied: the prompt would land in a worker `
684
+ + "that is already running, and its account stands.";
685
+ return core + account;
487
686
  }
488
687
  /** The inventory, in the shape every host answers `--list` in. */
489
- function emitNavarchList(io, base, agent, ranked, pretty) {
490
- const rows = ranked.map((worker, index) => workerRow(worker, agent, index === 0));
688
+ function emitNavarchList(io, base, agent, found, pretty) {
689
+ const rows = rowsOf(found.workers, agent, found.target);
491
690
  const agentName = nameOf(agent);
492
- const detail = rows.length === 0
691
+ const head = rows.length === 0
493
692
  ? `No ${agentName} worker is free in Navarch on ${base.folder}. A delegation would start one. `
494
693
  + "A worker holding a board mission, and an Argus Agent, are running agents that are "
495
694
  + "deliberately not candidates — so this can read empty with panes open on that folder."
496
695
  : `${rows.length} ${agentName} worker${rows.length === 1 ? "" : "s"} in Navarch on ${base.folder}. `
497
- + `A plain delegate would use ${rows[0].name} (${rows[0].status}).`;
696
+ + `A plain delegate would use ${(rows.find((row) => row.recommended) ?? rows[0]).name} `
697
+ + `(${(rows.find((row) => row.recommended) ?? rows[0]).status}).`;
698
+ // **Which side answered, in the list's own prose as well as in `source`.**
699
+ // The two lists are not the same claim: one is what argusd says it would
700
+ // accept, the other is what this CLI predicts it would — and on the older
701
+ // daemon the pick a caller makes from it cannot be honoured at all, which is
702
+ // the thing a person reading the list needs to know BEFORE they pick.
703
+ const provenance = found.source === "daemon"
704
+ ? " argusd answered this list itself (`delegate_candidates`), so a row here is a row it accepts:"
705
+ + " `--session` takes any of these ids."
706
+ : " This argusd predates `delegate_candidates` (argus #1402), so the list is this CLI's own"
707
+ + " reading of `fleet_status` and a prediction of the daemon's find. `--session` and"
708
+ + " `--gh-account` are refused `argusd-outdated` against it — reloading argusd is what lifts"
709
+ + " that, and it ends every live worker session.";
710
+ const detail = head + provenance;
498
711
  // **`incomplete` is false and means it.** Every other host infers the list
499
- // from `ps` and a probe that can fail; this one is the daemon's own register
500
- // of what it is running, so a short answer is an answer and not a gap.
501
- const listing = { ok: true, ...base, sessions: rows, incomplete: false, detail };
712
+ // from `ps` and a probe that can fail; both reads here are the daemon's own
713
+ // register of what it is running, so a short answer is an answer and not a
714
+ // gap.
715
+ const listing = {
716
+ ok: true,
717
+ ...base,
718
+ sessions: rows,
719
+ incomplete: false,
720
+ source: found.source,
721
+ detail,
722
+ };
502
723
  if (!pretty)
503
724
  return emit(io, listing);
504
725
  return emitText(io, [
@@ -506,6 +727,33 @@ function emitNavarchList(io, base, agent, ranked, pretty) {
506
727
  ...rows.map((row) => `${row.recommended ? "*" : " "} ${row.id} ${row.name} — ${row.status}, ${row.detail}`),
507
728
  ].join("\n"));
508
729
  }
730
+ /**
731
+ * The rows, with `recommended` on the one the daemon would take.
732
+ *
733
+ * `target` is read rather than assumed to be `[0]`, even though the two are
734
+ * the same by construction — it is the daemon's own statement of what a plain
735
+ * delegate takes, and `recommended` is defined as exactly that. It falls back
736
+ * to the head for the `fleet_status` route, where the ranking is this CLI's
737
+ * and there is nobody else to ask.
738
+ */
739
+ function rowsOf(ranked, agent, target) {
740
+ const head = headOf(ranked, target);
741
+ return ranked.map((worker) => workerRow(worker, agent, worker === head));
742
+ }
743
+ /**
744
+ * The worker a plain delegate would take: the daemon's own `target` where
745
+ * there is one to read, and the head of the ranking otherwise — which is the
746
+ * `fleet_status` fallback, where there is nobody to ask and this CLI's own
747
+ * rule is all there is.
748
+ */
749
+ function headOf(ranked, target) {
750
+ if (target !== null) {
751
+ const named = ranked.find((worker) => worker.id === target);
752
+ if (named !== undefined)
753
+ return named;
754
+ }
755
+ return ranked[0] ?? null;
756
+ }
509
757
  /**
510
758
  * One worker as a menu row.
511
759
  *
@@ -576,7 +824,7 @@ function reportOf(outcome) {
576
824
  }
577
825
  /** A refusal that came off the socket, in this verb's own shape. */
578
826
  function refuseFailure(refuse, failure) {
579
- return refuse(failure.reason, failure.detail, failure.delegation === undefined ? undefined : reportOf(failure.delegation));
827
+ return refuse(failure.reason, failure.detail, failure.delegation === undefined ? undefined : { delegation: reportOf(failure.delegation) });
580
828
  }
581
829
  /**
582
830
  * A socket that was not there, or a conversation that broke.
@@ -671,6 +919,64 @@ function agentNotInstalled(agent, env) {
671
919
  + (agent === "claude" ? "`npm i -g @anthropic-ai/claude-code`" : "`npm i -g @openai/codex`")
672
920
  + " puts it there. If it IS installed, `pharos doctor` prints the PATH that was searched.");
673
921
  }
922
+ /**
923
+ * `--session` against a bridge that cannot carry one — the version gate's
924
+ * detail, which has three shapes because it has three different fixes.
925
+ *
926
+ * `running` is the version in place AFTER the route's own install attempt, so
927
+ * reaching here at all means one of three things: an upgrade was tried and
928
+ * failed (`upgradeFailure` says how), this pharos ships nothing to replace it
929
+ * with, or what it ships is no newer. Each of those is somebody doing a
930
+ * different thing next, which is why none of them is folded into the others.
931
+ */
932
+ function sessionTooOld(name, running, shipped, upgradeFailure) {
933
+ const unreadable = running === null || running === "";
934
+ const head = `The Pharos bridge in ${name} `
935
+ + (unreadable
936
+ ? "carries no version this can read, so it cannot be shown to understand"
937
+ : `is ${running}, which predates`)
938
+ + ` \`--session\` — that arrived in ${SESSION_BRIDGE_VERSION}. It does not ignore the session in the `
939
+ + "URI: it refuses any query key it does not know, so the prompt would never reach the editor and "
940
+ + "this would have reported it as sent.";
941
+ const fix = upgradeFailure !== null
942
+ ? ` Replacing it with ${shipped ?? "the one this pharos ships"} was tried on the way here and failed: `
943
+ + `${upgradeFailure} \`pharos setup --install vscode-bridge\` runs the same install on its own.`
944
+ : shipped === null
945
+ ? " This pharos ships no bridge it can offer as a replacement, so updating pharos is what brings one."
946
+ : bridgeAtLeast(shipped, SESSION_BRIDGE_VERSION)
947
+ ? ` \`pharos setup --install vscode-bridge\` replaces it with the one this pharos ships (${shipped}).`
948
+ : ` This pharos ships ${shipped}, which is no newer, so updating pharos is what brings one.`;
949
+ return `${head}${fix} Delegating WITHOUT --session works with the bridge you have.`;
950
+ }
951
+ /**
952
+ * The one state installing cannot fix, because the files are only half of it.
953
+ *
954
+ * VS Code replaces an extension's files immediately; the extension host in a
955
+ * window that is ALREADY OPEN goes on running the code it activated with until
956
+ * that window reloads. For an ordinary delegation that costs nothing — the old
957
+ * bridge handles the URI. For `--session` against a bridge that predated
958
+ * {@link SESSION_BRIDGE_VERSION} it costs the prompt: that code refuses the
959
+ * whole URI rather than ignoring the parameter, `open` succeeds anyway, and
960
+ * this verb would answer `sent` about a message nobody received.
961
+ *
962
+ * So it refuses once, and only once — the reload (or the next window) is all
963
+ * that stands between the person and a working send. `cause` is what tells the
964
+ * app this `bridge-not-installed` wants a reload rather than its Install
965
+ * button: the install has already happened.
966
+ */
967
+ function staleWindow(name, replaced, now, dryRun) {
968
+ const installed = now === null || now === "" ? "the bundled bridge" : now;
969
+ return (`${dryRun ? `Installing ${installed} into ${name} would replace` : `The Pharos bridge in ${name} was just replaced with ${installed}, over`} `
970
+ + `${replaced}, which predates \`--session\` (${SESSION_BRIDGE_VERSION}). A ${name} window that is `
971
+ + `already open still runs ${replaced} until it reloads, and ${replaced} does not ignore a session in `
972
+ + "the URI — it refuses the whole thing, so the prompt would be reported as sent and never arrive. "
973
+ + (dryRun
974
+ ? "A real run would install it and then refuse once, for this reason: reload that window "
975
+ + "(Developer: Reload Window) or open a new one first. Delegating WITHOUT --session would work "
976
+ + "either way."
977
+ : "Reload that window (Developer: Reload Window) or open a new one, then run this again. "
978
+ + "Delegating WITHOUT --session works either way."));
979
+ }
674
980
  /**
675
981
  * The URI the bridge handles.
676
982
  *
@@ -703,31 +1009,35 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
703
1009
  }
704
1010
  const extensions = await deps.installedExtensions(host, env);
705
1011
  const bridge = extensions?.find((extension) => extension.id === BRIDGE_EXTENSION_ID);
706
- if (bridge === undefined) {
1012
+ const bundled = await deps.bundledBridge(env);
1013
+ const shipped = bundled?.manifest.version ?? null;
1014
+ // Nothing installed and nothing to install with: the one state this route can
1015
+ // do nothing about. A package that ships no `.vsix` is a real state — the
1016
+ // bridge's build writes it and a checkout that has not run it has none — so
1017
+ // it is named rather than reported as an absent extension, which would send
1018
+ // somebody to an install that refuses for a different reason.
1019
+ if (bridge === undefined && bundled === null) {
707
1020
  const directory = extensionsDirectory(host, env) ?? "the extensions directory";
708
1021
  return refuse("bridge-not-installed", `The Pharos bridge (${BRIDGE_EXTENSION_ID}) is not installed in ${name} — looked in ${directory}`
709
1022
  + (extensions === null ? ", which does not exist: that VS Code has never run" : "")
710
- + ". `pharos setup --install vscode-bridge` installs the one this pharos ships.");
711
- }
712
- // **A bridge older than `--session` does not ignore it — it refuses the whole
713
- // URI.** It rejects any query key it does not know (the guard that makes a
714
- // single-encoded `&` in a prompt loud instead of silently truncating one), so
715
- // an unexpected `session` trips it and the prompt never reaches the editor.
716
- // Nothing comes back from VS Code to say so — `open` succeeds either way — so
717
- // without this check the verb answers `sent` about a message that was thrown
718
- // away, and the app tells the person it was delivered. Every machine that
719
- // installed the bridge before this release carries one of these.
720
- //
721
- // `bridge-not-installed` rather than a new reason id: the fix the app already
722
- // renders for it — `pharos setup --install vscode-bridge` — is exactly the
723
- // fix for this, and a reason id is a contract the app decodes.
724
- if (pick !== undefined && !bridgeAtLeast(bridge.version, SESSION_BRIDGE_VERSION)) {
725
- return refuse("bridge-not-installed", `The Pharos bridge installed in ${name} is ${bridge.version}, which predates \`--session\` — that `
726
- + `arrived in ${SESSION_BRIDGE_VERSION}. It would not ignore the session in the URI: it refuses `
727
- + "any query key it does not know, so the prompt would never reach the editor and this would have "
728
- + "reported it as sent. `pharos setup --install vscode-bridge` replaces it with the one this pharos "
729
- + "ships. Delegating WITHOUT --session works with the bridge you have.");
1023
+ + `. This pharos ships no bridge to install either (${bundledVsixPath(env)} is absent), so there is `
1024
+ + "nothing here to put in: `pnpm --filter ./packages/pharos-vscode build` writes one in a checkout, "
1025
+ + "and a published pharos packs it.");
730
1026
  }
1027
+ // **What this pharos ships goes in, on the way.** Absent, or older than the
1028
+ // bundled one, and the same verified installer `setup --install
1029
+ // vscode-bridge` runs is run first — for this flavour only. That is the whole
1030
+ // of #1394: `npm i -g` runs no installer, so before this a CLI update carried
1031
+ // a bridge that never reached an editor.
1032
+ //
1033
+ // A version that cannot be compared is left alone. `shipped` is null when the
1034
+ // package has a `.vsix` whose version could not be read at all, and replacing
1035
+ // a working bridge on a guess is the one move that can make a machine worse.
1036
+ const wanted = bridge === undefined
1037
+ ? "install"
1038
+ : shipped !== null && !bridgeAtLeast(bridge.version, shipped)
1039
+ ? "upgrade"
1040
+ : null;
731
1041
  const claudeExt = extensions?.find((extension) => extension.id === CLAUDE_EXTENSION_ID);
732
1042
  const usesExtensionBinary = mode === "extension" && agent === "claude" && claudeExt !== undefined;
733
1043
  // **The extension route needs no agent on PATH.** The Claude Code extension
@@ -741,7 +1051,7 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
741
1051
  // Detect against the bundled binary when that is the only `claude` there, so
742
1052
  // the prediction still holds; the bridge does its own detection regardless.
743
1053
  const probePath = agent === "claude" ? agentPath ?? bundledClaude(claudeExt) : null;
744
- const { sessions, incomplete, detail: probeDetail } = await findSessions(agent, folder, deps.probes(env, probePath));
1054
+ const { sessions, incomplete, detail: probeDetail, carried } = await findSessions(agent, folder, deps.probes(env, probePath));
745
1055
  const ranked = rankSessions(sessions, host);
746
1056
  // **`--session <pid>` is pre-checked here AND honoured again by the bridge,
747
1057
  // and the two are independent on purpose.** The URI is one-way: nothing comes
@@ -764,12 +1074,100 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
764
1074
  return refuse("session-gone", sessionGone(pick.pid, agent, host, ranked, sessions, incomplete, probeDetail));
765
1075
  }
766
1076
  }
1077
+ // **The install happens HERE, after every refusal that is a pure fact.** An
1078
+ // agent off PATH and a pick that has vanished would both have refused anyway,
1079
+ // and provisioning somebody's editor on the way to a refusal is a side effect
1080
+ // on a call that delivered nothing. Past this line the only thing left to do
1081
+ // is open the URI.
1082
+ //
1083
+ // It costs a few seconds — `code --install-extension` spawns Electron — and
1084
+ // it costs them once per CLI update per flavour, on the call that is already
1085
+ // about that editor.
1086
+ let running = bridge?.version ?? null;
1087
+ /** The version this replaced, when it replaced one. Null on a fresh install. */
1088
+ let replaced = null;
1089
+ let leadNote = "";
1090
+ let tailNote = "";
1091
+ /** Why an upgrade did not happen, when one was tried. The version gate quotes it. */
1092
+ let upgradeFailure = null;
1093
+ if (wanted !== null) {
1094
+ const into = `${shipped ?? "the bridge this pharos ships"} into ${name}`;
1095
+ if (dryRun) {
1096
+ running = shipped;
1097
+ replaced = wanted === "upgrade" ? bridge.version : null;
1098
+ leadNote =
1099
+ `Would install ${into} first`
1100
+ + (replaced === null ? "" : `, replacing ${replaced}`)
1101
+ + " — the same checksum-verified `--install-extension --force` that `pharos setup --install "
1102
+ + "vscode-bridge` runs. ";
1103
+ }
1104
+ else {
1105
+ const outcome = await deps.installBridge(host, env);
1106
+ if (outcome.ok) {
1107
+ running = shipped;
1108
+ replaced = wanted === "upgrade" ? bridge.version : null;
1109
+ leadNote = `Installed the Pharos bridge ${into} first${replaced === null ? "" : `, replacing ${replaced}`}. `;
1110
+ }
1111
+ else if (wanted === "install") {
1112
+ // Nothing was there to fall back on, so this is the refusal the route
1113
+ // had before — with what was tried, rather than with an instruction to
1114
+ // try the same thing by hand.
1115
+ return refuse("bridge-not-installed", `The Pharos bridge (${BRIDGE_EXTENSION_ID}) is not installed in ${name}, and installing the one `
1116
+ + `this pharos ships${shipped === null ? "" : ` (${shipped})`} failed. ${outcome.detail}`
1117
+ + (outcome.command === null ? "" : ` The command was: ${outcome.command}.`)
1118
+ + " `pharos setup --install vscode-bridge` runs the same install on its own.");
1119
+ }
1120
+ else {
1121
+ // **A failed UPGRADE is not a refusal.** The bridge that was there is
1122
+ // still there and still serves this delegation; refusing would take
1123
+ // away something that worked before this release existed.
1124
+ upgradeFailure = outcome.detail;
1125
+ tailNote =
1126
+ ` This pharos ships ${shipped} and installing it over ${bridge.version} failed, so ${bridge.version} `
1127
+ + `handled this. ${outcome.detail}`;
1128
+ }
1129
+ }
1130
+ }
1131
+ // The version gate, now asked about the bridge that is actually in place —
1132
+ // which after the block above may be the one just installed, or the old one
1133
+ // an upgrade failed to replace. **A bridge older than `--session` does not
1134
+ // ignore it: it refuses the whole URI**, because it rejects any query key it
1135
+ // does not know (the guard that makes a single-encoded `&` in a prompt loud
1136
+ // instead of silently truncating one). Nothing comes back from VS Code to say
1137
+ // so — `open` succeeds either way — so without this the verb answers `sent`
1138
+ // about a message that was thrown away and the app tells the person it
1139
+ // arrived.
1140
+ //
1141
+ // `bridge-not-installed` rather than a new reason id: the fix the app already
1142
+ // renders for it is the fix for this, and a reason id is a contract it
1143
+ // decodes.
1144
+ if (pick !== undefined && !bridgeAtLeast(running ?? "", SESSION_BRIDGE_VERSION)) {
1145
+ return refuse("bridge-not-installed", sessionTooOld(name, running, shipped, upgradeFailure));
1146
+ }
1147
+ // …and the case installing cannot fix, because the files are only half of it.
1148
+ if (pick !== undefined && replaced !== null && !bridgeAtLeast(replaced, SESSION_BRIDGE_VERSION)) {
1149
+ return refuse("bridge-not-installed", staleWindow(name, replaced, running, dryRun), { cause: "stale-window" });
1150
+ }
1151
+ // A replacement a window may not have picked up yet is worth saying whenever
1152
+ // it happened — but only as a note, because for a delegation that names no
1153
+ // session the old bridge handles the URI perfectly well.
1154
+ if (replaced !== null) {
1155
+ tailNote +=
1156
+ ` A ${name} window that was already open keeps ${replaced} until it reloads (Developer: Reload Window);`
1157
+ + ` the next window gets ${running ?? "the new one"}. Either serves this delegation.`;
1158
+ }
767
1159
  const uri = delegateUri(spec.vscode.scheme, { folder, agent, mode, text, session: pick });
768
1160
  const agentName = nameOf(agent);
769
1161
  const claudeExtInstalled = mode === "extension" && agent === "claude" ? claudeExt !== undefined : true;
770
- // The bridge decides; this is the same detection it runs, so the prediction
771
- // is honest and the wording says whose call it is. One tense throughout —
1162
+ // The bridge decides; this is the detection it runs, so the prediction is
1163
+ // honest and the wording says whose call it is. One tense throughout —
772
1164
  // `would` in a dry run, plain past/present otherwise.
1165
+ //
1166
+ // One known gap in "the detection it runs" (#1436): this reads every Claude
1167
+ // registry on the machine, and the bridge reads only the one its editor
1168
+ // carries. They agree whenever a session's registry came from the editor —
1169
+ // which is the measured case — and disagree only when one terminal inside
1170
+ // it overrode `CLAUDE_CONFIG_DIR` itself.
773
1171
  const hand = dryRun ? "Would hand this to" : "Handed to";
774
1172
  const asked = pick?.kind !== "new"
775
1173
  ? ""
@@ -787,11 +1185,23 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
787
1185
  + `in an integrated terminal in ${folder}.`;
788
1186
  // A deliberate new session makes "a session may exist that was not seen"
789
1187
  // beside the point: one was not wanted. So the two notes are exclusive.
790
- const detail = pick?.kind === "new"
791
- ? `${core}${asked}`
792
- : session === null && incomplete
793
- ? `${core} Detection was incomplete (${probeDetail ?? "a probe failed"}); a running session may exist that could not be found.`
794
- : core;
1188
+ //
1189
+ // **What was done first leads and the caveats trail.** The app renders this
1190
+ // sentence as-is, and "Installed the Pharos bridge 0.3.0 into VS Code
1191
+ // Insiders first." is the fact somebody needs before the one about where the
1192
+ // prompt went.
1193
+ //
1194
+ // A session the bridge STARTS runs in one of this editor's terminals, so it
1195
+ // is the editor's environment that decides its login — said right after the
1196
+ // sentence about the start, and only when there is one.
1197
+ const detail = leadNote
1198
+ + (pick?.kind === "new"
1199
+ ? `${core}${asked}`
1200
+ : session === null && incomplete
1201
+ ? `${core} Detection was incomplete (${probeDetail ?? "a probe failed"}); a running session may exist that could not be found.`
1202
+ : core)
1203
+ + (session === null ? carriedNote(carried, host, env) : "")
1204
+ + tailNote;
795
1205
  if (!dryRun) {
796
1206
  try {
797
1207
  await deps.open(uri);
@@ -823,8 +1233,14 @@ async function delegateToVsCode(host, agent, mode, folder, text, agentPath, env,
823
1233
  * `automation-denied` with the Settings pane named.
824
1234
  */
825
1235
  async function delegateToTerminal(agent, folder, text, agentPath, env, deps, dryRun, pick, refuse, succeed) {
826
- const { sessions, incomplete, detail: probeDetail } = await findSessions(agent, folder, deps.probes(env, agent === "claude" ? agentPath : null));
1236
+ const { sessions, incomplete, detail: probeDetail, carried } = await findSessions(agent, folder, deps.probes(env, agent === "claude" ? agentPath : null));
827
1237
  const ranked = rankSessions(sessions, "terminal");
1238
+ // A new window's shell is started by Terminal, so it inherits Terminal's
1239
+ // environment — and with it, the login a session started there runs as.
1240
+ // On macOS 26 this is always empty: Terminal is a platform binary and `ps
1241
+ // -E` is shown none of its environment (see `delegate/sessions.ts`), so it
1242
+ // cannot be told, and nothing is said rather than a guess.
1243
+ const startedAs = carriedNote(carried, "terminal", env);
828
1244
  const rows = () => ranked.map((found, index) => toRow(found, index === 0));
829
1245
  let session;
830
1246
  if (pick === undefined) {
@@ -903,12 +1319,12 @@ async function delegateToTerminal(agent, folder, text, agentPath, env, deps, dry
903
1319
  session: null,
904
1320
  detail: `Opened a new Terminal window in ${folder} running ${agentName} with the prompt, because the `
905
1321
  + `${agentName} session attributed to Terminal (pid ${session.pid}) holds no tab on tty `
906
- + `${session.tty} that could be typed into.`,
1322
+ + `${session.tty} that could be typed into.${startedAs}`,
907
1323
  });
908
1324
  }
909
1325
  const opened = dryRun ? "Would open" : "Opened";
910
1326
  const detail = `${opened} a new Terminal window in ${folder} running ${agentName} with the prompt.`
911
- + `${askedNote}${elsewhereNote}${incompleteNote}`;
1327
+ + `${startedAs}${askedNote}${elsewhereNote}${incompleteNote}`;
912
1328
  if (dryRun) {
913
1329
  return succeed({ action: "launched", session: null, detail, sessions: rows() });
914
1330
  }
@@ -948,12 +1364,24 @@ function describeStatus(session) {
948
1364
  const parts = [session.status ?? "status unknown", `pid ${session.pid}`];
949
1365
  if (session.tty !== null)
950
1366
  parts.push(session.tty);
1367
+ // A send is unchanged by where the session was registered — it is a pid and
1368
+ // a tty like any other — but the person should still see which login it
1369
+ // landed in when that is not the default.
1370
+ if (session.profile !== undefined)
1371
+ parts.push(`profile ${session.profile}`);
951
1372
  return ` (${parts.join(", ")})`;
952
1373
  }
953
1374
  function report(session) {
954
1375
  if (session === null)
955
1376
  return null;
956
- return { pid: session.pid, cwd: session.cwd, name: session.name, status: session.status, tty: session.tty };
1377
+ return {
1378
+ pid: session.pid,
1379
+ cwd: session.cwd,
1380
+ name: session.name,
1381
+ status: session.status,
1382
+ tty: session.tty,
1383
+ ...(session.profile === undefined ? {} : { profile: session.profile }),
1384
+ };
957
1385
  }
958
1386
  function emitOutcome(io, pretty, outcome) {
959
1387
  return pretty ? emitText(io, outcome.detail) : emit(io, outcome);