balladeer 1.0.1 → 1.0.3

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.
@@ -2,17 +2,17 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, request, } from "../client.js";
5
+ import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, reviewLink, request, } from "../client.js";
6
+ import { CODEX_CONFIG_FILE, hasManagedCodexEntry, mergeCodexConfig, readCodexEntry, } from "../codex-config.js";
6
7
  import { writeConventions } from "../conventions.js";
7
8
  import { APPROVE_IN_THE_RIGHT_WORKSPACE, DISCOVERY_PLAYBOOK, JOIN_OR_CREATE } from "../copy.js";
8
9
  import { ghLogin, readPublicRepositoryFacts, readRepositoryFacts, runCommand, setRepositoryVariable, } from "../gh.js";
9
10
  import { CI_BRANCH, commitWorkflowOnBranch, repositoryRoot, workflowOnDefaultBranch, } from "../git.js";
10
- import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
11
+ import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, isOurEntry, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
11
12
  import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
12
13
  import { selectAgent } from "../agent.js";
13
14
  import { findLegacyInstall, legacyMessage } from "../legacy.js";
14
15
  import { formatInstant } from "../local-time.js";
15
- import { CONVENTIONS_VERSION } from "../conventions.js";
16
16
  import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
17
17
  import { hostHint, repositoryHint } from "../repository.js";
18
18
  import { StoreError, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
@@ -268,6 +268,7 @@ export async function runSetup(options) {
268
268
  const { controlPlane } = options;
269
269
  const now = options.now ?? (() => new Date());
270
270
  say(options, `Balladeer setup, version ${CLI_VERSION}.\n`);
271
+ say(options, `Agent client: ${options.client === "codex" ? "Codex" : "Claude"}. Use --client codex or --client claude to choose.`);
271
272
  emit(options, { step: "explain", version: CLI_VERSION });
272
273
  say(options, explainText(controlPlane));
273
274
  const chosen = chooseRepositories(namedRepositories(options), options.cwd);
@@ -289,6 +290,21 @@ export async function runSetup(options) {
289
290
  catch (error) {
290
291
  return storeFailure(options, error);
291
292
  }
293
+ // Workspace choice is explicit. Preserve all agent connections and other
294
+ // origins; persist the session replacement only when a new pairing starts.
295
+ const workspaceChoice = options.createWorkspace !== undefined
296
+ ? `create:${carriedName}`
297
+ : options.chooseWorkspace === true
298
+ ? "choose"
299
+ : undefined;
300
+ if (workspaceChoice !== undefined) {
301
+ credentials = {
302
+ ...credentials,
303
+ sessions: credentials.sessions.filter((entry) => entry.controlPlane !== controlPlane),
304
+ pendingPairings: credentials.pendingPairings.filter((entry) => entry.controlPlane !== controlPlane || entry.workspaceChoice === workspaceChoice),
305
+ };
306
+ say(options, "Choose the workspace in the new browser pairing. Existing agent connections remain saved.");
307
+ }
292
308
  // A still-valid session carries straight on to the steps that need it.
293
309
  const stored = findSession(credentials, controlPlane);
294
310
  if (stored && Date.parse(stored.expiresAt) > now().getTime()) {
@@ -376,6 +392,7 @@ export async function runSetup(options) {
376
392
  verificationUri: started.verificationUriComplete,
377
393
  expiresAt: started.expiresAt,
378
394
  startedAt: now().toISOString(),
395
+ ...(workspaceChoice === undefined ? {} : { workspaceChoice }),
379
396
  };
380
397
  credentials = putPendingPairing(credentials, pending);
381
398
  writeCredentials(credentials, options.environment);
@@ -606,13 +623,26 @@ async function runRemainingSteps(options, credentials, session) {
606
623
  if (view !== undefined) {
607
624
  const step3 = await stepAgent(options, credentials, session, view, name, published);
608
625
  view = step3 ?? view;
609
- view = (await stepCi(options, session, view, name)) ?? view;
626
+ view =
627
+ (options.existingOnly === true
628
+ ? reportInviteeCi(options, view)
629
+ : await stepCi(options, session, view, name)) ?? view;
610
630
  await stepPromise(options, session, view, published);
611
631
  }
612
632
  const finalState = await readState(options, session);
613
633
  printReceipts(options, session, finalState ?? state, view);
614
634
  return 0;
615
635
  }
636
+ const ENFORCEMENT_UNAVAILABLE_WARNING = "This repository is not connected to Balladeer enforcement yet. You can continue onboarding and record promises, but Balladeer cannot track whether this repository's promises are enforced until its CI reports an authenticated run.";
637
+ const NO_REPOSITORY_WARNING = "No repository in this workspace is connected for this checkout yet. You can continue onboarding in Balladeer, but a repository must be connected before its promises can be recorded and their enforcement tracked.";
638
+ function warnEnforcementUnavailable(options, message = ENFORCEMENT_UNAVAILABLE_WARNING) {
639
+ say(options, ` WARNING ${message}`);
640
+ emit(options, {
641
+ step: "warning",
642
+ code: "enforcement_unavailable",
643
+ message,
644
+ });
645
+ }
616
646
  /**
617
647
  * Hands over the GitHub login this machine is signed in as, so a run this
618
648
  * person starts reads as their name rather than as a handle.
@@ -699,6 +729,15 @@ async function stepRepository(options, session, state, name, role = "primary") {
699
729
  });
700
730
  return { view };
701
731
  }
732
+ if (options.existingOnly === true) {
733
+ const reason = `${name} is not already in ${session.workspaceName}, so this teammate run left repository enrollment unchanged`;
734
+ say(options, enrollmentHeadline(role, `${name} is not connected onboarding continues`));
735
+ say(options, ` ${reason}.`);
736
+ say(options, ` You can keep using Balladeer in the browser. A workspace administrator can connect a repository later; this run will not add one or change CI.`);
737
+ warnEnforcementUnavailable(options, NO_REPOSITORY_WARNING);
738
+ emit(options, { step: "repository", status: "not_connected", repository: name, reason });
739
+ return { view: undefined };
740
+ }
702
741
  if (!/^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/.test(name) || name === "unknown/unknown") {
703
742
  return blockedRepository(options, role, name, "this directory has no GitHub origin remote I could read", "Run this command inside a repository with a GitHub origin, or name one with --repository owner/name.");
704
743
  }
@@ -818,7 +857,7 @@ function blockedRepository(options, role, name, reason, next, publicFacts) {
818
857
  }
819
858
  const NO_DESKTOP = { lines: [], json: undefined };
820
859
  function connectClaudeDesktop(options, repositoryId, repositoryName) {
821
- if (options.claudeDesktop === false)
860
+ if (options.claudeDesktop === false || options.client === "codex")
822
861
  return NO_DESKTOP;
823
862
  // Asked for by name, or taken by default. The difference is only whether a
824
863
  // machine with no Claude desktop on it hears about it: a person who typed the
@@ -877,11 +916,28 @@ function connectClaudeDesktop(options, repositoryId, repositoryName) {
877
916
  },
878
917
  };
879
918
  }
880
- function hostNextSteps() {
919
+ function hostConfigFile(options) {
920
+ return options.client === "codex" ? CODEX_CONFIG_FILE : MCP_CONFIG_FILE;
921
+ }
922
+ function mergeHostConfig(options, root, repositoryId, published) {
923
+ const entry = stdioEntry(repositoryId, published);
924
+ return options.client === "codex"
925
+ ? mergeCodexConfig(root, { ...entry, args: [...entry.args, "--control-plane", options.controlPlane] }, options.controlPlane)
926
+ : mergeMcpConfig(root, entry, options.controlPlane);
927
+ }
928
+ function writeHostConventions(options, root) {
929
+ return writeConventions(root, options.client === "codex" ? { file: "AGENTS.md" } : {});
930
+ }
931
+ function hostNextSteps(options) {
932
+ if (options.client === "codex")
933
+ return [
934
+ " Codex project configuration is ready. Open this repository in Codex and trust the project if appropriate; Codex ignores project configuration in untrusted projects.",
935
+ " Start a fresh Codex session to load .codex/config.toml and AGENTS.md, then check that Balladeer's tools are available. The connection probe does not prove your Codex host loaded them.",
936
+ ];
881
937
  return [
882
- ` Your agent host reads ${MCP_CONFIG_FILE} when it starts, so the tools appear in its next session rather than in one already running. Nothing here needs a restart: finish setup first.`,
938
+ ` Your agent host reads ${hostConfigFile(options)} when it starts, so the tools appear in its next session rather than in one already running. Nothing here needs a restart: finish setup first.`,
883
939
  " Claude Code asks you to approve a project MCP server the first time it sees one, so approve balladeer when it asks. In a session already running, /mcp lists the servers and reconnects them.",
884
- " Another host reads its own configuration rather than this file: add the same entry there, and start it again.",
940
+ " For Codex, run setup --client codex --refresh to configure this project's Codex MCP and AGENTS.md, then review project trust and start a fresh Codex session.",
885
941
  ];
886
942
  }
887
943
  /**
@@ -907,7 +963,69 @@ async function proveAgent(agent) {
907
963
  * is not mistaken for this one's.
908
964
  */
909
965
  function credentialOnThisMachine(agents, controlPlane, repositoryId) {
910
- return agents.some((agent) => agent.controlPlane === controlPlane && agent.repositoryId === repositoryId);
966
+ return agents.find((agent) => agent.controlPlane === controlPlane && agent.repositoryId === repositoryId);
967
+ }
968
+ /**
969
+ * Reconcile every local host surface whether the bearer was issued now or was
970
+ * already on this machine. A credential is not a project MCP installation:
971
+ * another checkout, a deleted uncommitted file, or an interrupted earlier run
972
+ * can leave the first present and the second absent.
973
+ */
974
+ async function configureAgentHost(options, repositoryId, name, published) {
975
+ const root = await repositoryRoot(options.cwd);
976
+ const files = [];
977
+ let merged;
978
+ let conventions;
979
+ if (root !== undefined) {
980
+ merged = mergeHostConfig(options, root, repositoryId, published);
981
+ if (merged.kind === "written") {
982
+ if (merged.changed)
983
+ files.push(hostConfigFile(options));
984
+ conventions = writeHostConventions(options, root);
985
+ if (conventions.kind === "written" && conventions.changed)
986
+ files.push(conventions.file);
987
+ }
988
+ }
989
+ return {
990
+ root,
991
+ files,
992
+ merged,
993
+ conventions,
994
+ desktop: connectClaudeDesktop(options, repositoryId, name),
995
+ };
996
+ }
997
+ function reportAgentHost(options, configured, published) {
998
+ if (configured.root === undefined) {
999
+ say(options, ` This directory is not a git work tree, so I wrote no ${hostConfigFile(options)} and no instructions block.`);
1000
+ }
1001
+ else {
1002
+ if (configured.merged?.kind === "refused") {
1003
+ say(options, ` ${configured.merged.reason}`);
1004
+ say(options, " Merge this block into it yourself:");
1005
+ for (const line of configured.merged.block.split("\n"))
1006
+ say(options, ` ${line}`);
1007
+ }
1008
+ if (configured.conventions?.kind === "refused") {
1009
+ say(options, ` ${configured.conventions.reason}`);
1010
+ say(options, ` Repair the markers in ${configured.conventions.file}, or delete the block between them, then run this command again.`);
1011
+ }
1012
+ if (configured.merged?.kind === "written") {
1013
+ say(options, ` The ${hostConfigFile(options)} entry runs this command's own MCP forwarder for this repository and reads the credential from that store, so there is nothing else to set. This command never prints the credential.`);
1014
+ if (published === null && !existsSync(checkoutEntryPath())) {
1015
+ say(options, ` That entry runs ${checkoutEntryPath()}, which this checkout has not built yet. Run \`pnpm --filter balladeer build\` once so your agent host can start it.`);
1016
+ }
1017
+ }
1018
+ }
1019
+ for (const line of configured.desktop.lines)
1020
+ say(options, line);
1021
+ for (const line of hostNextSteps(options))
1022
+ say(options, line);
1023
+ say(options, published === null
1024
+ ? ` Setup does not install a global balladeer executable. This checkout's CLI runs as \`${commandLine(null, "<command>")}\` after it is built.`
1025
+ : ` Setup does not install a global balladeer executable. Run the CLI as \`${commandLine(published, "<command>")}\`; ${hostConfigFile(options)} invokes that same package automatically.`);
1026
+ if (configured.files.length > 0) {
1027
+ say(options, ` ${configured.files.join(" and ")} ${configured.files.length === 1 ? "is an uncommitted change" : "are uncommitted changes"} in your working tree. Commit ${configured.files.length === 1 ? "it" : "them"} when you are ready; they carry no secret.`);
1028
+ }
911
1029
  }
912
1030
  /**
913
1031
  * Whose credential this step is about: this machine's.
@@ -932,15 +1050,36 @@ function credentialOnThisMachine(agents, controlPlane, repositoryId) {
932
1050
  */
933
1051
  async function stepAgent(options, credentials, session, view, name, published) {
934
1052
  const heldHere = credentialOnThisMachine(credentials.agents, options.controlPlane, view.id);
935
- if (heldHere && view.agentConfigured) {
936
- say(options, "Step 3 of 5 This coding agent is already connected on this machine");
1053
+ if (heldHere !== undefined && view.agentConfigured) {
1054
+ const configured = await configureAgentHost(options, view.id, name, published);
1055
+ const proof = await proveAgent(heldHere);
1056
+ const repaired = configured.files.length > 0 ? ` repaired ${configured.files.join(" and ")}` : "";
1057
+ say(options, proof.kind === "answered"
1058
+ ? `Step 3 of 5 This coding agent is already connected on this machine${repaired}`
1059
+ : `Step 3 of 5 This machine holds an agent credential not yet proven${repaired}`);
937
1060
  say(options, ` ${name}'s credential is in ${credentialsPath(options.environment)}, so nothing was issued and nothing was rotated. A credential belongs to the machine it was issued on: another machine runs this command there to get its own.`);
1061
+ if (proof.kind === "answered") {
1062
+ say(options, ` Balladeer answered a get_promise_setup call for ${name} over this connection just now.`);
1063
+ }
1064
+ else {
1065
+ say(options, ` ${proof.reason}. ${proof.nextAction}`);
1066
+ }
1067
+ reportAgentHost(options, configured, published);
938
1068
  emit(options, {
939
1069
  step: "agent",
940
- status: "already",
1070
+ status: proof.kind === "answered" ? "already" : "unproven",
941
1071
  repositoryId: view.id,
942
1072
  storedHere: true,
943
1073
  ...(view.agentConnectionId === null ? {} : { connectionId: view.agentConnectionId }),
1074
+ files: configured.files,
1075
+ ...(proof.kind === "answered"
1076
+ ? { provedBy: "get_promise_setup" }
1077
+ : { reason: proof.reason, nextAction: proof.nextAction }),
1078
+ ...(configured.merged?.kind === "refused" ? { mcpConfig: configured.merged.reason } : {}),
1079
+ ...(configured.conventions?.kind === "refused"
1080
+ ? { conventions: configured.conventions.reason }
1081
+ : {}),
1082
+ ...(configured.desktop.json === undefined ? {} : { claudeDesktop: configured.desktop.json }),
944
1083
  });
945
1084
  return undefined;
946
1085
  }
@@ -950,7 +1089,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
950
1089
  // A credential in this store, and no live connection anywhere for the
951
1090
  // repository: the one this machine holds was revoked. Holding it is not being
952
1091
  // connected, so this issues rather than reporting the stored entry as working.
953
- const revokedHere = heldHere && !view.agentConfigured;
1092
+ const revokedHere = heldHere !== undefined && !view.agentConfigured;
954
1093
  let packet;
955
1094
  try {
956
1095
  packet = await request(options.controlPlane, {
@@ -980,30 +1119,13 @@ async function stepAgent(options, credentials, session, view, name, published) {
980
1119
  catch (error) {
981
1120
  return blockedAgent(options, view, error instanceof StoreError ? error.message : "the credential store could not be written");
982
1121
  }
983
- const root = await repositoryRoot(options.cwd);
984
- const files = [];
985
- let merged;
986
- let conventions;
987
- if (root !== undefined) {
988
- // The stdio forwarder, bound to this repository. It reads the bearer from
989
- // the credential store this step just wrote, so the connection works from
990
- // here with nothing further for anyone to set, and the file carries no
991
- // secret.
992
- merged = mergeMcpConfig(root, stdioEntry(packet.repositoryId, published), options.controlPlane);
993
- if (merged.kind === "written") {
994
- if (merged.changed)
995
- files.push(MCP_CONFIG_FILE);
996
- conventions = writeConventions(root);
997
- if (conventions.kind === "written" && conventions.changed)
998
- files.push(conventions.file);
999
- }
1000
- }
1122
+ const configured = await configureAgentHost(options, packet.repositoryId, name, published);
1001
1123
  // The probe runs after the files, because the files are not what it proves.
1002
1124
  // The credential, the endpoint, and the frames this command's own forwarder
1003
1125
  // sends are.
1004
1126
  const proof = await proveAgent(agent);
1005
- const wrote = merged?.kind === "written" && conventions?.kind === "written"
1006
- ? `, ${MCP_CONFIG_FILE} and ${conventions.file} updated`
1127
+ const wrote = configured.merged?.kind === "written" && configured.conventions?.kind === "written"
1128
+ ? `, ${hostConfigFile(options)} and ${configured.conventions.file} updated`
1007
1129
  : "";
1008
1130
  say(options, proof.kind === "answered"
1009
1131
  ? `Step 3 of 5 Connected this coding agent on this machine credential stored in ${credentialsPath(options.environment)}${wrote}`
@@ -1020,45 +1142,7 @@ async function stepAgent(options, credentials, session, view, name, published) {
1020
1142
  say(options, ` The credential is stored, but ${proof.reason}, so this is not reported as connected.`);
1021
1143
  say(options, ` ${proof.nextAction}`);
1022
1144
  }
1023
- if (root === undefined) {
1024
- say(options, ` This directory is not a git work tree, so I wrote no ${MCP_CONFIG_FILE} and no instructions block.`);
1025
- }
1026
- else {
1027
- if (merged?.kind === "refused") {
1028
- say(options, ` ${merged.reason}`);
1029
- say(options, " Merge this block into it yourself:");
1030
- for (const line of merged.block.split("\n"))
1031
- say(options, ` ${line}`);
1032
- }
1033
- if (conventions?.kind === "refused") {
1034
- say(options, ` ${conventions.reason}`);
1035
- say(options, ` Repair the markers in ${conventions.file}, or delete the block between them, then run this command again.`);
1036
- }
1037
- if (merged?.kind === "written") {
1038
- // Nothing further is asked of anybody about the credential. The entry runs
1039
- // this command's own forwarder, which reads the bearer out of the store
1040
- // above, so there is no variable to export and no secret to carry by hand.
1041
- say(options, ` The ${MCP_CONFIG_FILE} entry runs this command's own MCP forwarder for this repository and reads the credential from that store, so there is nothing else to set. This command never prints the credential.`);
1042
- if (published === null && !existsSync(checkoutEntryPath())) {
1043
- say(options, ` That entry runs ${checkoutEntryPath()}, which this checkout has not built yet. Run \`pnpm --filter balladeer build\` once so your agent host can start it.`);
1044
- }
1045
- }
1046
- }
1047
- // The chat client, connected from the same credential this step just stored.
1048
- // It is not conditional on a git work tree, because the desktop app has no
1049
- // notion of a project and its file lives in this person's home directory
1050
- // rather than in the repository.
1051
- const desktop = connectClaudeDesktop(options, packet.repositoryId, name);
1052
- for (const line of desktop.lines)
1053
- say(options, line);
1054
- // Said on every ending of this step, including the ones where no file was
1055
- // written: whoever is reading has to configure that host by hand, and they
1056
- // need the same two facts about when a host picks an entry up.
1057
- for (const line of hostNextSteps())
1058
- say(options, line);
1059
- if (files.length > 0) {
1060
- say(options, ` ${files.join(" and ")} ${files.length === 1 ? "is an uncommitted change" : "are uncommitted changes"} in your working tree. Commit ${files.length === 1 ? "it" : "them"} when you are ready; they carry no secret.`);
1061
- }
1145
+ reportAgentHost(options, configured, published);
1062
1146
  emit(options, {
1063
1147
  step: "agent",
1064
1148
  status: proof.kind === "answered" ? "connected" : "unproven",
@@ -1066,13 +1150,15 @@ async function stepAgent(options, credentials, session, view, name, published) {
1066
1150
  connectionId: packet.connectionId,
1067
1151
  storedHere: true,
1068
1152
  ...(connectedElsewhere ? { alreadyConnectedElsewhere: true } : {}),
1069
- files,
1153
+ files: configured.files,
1070
1154
  ...(proof.kind === "answered"
1071
1155
  ? { provedBy: "get_promise_setup" }
1072
1156
  : { reason: proof.reason, nextAction: proof.nextAction }),
1073
- ...(merged?.kind === "refused" ? { mcpConfig: merged.reason } : {}),
1074
- ...(conventions?.kind === "refused" ? { conventions: conventions.reason } : {}),
1075
- ...(desktop.json === undefined ? {} : { claudeDesktop: desktop.json }),
1157
+ ...(configured.merged?.kind === "refused" ? { mcpConfig: configured.merged.reason } : {}),
1158
+ ...(configured.conventions?.kind === "refused"
1159
+ ? { conventions: configured.conventions.reason }
1160
+ : {}),
1161
+ ...(configured.desktop.json === undefined ? {} : { claudeDesktop: configured.desktop.json }),
1076
1162
  });
1077
1163
  return undefined;
1078
1164
  }
@@ -1084,6 +1170,34 @@ function blockedAgent(options, view, reason) {
1084
1170
  return undefined;
1085
1171
  }
1086
1172
  /* ----------------------------- Step 4 ------------------------------------ */
1173
+ /**
1174
+ * A teammate run reports CI truth and never becomes repository maintenance.
1175
+ * Adding or updating a workflow, setting repository variables, and opening a
1176
+ * pull request all belong to the administrator/maintainer path.
1177
+ */
1178
+ function reportInviteeCi(options, view) {
1179
+ if (view.ciConfigured) {
1180
+ say(options, `Step 4 of 5 CI already connected ${view.ciValidatedRunCount} authenticated run${view.ciValidatedRunCount === 1 ? "" : "s"} observed`);
1181
+ emit(options, {
1182
+ step: "ci",
1183
+ status: "connected",
1184
+ repositoryId: view.id,
1185
+ validatedRunCount: view.ciValidatedRunCount,
1186
+ ...(view.ciFirstValidatedAt === null ? {} : { firstValidatedAt: view.ciFirstValidatedAt }),
1187
+ });
1188
+ return undefined;
1189
+ }
1190
+ say(options, "Step 4 of 5 CI is not connected teammate onboarding continues");
1191
+ say(options, " This run did not write a workflow, set GitHub variables, push a branch, or open a pull request.");
1192
+ warnEnforcementUnavailable(options);
1193
+ emit(options, {
1194
+ step: "ci",
1195
+ status: "not_changed",
1196
+ repositoryId: view.id,
1197
+ reason: "Teammate setup leaves repository CI unchanged.",
1198
+ });
1199
+ return undefined;
1200
+ }
1087
1201
  async function stepCi(options, session, view, name) {
1088
1202
  // Checked on every run, and BEFORE the connected branch below, because an
1089
1203
  // already-connected repository is exactly the one this happens to. Balladeer
@@ -1342,7 +1456,7 @@ async function stepPromise(options, session, view, published) {
1342
1456
  const state = await readState(options, session);
1343
1457
  const current = state?.repositories.find((repository) => repository.id === view.id) ?? view;
1344
1458
  if (current.firstPromiseId !== null) {
1345
- const promiseUrl = `${options.controlPlane}/promises/${current.firstPromiseId}`;
1459
+ const promiseUrl = reviewLink(options.controlPlane, `promises/${encodeURIComponent(current.firstPromiseId)}`, session.workspaceId);
1346
1460
  say(options, "Step 5 of 5 A named person agreed to the first promise");
1347
1461
  say(options, ` ${promiseUrl}`);
1348
1462
  say(options, " It stands agreed and is not yet protected. Protection starts on its own the moment a verifier qualifies, once the verifier pull request has merged. Nobody is asked to turn it on.");
@@ -1355,7 +1469,7 @@ async function stepPromise(options, session, view, published) {
1355
1469
  return;
1356
1470
  }
1357
1471
  if (current.latestCandidateId !== null) {
1358
- const reviewUrl = proposalReviewLink(options.controlPlane, current.latestCandidateId);
1472
+ const reviewUrl = proposalReviewLink(options.controlPlane, current.latestCandidateId, session.workspaceId);
1359
1473
  say(options, "Step 5 of 5 A first promise is proposed and waiting for a person");
1360
1474
  say(options, ` Review it: ${reviewUrl}`);
1361
1475
  say(options, " A named person reads the proposal and clicks Agree. Nothing else can.");
@@ -1415,10 +1529,12 @@ function printReceipts(options, session, state, view) {
1415
1529
  // promise the receipt below it said already stood agreed.
1416
1530
  const promiseId = current?.firstPromiseId ?? null;
1417
1531
  const candidateId = current?.latestCandidateId ?? null;
1418
- const promiseUrl = promiseId === null ? undefined : `${options.controlPlane}/promises/${promiseId}`;
1532
+ const promiseUrl = promiseId === null
1533
+ ? undefined
1534
+ : reviewLink(options.controlPlane, `promises/${encodeURIComponent(promiseId)}`, state.workspace.id);
1419
1535
  const reviewUrl = promiseId !== null || candidateId === null
1420
1536
  ? undefined
1421
- : proposalReviewLink(options.controlPlane, candidateId);
1537
+ : proposalReviewLink(options.controlPlane, candidateId, state.workspace.id);
1422
1538
  const promiseLink = promiseUrl ?? reviewUrl;
1423
1539
  const promiseSentence = promiseId !== null
1424
1540
  ? "Agreed. A named person agreed to this repository's first promise."
@@ -1508,13 +1624,21 @@ export function refreshPublishedForm(existing) {
1508
1624
  * changed. Run twice it changes nothing the second time, which is what makes it
1509
1625
  * safe to put in a nag an agent will act on without asking.
1510
1626
  */
1511
- async function runRefresh(options) {
1627
+ async function runRefresh(input) {
1628
+ let options = input;
1512
1629
  say(options, `Balladeer setup --refresh, version ${CLI_VERSION}.\n`);
1513
1630
  const root = await repositoryRoot(options.cwd);
1514
1631
  if (root === undefined) {
1515
1632
  return failRefresh(options, "not_a_repository", "This directory is not a git work tree, so there is no repository configuration to refresh.");
1516
1633
  }
1517
- const existing = currentEntry(readMcpConfig(root));
1634
+ if (options.client === undefined && hasManagedCodexEntry(root, options.controlPlane)) {
1635
+ if (isOurEntry(currentEntry(readMcpConfig(root)), options.controlPlane)) {
1636
+ return failRefresh(options, "client_ambiguous", "Both Codex and Claude have Balladeer project entries. Run setup --client codex --refresh or setup --client claude --refresh. No files were changed.");
1637
+ }
1638
+ options = { ...options, client: "codex" };
1639
+ say(options, "Using Codex for refresh because this repository has only its managed Balladeer entry.");
1640
+ }
1641
+ const existing = options.client === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
1518
1642
  let stored;
1519
1643
  let storedName;
1520
1644
  try {
@@ -1535,9 +1659,9 @@ async function runRefresh(options) {
1535
1659
  // that never paired can still repair a checkout somebody else set up.
1536
1660
  const repositoryId = stored ?? entryRepositoryId(existing);
1537
1661
  if (repositoryId === undefined) {
1538
- return failRefresh(options, "not_connected", `Nothing here names a repository to refresh: this machine holds no Balladeer credential for it and ${MCP_CONFIG_FILE} has no balladeer entry. Run \`${commandLine(null, "setup")}\` to connect it.`);
1662
+ return failRefresh(options, "not_connected", `Nothing here names a repository to refresh: this machine holds no Balladeer credential for it and ${hostConfigFile(options)} has no balladeer entry. Run \`${commandLine(null, "setup")}\` to connect it.`);
1539
1663
  }
1540
- const merged = mergeMcpConfig(root, stdioEntry(repositoryId, refreshPublishedForm(existing)), options.controlPlane);
1664
+ const merged = mergeHostConfig(options, root, repositoryId, refreshPublishedForm(existing));
1541
1665
  if (merged.kind === "refused") {
1542
1666
  say(options, merged.reason);
1543
1667
  say(options, "Merge this block into it yourself:");
@@ -1545,7 +1669,7 @@ async function runRefresh(options) {
1545
1669
  say(options, ` ${line}`);
1546
1670
  return failRefresh(options, "mcp_config_refused", merged.reason);
1547
1671
  }
1548
- const conventions = writeConventions(root);
1672
+ const conventions = writeHostConventions(options, root);
1549
1673
  if (conventions.kind === "refused") {
1550
1674
  say(options, conventions.reason);
1551
1675
  return failRefresh(options, "conventions_refused", conventions.reason);
@@ -1558,12 +1682,12 @@ async function runRefresh(options) {
1558
1682
  const desktop = connectClaudeDesktop(options, repositoryId, storedName ?? repositoryHint(options.cwd));
1559
1683
  const files = [];
1560
1684
  if (merged.changed)
1561
- files.push(MCP_CONFIG_FILE);
1685
+ files.push(hostConfigFile(options));
1562
1686
  if (conventions.changed)
1563
1687
  files.push(conventions.file);
1564
1688
  say(options, merged.changed
1565
- ? `Rewrote the balladeer entry in ${MCP_CONFIG_FILE}. Every other entry in that file is untouched.`
1566
- : `${MCP_CONFIG_FILE} already names the current command; I left it alone.`);
1689
+ ? `Rewrote the balladeer entry in ${hostConfigFile(options)}. Every other entry in that file is untouched.`
1690
+ : `${hostConfigFile(options)} already names the current command; I left it alone.`);
1567
1691
  say(options, conventions.changed
1568
1692
  ? `Rewrote the Balladeer block in ${conventions.file} to conventions v${conventions.version}${conventions.previousVersion === undefined
1569
1693
  ? ""
@@ -1574,6 +1698,9 @@ async function runRefresh(options) {
1574
1698
  // never a change anybody commits: it lives in this person's home directory.
1575
1699
  for (const line of desktop.lines)
1576
1700
  say(options, line.replace(/^ {2}/, ""));
1701
+ if (options.client === "codex")
1702
+ for (const line of hostNextSteps(options))
1703
+ say(options, line);
1577
1704
  if (files.length === 0 && desktop.json?.status !== "connected") {
1578
1705
  say(options, "Nothing to change: this repository is already on the current form.");
1579
1706
  }
@@ -1,5 +1,5 @@
1
- import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
2
- import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, request, } from "../client.js";
1
+ import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
2
+ import { ClientTooOldError, RefusalError, TransportError, proposalReviewLink, reviewLink, request, } from "../client.js";
3
3
  import { repositoryRoot } from "../git.js";
4
4
  import { MARKER_OBSERVATION_LIMITS, observeMissingMarkers, staleMarkerRow, } from "../markers.js";
5
5
  import { formatInstant } from "../local-time.js";
@@ -7,7 +7,7 @@ import { commandLine } from "../release.js";
7
7
  import { repositoryHint } from "../repository.js";
8
8
  import { SEALED_PATHS_SHOWN, sealedPromises } from "../seals.js";
9
9
  import { StoreError, findSession, readCredentials } from "../store.js";
10
- import {} from "../wire.js";
10
+ import { CLI_INVOCATION } from "../wire.js";
11
11
  /**
12
12
  * The bounded code for "this machine holds no credential for that repository".
13
13
  * It is its own code rather than the session's: an agent branching on
@@ -104,7 +104,23 @@ async function reportBalladeer(options) {
104
104
  }
105
105
  const here = options.repo ?? repositoryHint(options.cwd);
106
106
  const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, here);
107
- const session = findSession(credentials, options.controlPlane);
107
+ const storedSession = findSession(credentials, options.controlPlane);
108
+ const repositoryScoped = options.repo !== undefined ||
109
+ options.repository !== undefined ||
110
+ here !== "unknown/unknown" ||
111
+ (await repositoryRoot(options.cwd)) !== undefined;
112
+ const hasBoundSession = typeof storedSession?.workspaceId === "string" && storedSession.workspaceId.trim().length > 0;
113
+ // A setup session belongs to one workspace, not to every repository whose
114
+ // agent happens to be stored on this machine. Establish that relationship
115
+ // before sending its bearer or fetching its workspace-wide context.
116
+ const session = hasBoundSession &&
117
+ (!repositoryScoped ||
118
+ (selection.kind === "agent" && selection.agent.workspaceId === storedSession.workspaceId))
119
+ ? storedSession
120
+ : undefined;
121
+ const missingWorkspace = () => sayWorkspaceUnavailable(emit, say, storedSession === undefined ? "no_stored_setup_session" : "setup_session_scope_unverified", storedSession === undefined
122
+ ? noSessionSentence(options.controlPlane)
123
+ : "An optional workspace view was skipped because the saved setup session could not be tied to this request. No workspace read was sent.");
108
124
  // The credential this machine holds is what this repository is read over, so
109
125
  // its absence is the first thing said, and it is said in its own words. What
110
126
  // the founder got instead was the workspace half's refusal, "Balladeer refused
@@ -120,21 +136,13 @@ async function reportBalladeer(options) {
120
136
  else {
121
137
  options.write(`${message}\n`);
122
138
  }
123
- if (session === undefined) {
124
- sayWorkspaceUnavailable(emit, say, "no_stored_setup_session", noSessionSentence(options.controlPlane));
125
- }
126
- else {
127
- // A different question, over a different credential, so it is still
128
- // answered underneath. Its outcome never becomes this run's exit code:
129
- // this repository went unreported whatever the workspace read did.
130
- await reportWorkspace(options, session, here, emit, say, true);
131
- }
139
+ missingWorkspace();
132
140
  return 4;
133
141
  }
134
142
  if (selection.kind === "refused" && session === undefined) {
135
143
  // Nothing stored can answer anything, so this names the form that always
136
144
  // works: the one this copy was run as.
137
- return fail("nothing_stored", `${selection.reason} No Balladeer setup session is stored for ${options.controlPlane} either. Run: ${commandLine(null, "setup")}`, 4);
145
+ return fail("nothing_stored", `${selection.reason} ${storedSession === undefined ? `No Balladeer setup session is stored for ${options.controlPlane} either.` : "The stored setup session was not read because its workspace could not be established for this request."} Run: ${commandLine(null, "setup")}`, 4);
138
146
  }
139
147
  // One promise, by id: a different question, answered over the same connection
140
148
  // and on its own. Somebody who pasted the repair line wants what the failing
@@ -150,9 +158,13 @@ async function reportBalladeer(options) {
150
158
  let reported = false;
151
159
  if (selection.kind === "agent") {
152
160
  reported = await reportThisRepository(options, selection.agent, emit, say);
161
+ // A revoked or mismatched repository connection must not become a
162
+ // successful repository report through a different credential.
163
+ if (!reported && repositoryScoped)
164
+ return 4;
153
165
  }
154
166
  if (session === undefined) {
155
- sayWorkspaceUnavailable(emit, say, "no_stored_setup_session", noSessionSentence(options.controlPlane));
167
+ missingWorkspace();
156
168
  return reported ? 0 : 4;
157
169
  }
158
170
  return reportWorkspace(options, session, here, emit, say, reported);
@@ -167,6 +179,7 @@ async function reportBalladeer(options) {
167
179
  */
168
180
  async function reportThisRepository(options, agent, emit, say) {
169
181
  const call = await callAgentTool(agent, "get_promise_setup", {});
182
+ reportAgentEnforcementWarning(call, options);
170
183
  if (call.kind !== "result") {
171
184
  say(`This repository could not be read over its Balladeer agent connection: ${reason(call)}`);
172
185
  return false;
@@ -277,6 +290,7 @@ async function reportStaleMarkers(options, agent, emit, say) {
277
290
  */
278
291
  async function reportOnePromise(options, promiseId, agent, emit, say) {
279
292
  const call = await callAgentTool(agent, "get_promise", { promiseId });
293
+ reportAgentEnforcementWarning(call, options);
280
294
  if (call.kind !== "result") {
281
295
  const message = `${promiseId} could not be read over this repository's Balladeer agent connection: ${reason(call)}`;
282
296
  emit({ step: "promise", status: "unreadable", promiseId, message });
@@ -409,7 +423,7 @@ async function reportSealedPaths(options, emit, say) {
409
423
  say(` ${promise.sealedPath}${promise.title === undefined ? "" : ` ${promise.title}`}`);
410
424
  if (sealed.length > shown.length)
411
425
  say(` and ${sealed.length - shown.length} more, not listed here.`);
412
- say(" Run `balladeer check-seals` before you push to find out whether your change broke one.");
426
+ say(` Run \`${CLI_INVOCATION} check-seals\` before you push to find out whether your change broke one.`);
413
427
  }
414
428
  function reason(call) {
415
429
  switch (call.kind) {
@@ -476,6 +490,15 @@ async function reportWorkspace(options, session, here, emit, say, alreadyReporte
476
490
  }
477
491
  return failOutside(options, emit, code, "Balladeer could not report this workspace.", 5);
478
492
  }
493
+ if (state?.workspace?.id !== session.workspaceId) {
494
+ sayWorkspaceUnavailable(emit, say, "setup_workspace_mismatch", "The setup session answered for a different workspace, so none of its workspace context was reported.");
495
+ return alreadyReported ? 0 : 4;
496
+ }
497
+ const scopedReviewLink = (url) => {
498
+ const scoped = new URL(url);
499
+ scoped.searchParams.set("workspaceId", state.workspace.id);
500
+ return scoped.toString();
501
+ };
479
502
  const active = state.repositories.filter((repository) => repository.status === "active");
480
503
  emit({
481
504
  step: "receipt",
@@ -525,10 +548,10 @@ async function reportWorkspace(options, session, here, emit, say, alreadyReporte
525
548
  // awaiting them, and a rejected proposal is listed nowhere at all.
526
549
  say(` Promises: ${repository.promiseCount} agreed, ${repository.candidateCount} proposed and awaiting a person.`);
527
550
  if (repository.firstPromiseId !== null) {
528
- say(` First promise: ${options.controlPlane}/promises/${repository.firstPromiseId}`);
551
+ say(` First promise: ${scopedReviewLink(reviewLink(options.controlPlane, `promises/${encodeURIComponent(repository.firstPromiseId)}`))}`);
529
552
  }
530
553
  if (repository.latestCandidateId !== null) {
531
- say(` Newest proposal: ${proposalReviewLink(options.controlPlane, repository.latestCandidateId)}`);
554
+ say(` Newest proposal: ${scopedReviewLink(proposalReviewLink(options.controlPlane, repository.latestCandidateId))}`);
532
555
  }
533
556
  }
534
557
  return 0;
@@ -4,7 +4,7 @@ import { dirname, join, resolve } from "node:path";
4
4
  import { runCommand } from "../gh.js";
5
5
  import { repositoryRoot } from "../git.js";
6
6
  import { CONTINUITY_DIRECTORY, TOUCH_MAP_FILE, buildTouchMap, executedPaths, mappablePath, measurableWithNode, readCoverageDirectory, readLocalPackages, repositoryPath, serializeTouchMap, touchMapBody, touchMapEnvironment, verifierCwd, verifierDigest, } from "../touch-map.js";
7
- import {} from "../wire.js";
7
+ import { CLI_INVOCATION } from "../wire.js";
8
8
  /** A verifier gets this long before the measurement gives up on it. */
9
9
  const DEFAULT_TIMEOUT_MS = 120_000;
10
10
  /** However long a package asks for, no verifier holds this command longer. */
@@ -214,7 +214,7 @@ export async function runTouchMap(options) {
214
214
  say(` and ${map.unmapped.length - NAMED_LIMIT} more, not listed here.`);
215
215
  }
216
216
  say("");
217
- say("Ask it which promises a change touches with: balladeer affected <paths...>");
217
+ say(`Ask it which promises a change touches with: ${CLI_INVOCATION} affected <paths...>`);
218
218
  return 0;
219
219
  }
220
220
  /**