balladeer 1.0.7 → 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;
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,17 +19,33 @@ 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";
22
+ import { PUBLISHED_SPECIFIER } from "./release.js";
23
+ import { installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
21
24
  import { updateNotice } from "./currency.js";
25
+ import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
22
26
  import { StoreError, normalizeControlPlane } from "./store.js";
23
27
  import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
24
28
  const USAGE = `balladeer ${CLI_VERSION}
25
29
 
26
30
  ${CLI_INVOCATION} install [--json]
27
- Install this exact release as the durable balladeer command, without pairing
28
- or changing any workspace, repository, or CI configuration. Use the latest
29
- npx bootstrap to upgrade an older global command.
31
+ Install this exact release as the durable balladeer command, and register
32
+ Balladeer once for every Claude Code and Codex session on this machine:
33
+ it decides per folder, from the git remote, which connection applies.
34
+ Pairs nothing and changes no workspace, repository, or CI configuration.
30
35
 
31
- ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
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
+
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>]
32
49
  [--create-workspace <name>] [--choose-workspace] [--refresh] [--force]
33
50
  [--client codex|claude]
34
51
  [--existing]
@@ -59,9 +76,12 @@ const USAGE = `balladeer ${CLI_VERSION}
59
76
  other entry in those files alone, and prints what it changed. Installing
60
77
  the command may contact npm; refresh never contacts Balladeer. Run it when
61
78
  Balladeer says a newer version is available.
62
- --force sets up even though an earlier Balladeer is still installed on
63
- this machine. Without it, a run that finds the older client's machine-wide
64
- 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.
65
85
  Claude desktop chat is connected too, under a key named for this
66
86
  repository, wherever the app is installed. --claude-desktop asks for it by
67
87
  name, so a machine with no Claude desktop on it says so instead of
@@ -158,7 +178,7 @@ const USAGE = `balladeer ${CLI_VERSION}
158
178
  Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
159
179
  claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
160
180
  5 transport or server failure, 6 an earlier Balladeer is still installed on this
161
- machine and nothing was changed.
181
+ machine and part of it could not be removed.
162
182
  `;
163
183
  const SUBCOMMANDS = { agent: ["rotate"] };
164
184
  export function parseArguments(argv) {
@@ -175,6 +195,7 @@ export function parseArguments(argv) {
175
195
  let event;
176
196
  let json = false;
177
197
  let wait = false;
198
+ let noOpen = false;
178
199
  let refresh = false;
179
200
  let client;
180
201
  let chooseWorkspace = false;
@@ -247,6 +268,8 @@ export function parseArguments(argv) {
247
268
  runner = value("--runner", inline);
248
269
  else if (name === "--wait")
249
270
  wait = true;
271
+ else if (name === "--no-open")
272
+ noOpen = true;
250
273
  else if (name === "--refresh")
251
274
  refresh = true;
252
275
  else if (name === "--choose-workspace")
@@ -361,6 +384,7 @@ export function parseArguments(argv) {
361
384
  subcommand,
362
385
  json,
363
386
  wait,
387
+ noOpen,
364
388
  refresh,
365
389
  client,
366
390
  chooseWorkspace,
@@ -405,26 +429,114 @@ export async function main(argv) {
405
429
  process.stderr.write(`${notice}\n`);
406
430
  return code;
407
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
+ }
408
479
  async function dispatch(parsed, write) {
409
480
  switch (parsed.command) {
410
481
  case "explain":
411
482
  return runExplain(write, parsed.controlPlane);
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,
491
+ });
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,
506
+ environment: process.env,
507
+ cwd: process.cwd(),
508
+ });
509
+ if (earlier.length === 0) {
510
+ write("No earlier Balladeer is on this machine.\n");
511
+ return 0;
512
+ }
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;
522
+ }
412
523
  case "install":
413
- return runInstall({ environment: process.env, json: parsed.json, write });
524
+ return installEverything({ json: parsed.json, write, allowPartial: false });
414
525
  case "setup":
415
526
  return runSetup({
416
527
  installCommand: () => {
417
- const result = runInstall({
418
- environment: process.env,
419
- json: parsed.json,
420
- write,
421
- allowPartial: true,
422
- });
528
+ const result = installEverything({ json: parsed.json, write, allowPartial: true });
423
529
  return process.platform === "win32" ? 0 : result;
424
530
  },
425
531
  controlPlane: parsed.controlPlane,
426
532
  json: parsed.json,
427
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,
428
540
  refresh: parsed.refresh,
429
541
  chooseWorkspace: parsed.chooseWorkspace,
430
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,4 +1,14 @@
1
1
  import { type GuidanceEvent } from "../guidance.js";
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
+ */
11
+ export declare function untrackedLine(remote: string): string;
2
12
  export type GuidanceOptions = Readonly<{
3
13
  controlPlane: string;
4
14
  repositoryId?: string;
@@ -12,6 +22,8 @@ export type GuidanceOptions = Readonly<{
12
22
  error?: (text: string) => void;
13
23
  fetchImpl?: typeof fetch;
14
24
  now?: () => number;
25
+ /** Runs the background update; injected so tests never reach npm. */
26
+ startUpdate?: (command: string, args: readonly string[]) => void;
15
27
  }>;
16
28
  /** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
17
29
  export declare function runGuidance(options: GuidanceOptions): Promise<number>;
@@ -1,6 +1,24 @@
1
1
  import { validateProjectGuidanceScope } from "../guidance-install.js";
2
2
  import { GUIDANCE_UNAVAILABLE, loadGuidance, recordGuidanceContext, } from "../guidance.js";
3
3
  import { readCredentials } from "../store.js";
4
+ import { selectAgent } from "../agent.js";
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
+ */
17
+ export function untrackedLine(remote) {
18
+ const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
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.`);
21
+ }
4
22
  function readInput(stream) {
5
23
  return new Promise((resolve, reject) => {
6
24
  let bytes = 0;
@@ -51,8 +69,11 @@ function identifier(input) {
51
69
  }
52
70
  /** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
53
71
  export async function runGuidance(options) {
54
- // A globally invoked hook cannot use --repository to reach another project's connection.
55
- if (!options.repositoryId ||
72
+ // A project hook names its repository and must match the checkout it sits
73
+ // in. A user-scope hook names none: the folder's remote decides, and a folder
74
+ // with no connection hears that it is untracked rather than nothing at all.
75
+ const projectScoped = options.repositoryId !== undefined;
76
+ if (projectScoped &&
56
77
  !validateProjectGuidanceScope({
57
78
  cwd: options.cwd,
58
79
  hook: options.hook,
@@ -71,15 +92,34 @@ export async function runGuidance(options) {
71
92
  throw new Error("invalid_hook_event");
72
93
  event = parsedEvent;
73
94
  const record = input;
74
- const agents = readCredentials(options.environment).agents.filter((agent) => agent.controlPlane === options.controlPlane && agent.repositoryId === options.repositoryId);
75
- if (agents.length !== 1 || !agents[0])
76
- throw new Error("guidance_connection_unavailable");
95
+ const credentials = readCredentials(options.environment).agents;
96
+ const agents = projectScoped
97
+ ? credentials.filter((agent) => agent.controlPlane === options.controlPlane &&
98
+ agent.repositoryId === options.repositoryId)
99
+ : (() => {
100
+ const selection = selectAgent(credentials, options.controlPlane, undefined, repositoryHints(options.cwd));
101
+ return selection.kind === "refused" ? [] : [selection.agent];
102
+ })();
103
+ if (agents.length !== 1 || !agents[0]) {
104
+ if (projectScoped)
105
+ throw new Error("guidance_connection_unavailable");
106
+ // Untracked: nothing is said. `status` in the folder says why.
107
+ return 0;
108
+ }
77
109
  const loaded = await loadGuidance({
78
110
  agent: agents[0],
79
111
  environment: options.environment,
80
112
  timeoutMs: 750,
81
113
  ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
82
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
+ });
83
123
  const sessionId = identifier(record.session_id);
84
124
  const agentId = identifier(record.agent_id);
85
125
  recordGuidanceContext({
@@ -2,7 +2,9 @@ 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
+ import { CLI_VERSION } from "../wire.js";
7
+ import { untrackedLine } from "./guidance.js";
6
8
  import { StoreError, readCredentials } from "../store.js";
7
9
  // The three commands that use an agent connection select and address it through
8
10
  // one module. These are re-exported because this is where the forwarder's
@@ -127,6 +129,39 @@ function frameId(line) {
127
129
  }
128
130
  return null;
129
131
  }
132
+ /** The smallest server a host accepts: initialize, an empty tools list, ping. */
133
+ async function serveUntracked(options, instructions) {
134
+ const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
135
+ for await (const line of lines) {
136
+ if (line.trim().length === 0)
137
+ continue;
138
+ let request;
139
+ try {
140
+ request = JSON.parse(line);
141
+ }
142
+ catch {
143
+ continue;
144
+ }
145
+ if (request.id === undefined)
146
+ continue; // a notification
147
+ const result = request.method === "initialize"
148
+ ? {
149
+ protocolVersion: request.params?.protocolVersion ?? "2025-06-18",
150
+ capabilities: { tools: {} },
151
+ serverInfo: { name: "balladeer", version: CLI_VERSION },
152
+ instructions,
153
+ }
154
+ : request.method === "tools/list"
155
+ ? { tools: [] }
156
+ : request.method === "ping"
157
+ ? {}
158
+ : undefined;
159
+ options.write(JSON.stringify(result === undefined
160
+ ? { jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } }
161
+ : { jsonrpc: "2.0", id: request.id, result }) + "\n");
162
+ }
163
+ return 0;
164
+ }
130
165
  export async function runMcp(options) {
131
166
  let credentials;
132
167
  try {
@@ -136,7 +171,7 @@ export async function runMcp(options) {
136
171
  options.error(`${error instanceof StoreError ? error.message : String(error)}\n`);
137
172
  return 4;
138
173
  }
139
- 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));
140
175
  if (selection.kind === "refused") {
141
176
  // Issuing one in the browser is what this used to suggest here, and it is the
142
177
  // move that produces this state: the bearer is shown once, there, and the
@@ -144,7 +179,14 @@ export async function runMcp(options) {
144
179
  options.error(selection.missingFor === undefined
145
180
  ? `${selection.reason} Run setup in this repository, or issue the connection at ${options.controlPlane}/setup.\n`
146
181
  : `${noAgentCredentialSentence(selection.missingFor)}\n`);
147
- return 4;
182
+ // A project entry named its repository and got it wrong: that is an error
183
+ // the host should show. A user-scope entry named none and simply landed in
184
+ // a folder nobody tracks: that is ordinary, and a server that dies here
185
+ // shows up red in every untracked folder on the laptop. Answer the host
186
+ // with an empty catalog and the one sentence the hook also says.
187
+ if (options.repositoryId !== undefined)
188
+ return 4;
189
+ return serveUntracked(options, untrackedLine(repositoryHint(options.cwd)));
148
190
  }
149
191
  const { agent } = selection;
150
192
  const guidanceInstall = installGuidanceLoader({
@@ -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
  *