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.
- package/README.md +75 -41
- package/dist/agent.d.ts +5 -0
- package/dist/agent.js +13 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +78 -24
- package/dist/client.d.ts +2 -2
- package/dist/client.js +7 -4
- package/dist/codex-config.d.ts +7 -0
- package/dist/codex-config.js +159 -0
- package/dist/commands/affected.js +6 -6
- package/dist/commands/check-seals.js +4 -4
- package/dist/commands/discover.js +5 -4
- package/dist/commands/prepare.js +2 -1
- package/dist/commands/propose.js +5 -4
- package/dist/commands/repositories.js +1 -0
- package/dist/commands/session.js +17 -4
- package/dist/commands/setup.d.ts +8 -0
- package/dist/commands/setup.js +215 -88
- package/dist/commands/status.js +41 -18
- package/dist/commands/touch-map.js +2 -2
- package/dist/conventions.d.ts +2 -1
- package/dist/conventions.js +2 -2
- package/dist/copy.d.ts +8 -8
- package/dist/copy.js +60 -39
- package/dist/store.d.ts +3 -1
- package/dist/store.js +3 -3
- package/dist/wire.d.ts +9 -4
- package/dist/wire.js +2 -1
- package/package.json +4 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse } from "@iarna/toml";
|
|
5
|
+
import { isOurEntry } from "./mcp-config.js";
|
|
6
|
+
export const CODEX_CONFIG_FILE = ".codex/config.toml";
|
|
7
|
+
const START = "# balladeer:mcp:start";
|
|
8
|
+
const END = "# balladeer:mcp:end";
|
|
9
|
+
function render(entry) {
|
|
10
|
+
return [
|
|
11
|
+
START,
|
|
12
|
+
"[mcp_servers.balladeer]",
|
|
13
|
+
`command = ${JSON.stringify(entry.command)}`,
|
|
14
|
+
`args = ${JSON.stringify(entry.args)}`,
|
|
15
|
+
...(entry.env
|
|
16
|
+
? [
|
|
17
|
+
"[mcp_servers.balladeer.env]",
|
|
18
|
+
...Object.entries(entry.env).map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`),
|
|
19
|
+
]
|
|
20
|
+
: []),
|
|
21
|
+
END,
|
|
22
|
+
"",
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
function withoutOurs(value) {
|
|
26
|
+
const copy = structuredClone(value);
|
|
27
|
+
const servers = copy.mcp_servers;
|
|
28
|
+
if (servers &&
|
|
29
|
+
typeof servers === "object" &&
|
|
30
|
+
!Array.isArray(servers) &&
|
|
31
|
+
!(servers instanceof Date)) {
|
|
32
|
+
delete servers.balladeer;
|
|
33
|
+
if (Object.keys(servers).length === 0)
|
|
34
|
+
delete copy.mcp_servers;
|
|
35
|
+
}
|
|
36
|
+
return JSON.stringify(copy);
|
|
37
|
+
}
|
|
38
|
+
export function readCodexEntry(root) {
|
|
39
|
+
try {
|
|
40
|
+
const config = parse(readFileSync(join(root, CODEX_CONFIG_FILE), "utf8"));
|
|
41
|
+
const servers = config.mcp_servers;
|
|
42
|
+
return servers &&
|
|
43
|
+
typeof servers === "object" &&
|
|
44
|
+
!Array.isArray(servers) &&
|
|
45
|
+
!(servers instanceof Date)
|
|
46
|
+
? servers.balladeer
|
|
47
|
+
: undefined;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** A client hint only when the complete file and our exact fenced table agree. */
|
|
54
|
+
export function hasManagedCodexEntry(root, controlPlane) {
|
|
55
|
+
try {
|
|
56
|
+
const text = readFileSync(join(root, CODEX_CONFIG_FILE), "utf8");
|
|
57
|
+
const starts = [...text.matchAll(/^# balladeer:mcp:start\r?$/gm)];
|
|
58
|
+
const ends = [...text.matchAll(/^# balladeer:mcp:end\r?$/gm)];
|
|
59
|
+
if (starts.length !== 1 ||
|
|
60
|
+
ends.length !== 1 ||
|
|
61
|
+
!starts[0] ||
|
|
62
|
+
!ends[0] ||
|
|
63
|
+
starts[0].index >= ends[0].index)
|
|
64
|
+
return false;
|
|
65
|
+
const block = parse(text.slice(starts[0].index + starts[0][0].length, ends[0].index));
|
|
66
|
+
const servers = block.mcp_servers;
|
|
67
|
+
if (Object.keys(block).length !== 1 ||
|
|
68
|
+
!servers ||
|
|
69
|
+
typeof servers !== "object" ||
|
|
70
|
+
Array.isArray(servers) ||
|
|
71
|
+
servers instanceof Date ||
|
|
72
|
+
Object.keys(servers).length !== 1)
|
|
73
|
+
return false;
|
|
74
|
+
const own = readCodexEntry(root);
|
|
75
|
+
return (isOurEntry(own, controlPlane) && JSON.stringify(own) === JSON.stringify(servers.balladeer));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Only our fenced table is replaced; every unrelated byte and parsed setting survives. */
|
|
82
|
+
export function mergeCodexConfig(root, entry, controlPlane) {
|
|
83
|
+
const block = render(entry);
|
|
84
|
+
const refused = (reason) => ({
|
|
85
|
+
kind: "refused",
|
|
86
|
+
reason: `${CODEX_CONFIG_FILE}: ${reason} No configuration was changed.`,
|
|
87
|
+
block,
|
|
88
|
+
});
|
|
89
|
+
const directory = join(root, ".codex");
|
|
90
|
+
const path = join(root, CODEX_CONFIG_FILE);
|
|
91
|
+
if (existsSync(directory) &&
|
|
92
|
+
(!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()))
|
|
93
|
+
return refused("the directory is not an ordinary project directory.");
|
|
94
|
+
if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()))
|
|
95
|
+
return refused("the file is not an ordinary project file.");
|
|
96
|
+
let previous = "";
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
if (existsSync(path))
|
|
100
|
+
previous = readFileSync(path, "utf8");
|
|
101
|
+
parsed = parse(previous);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return refused("could not read valid TOML; repair the file before running setup again.");
|
|
105
|
+
}
|
|
106
|
+
const starts = [...previous.matchAll(/^# balladeer:mcp:start\r?$/gm)];
|
|
107
|
+
const ends = [...previous.matchAll(/^# balladeer:mcp:end\r?$/gm)];
|
|
108
|
+
if (starts.length !== ends.length ||
|
|
109
|
+
starts.length > 1 ||
|
|
110
|
+
(starts[0] && ends[0] && starts[0].index >= ends[0].index))
|
|
111
|
+
return refused("the Balladeer markers are damaged; repair them before running setup again.");
|
|
112
|
+
const servers = parsed.mcp_servers;
|
|
113
|
+
if (servers !== undefined &&
|
|
114
|
+
(servers === null ||
|
|
115
|
+
typeof servers !== "object" ||
|
|
116
|
+
Array.isArray(servers) ||
|
|
117
|
+
servers instanceof Date))
|
|
118
|
+
return refused("mcp_servers must be a table.");
|
|
119
|
+
const own = servers && typeof servers === "object" && !Array.isArray(servers) && !(servers instanceof Date)
|
|
120
|
+
? servers.balladeer
|
|
121
|
+
: undefined;
|
|
122
|
+
if (own !== undefined && (!starts[0] || !isOurEntry(own, controlPlane)))
|
|
123
|
+
return refused("an unmanaged or unrecognized balladeer table already exists. Review it and merge the supplied block yourself; setup will not replace it.");
|
|
124
|
+
if (starts[0] && own === undefined)
|
|
125
|
+
return refused("the marked block does not contain a Balladeer server; review the markers first.");
|
|
126
|
+
const next = starts[0] && ends[0]
|
|
127
|
+
? previous.slice(0, starts[0].index) +
|
|
128
|
+
block.trimEnd() +
|
|
129
|
+
previous.slice(ends[0].index + ends[0][0].length)
|
|
130
|
+
: previous + (previous.endsWith("\n") || previous === "" ? "" : "\n") + "\n" + block;
|
|
131
|
+
try {
|
|
132
|
+
const result = parse(next);
|
|
133
|
+
if (withoutOurs(parsed) !== withoutOurs(result))
|
|
134
|
+
return refused("the marked replacement would change another setting; review the block manually.");
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return refused("the proposed block conflicts with existing TOML tables; merge the supplied block manually.");
|
|
138
|
+
}
|
|
139
|
+
if (next === previous)
|
|
140
|
+
return { kind: "written", changed: false };
|
|
141
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
142
|
+
const temporary = join(directory, `.balladeer.${randomBytes(8).toString("hex")}.tmp`);
|
|
143
|
+
let descriptor;
|
|
144
|
+
try {
|
|
145
|
+
descriptor = openSync(temporary, "wx", existsSync(path) ? lstatSync(path).mode & 0o777 : 0o600);
|
|
146
|
+
writeSync(descriptor, next);
|
|
147
|
+
fsyncSync(descriptor);
|
|
148
|
+
closeSync(descriptor);
|
|
149
|
+
descriptor = undefined;
|
|
150
|
+
renameSync(temporary, path);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
if (descriptor !== undefined)
|
|
154
|
+
closeSync(descriptor);
|
|
155
|
+
if (existsSync(temporary))
|
|
156
|
+
unlinkSync(temporary);
|
|
157
|
+
}
|
|
158
|
+
return { kind: "written", changed: true };
|
|
159
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
2
|
import { TOUCH_MAP_FILE, affectedPaths, readLocalPackages, readTouchMap, verifierDigest, } from "../touch-map.js";
|
|
3
3
|
import { formatInstant } from "../local-time.js";
|
|
4
|
-
import {} from "../wire.js";
|
|
4
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
5
5
|
import { promiseRoot } from "./touch-map.js";
|
|
6
6
|
/** How many promises one path lists before the report says how many more. */
|
|
7
7
|
const NAMED_LIMIT = 20;
|
|
@@ -36,7 +36,7 @@ export async function runAffected(options) {
|
|
|
36
36
|
options.write(`${text}\n`);
|
|
37
37
|
};
|
|
38
38
|
if (options.paths.length === 0) {
|
|
39
|
-
const message =
|
|
39
|
+
const message = `Name at least one path: ${CLI_INVOCATION} affected src/orders/total.ts`;
|
|
40
40
|
if (options.json)
|
|
41
41
|
emit({ step: "error", reason: "usage", message, changed: false, exitCode: 4 });
|
|
42
42
|
else
|
|
@@ -55,8 +55,8 @@ export async function runAffected(options) {
|
|
|
55
55
|
const reading = readTouchMap(root);
|
|
56
56
|
if (reading.kind !== "map") {
|
|
57
57
|
const message = reading.kind === "absent"
|
|
58
|
-
? `There is no touch map in this checkout yet. Build one with:
|
|
59
|
-
: `${TOUCH_MAP_FILE} could not be read as a touch map. Build it again with:
|
|
58
|
+
? `There is no touch map in this checkout yet. Build one with: ${CLI_INVOCATION} touch-map`
|
|
59
|
+
: `${TOUCH_MAP_FILE} could not be read as a touch map. Build it again with: ${CLI_INVOCATION} touch-map`;
|
|
60
60
|
emit({ step: "affected", mapped: false, paths: [], changed: false });
|
|
61
61
|
say(message);
|
|
62
62
|
// Zero, because having no map is not a fault in the change somebody is
|
|
@@ -115,8 +115,8 @@ export async function runAffected(options) {
|
|
|
115
115
|
say("");
|
|
116
116
|
if (stale > 0) {
|
|
117
117
|
say(stale === 1
|
|
118
|
-
?
|
|
119
|
-
: `${stale} of those answers are stale. Run
|
|
118
|
+
? `One answer above is stale. Run ${CLI_INVOCATION} touch-map to measure it again.`
|
|
119
|
+
: `${stale} of those answers are stale. Run ${CLI_INVOCATION} touch-map to measure them again.`);
|
|
120
120
|
}
|
|
121
121
|
say("This came from your own checkout. Balladeer was not asked, and was told nothing.");
|
|
122
122
|
return 0;
|
|
@@ -3,7 +3,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import { runCommand } from "../gh.js";
|
|
4
4
|
import { repositoryRoot } from "../git.js";
|
|
5
5
|
import { promiseForPath, sealedPromises } from "../seals.js";
|
|
6
|
-
import {} from "../wire.js";
|
|
6
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
7
7
|
/**
|
|
8
8
|
* Where the pinned runner sits once setup's instructions have been followed.
|
|
9
9
|
*
|
|
@@ -258,17 +258,17 @@ function installPrePushHook(root, options, emit, say) {
|
|
|
258
258
|
const hookPath = join(root, ".git", "hooks", "pre-push");
|
|
259
259
|
if (existsSync(hookPath)) {
|
|
260
260
|
const message = `${hookPath} already exists, so nothing was written. Add this line to it yourself if you ` +
|
|
261
|
-
|
|
261
|
+
`want the check: ${CLI_INVOCATION} check-seals`;
|
|
262
262
|
emit({ step: "error", reason: "hook_exists", message, changed: false, exitCode: 4 });
|
|
263
263
|
say(message);
|
|
264
264
|
return 4;
|
|
265
265
|
}
|
|
266
266
|
const script = [
|
|
267
267
|
"#!/bin/sh",
|
|
268
|
-
|
|
268
|
+
`# Installed by: ${CLI_INVOCATION} check-seals --install-hook`,
|
|
269
269
|
"# It stops a push that would break a promise's seal, and says nothing otherwise.",
|
|
270
270
|
"# Delete this file to remove it.",
|
|
271
|
-
|
|
271
|
+
`${CLI_INVOCATION} check-seals || exit 1`,
|
|
272
272
|
"",
|
|
273
273
|
].join("\n");
|
|
274
274
|
try {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { callAgentTool, selectAgent, structuredString } from "../agent.js";
|
|
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
5
|
import { repositoryHint } from "../repository.js";
|
|
6
6
|
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
7
|
-
import {} from "../wire.js";
|
|
7
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
8
8
|
import { readTeachBackFile } from "./propose.js";
|
|
9
9
|
/**
|
|
10
10
|
* Ten promises at 64 KiB each, which is the per-promise cap `propose` already
|
|
@@ -197,7 +197,7 @@ export async function runDiscover(options) {
|
|
|
197
197
|
return exitCode;
|
|
198
198
|
};
|
|
199
199
|
if (!options.file) {
|
|
200
|
-
return fail("usage",
|
|
200
|
+
return fail("usage", `Give a catalog file: ${CLI_INVOCATION} discover --file <path>.`, 4);
|
|
201
201
|
}
|
|
202
202
|
let raw;
|
|
203
203
|
try {
|
|
@@ -232,6 +232,7 @@ export async function runDiscover(options) {
|
|
|
232
232
|
`Run \`${commandLine(null, "setup")}\` here again to pair this machine, which records who you are, or name the owner yourself with --owner <membership id> from the members list on the Balladeer MCP server's setup tool.`, 4);
|
|
233
233
|
}
|
|
234
234
|
const setup = await callAgentTool(agent, "get_promise_setup", {});
|
|
235
|
+
reportAgentEnforcementWarning(setup, options);
|
|
235
236
|
if (setup.kind !== "result") {
|
|
236
237
|
return fail("owner_unverified", `${memberListRefusal(setup.kind, options.controlPlane)} Nothing was sent.`, 5);
|
|
237
238
|
}
|
|
@@ -254,7 +255,7 @@ export async function runDiscover(options) {
|
|
|
254
255
|
index: index + 1,
|
|
255
256
|
title,
|
|
256
257
|
candidateId: outcome.candidateId,
|
|
257
|
-
reviewUrl: proposalReviewLink(options.controlPlane, outcome.candidateId),
|
|
258
|
+
reviewUrl: proposalReviewLink(options.controlPlane, outcome.candidateId, agent.workspaceId),
|
|
258
259
|
};
|
|
259
260
|
filed.push(entry);
|
|
260
261
|
emit({
|
package/dist/commands/prepare.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
-
import { callAgentTool, noAgentCredentialSentence, selectAgent } from "../agent.js";
|
|
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
6
|
import { repositoryHint } from "../repository.js";
|
|
@@ -150,6 +150,7 @@ export async function runPrepare(options) {
|
|
|
150
150
|
promiseId: options.promiseId,
|
|
151
151
|
again: options.again,
|
|
152
152
|
});
|
|
153
|
+
reportAgentEnforcementWarning(call, options);
|
|
153
154
|
switch (call.kind) {
|
|
154
155
|
case "endpoint_refused":
|
|
155
156
|
return fail("agent_endpoint_unsafe", `${call.reason} Nothing was prepared.`, 4);
|
package/dist/commands/propose.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
|
|
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
5
|
import { repositoryHint } from "../repository.js";
|
|
6
6
|
import { StoreError, readCredentials } from "../store.js";
|
|
7
|
-
import {} from "../wire.js";
|
|
7
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
8
8
|
const MAX_FILE_BYTES = 64 * 1024;
|
|
9
9
|
/**
|
|
10
10
|
* Every field the server's schema requires with no default. It is every
|
|
@@ -190,7 +190,7 @@ export async function runPropose(options) {
|
|
|
190
190
|
return exitCode;
|
|
191
191
|
};
|
|
192
192
|
if (!options.file) {
|
|
193
|
-
return fail("usage",
|
|
193
|
+
return fail("usage", `Give a proposal file: ${CLI_INVOCATION} propose --file <path>.`, 4);
|
|
194
194
|
}
|
|
195
195
|
let raw;
|
|
196
196
|
try {
|
|
@@ -240,6 +240,7 @@ export async function runPropose(options) {
|
|
|
240
240
|
? {}
|
|
241
241
|
: { leastSure: parsed.value.teachBack.leastSure }),
|
|
242
242
|
});
|
|
243
|
+
reportAgentEnforcementWarning(call, options);
|
|
243
244
|
switch (call.kind) {
|
|
244
245
|
case "endpoint_refused":
|
|
245
246
|
return fail("agent_endpoint_unsafe", `${call.reason} Nothing was sent.`, 4);
|
|
@@ -273,7 +274,7 @@ export async function runPropose(options) {
|
|
|
273
274
|
// The review link is built from the address this copy paired with. The tool
|
|
274
275
|
// answers with an id and no link at all, so there is nothing here a
|
|
275
276
|
// misconfigured public base URL could redirect.
|
|
276
|
-
const review = proposalReviewLink(options.controlPlane, candidateId);
|
|
277
|
+
const review = proposalReviewLink(options.controlPlane, candidateId, agent.workspaceId);
|
|
277
278
|
emit({ step: "promise", status: "proposed", candidateId, reviewUrl: review });
|
|
278
279
|
if (!options.json) {
|
|
279
280
|
options.write("Proposed. You will own it unless you named someone else; the owner reads it and clicks Agree, and nothing else can.\n");
|
|
@@ -94,6 +94,7 @@ function report(options, rows, workspaceKnown) {
|
|
|
94
94
|
say(options, workspaceKnown
|
|
95
95
|
? "Repositories your GitHub account can see. Ones already in Balladeer are marked."
|
|
96
96
|
: "Repositories your GitHub account can see. Nothing is stored for this Balladeer, so which of them are already added is unknown.");
|
|
97
|
+
say(options, "These are GitHub repositories, not local directories or git worktrees.");
|
|
97
98
|
say(options, "");
|
|
98
99
|
for (const row of rows) {
|
|
99
100
|
const marks = [
|
package/dist/commands/session.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { callAgentTool, noAgentCredentialSentence, selectAgent } from "../agent.js";
|
|
1
|
+
import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, } from "../agent.js";
|
|
2
2
|
import { headCommit } from "../git.js";
|
|
3
3
|
import { repositoryHint } from "../repository.js";
|
|
4
4
|
import { AGENT_SESSION_TRAILER, agentSessionTrailerLine, currentSession, readAgentSessionTrailer, } from "../session.js";
|
|
5
5
|
import { StoreError, readCredentials } from "../store.js";
|
|
6
|
-
import {} from "../wire.js";
|
|
6
|
+
import { CLI_INVOCATION } from "../wire.js";
|
|
7
7
|
/**
|
|
8
8
|
* The id that joins what an agent was told to what it then wrote.
|
|
9
9
|
*
|
|
@@ -63,6 +63,18 @@ export async function runSession(options) {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
catch (error) {
|
|
66
|
+
// This catch follows successful credential selection and covers only the
|
|
67
|
+
// local marker write. It must never turn an auth or --record refusal into
|
|
68
|
+
// permission to continue, or print the freshly generated but unsaved ID.
|
|
69
|
+
const filesystemError = error instanceof StoreError && error.code === "credential_store_unwritable"
|
|
70
|
+
? error.cause
|
|
71
|
+
: error;
|
|
72
|
+
const permissionCode = filesystemError instanceof Error && "code" in filesystemError
|
|
73
|
+
? filesystemError.code
|
|
74
|
+
: undefined;
|
|
75
|
+
if (permissionCode === "EPERM" || permissionCode === "EACCES") {
|
|
76
|
+
return fail("session_store_permission_denied", `No session stamp was saved: local file permissions refused the write (${permissionCode}). Your saved authenticated connection is unchanged; this command has not checked it with the server. Continue already-authorized MCP reads, coding and explicitly requested capture without the optional session field. Do not invent an ID, add a session trailer, or run session --record for this unsaved stamp. Do not broaden filesystem access or move credentials to retry this write. Capture still requires the person's request or accepted offer, and human meaning approval is unchanged.`, 4);
|
|
77
|
+
}
|
|
66
78
|
return fail(error instanceof StoreError ? error.code : "session_store_unwritable", error instanceof StoreError ? error.message : String(error), 4);
|
|
67
79
|
}
|
|
68
80
|
const trailer = agentSessionTrailerLine(session.sessionId);
|
|
@@ -83,7 +95,7 @@ export async function runSession(options) {
|
|
|
83
95
|
say("");
|
|
84
96
|
say(` ${trailer}`);
|
|
85
97
|
say("");
|
|
86
|
-
say(`and run
|
|
98
|
+
say(`and run \`${CLI_INVOCATION} session --record\` once the commit exists, so a check that goes red on it can be read back against what you were told before you started. Balladeer is sent the session id and the commit SHA, and nothing else.`);
|
|
87
99
|
return 0;
|
|
88
100
|
}
|
|
89
101
|
async function recordCommit(options, agent, emit, say, fail) {
|
|
@@ -96,12 +108,13 @@ async function recordCommit(options, agent, emit, say, fail) {
|
|
|
96
108
|
// Said in full rather than as a code. The likeliest reader of this line is
|
|
97
109
|
// an agent that forgot the trailer, and the remedy is one line it can add
|
|
98
110
|
// to the commit it just made.
|
|
99
|
-
return fail("no_session_trailer", `The commit at HEAD carries no ${AGENT_SESSION_TRAILER} line this release recognises, so there is nothing to record. Add \`${AGENT_SESSION_TRAILER}: <the id
|
|
111
|
+
return fail("no_session_trailer", `The commit at HEAD carries no ${AGENT_SESSION_TRAILER} line this release recognises, so there is nothing to record. Add \`${AGENT_SESSION_TRAILER}: <the id ${CLI_INVOCATION} session prints>\` to the commit message and run this again. A commit carrying two different session ids is refused rather than guessed at.`, 4);
|
|
100
112
|
}
|
|
101
113
|
const call = await callAgentTool(agent, "record_session_commit", {
|
|
102
114
|
session: sessionId,
|
|
103
115
|
commitSha: head.sha,
|
|
104
116
|
});
|
|
117
|
+
reportAgentEnforcementWarning(call, options);
|
|
105
118
|
if (call.kind !== "result") {
|
|
106
119
|
return fail(call.kind === "tool_refusal" ? "refused" : call.kind, call.kind === "tool_refusal"
|
|
107
120
|
? call.text
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export type SetupOptions = Readonly<{
|
|
|
4
4
|
wait: boolean;
|
|
5
5
|
repo?: string;
|
|
6
6
|
createWorkspace?: string;
|
|
7
|
+
client?: "codex" | "claude";
|
|
8
|
+
chooseWorkspace?: boolean;
|
|
7
9
|
/**
|
|
8
10
|
* Every repository this run was told to set up, in the order they were named.
|
|
9
11
|
*
|
|
@@ -14,6 +16,12 @@ export type SetupOptions = Readonly<{
|
|
|
14
16
|
* one checkout can honestly do from here, and the run says so per repository.
|
|
15
17
|
*/
|
|
16
18
|
repositories?: readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* Connect an invited teammate to a repository the workspace already holds.
|
|
21
|
+
* This is deliberately a refusal boundary: if the repository is absent, the
|
|
22
|
+
* run stops rather than turning an invitation into authority to enroll it.
|
|
23
|
+
*/
|
|
24
|
+
existingOnly?: boolean;
|
|
17
25
|
/**
|
|
18
26
|
* Repair the two files a previous setup wrote in this repository, and do
|
|
19
27
|
* nothing else. No pairing, no enrollment, no credential, no network: a
|