balladeer 1.0.0 → 1.0.2

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.
Files changed (47) hide show
  1. package/README.md +53 -32
  2. package/dist/agent.d.ts +5 -0
  3. package/dist/agent.js +13 -1
  4. package/dist/cli.d.ts +16 -0
  5. package/dist/cli.js +181 -24
  6. package/dist/client.d.ts +24 -2
  7. package/dist/client.js +34 -3
  8. package/dist/commands/affected.js +8 -7
  9. package/dist/commands/check-seals.js +4 -4
  10. package/dist/commands/discover.js +16 -7
  11. package/dist/commands/explain.d.ts +1 -1
  12. package/dist/commands/explain.js +1 -1
  13. package/dist/commands/invite.js +2 -1
  14. package/dist/commands/prepare.d.ts +74 -0
  15. package/dist/commands/prepare.js +218 -0
  16. package/dist/commands/propose.d.ts +10 -0
  17. package/dist/commands/propose.js +29 -6
  18. package/dist/commands/repositories.js +1 -0
  19. package/dist/commands/session.d.ts +35 -0
  20. package/dist/commands/session.js +131 -0
  21. package/dist/commands/setup.d.ts +29 -0
  22. package/dist/commands/setup.js +302 -92
  23. package/dist/commands/status.d.ts +16 -0
  24. package/dist/commands/status.js +106 -23
  25. package/dist/commands/touch-map.js +2 -2
  26. package/dist/commands/whoami.js +2 -1
  27. package/dist/conventions.d.ts +9 -1
  28. package/dist/conventions.js +9 -1
  29. package/dist/copy.d.ts +83 -7
  30. package/dist/copy.js +226 -29
  31. package/dist/desktop-config.d.ts +85 -0
  32. package/dist/desktop-config.js +217 -0
  33. package/dist/git.d.ts +15 -0
  34. package/dist/git.js +23 -0
  35. package/dist/legacy.d.ts +41 -0
  36. package/dist/legacy.js +143 -0
  37. package/dist/local-time.d.ts +66 -0
  38. package/dist/local-time.js +84 -0
  39. package/dist/mcp-config.d.ts +10 -0
  40. package/dist/mcp-config.js +8 -4
  41. package/dist/session.d.ts +84 -0
  42. package/dist/session.js +135 -0
  43. package/dist/store.d.ts +11 -1
  44. package/dist/store.js +18 -6
  45. package/dist/wire.d.ts +95 -4
  46. package/dist/wire.js +2 -1
  47. package/package.json +1 -1
package/dist/git.d.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  export declare const CI_BRANCH = "balladeer/connect-ci";
2
2
  export declare function repositoryRoot(cwd: string): Promise<string | undefined>;
3
+ /**
4
+ * The commit at HEAD, and the whole of its message.
5
+ *
6
+ * Both are read here, on the customer's machine, and only the pair of a SHA and
7
+ * a session id ever leaves it. That is the boundary this feature turns on:
8
+ * Balladeer receives commit SHAs from CI and never a commit message, so the
9
+ * trailer has to be read where the text already is.
10
+ *
11
+ * Absent on a repository with no commits, or on a directory that is not a
12
+ * checkout, which is an answer rather than a fault.
13
+ */
14
+ export declare function headCommit(cwd: string): Promise<Readonly<{
15
+ sha: string;
16
+ message: string;
17
+ }> | undefined>;
3
18
  /**
4
19
  * A repository in the middle of a merge or a rebase is not a repository to open
5
20
  * a pull request from. The person is mid-operation, and anything this command
package/dist/git.js CHANGED
@@ -8,6 +8,29 @@ export async function repositoryRoot(cwd) {
8
8
  const answer = await runCommand("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
9
9
  return answer.ok ? answer.stdout.trim() : undefined;
10
10
  }
11
+ /**
12
+ * The commit at HEAD, and the whole of its message.
13
+ *
14
+ * Both are read here, on the customer's machine, and only the pair of a SHA and
15
+ * a session id ever leaves it. That is the boundary this feature turns on:
16
+ * Balladeer receives commit SHAs from CI and never a commit message, so the
17
+ * trailer has to be read where the text already is.
18
+ *
19
+ * Absent on a repository with no commits, or on a directory that is not a
20
+ * checkout, which is an answer rather than a fault.
21
+ */
22
+ export async function headCommit(cwd) {
23
+ const sha = await runCommand("git", ["-C", cwd, "rev-parse", "HEAD"]);
24
+ if (!sha.ok)
25
+ return undefined;
26
+ const trimmed = sha.stdout.trim();
27
+ if (!/^[0-9a-f]{40}$/.test(trimmed))
28
+ return undefined;
29
+ const message = await runCommand("git", ["-C", cwd, "log", "-1", "--pretty=%B", trimmed]);
30
+ if (!message.ok)
31
+ return undefined;
32
+ return { sha: trimmed, message: message.stdout };
33
+ }
11
34
  async function gitDirectory(root) {
12
35
  const answer = await runCommand("git", ["-C", root, "rev-parse", "--absolute-git-dir"]);
13
36
  return answer.ok ? answer.stdout.trim() : undefined;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The earlier Balladeer, still installed, found before this one writes anything.
3
+ *
4
+ * A team that used the July client has a program on their machine that this one
5
+ * knows nothing about: it updates itself from its own server rather than from
6
+ * npm, it registers an MCP server named `balladeer` for the whole machine, and
7
+ * it installs a session hook and a conventions block of its own. This product
8
+ * registers an MCP server under the same name, per repository. Nothing breaks
9
+ * loudly when both are present, which is the problem: the older session hook
10
+ * keeps firing inside the repository somebody enrolled today, one file ends up
11
+ * carrying two conventions blocks, and the person reads the result as this
12
+ * product misbehaving.
13
+ *
14
+ * The older client can take itself off, reversibly, with `balladeer uninstall`
15
+ * followed by the removal command it prints. So this is a warning that stops
16
+ * rather than a refusal that stands: the sentence names the one thing to do,
17
+ * and `--force` is there for whoever has decided to run both anyway.
18
+ */
19
+ export type LegacyInstall = Readonly<{
20
+ /** Which file said so, in the form a person can go and open. */
21
+ where: string;
22
+ /** What was found there, bounded, so a config file cannot become an essay. */
23
+ detail: string;
24
+ }>;
25
+ /**
26
+ * The instruction, in the same words `docs/didero-day-one.md` and
27
+ * `docs/didero-mcp-walkthrough.md` print. A person who hits this in the terminal
28
+ * and a person who read the document ahead of time are told to do the same
29
+ * thing, by the same sentence.
30
+ */
31
+ export declare const UNINSTALL_INSTRUCTION = "If you used the earlier Balladeer on this machine: run `balladeer uninstall`, then the removal command it prints, then continue.";
32
+ /**
33
+ * The older install this machine still carries, or nothing.
34
+ *
35
+ * Two places, because those are the two the older client owns that reach into a
36
+ * session in a repository enrolled today: the machine-wide MCP entry under our
37
+ * own name, and the hook that fires at session start.
38
+ */
39
+ export declare function findLegacyInstall(environment: NodeJS.ProcessEnv, controlPlane: string): LegacyInstall | undefined;
40
+ /** What the person reads, whichever of the two files said so. */
41
+ export declare function legacyMessage(found: LegacyInstall): string;
package/dist/legacy.js ADDED
@@ -0,0 +1,143 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isOurEntry } from "./mcp-config.js";
4
+ /**
5
+ * The instruction, in the same words `docs/didero-day-one.md` and
6
+ * `docs/didero-mcp-walkthrough.md` print. A person who hits this in the terminal
7
+ * and a person who read the document ahead of time are told to do the same
8
+ * thing, by the same sentence.
9
+ */
10
+ export const UNINSTALL_INSTRUCTION = "If you used the earlier Balladeer on this machine: run `balladeer uninstall`, then the removal command it prints, then continue.";
11
+ /** How much of a foreign command line is worth printing back. */
12
+ const DETAIL_LIMIT = 120;
13
+ /**
14
+ * Where the agent host keeps its configuration, read from the environment this
15
+ * command was handed and never from the ambient process.
16
+ *
17
+ * Every command in this package takes its configuration as a parameter, which is
18
+ * what lets a test plant a whole home directory. When the environment names no
19
+ * home there is nothing to read, and a check that cannot read cannot warn: it
20
+ * stays silent rather than guessing at a path.
21
+ */
22
+ function agentHome(environment) {
23
+ const home = environment.HOME?.trim() || environment.USERPROFILE?.trim() || "";
24
+ return home.length > 0 ? home : undefined;
25
+ }
26
+ function readJson(path) {
27
+ try {
28
+ return JSON.parse(readFileSync(path, "utf8"));
29
+ }
30
+ catch {
31
+ // Absent, unreadable, or somebody's hand-edited file mid-save. None of those
32
+ // is evidence of an older install, and none of them is this command's to fix.
33
+ return undefined;
34
+ }
35
+ }
36
+ function bounded(text) {
37
+ const flattened = text.replace(/\s+/g, " ").trim();
38
+ return flattened.length > DETAIL_LIMIT ? `${flattened.slice(0, DETAIL_LIMIT)}...` : flattened;
39
+ }
40
+ /** The command line an MCP entry runs, in the shape a person would recognise. */
41
+ function describeEntry(entry) {
42
+ const record = entry;
43
+ if (record !== null && typeof record?.url === "string")
44
+ return bounded(record.url);
45
+ const command = typeof record?.command === "string" ? record.command : "";
46
+ const args = Array.isArray(record?.args)
47
+ ? record.args.filter((value) => typeof value === "string")
48
+ : [];
49
+ const line = [command, ...args].join(" ").trim();
50
+ return line.length > 0 ? bounded(line) : "a command this copy does not recognise";
51
+ }
52
+ /**
53
+ * Whether a hook's command line is one of ours.
54
+ *
55
+ * This product installs no agent-host hook at all, so in practice every hook
56
+ * naming `balladeer` belongs to the older client. The exception is written down
57
+ * anyway: a team that wired one of our own published or checkout invocations
58
+ * into a hook of their own must not be told their own line is somebody else's
59
+ * program.
60
+ */
61
+ function isOurCommandLine(command) {
62
+ if (/balladeer@(?:latest|\d+\.\d+\.\d+)\b/.test(command))
63
+ return true;
64
+ return /\bnode\b[^\n]*\bpackages[/\\]cli[/\\]dist[/\\]cli\.js\b/.test(command);
65
+ }
66
+ /** Every `command` string anywhere under an agent host's `hooks` setting. */
67
+ function hookCommands(value, found = []) {
68
+ if (Array.isArray(value)) {
69
+ for (const item of value)
70
+ hookCommands(item, found);
71
+ return found;
72
+ }
73
+ if (value === null || typeof value !== "object")
74
+ return found;
75
+ for (const [key, child] of Object.entries(value)) {
76
+ if (key === "command" && typeof child === "string")
77
+ found.push(child);
78
+ else
79
+ hookCommands(child, found);
80
+ }
81
+ return found;
82
+ }
83
+ /**
84
+ * The older Balladeer's own MCP entry, if the agent host still carries one.
85
+ *
86
+ * Only the machine-wide block counts. This product writes its entry into the
87
+ * repository's `.mcp.json`, and a host that also records a per-project entry
88
+ * under `projects` records ours there: reading those as foreign would stop
89
+ * setup on the very install it just performed. Under the machine-wide key, an
90
+ * entry named `balladeer` that is not one of our recognised shapes is the older
91
+ * client, because nothing else has reason to claim that name.
92
+ */
93
+ function legacyMcpEntry(home, controlPlane) {
94
+ const parsed = readJson(join(home, ".claude.json"));
95
+ const servers = parsed?.mcpServers;
96
+ if (servers === null || typeof servers !== "object")
97
+ return undefined;
98
+ const entry = servers.balladeer;
99
+ if (entry === undefined || entry === null)
100
+ return undefined;
101
+ if (isOurEntry(entry, controlPlane))
102
+ return undefined;
103
+ return {
104
+ where: join(home, ".claude.json"),
105
+ detail: `an MCP server named balladeer, registered for the whole machine, running ${describeEntry(entry)}`,
106
+ };
107
+ }
108
+ /** The older Balladeer's session hook, if the agent host still runs one. */
109
+ function legacyHook(home) {
110
+ const parsed = readJson(join(home, ".claude", "settings.json"));
111
+ const hooks = parsed?.hooks;
112
+ if (hooks === undefined)
113
+ return undefined;
114
+ const command = hookCommands(hooks).find((line) => /balladeer/i.test(line) && !isOurCommandLine(line));
115
+ if (command === undefined)
116
+ return undefined;
117
+ return {
118
+ where: join(home, ".claude", "settings.json"),
119
+ detail: `a hook that runs ${bounded(command)}`,
120
+ };
121
+ }
122
+ /**
123
+ * The older install this machine still carries, or nothing.
124
+ *
125
+ * Two places, because those are the two the older client owns that reach into a
126
+ * session in a repository enrolled today: the machine-wide MCP entry under our
127
+ * own name, and the hook that fires at session start.
128
+ */
129
+ export function findLegacyInstall(environment, controlPlane) {
130
+ const home = agentHome(environment);
131
+ if (home === undefined)
132
+ return undefined;
133
+ return legacyMcpEntry(home, controlPlane) ?? legacyHook(home);
134
+ }
135
+ /** What the person reads, whichever of the two files said so. */
136
+ export function legacyMessage(found) {
137
+ return [
138
+ `An earlier Balladeer is still installed on this machine: ${found.where} carries ${found.detail}.`,
139
+ "Both products register an MCP server named balladeer and the earlier one keeps firing its own session hook, so leaving it in place puts two Balladeers in the sessions you open in this repository.",
140
+ UNINSTALL_INSTRUCTION,
141
+ "Nothing was changed. Run this command again with --force to set up anyway.",
142
+ ].join("\n");
143
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Every absolute time this command prints for a person, on the clock they read.
3
+ *
4
+ * Robert approved a pairing code that had already lapsed. The screen said the
5
+ * code stopped being approvable at a time four hours ahead of the one on his
6
+ * wall, and named no zone, so he read a moment that had gone as a moment still
7
+ * to come. `apps/control-plane/lib/local-time.ts` fixed that on the web pages
8
+ * and says why: an unlabelled time is not a shorter time, it is a different one.
9
+ *
10
+ * The terminal had no equivalent, and it prints the same deadlines: the pairing
11
+ * code, the setup session, the invitation, when a map was measured, when a run
12
+ * observed a failure. Every one of them was a UTC instant in a sentence a person
13
+ * reads. So nothing here formats an instant without saying which clock it is on.
14
+ *
15
+ * Unlike a server render, this one always knows: the command runs on the
16
+ * reader's own machine, so there is a single shape rather than the web's pair of
17
+ * them, and it is the shape the web already shows a signed-in viewer, so a
18
+ * person who reads a deadline in the terminal and again on the promise page
19
+ * reads it written the same way both times.
20
+ *
21
+ * It is a copy of the control plane's formatter rather than an import of it.
22
+ * This package is published to npm on its own and must not carry the web
23
+ * application behind it; the rule the two share is the one in the guard tests,
24
+ * which hold both surfaces to the same sentence.
25
+ *
26
+ * Machine-readable output is not this module's business. `--json` steps carry
27
+ * the ISO instant exactly as Balladeer sent it, because the reader there is a
28
+ * program with its own clock and its own zone.
29
+ */
30
+ /** What a stored value that is not a time says, rather than "Invalid Date". */
31
+ export declare const UNREADABLE_TIME = "an unrecorded time";
32
+ /**
33
+ * The zone this machine is set to.
34
+ *
35
+ * Every reach for `Intl` in the command is in this one file, which is what lets
36
+ * the guard test state the rule as a scan rather than as a list of sentences
37
+ * somebody has to remember to extend.
38
+ */
39
+ export declare function machineTimeZone(): string;
40
+ /**
41
+ * One instant, in the reader's own zone, with the zone named beside it.
42
+ *
43
+ * `timeZoneName: "short"` is the load-bearing option. Remove it and every
44
+ * sentence below silently goes back to printing a bare wall clock, which is the
45
+ * bug this module exists for, so the guard test asserts a zone token on the end
46
+ * of the output in every zone it checks.
47
+ *
48
+ * The zone is a parameter so the tests can read one instant on several clocks.
49
+ * No caller passes one: the machine's own zone is the answer for a command that
50
+ * runs on the reader's machine.
51
+ */
52
+ export declare function formatInstant(value: string, timeZone?: string): string;
53
+ /**
54
+ * A sentence Balladeer wrote, with the instants in it moved onto the reader's
55
+ * clock.
56
+ *
57
+ * Some refusals are relayed to the terminal word for word, on purpose: the
58
+ * server's sentence names what to do next, and rewriting it here would lose the
59
+ * part the person has to act on. But the server has no idea where the reader is
60
+ * sitting, so it writes UTC, and "already prepared by Robert Clark on
61
+ * 2026-09-07T18:05:10.698Z" reaches somebody whose clock says 2:05 in the
62
+ * afternoon. This moves the instants and leaves every other word alone, so what
63
+ * an agent is told over the tool call and what a person is told at a terminal
64
+ * stay the same sentence.
65
+ */
66
+ export declare function inReadersZone(text: string, timeZone?: string): string;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Every absolute time this command prints for a person, on the clock they read.
3
+ *
4
+ * Robert approved a pairing code that had already lapsed. The screen said the
5
+ * code stopped being approvable at a time four hours ahead of the one on his
6
+ * wall, and named no zone, so he read a moment that had gone as a moment still
7
+ * to come. `apps/control-plane/lib/local-time.ts` fixed that on the web pages
8
+ * and says why: an unlabelled time is not a shorter time, it is a different one.
9
+ *
10
+ * The terminal had no equivalent, and it prints the same deadlines: the pairing
11
+ * code, the setup session, the invitation, when a map was measured, when a run
12
+ * observed a failure. Every one of them was a UTC instant in a sentence a person
13
+ * reads. So nothing here formats an instant without saying which clock it is on.
14
+ *
15
+ * Unlike a server render, this one always knows: the command runs on the
16
+ * reader's own machine, so there is a single shape rather than the web's pair of
17
+ * them, and it is the shape the web already shows a signed-in viewer, so a
18
+ * person who reads a deadline in the terminal and again on the promise page
19
+ * reads it written the same way both times.
20
+ *
21
+ * It is a copy of the control plane's formatter rather than an import of it.
22
+ * This package is published to npm on its own and must not carry the web
23
+ * application behind it; the rule the two share is the one in the guard tests,
24
+ * which hold both surfaces to the same sentence.
25
+ *
26
+ * Machine-readable output is not this module's business. `--json` steps carry
27
+ * the ISO instant exactly as Balladeer sent it, because the reader there is a
28
+ * program with its own clock and its own zone.
29
+ */
30
+ /** What a stored value that is not a time says, rather than "Invalid Date". */
31
+ export const UNREADABLE_TIME = "an unrecorded time";
32
+ /**
33
+ * The zone this machine is set to.
34
+ *
35
+ * Every reach for `Intl` in the command is in this one file, which is what lets
36
+ * the guard test state the rule as a scan rather than as a list of sentences
37
+ * somebody has to remember to extend.
38
+ */
39
+ export function machineTimeZone() {
40
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
41
+ }
42
+ /**
43
+ * One instant, in the reader's own zone, with the zone named beside it.
44
+ *
45
+ * `timeZoneName: "short"` is the load-bearing option. Remove it and every
46
+ * sentence below silently goes back to printing a bare wall clock, which is the
47
+ * bug this module exists for, so the guard test asserts a zone token on the end
48
+ * of the output in every zone it checks.
49
+ *
50
+ * The zone is a parameter so the tests can read one instant on several clocks.
51
+ * No caller passes one: the machine's own zone is the answer for a command that
52
+ * runs on the reader's machine.
53
+ */
54
+ export function formatInstant(value, timeZone = machineTimeZone()) {
55
+ const when = new Date(value);
56
+ if (Number.isNaN(when.valueOf()))
57
+ return UNREADABLE_TIME;
58
+ return new Intl.DateTimeFormat("en-US", {
59
+ month: "short",
60
+ day: "numeric",
61
+ hour: "numeric",
62
+ minute: "2-digit",
63
+ timeZone,
64
+ timeZoneName: "short",
65
+ }).format(when);
66
+ }
67
+ /** An instant as Balladeer writes one: ISO 8601, UTC, to the millisecond. */
68
+ const ISO_INSTANT = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g;
69
+ /**
70
+ * A sentence Balladeer wrote, with the instants in it moved onto the reader's
71
+ * clock.
72
+ *
73
+ * Some refusals are relayed to the terminal word for word, on purpose: the
74
+ * server's sentence names what to do next, and rewriting it here would lose the
75
+ * part the person has to act on. But the server has no idea where the reader is
76
+ * sitting, so it writes UTC, and "already prepared by Robert Clark on
77
+ * 2026-09-07T18:05:10.698Z" reaches somebody whose clock says 2:05 in the
78
+ * afternoon. This moves the instants and leaves every other word alone, so what
79
+ * an agent is told over the tool call and what a person is told at a terminal
80
+ * stay the same sentence.
81
+ */
82
+ export function inReadersZone(text, timeZone = machineTimeZone()) {
83
+ return text.replace(ISO_INSTANT, (instant) => formatInstant(instant, timeZone));
84
+ }
@@ -75,6 +75,16 @@ export declare function stdioEntry(repositoryId: string, publishedVersion: strin
75
75
  * Balladeer entry is foreign.
76
76
  */
77
77
  export declare function isOurEntry(entry: unknown, controlPlane: string): boolean;
78
+ /**
79
+ * Atomic, and never through a path something could have pre-planted: the
80
+ * temporary file is opened with `wx` under a random name in the same directory,
81
+ * flushed, and renamed over the target.
82
+ *
83
+ * Exported because the Claude desktop configuration is written under the same
84
+ * rule and for a stronger reason: that file belongs to a running application,
85
+ * and a half-written one is a chat client that starts with no servers at all.
86
+ */
87
+ export declare function writeJsonAtomically(path: string, contents: string): void;
78
88
  /**
79
89
  * Merges our entry into the repository's `.mcp.json`, or refuses.
80
90
  *
@@ -112,9 +112,13 @@ function renderBlock(entry) {
112
112
  * Atomic, and never through a path something could have pre-planted: the
113
113
  * temporary file is opened with `wx` under a random name in the same directory,
114
114
  * flushed, and renamed over the target.
115
+ *
116
+ * Exported because the Claude desktop configuration is written under the same
117
+ * rule and for a stronger reason: that file belongs to a running application,
118
+ * and a half-written one is a chat client that starts with no servers at all.
115
119
  */
116
- function writeAtomically(path, contents) {
117
- const temporary = join(dirname(path), `.mcp.${randomBytes(8).toString("hex")}.tmp`);
120
+ export function writeJsonAtomically(path, contents) {
121
+ const temporary = join(dirname(path), `.balladeer.${randomBytes(8).toString("hex")}.tmp`);
118
122
  let descriptor;
119
123
  try {
120
124
  descriptor = openSync(temporary, "wx", 0o644);
@@ -157,7 +161,7 @@ export function mergeMcpConfig(repositoryRoot, entry, controlPlane) {
157
161
  existing = readFileSync(path, "utf8");
158
162
  }
159
163
  catch {
160
- writeAtomically(path, `${JSON.stringify({ mcpServers: { balladeer: entry } }, null, 2)}\n`);
164
+ writeJsonAtomically(path, `${JSON.stringify({ mcpServers: { balladeer: entry } }, null, 2)}\n`);
161
165
  return { kind: "written", changed: true };
162
166
  }
163
167
  let parsed;
@@ -192,7 +196,7 @@ export function mergeMcpConfig(repositoryRoot, entry, controlPlane) {
192
196
  const next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
193
197
  if (next === existing)
194
198
  return { kind: "written", changed: false };
195
- writeAtomically(path, next);
199
+ writeJsonAtomically(path, next);
196
200
  return { kind: "written", changed: true };
197
201
  }
198
202
  /**
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The id that ties what an agent was told to what it then wrote.
3
+ *
4
+ * A retrieval read happens before the commit exists, so nothing joins the two
5
+ * on its own. The agent's own working session is what spans them: it reads the
6
+ * index under this id, and writes the same id into the commit trailer. That is
7
+ * the whole mechanism, and it lives here rather than on the server because the
8
+ * server never sees a commit message.
9
+ *
10
+ * The id is opaque and says nothing about the work. It is not a secret either:
11
+ * it is written into commit messages, which is exactly where people will read
12
+ * it. What it must be is stable across one piece of work and different across
13
+ * two, which is what the marker file below is for.
14
+ */
15
+ /**
16
+ * The shape of a session id, and the line that carries it into a commit.
17
+ *
18
+ * Written out here rather than imported. This command is published to npm on
19
+ * its own and depends on nothing, so the grammar it uses has to live inside it;
20
+ * a packaging test compares these three against the control plane's own
21
+ * definitions, so a drift fails here rather than as a token the server refuses
22
+ * out of a customer's commit message.
23
+ */
24
+ export declare const AGENT_SESSION_ID_PATTERN: RegExp;
25
+ /** The trailer key an agent writes into the commit or the pull-request body. */
26
+ export declare const AGENT_SESSION_TRAILER = "Balladeer-Session";
27
+ /** The exact line to paste, so every carrier spells the trailer one way. */
28
+ export declare function agentSessionTrailerLine(sessionId: string): string;
29
+ /**
30
+ * The session id a commit message or pull-request body carries, if it carries
31
+ * one this release would recognise.
32
+ *
33
+ * A body carrying two different session ids is refused rather than resolved:
34
+ * two answers to "which session wrote this" is not one answer, and guessing
35
+ * which of them to record would put an invented join in front of a number.
36
+ */
37
+ export declare function readAgentSessionTrailer(text: string): string | undefined;
38
+ /** Where the current session for a repository is remembered. */
39
+ export declare const SESSION_FILE = "sessions.json";
40
+ /**
41
+ * How long one session id stays current.
42
+ *
43
+ * A working session is a sitting, not a calendar day. Twelve hours is long
44
+ * enough that a morning's work and the commit that ends it share an id, and
45
+ * short enough that a machine left running overnight starts the next day's work
46
+ * under a new one rather than attributing tomorrow's commits to yesterday's
47
+ * reads. `--new` is the manual answer for anyone whose sitting ends earlier.
48
+ */
49
+ export declare const SESSION_LIFETIME_MS: number;
50
+ export type SessionMark = Readonly<{
51
+ /** Which repository this session belongs to, as the credential names it. */
52
+ repository: string;
53
+ sessionId: string;
54
+ startedAt: string;
55
+ }>;
56
+ type SessionFile = {
57
+ version: 1;
58
+ sessions: SessionMark[];
59
+ };
60
+ /** A fresh id: the prefix a person can recognise, and 128 bits of nothing else. */
61
+ export declare function mintSessionId(): string;
62
+ export declare function isSessionId(value: string): boolean;
63
+ export declare function readSessionMarks(environment?: NodeJS.ProcessEnv): SessionFile;
64
+ export type CurrentSession = Readonly<{
65
+ sessionId: string;
66
+ startedAt: string;
67
+ /** True when this call minted it, false when it was already current. */
68
+ minted: boolean;
69
+ }>;
70
+ /**
71
+ * The session id for this repository right now, minting one when there is none
72
+ * current.
73
+ *
74
+ * Reusing a live one is the point. An agent that asks twice in one sitting must
75
+ * get the same answer, or its reads and its commit end up under two ids and the
76
+ * join it exists to make is broken by the act of asking for it.
77
+ */
78
+ export declare function currentSession(input: Readonly<{
79
+ repository: string;
80
+ now: string;
81
+ forceNew?: boolean;
82
+ environment?: NodeJS.ProcessEnv;
83
+ }>): CurrentSession;
84
+ export {};
@@ -0,0 +1,135 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { assertSafeStoreLocation, configHome, writeStoreFile } from "./store.js";
5
+ /**
6
+ * The id that ties what an agent was told to what it then wrote.
7
+ *
8
+ * A retrieval read happens before the commit exists, so nothing joins the two
9
+ * on its own. The agent's own working session is what spans them: it reads the
10
+ * index under this id, and writes the same id into the commit trailer. That is
11
+ * the whole mechanism, and it lives here rather than on the server because the
12
+ * server never sees a commit message.
13
+ *
14
+ * The id is opaque and says nothing about the work. It is not a secret either:
15
+ * it is written into commit messages, which is exactly where people will read
16
+ * it. What it must be is stable across one piece of work and different across
17
+ * two, which is what the marker file below is for.
18
+ */
19
+ /**
20
+ * The shape of a session id, and the line that carries it into a commit.
21
+ *
22
+ * Written out here rather than imported. This command is published to npm on
23
+ * its own and depends on nothing, so the grammar it uses has to live inside it;
24
+ * a packaging test compares these three against the control plane's own
25
+ * definitions, so a drift fails here rather than as a token the server refuses
26
+ * out of a customer's commit message.
27
+ */
28
+ export const AGENT_SESSION_ID_PATTERN = /^bs_[0-9a-f]{32}$/;
29
+ /** The trailer key an agent writes into the commit or the pull-request body. */
30
+ export const AGENT_SESSION_TRAILER = "Balladeer-Session";
31
+ /** The exact line to paste, so every carrier spells the trailer one way. */
32
+ export function agentSessionTrailerLine(sessionId) {
33
+ return `${AGENT_SESSION_TRAILER}: ${sessionId}`;
34
+ }
35
+ /**
36
+ * The session id a commit message or pull-request body carries, if it carries
37
+ * one this release would recognise.
38
+ *
39
+ * A body carrying two different session ids is refused rather than resolved:
40
+ * two answers to "which session wrote this" is not one answer, and guessing
41
+ * which of them to record would put an invented join in front of a number.
42
+ */
43
+ export function readAgentSessionTrailer(text) {
44
+ const pattern = new RegExp(`^[ \\t]*${AGENT_SESSION_TRAILER}[ \\t]*:[ \\t]*(\\S+)[ \\t]*$`, "gim");
45
+ const found = new Set();
46
+ for (const match of text.matchAll(pattern)) {
47
+ const value = match[1];
48
+ if (value !== undefined && isSessionId(value))
49
+ found.add(value);
50
+ }
51
+ if (found.size !== 1)
52
+ return undefined;
53
+ return [...found][0];
54
+ }
55
+ /** Where the current session for a repository is remembered. */
56
+ export const SESSION_FILE = "sessions.json";
57
+ /**
58
+ * How long one session id stays current.
59
+ *
60
+ * A working session is a sitting, not a calendar day. Twelve hours is long
61
+ * enough that a morning's work and the commit that ends it share an id, and
62
+ * short enough that a machine left running overnight starts the next day's work
63
+ * under a new one rather than attributing tomorrow's commits to yesterday's
64
+ * reads. `--new` is the manual answer for anyone whose sitting ends earlier.
65
+ */
66
+ export const SESSION_LIFETIME_MS = 12 * 60 * 60 * 1000;
67
+ const EMPTY = { version: 1, sessions: [] };
68
+ /** A fresh id: the prefix a person can recognise, and 128 bits of nothing else. */
69
+ export function mintSessionId() {
70
+ return `bs_${randomBytes(16).toString("hex")}`;
71
+ }
72
+ export function isSessionId(value) {
73
+ return AGENT_SESSION_ID_PATTERN.test(value);
74
+ }
75
+ function sessionPath(environment) {
76
+ return join(configHome(environment), SESSION_FILE);
77
+ }
78
+ export function readSessionMarks(environment = process.env) {
79
+ let raw;
80
+ try {
81
+ raw = readFileSync(sessionPath(environment), "utf8");
82
+ }
83
+ catch {
84
+ return { version: 1, sessions: [] };
85
+ }
86
+ try {
87
+ const parsed = JSON.parse(raw);
88
+ const sessions = Array.isArray(parsed.sessions) ? parsed.sessions : [];
89
+ // A marker whose id this build would not recognise is dropped rather than
90
+ // repaired. It can only have come from a copy of this command that spelled
91
+ // ids differently, and reusing one would put a token the server refuses
92
+ // into a commit message where nobody would ever look for the cause.
93
+ return { version: 1, sessions: sessions.filter((mark) => isSessionId(mark.sessionId)) };
94
+ }
95
+ catch {
96
+ return { ...EMPTY, sessions: [] };
97
+ }
98
+ }
99
+ function writeSessionMarks(file, environment) {
100
+ assertSafeStoreLocation(configHome(environment), environment);
101
+ writeStoreFile(SESSION_FILE, `${JSON.stringify(file, null, 2)}\n`, environment);
102
+ }
103
+ /**
104
+ * The session id for this repository right now, minting one when there is none
105
+ * current.
106
+ *
107
+ * Reusing a live one is the point. An agent that asks twice in one sitting must
108
+ * get the same answer, or its reads and its commit end up under two ids and the
109
+ * join it exists to make is broken by the act of asking for it.
110
+ */
111
+ export function currentSession(input) {
112
+ const environment = input.environment ?? process.env;
113
+ const file = readSessionMarks(environment);
114
+ const existing = file.sessions.find((mark) => mark.repository === input.repository);
115
+ const age = existing === undefined ? undefined : Date.parse(input.now) - Date.parse(existing.startedAt);
116
+ const usable = existing !== undefined &&
117
+ input.forceNew !== true &&
118
+ age !== undefined &&
119
+ Number.isFinite(age) &&
120
+ age >= 0 &&
121
+ age < SESSION_LIFETIME_MS;
122
+ if (usable && existing !== undefined) {
123
+ return { sessionId: existing.sessionId, startedAt: existing.startedAt, minted: false };
124
+ }
125
+ const minted = {
126
+ repository: input.repository,
127
+ sessionId: mintSessionId(),
128
+ startedAt: input.now,
129
+ };
130
+ writeSessionMarks({
131
+ version: 1,
132
+ sessions: [...file.sessions.filter((mark) => mark.repository !== input.repository), minted],
133
+ }, environment);
134
+ return { sessionId: minted.sessionId, startedAt: minted.startedAt, minted: true };
135
+ }