balladeer 1.0.8 → 1.0.11

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,10 @@ 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 { PUBLISHED_SPECIFIER } from "./release.js";
23
+ import { installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
23
24
  import { updateNotice } from "./currency.js";
25
+ import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
24
26
  import { StoreError, normalizeControlPlane } from "./store.js";
25
27
  import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
26
28
  const USAGE = `balladeer ${CLI_VERSION}
@@ -31,13 +33,19 @@ const USAGE = `balladeer ${CLI_VERSION}
31
33
  it decides per folder, from the git remote, which connection applies.
32
34
  Pairs nothing and changes no workspace, repository, or CI configuration.
33
35
 
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.
36
+ balladeer update
37
+ Move the durable balladeer command to the latest published release. The
38
+ hooks and MCP entries already run the latest release on their own.
39
39
 
40
- ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
40
+ balladeer remove-earlier
41
+ Take the earlier Balladeer (the July client) off this machine and out of
42
+ this repository: its MCP server under the same name, its hooks and status
43
+ line, its guidance blocks and managed files (under your home and in this
44
+ repository's CLAUDE.md or AGENTS.md), and its 0.x command. A file git
45
+ does not track is copied beside itself first; a tracked one is for you
46
+ to commit. setup runs this itself when it finds one.
47
+
48
+ ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--no-open] [--control-plane <url>]
41
49
  [--create-workspace <name>] [--choose-workspace] [--refresh] [--force]
42
50
  [--client codex|claude]
43
51
  [--existing]
@@ -68,9 +76,12 @@ const USAGE = `balladeer ${CLI_VERSION}
68
76
  other entry in those files alone, and prints what it changed. Installing
69
77
  the command may contact npm; refresh never contacts Balladeer. Run it when
70
78
  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.
79
+ At a terminal with a person at it, the approval page opens in their
80
+ browser and the run waits for the approval; --no-open prints the link
81
+ instead. An agent shell, or --json, always prints the link and returns.
82
+ --force sets up alongside an earlier Balladeer instead of removing it.
83
+ Without it, a run that finds the older client takes it off first, with a
84
+ copy of every file it changes kept beside the file.
74
85
  Claude desktop chat is connected too, under a key named for this
75
86
  repository, wherever the app is installed. --claude-desktop asks for it by
76
87
  name, so a machine with no Claude desktop on it says so instead of
@@ -167,7 +178,7 @@ const USAGE = `balladeer ${CLI_VERSION}
167
178
  Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
168
179
  claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
169
180
  5 transport or server failure, 6 an earlier Balladeer is still installed on this
170
- machine and nothing was changed.
181
+ machine and part of it could not be removed.
171
182
  `;
172
183
  const SUBCOMMANDS = { agent: ["rotate"] };
173
184
  export function parseArguments(argv) {
@@ -184,6 +195,7 @@ export function parseArguments(argv) {
184
195
  let event;
185
196
  let json = false;
186
197
  let wait = false;
198
+ let noOpen = false;
187
199
  let refresh = false;
188
200
  let client;
189
201
  let chooseWorkspace = false;
@@ -197,7 +209,6 @@ export function parseArguments(argv) {
197
209
  let runner;
198
210
  let controlPlane;
199
211
  let repo;
200
- let repoScope = false;
201
212
  let file;
202
213
  let owner;
203
214
  let createWorkspace;
@@ -257,6 +268,8 @@ export function parseArguments(argv) {
257
268
  runner = value("--runner", inline);
258
269
  else if (name === "--wait")
259
270
  wait = true;
271
+ else if (name === "--no-open")
272
+ noOpen = true;
260
273
  else if (name === "--refresh")
261
274
  refresh = true;
262
275
  else if (name === "--choose-workspace")
@@ -277,8 +290,6 @@ export function parseArguments(argv) {
277
290
  claudeDesktop = false;
278
291
  else if (name === "--control-plane")
279
292
  controlPlane = value("--control-plane", inline);
280
- else if (name === "--repo" && (command === "quiet" || command === "track"))
281
- repoScope = true;
282
293
  else if (name === "--repo")
283
294
  repo = value("--repo", inline);
284
295
  else if (name === "--file")
@@ -373,6 +384,7 @@ export function parseArguments(argv) {
373
384
  subcommand,
374
385
  json,
375
386
  wait,
387
+ noOpen,
376
388
  refresh,
377
389
  client,
378
390
  chooseWorkspace,
@@ -380,7 +392,6 @@ export function parseArguments(argv) {
380
392
  existingOnly,
381
393
  claudeDesktop,
382
394
  repo,
383
- repoScope,
384
395
  file,
385
396
  repository: repositories[0],
386
397
  repositories,
@@ -418,64 +429,114 @@ export async function main(argv) {
418
429
  process.stderr.write(`${notice}\n`);
419
430
  return code;
420
431
  }
432
+ /** What `balladeer --version` answers on this PATH, or nothing. */
433
+ function commandVersion(name) {
434
+ const result = spawnSync(name, ["--version"], { encoding: "utf8", env: process.env });
435
+ const text = result.status === 0 ? result.stdout.trim() : "";
436
+ return /^\d+\.\d+\.\d+/.test(text) ? text : undefined;
437
+ }
438
+ /**
439
+ * Once per laptop: the durable command, then the same server and hook
440
+ * registered where every session on this machine reads them, naming that
441
+ * command by path so a session start costs node's startup and no npx. When
442
+ * the durable install is refused, the entries are still written, on the
443
+ * `npx` form of the current major: a blocked PATH must not keep a laptop from
444
+ * getting them. Nothing here needs approving in the coding host; Claude Code
445
+ * runs hooks from a person's own settings as they are.
446
+ */
447
+ function installEverything(options) {
448
+ const published = !runningFromCheckout();
449
+ let writes;
450
+ const register = (installed) => {
451
+ writes = installUserScope({
452
+ environment: process.env,
453
+ published,
454
+ ...(installed === undefined ? {} : { installed }),
455
+ });
456
+ return writes;
457
+ };
458
+ const code = runInstall({
459
+ environment: process.env,
460
+ json: options.json,
461
+ write: options.write,
462
+ allowPartial: options.allowPartial,
463
+ onInstalled: (result) => ({ userScope: register(result.command) }),
464
+ });
465
+ if (writes === undefined) {
466
+ const fallback = register();
467
+ if (options.json)
468
+ options.write(`${JSON.stringify({ step: "user_scope", userScope: fallback })}\n`);
469
+ }
470
+ for (const w of writes ?? [])
471
+ if (!options.json)
472
+ options.write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
473
+ if (!options.json && (writes ?? []).some((w) => w.status === "written"))
474
+ options.write("Balladeer now runs in every Claude Code" +
475
+ ((writes ?? []).some((w) => w.host === "codex") ? " and Codex" : "") +
476
+ " 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");
477
+ return (writes ?? []).some((w) => w.status === "refused") ? 4 : code;
478
+ }
421
479
  async function dispatch(parsed, write) {
422
480
  switch (parsed.command) {
423
481
  case "explain":
424
482
  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(),
483
+ case "update": {
484
+ // The latest published copy installs itself as the durable command, so
485
+ // this is one child process and its own report. Old and new are read
486
+ // from the command on PATH, before and after.
487
+ const before = commandVersion("balladeer");
488
+ const result = spawnSync("npx", ["-y", PUBLISHED_SPECIFIER, "install"], {
489
+ stdio: "inherit",
490
+ env: process.env,
434
491
  });
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({
492
+ if (result.status !== 0) {
493
+ write(`The update did not finish (exit ${result.status ?? "signal"}). Nothing else was changed.\n`);
494
+ return result.status ?? 5;
495
+ }
496
+ const after = commandVersion("balladeer");
497
+ write(before === after
498
+ ? `balladeer is already ${after ?? "installed"}.\n`
499
+ : `balladeer ${before ?? "(not on PATH)"} is now ${after ?? "installed"}.\n`);
500
+ return 0;
501
+ }
502
+ case "remove-earlier": {
503
+ const earlier = findEarlier({
504
+ home: userHome(process.env),
505
+ controlPlane: parsed.controlPlane,
443
506
  environment: process.env,
444
- json: parsed.json,
445
- write,
446
- extra: { userScope: writes },
507
+ cwd: process.cwd(),
447
508
  });
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`);
509
+ if (earlier.length === 0) {
510
+ write("No earlier Balladeer is on this machine.\n");
458
511
  return 0;
459
512
  }
460
- catch (error) {
461
- write(`${error instanceof Error ? error.message : String(error)}\n`);
462
- return 4;
463
- }
513
+ write("An earlier Balladeer is on this machine:\n");
514
+ for (const line of describeEarlier(earlier))
515
+ write(`${line}\n`);
516
+ const report = removeEarlier(earlier);
517
+ for (const line of describeRemoval(report))
518
+ write(`${line}\n`);
519
+ if (parsed.json)
520
+ write(`${JSON.stringify({ step: "earlier", removed: report.removed, failed: report.failed })}\n`);
521
+ return report.failed.length === 0 ? 0 : 6;
464
522
  }
523
+ case "install":
524
+ return installEverything({ json: parsed.json, write, allowPartial: false });
465
525
  case "setup":
466
526
  return runSetup({
467
527
  installCommand: () => {
468
- const result = runInstall({
469
- environment: process.env,
470
- json: parsed.json,
471
- write,
472
- allowPartial: true,
473
- });
528
+ const result = installEverything({ json: parsed.json, write, allowPartial: true });
474
529
  return process.platform === "win32" ? 0 : result;
475
530
  },
476
531
  controlPlane: parsed.controlPlane,
477
532
  json: parsed.json,
478
533
  wait: parsed.wait,
534
+ // A person is here when both ends of the terminal are a TTY; an agent
535
+ // shell has neither and keeps the print-and-return behaviour.
536
+ interactive: !parsed.noOpen &&
537
+ !parsed.json &&
538
+ process.stdout.isTTY === true &&
539
+ process.stdin.isTTY === true,
479
540
  refresh: parsed.refresh,
480
541
  chooseWorkspace: parsed.chooseWorkspace,
481
542
  ...(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,15 @@ 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
+ openLink?: (url: string) => boolean;
5
14
  repo?: string;
6
15
  createWorkspace?: string;
7
16
  client?: "codex" | "claude";
@@ -93,6 +102,12 @@ export declare function chooseRepositories(named: readonly string[], cwd: string
93
102
  * a command that blocks for minutes and the pairing would be lost with it.
94
103
  */
95
104
  export declare function runSetup(options: SetupOptions): Promise<number>;
105
+ /**
106
+ * The link, opened for a person at the terminal and for nobody else. Whether a
107
+ * browser appeared is not known here; the line beside the link says what to do
108
+ * if none did.
109
+ */
110
+ export declare function openForPerson(options: SetupOptions, url: string): boolean;
96
111
  /**
97
112
  * Which invocation this repair writes, decided without asking anybody.
98
113
  *