balladeer 1.0.8 → 1.0.12

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/dist/agent.d.ts CHANGED
@@ -41,7 +41,7 @@ export type AgentSelection = Readonly<{
41
41
  * points somewhere other than the control plane it belongs to is refused: a
42
42
  * server response must not be able to send this credential to a new host.
43
43
  */
44
- export declare function selectAgent(agents: readonly StoredAgent[], controlPlane: string, repositoryId: string | undefined, currentRepository: string): AgentSelection;
44
+ export declare function selectAgent(agents: readonly StoredAgent[], controlPlane: string, repositoryId: string | undefined, currentRepository: string | readonly string[]): AgentSelection;
45
45
  /**
46
46
  * What a command says when this machine holds no credential for the repository.
47
47
  *
package/dist/agent.js CHANGED
@@ -20,6 +20,18 @@ const REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
20
20
  */
21
21
  export function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
22
22
  const here = agents.filter((agent) => agent.controlPlane === controlPlane);
23
+ // Several remotes, as `repositoryHints` lists them: the first with a
24
+ // connection is the folder's repository. One string is the older one-remote
25
+ // form and every caller of it still works.
26
+ if (typeof currentRepository !== "string") {
27
+ const candidates = currentRepository.filter((name) => REPOSITORY_NAME.test(name) && name !== "unknown/unknown");
28
+ for (const name of candidates) {
29
+ const found = selectAgent(agents, controlPlane, repositoryId, name);
30
+ if (found.kind !== "refused")
31
+ return found;
32
+ }
33
+ return selectAgent(agents, controlPlane, repositoryId, candidates[0] ?? "unknown/unknown");
34
+ }
23
35
  if (repositoryId !== undefined) {
24
36
  const named = here.find((agent) => agent.repositoryId === repositoryId ||
25
37
  agent.repository?.toLowerCase() === repositoryId.toLowerCase());
package/dist/cli.d.ts CHANGED
@@ -4,11 +4,13 @@ type Parsed = Readonly<{
4
4
  subcommand: string | undefined;
5
5
  json: boolean;
6
6
  wait: boolean;
7
+ /** `setup --no-open`: print the approval link instead of opening it. */
8
+ noOpen: boolean;
7
9
  /** Repair the files a previous setup wrote, and do nothing else. */
8
10
  refresh: boolean;
9
11
  client: "codex" | "claude" | undefined;
10
12
  chooseWorkspace: boolean;
11
- /** `setup --force`: set up even though an earlier Balladeer is still installed. */
13
+ /** `setup --force`: set up alongside an earlier Balladeer instead of removing it. */
12
14
  force: boolean;
13
15
  /** `setup --existing`: connect only when this checkout is already enrolled. */
14
16
  existingOnly: boolean;
@@ -19,7 +21,6 @@ type Parsed = Readonly<{
19
21
  */
20
22
  claudeDesktop: boolean | undefined;
21
23
  repo: string | undefined;
22
- repoScope: boolean;
23
24
  file: string | undefined;
24
25
  repository: string | undefined;
25
26
  /**
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
2
3
  import { realpathSync } from "node:fs";
3
4
  import { resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
@@ -18,9 +19,11 @@ import { runStatus } from "./commands/status.js";
18
19
  import { runTouchMap } from "./commands/touch-map.js";
19
20
  import { runWhoami } from "./commands/whoami.js";
20
21
  import { runInstall } from "./install.js";
21
- import { installUserScope, runningFromCheckout } from "./user-scope.js";
22
- import { setQuiet } from "./quiet.js";
22
+ import { openInBrowser } from "./open-browser.js";
23
+ import { PUBLISHED_SPECIFIER } from "./release.js";
24
+ import { installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
23
25
  import { updateNotice } from "./currency.js";
26
+ import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
24
27
  import { StoreError, normalizeControlPlane } from "./store.js";
25
28
  import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
26
29
  const USAGE = `balladeer ${CLI_VERSION}
@@ -31,13 +34,19 @@ const USAGE = `balladeer ${CLI_VERSION}
31
34
  it decides per folder, from the git remote, which connection applies.
32
35
  Pairs nothing and changes no workspace, repository, or CI configuration.
33
36
 
34
- ${CLI_INVOCATION} quiet [--repo]
35
- ${CLI_INVOCATION} track [--repo]
36
- In a folder Balladeer does not track, each session hears that once. quiet
37
- stops it for this folder (or --repo, for every checkout of this
38
- repository); track undoes it.
37
+ balladeer update
38
+ Move the durable balladeer command to the latest published release. The
39
+ hooks and MCP entries already run the latest release on their own.
39
40
 
40
- ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
41
+ balladeer remove-earlier
42
+ Take the earlier Balladeer (the July client) off this machine and out of
43
+ this repository: its MCP server under the same name, its hooks and status
44
+ line, its guidance blocks and managed files (under your home and in this
45
+ repository's CLAUDE.md or AGENTS.md), and its 0.x command. A file git
46
+ does not track is copied beside itself first; a tracked one is for you
47
+ to commit. setup runs this itself when it finds one.
48
+
49
+ ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--no-open] [--control-plane <url>]
41
50
  [--create-workspace <name>] [--choose-workspace] [--refresh] [--force]
42
51
  [--client codex|claude]
43
52
  [--existing]
@@ -68,9 +77,12 @@ const USAGE = `balladeer ${CLI_VERSION}
68
77
  other entry in those files alone, and prints what it changed. Installing
69
78
  the command may contact npm; refresh never contacts Balladeer. Run it when
70
79
  Balladeer says a newer version is available.
71
- --force sets up even though an earlier Balladeer is still installed on
72
- this machine. Without it, a run that finds the older client's machine-wide
73
- MCP entry or session hook stops and says to remove it first.
80
+ At a terminal with a person at it, the approval page opens in their
81
+ browser and the run waits for the approval; --no-open prints the link
82
+ instead. An agent shell, or --json, always prints the link and returns.
83
+ --force sets up alongside an earlier Balladeer instead of removing it.
84
+ Without it, a run that finds the older client takes it off first, with a
85
+ copy of every file it changes kept beside the file.
74
86
  Claude desktop chat is connected too, under a key named for this
75
87
  repository, wherever the app is installed. --claude-desktop asks for it by
76
88
  name, so a machine with no Claude desktop on it says so instead of
@@ -167,7 +179,7 @@ const USAGE = `balladeer ${CLI_VERSION}
167
179
  Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
168
180
  claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
169
181
  5 transport or server failure, 6 an earlier Balladeer is still installed on this
170
- machine and nothing was changed.
182
+ machine and part of it could not be removed.
171
183
  `;
172
184
  const SUBCOMMANDS = { agent: ["rotate"] };
173
185
  export function parseArguments(argv) {
@@ -184,6 +196,7 @@ export function parseArguments(argv) {
184
196
  let event;
185
197
  let json = false;
186
198
  let wait = false;
199
+ let noOpen = false;
187
200
  let refresh = false;
188
201
  let client;
189
202
  let chooseWorkspace = false;
@@ -197,7 +210,6 @@ export function parseArguments(argv) {
197
210
  let runner;
198
211
  let controlPlane;
199
212
  let repo;
200
- let repoScope = false;
201
213
  let file;
202
214
  let owner;
203
215
  let createWorkspace;
@@ -257,6 +269,8 @@ export function parseArguments(argv) {
257
269
  runner = value("--runner", inline);
258
270
  else if (name === "--wait")
259
271
  wait = true;
272
+ else if (name === "--no-open")
273
+ noOpen = true;
260
274
  else if (name === "--refresh")
261
275
  refresh = true;
262
276
  else if (name === "--choose-workspace")
@@ -277,8 +291,6 @@ export function parseArguments(argv) {
277
291
  claudeDesktop = false;
278
292
  else if (name === "--control-plane")
279
293
  controlPlane = value("--control-plane", inline);
280
- else if (name === "--repo" && (command === "quiet" || command === "track"))
281
- repoScope = true;
282
294
  else if (name === "--repo")
283
295
  repo = value("--repo", inline);
284
296
  else if (name === "--file")
@@ -373,6 +385,7 @@ export function parseArguments(argv) {
373
385
  subcommand,
374
386
  json,
375
387
  wait,
388
+ noOpen,
376
389
  refresh,
377
390
  client,
378
391
  chooseWorkspace,
@@ -380,7 +393,6 @@ export function parseArguments(argv) {
380
393
  existingOnly,
381
394
  claudeDesktop,
382
395
  repo,
383
- repoScope,
384
396
  file,
385
397
  repository: repositories[0],
386
398
  repositories,
@@ -418,64 +430,116 @@ export async function main(argv) {
418
430
  process.stderr.write(`${notice}\n`);
419
431
  return code;
420
432
  }
433
+ /** What `balladeer --version` answers on this PATH, or nothing. */
434
+ function commandVersion(name) {
435
+ const result = spawnSync(name, ["--version"], { encoding: "utf8", env: process.env });
436
+ const text = result.status === 0 ? result.stdout.trim() : "";
437
+ return /^\d+\.\d+\.\d+/.test(text) ? text : undefined;
438
+ }
439
+ /**
440
+ * Once per laptop: the durable command, then the same server and hook
441
+ * registered where every session on this machine reads them, naming that
442
+ * command by path so a session start costs node's startup and no npx. When
443
+ * the durable install is refused, the entries are still written, on the
444
+ * `npx` form of the current major: a blocked PATH must not keep a laptop from
445
+ * getting them. Nothing here needs approving in the coding host; Claude Code
446
+ * runs hooks from a person's own settings as they are.
447
+ */
448
+ function installEverything(options) {
449
+ const published = !runningFromCheckout();
450
+ let writes;
451
+ const register = (installed) => {
452
+ writes = installUserScope({
453
+ environment: process.env,
454
+ published,
455
+ ...(installed === undefined ? {} : { installed }),
456
+ });
457
+ return writes;
458
+ };
459
+ const code = runInstall({
460
+ environment: process.env,
461
+ json: options.json,
462
+ write: options.write,
463
+ allowPartial: options.allowPartial,
464
+ onInstalled: (result) => ({ userScope: register(result.command) }),
465
+ });
466
+ if (writes === undefined) {
467
+ const fallback = register();
468
+ if (options.json)
469
+ options.write(`${JSON.stringify({ step: "user_scope", userScope: fallback })}\n`);
470
+ }
471
+ for (const w of writes ?? [])
472
+ if (!options.json)
473
+ options.write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
474
+ if (!options.json && (writes ?? []).some((w) => w.status === "written"))
475
+ options.write("Balladeer now runs in every Claude Code" +
476
+ ((writes ?? []).some((w) => w.host === "codex") ? " and Codex" : "") +
477
+ " session on this machine. In a folder of a connected repository it works as before; anywhere else it stays quiet, and `balladeer status` there says why.\n");
478
+ return (writes ?? []).some((w) => w.status === "refused") ? 4 : code;
479
+ }
421
480
  async function dispatch(parsed, write) {
422
481
  switch (parsed.command) {
423
482
  case "explain":
424
483
  return runExplain(write, parsed.controlPlane);
425
- case "install": {
426
- // Once per laptop: the same server and hook, registered where every
427
- // session on this machine reads them, deciding per folder at run time.
428
- // Registered first and independently: the entries run through npx and
429
- // need no durable command, so a blocked PATH must not keep a laptop
430
- // from getting them.
431
- const writes = installUserScope({
432
- environment: process.env,
433
- published: !runningFromCheckout(),
484
+ case "update": {
485
+ // The latest published copy installs itself as the durable command, so
486
+ // this is one child process and its own report. Old and new are read
487
+ // from the command on PATH, before and after.
488
+ const before = commandVersion("balladeer");
489
+ const result = spawnSync("npx", ["-y", PUBLISHED_SPECIFIER, "install"], {
490
+ stdio: "inherit",
491
+ env: process.env,
434
492
  });
435
- for (const w of writes)
436
- if (!parsed.json)
437
- write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
438
- if (!parsed.json && writes.some((w) => w.status === "written"))
439
- write("Balladeer now runs in every Claude Code" +
440
- (writes.some((w) => w.host === "codex") ? " and Codex" : "") +
441
- " session on this machine. In a folder of a connected repository it works as before; anywhere else it says once that the folder isn't tracked. Approve the new hook when your coding host asks.\n");
442
- const code = runInstall({
493
+ if (result.status !== 0) {
494
+ write(`The update did not finish (exit ${result.status ?? "signal"}). Nothing else was changed.\n`);
495
+ return result.status ?? 5;
496
+ }
497
+ const after = commandVersion("balladeer");
498
+ write(before === after
499
+ ? `balladeer is already ${after ?? "installed"}.\n`
500
+ : `balladeer ${before ?? "(not on PATH)"} is now ${after ?? "installed"}.\n`);
501
+ return 0;
502
+ }
503
+ case "remove-earlier": {
504
+ const earlier = findEarlier({
505
+ home: userHome(process.env),
506
+ controlPlane: parsed.controlPlane,
443
507
  environment: process.env,
444
- json: parsed.json,
445
- write,
446
- extra: { userScope: writes },
508
+ cwd: process.cwd(),
447
509
  });
448
- return writes.some((w) => w.status === "refused") ? 4 : code;
449
- }
450
- case "quiet":
451
- case "track": {
452
- const scope = parsed.repoScope ? "repo" : "folder";
453
- try {
454
- const { key, changed } = setQuiet(process.cwd(), scope, parsed.command === "quiet", process.env);
455
- write(parsed.command === "quiet"
456
- ? `${changed ? "Balladeer will stay quiet" : "Balladeer was already quiet"} in ${scope === "repo" ? `every checkout of ${key}` : key}. Run \`balladeer track${scope === "repo" ? " --repo" : ""}\` here to undo.\n`
457
- : `${changed ? "Balladeer will speak again" : "Balladeer was not quiet"} in ${scope === "repo" ? `checkouts of ${key}` : key}.\n`);
510
+ if (earlier.length === 0) {
511
+ write("No earlier Balladeer is on this machine.\n");
458
512
  return 0;
459
513
  }
460
- catch (error) {
461
- write(`${error instanceof Error ? error.message : String(error)}\n`);
462
- return 4;
463
- }
514
+ write("An earlier Balladeer is on this machine:\n");
515
+ for (const line of describeEarlier(earlier))
516
+ write(`${line}\n`);
517
+ const report = removeEarlier(earlier);
518
+ for (const line of describeRemoval(report))
519
+ write(`${line}\n`);
520
+ if (parsed.json)
521
+ write(`${JSON.stringify({ step: "earlier", removed: report.removed, failed: report.failed })}\n`);
522
+ return report.failed.length === 0 ? 0 : 6;
464
523
  }
524
+ case "install":
525
+ return installEverything({ json: parsed.json, write, allowPartial: false });
465
526
  case "setup":
466
527
  return runSetup({
467
528
  installCommand: () => {
468
- const result = runInstall({
469
- environment: process.env,
470
- json: parsed.json,
471
- write,
472
- allowPartial: true,
473
- });
529
+ const result = installEverything({ json: parsed.json, write, allowPartial: true });
474
530
  return process.platform === "win32" ? 0 : result;
475
531
  },
476
532
  controlPlane: parsed.controlPlane,
477
533
  json: parsed.json,
478
534
  wait: parsed.wait,
535
+ // A person is here when both ends of the terminal are a TTY; an agent
536
+ // shell has neither and keeps the print-and-return behaviour.
537
+ // A person at the terminal waits ten minutes; an agent's shell, whose
538
+ // tool may cut the command at two, waits ninety seconds. Both open the
539
+ // page, because both run on the person's laptop; --no-open and --json
540
+ // print the link only.
541
+ interactive: process.stdout.isTTY === true && process.stdin.isTTY === true,
542
+ ...(parsed.noOpen ? {} : { openLink: openInBrowser }),
479
543
  refresh: parsed.refresh,
480
544
  chooseWorkspace: parsed.chooseWorkspace,
481
545
  ...(parsed.client === undefined ? {} : { client: parsed.client }),
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { callAgentTool, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
3
3
  import { batchProposalReviewLink, proposalReviewLink } from "../client.js";
4
4
  import { commandLine } from "../release.js";
5
- import { repositoryHint } from "../repository.js";
5
+ import { repositoryHint, repositoryHints } from "../repository.js";
6
6
  import { StoreError, findSession, readCredentials } from "../store.js";
7
7
  import { CLI_INVOCATION } from "../wire.js";
8
8
  import { readTeachBackFile } from "./propose.js";
@@ -217,7 +217,7 @@ export async function runDiscover(options) {
217
217
  catch (error) {
218
218
  return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
219
219
  }
220
- const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHint(options.cwd));
220
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
221
221
  if (selection.kind === "refused") {
222
222
  return fail("no_agent_connection", `${selection.reason} Run \`${commandLine(null, "setup")}\` in this repository first. Nothing was sent.`, 4);
223
223
  }
@@ -1,5 +1,13 @@
1
1
  import { type GuidanceEvent } from "../guidance.js";
2
- /** What a session hears, once, in a folder Balladeer is not tracking. */
2
+ /**
3
+ * What an agent, or `status`, is told about a folder Balladeer is not tracking.
4
+ *
5
+ * Robert, 22 September 2026: the session hook says nothing in such a folder.
6
+ * A customer with one repository has every other folder on the laptop
7
+ * untracked, and a line in each of those is noise in the wrong place. This
8
+ * sentence stays for the two readers who ask: `balladeer status` in the folder,
9
+ * and an agent that opens the empty catalog and wonders why.
10
+ */
3
11
  export declare function untrackedLine(remote: string): string;
4
12
  export type GuidanceOptions = Readonly<{
5
13
  controlPlane: string;
@@ -14,6 +22,8 @@ export type GuidanceOptions = Readonly<{
14
22
  error?: (text: string) => void;
15
23
  fetchImpl?: typeof fetch;
16
24
  now?: () => number;
25
+ /** Runs the background update; injected so tests never reach npm. */
26
+ startUpdate?: (command: string, args: readonly string[]) => void;
17
27
  }>;
18
28
  /** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
19
29
  export declare function runGuidance(options: GuidanceOptions): Promise<number>;
@@ -2,15 +2,22 @@ import { validateProjectGuidanceScope } from "../guidance-install.js";
2
2
  import { GUIDANCE_UNAVAILABLE, loadGuidance, recordGuidanceContext, } from "../guidance.js";
3
3
  import { readCredentials } from "../store.js";
4
4
  import { selectAgent } from "../agent.js";
5
- import { repositoryHint } from "../repository.js";
6
- import { isQuiet } from "../quiet.js";
7
- import { PUBLISHED_SPECIFIER } from "../release.js";
8
- /** What a session hears, once, in a folder Balladeer is not tracking. */
5
+ import { repositoryHints } from "../repository.js";
6
+ import { newerVersionPublished } from "../currency.js";
7
+ import { startSelfUpdateIfDue } from "../self-update.js";
8
+ /**
9
+ * What an agent, or `status`, is told about a folder Balladeer is not tracking.
10
+ *
11
+ * Robert, 22 September 2026: the session hook says nothing in such a folder.
12
+ * A customer with one repository has every other folder on the laptop
13
+ * untracked, and a line in each of those is noise in the wrong place. This
14
+ * sentence stays for the two readers who ask: `balladeer status` in the folder,
15
+ * and an agent that opens the empty catalog and wonders why.
16
+ */
9
17
  export function untrackedLine(remote) {
10
18
  const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
11
- return (`${here} isn't tracked by Balladeer, so promises can't be tracked from here. ` +
12
- `To track it, run \`npx -y ${PUBLISHED_SPECIFIER} setup\` in this folder. ` +
13
- `To stop this message here, run \`npx -y ${PUBLISHED_SPECIFIER} quiet\` (or \`quiet --repo\` for the whole repository).`);
19
+ return (`${here} isn't connected to Balladeer on this laptop, so nothing is tracked from here. ` +
20
+ `To connect it, run \`balladeer setup\` in this folder.`);
14
21
  }
15
22
  function readInput(stream) {
16
23
  return new Promise((resolve, reject) => {
@@ -90,15 +97,13 @@ export async function runGuidance(options) {
90
97
  ? credentials.filter((agent) => agent.controlPlane === options.controlPlane &&
91
98
  agent.repositoryId === options.repositoryId)
92
99
  : (() => {
93
- const selection = selectAgent(credentials, options.controlPlane, undefined, repositoryHint(options.cwd));
100
+ const selection = selectAgent(credentials, options.controlPlane, undefined, repositoryHints(options.cwd));
94
101
  return selection.kind === "refused" ? [] : [selection.agent];
95
102
  })();
96
103
  if (agents.length !== 1 || !agents[0]) {
97
104
  if (projectScoped)
98
105
  throw new Error("guidance_connection_unavailable");
99
- // Untracked. Said once, at session start, and never when silenced.
100
- if (event === "SessionStart" && !isQuiet(options.cwd, options.environment))
101
- emit(untrackedLine(repositoryHint(options.cwd)));
106
+ // Untracked: nothing is said. `status` in the folder says why.
102
107
  return 0;
103
108
  }
104
109
  const loaded = await loadGuidance({
@@ -107,6 +112,14 @@ export async function runGuidance(options) {
107
112
  timeoutMs: 750,
108
113
  ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
109
114
  });
115
+ // Behind? Start the install in the background, once a day at most, and
116
+ // carry on serving this session from the copy that is here.
117
+ if (event === "SessionStart")
118
+ startSelfUpdateIfDue(newerVersionPublished(), {
119
+ environment: options.environment,
120
+ ...(options.startUpdate ? { run: options.startUpdate } : {}),
121
+ ...(options.now ? { now: options.now } : {}),
122
+ });
110
123
  const sessionId = identifier(record.session_id);
111
124
  const agentId = identifier(record.agent_id);
112
125
  recordGuidanceContext({
@@ -2,7 +2,7 @@ import { createInterface } from "node:readline";
2
2
  import { installGuidanceLoader } from "../guidance-install.js";
3
3
  import { callAgentTool, forwarderHeaders, noAgentCredentialSentence, safeJson, selectAgent, structuredString, updateLine, } from "../agent.js";
4
4
  import { noteServerVersion, updateNotice } from "../currency.js";
5
- import { repositoryHint } from "../repository.js";
5
+ import { repositoryHint, repositoryHints } from "../repository.js";
6
6
  import { CLI_VERSION } from "../wire.js";
7
7
  import { untrackedLine } from "./guidance.js";
8
8
  import { StoreError, readCredentials } from "../store.js";
@@ -171,7 +171,7 @@ export async function runMcp(options) {
171
171
  options.error(`${error instanceof StoreError ? error.message : String(error)}\n`);
172
172
  return 4;
173
173
  }
174
- const selection = selectAgent(credentials.agents, options.controlPlane, options.repositoryId, repositoryHint(options.cwd));
174
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repositoryId, repositoryHints(options.cwd));
175
175
  if (selection.kind === "refused") {
176
176
  // Issuing one in the browser is what this used to suggest here, and it is the
177
177
  // move that produces this state: the bearer is shown once, there, and the
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, } from "../agent.js";
4
4
  import { inReadersZone } from "../local-time.js";
5
5
  import { commandLine } from "../release.js";
6
- import { repositoryHint } from "../repository.js";
6
+ import { repositoryHint, repositoryHints } from "../repository.js";
7
7
  import { StoreError, readCredentials } from "../store.js";
8
8
  import {} from "../wire.js";
9
9
  const PROMISE_ID = /^prom_[a-z0-9]{8,64}$/;
@@ -138,7 +138,7 @@ export async function runPrepare(options) {
138
138
  catch (error) {
139
139
  return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
140
140
  }
141
- const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHint(options.cwd));
141
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
142
142
  if (selection.kind === "refused") {
143
143
  if (selection.missingFor !== undefined) {
144
144
  return fail("no_agent_credential_on_this_machine", `${noAgentCredentialSentence(selection.missingFor)} Nothing was prepared.`, 4);
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
3
3
  import { proposalReviewLink } from "../client.js";
4
4
  import { commandLine } from "../release.js";
5
- import { repositoryHint } from "../repository.js";
5
+ import { repositoryHint, repositoryHints } from "../repository.js";
6
6
  import { StoreError, readCredentials } from "../store.js";
7
7
  import { CLI_INVOCATION } from "../wire.js";
8
8
  const MAX_FILE_BYTES = 64 * 1024;
@@ -215,7 +215,7 @@ export async function runPropose(options) {
215
215
  catch (error) {
216
216
  return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
217
217
  }
218
- const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHint(options.cwd));
218
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
219
219
  if (selection.kind === "refused") {
220
220
  // The repository being connected somewhere else is not this machine holding
221
221
  // a credential, and a proposal runs over the one this machine holds. Said in
@@ -1,6 +1,6 @@
1
1
  import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, } from "../agent.js";
2
2
  import { headCommit } from "../git.js";
3
- import { repositoryHint } from "../repository.js";
3
+ import { repositoryHint, repositoryHints } from "../repository.js";
4
4
  import { AGENT_SESSION_TRAILER, agentSessionTrailerLine, currentSession, readAgentSessionTrailer, } from "../session.js";
5
5
  import { StoreError, readCredentials } from "../store.js";
6
6
  import { CLI_INVOCATION } from "../wire.js";
@@ -43,7 +43,7 @@ export async function runSession(options) {
43
43
  return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
44
44
  }
45
45
  const here = options.repo ?? repositoryHint(options.cwd);
46
- const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, here);
46
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
47
47
  if (selection.kind === "refused") {
48
48
  const message = selection.missingFor === undefined
49
49
  ? selection.reason
@@ -2,6 +2,16 @@ export type SetupOptions = Readonly<{
2
2
  controlPlane: string;
3
3
  json: boolean;
4
4
  wait: boolean;
5
+ /**
6
+ * A person is at this terminal. The pairing link is opened in their browser
7
+ * and the run waits for the approval, so one command carries them through.
8
+ * Off in an agent shell, in `--json`, and under `--no-open`: those print the
9
+ * link and return. Robert, 22 September 2026.
10
+ */
11
+ interactive?: boolean;
12
+ /** Opens a page for the person; injected so tests never reach a browser.
13
+ * `null` is `--no-open`. */
14
+ openLink?: ((url: string) => boolean) | null;
5
15
  repo?: string;
6
16
  createWorkspace?: string;
7
17
  client?: "codex" | "claude";
@@ -93,6 +103,12 @@ export declare function chooseRepositories(named: readonly string[], cwd: string
93
103
  * a command that blocks for minutes and the pairing would be lost with it.
94
104
  */
95
105
  export declare function runSetup(options: SetupOptions): Promise<number>;
106
+ /**
107
+ * The link, opened for a person at the terminal and for nobody else. Whether a
108
+ * browser appeared is not known here; the line beside the link says what to do
109
+ * if none did.
110
+ */
111
+ export declare function openForPerson(options: SetupOptions, url: string): boolean;
96
112
  /**
97
113
  * Which invocation this repair writes, decided without asking anybody.
98
114
  *