balladeer 1.0.0 → 1.0.1

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.
@@ -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
+ }
package/dist/store.d.ts CHANGED
@@ -81,6 +81,16 @@ export declare function readCredentials(environment?: NodeJS.ProcessEnv): Creden
81
81
  * its mode is set on the open descriptor, and it is flushed before the rename.
82
82
  */
83
83
  export declare function writeCredentials(credentials: Credentials, environment?: NodeJS.ProcessEnv): void;
84
+ /**
85
+ * One private file in the store directory, written the same careful way the
86
+ * credential is.
87
+ *
88
+ * Pulled out of `writeCredentials` rather than copied beside it: the session
89
+ * marker written next to the credential is not a secret, but it is written into
90
+ * the same directory by the same command, and a second, sloppier writer there
91
+ * would be the one an attacker pre-plants a symlink for.
92
+ */
93
+ export declare function writeStoreFile(fileName: string, contents: string, environment?: NodeJS.ProcessEnv): void;
84
94
  /** Origins, not URL strings, so `https://host/../` cannot alias a stored entry. */
85
95
  export declare function normalizeControlPlane(value: string): string;
86
96
  /** At most one pending pairing per control plane, newest wins. */
package/dist/store.js CHANGED
@@ -124,14 +124,26 @@ export function readCredentials(environment = process.env) {
124
124
  * its mode is set on the open descriptor, and it is flushed before the rename.
125
125
  */
126
126
  export function writeCredentials(credentials, environment = process.env) {
127
+ writeStoreFile("credentials.json", `${JSON.stringify(credentials, null, 2)}\n`, environment);
128
+ }
129
+ /**
130
+ * One private file in the store directory, written the same careful way the
131
+ * credential is.
132
+ *
133
+ * Pulled out of `writeCredentials` rather than copied beside it: the session
134
+ * marker written next to the credential is not a secret, but it is written into
135
+ * the same directory by the same command, and a second, sloppier writer there
136
+ * would be the one an attacker pre-plants a symlink for.
137
+ */
138
+ export function writeStoreFile(fileName, contents, environment = process.env) {
127
139
  const directory = ensureStoreDirectory(environment);
128
- const path = join(directory, "credentials.json");
129
- const temporary = join(directory, `.credentials.${randomBytes(8).toString("hex")}.tmp`);
140
+ const path = join(directory, fileName);
141
+ const temporary = join(directory, `.${fileName}.${randomBytes(8).toString("hex")}.tmp`);
130
142
  let descriptor;
131
143
  try {
132
144
  descriptor = openSync(temporary, "wx", 0o600);
133
145
  fchmodSync(descriptor, 0o600);
134
- writeSync(descriptor, `${JSON.stringify(credentials, null, 2)}\n`);
146
+ writeSync(descriptor, contents);
135
147
  fsyncSync(descriptor);
136
148
  closeSync(descriptor);
137
149
  descriptor = undefined;
package/dist/wire.d.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export declare const CLI_VERSION = "1.0.0";
8
+ export declare const CLI_VERSION = "1.0.1";
9
9
  export declare const CLIENT_HEADER = "x-balladeer-client";
10
- export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.0";
10
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.1";
11
11
  export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
12
12
  export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
13
13
  export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
@@ -108,6 +108,21 @@ export type SetupInvitationResponse = Readonly<{
108
108
  export type SetupCiRequest = Readonly<{
109
109
  eventClasses: readonly ("push" | "pull_request")[];
110
110
  }>;
111
+ /**
112
+ * The GitHub login this machine's `gh` is signed in as.
113
+ *
114
+ * Sent so a run started by this person can be shown under their name instead of
115
+ * a handle nobody has learned. No token is sent, here or anywhere: the login is
116
+ * read from the GitHub CLI they already signed in to, and Balladeer stores it as
117
+ * something they said rather than something anyone checked. It grants nothing.
118
+ * An empty string clears it.
119
+ */
120
+ export type SetupGithubLoginRequest = Readonly<{
121
+ login: string;
122
+ }>;
123
+ export type SetupGithubLoginResponse = Readonly<{
124
+ claimedLogin: string | null;
125
+ }>;
111
126
  export type SetupRepositoryView = Readonly<{
112
127
  id: string;
113
128
  displayName: string;
@@ -193,9 +208,32 @@ export type SetupCandidateResponse = Readonly<{
193
208
  reviewUrl: string;
194
209
  }>;
195
210
  /** The step objects `--json` emits, one per line, and nothing else on stdout. */
211
+ /**
212
+ * What a run did about Claude desktop chat, on the two steps that can touch it.
213
+ *
214
+ * Its own type because both `agent` and `refresh` report it and they must report
215
+ * it identically: an agent reading either step reads the same three fields.
216
+ */
217
+ export type ClaudeDesktopStep = Readonly<{
218
+ status: "connected" | "current" | "absent" | "unsupported" | "refused";
219
+ key?: string;
220
+ path?: string;
221
+ reason?: string;
222
+ }>;
196
223
  export type JsonStep = Readonly<{
197
224
  step: "explain";
198
225
  version: string;
226
+ }> | Readonly<{
227
+ /**
228
+ * The GitHub login this machine was signed in as, handed over so a run
229
+ * this person starts can be shown under their name. `status` says what
230
+ * happened: `claimed` when it was recorded, `unavailable` when `gh` could
231
+ * not name a login, and `refused` when the control plane would not take
232
+ * it. None of the three stops setup.
233
+ */
234
+ step: "github_login";
235
+ status: "claimed" | "unavailable" | "refused";
236
+ login?: string;
199
237
  }> | Readonly<{
200
238
  step: "pair";
201
239
  status: "pending" | "paired" | "expired" | "denied";
@@ -258,6 +296,15 @@ export type JsonStep = Readonly<{
258
296
  /** Present only where this command refused to change one of these files. */
259
297
  mcpConfig?: string;
260
298
  conventions?: string;
299
+ /**
300
+ * What happened to Claude desktop chat, present only where this run had
301
+ * something to say about it. `connected` and `current` carry the key this
302
+ * repository's server is named under and the file it was written to;
303
+ * `absent`, `unsupported` and `refused` carry the sentence saying why
304
+ * nothing was written, and are only ever reported where the flag asked for
305
+ * it or the file itself refused the merge.
306
+ */
307
+ claudeDesktop?: ClaudeDesktopStep;
261
308
  }> | Readonly<{
262
309
  step: "ci";
263
310
  /**
@@ -284,6 +331,20 @@ export type JsonStep = Readonly<{
284
331
  reviewUrl?: string;
285
332
  promiseUrl?: string;
286
333
  }>
334
+ /**
335
+ * The one-time qualification setup, minted and written to disk.
336
+ *
337
+ * `supersededPackets` is the number of earlier packets this run invalidated,
338
+ * so a caller can tell an ordinary first preparation from a deliberate
339
+ * replacement without reading prose. It is zero on the ordinary case.
340
+ */
341
+ | Readonly<{
342
+ step: "qualification";
343
+ status: "prepared";
344
+ promiseId: string;
345
+ metadataPath: string;
346
+ supersededPackets: number;
347
+ }>
287
348
  /**
288
349
  * A whole discovered catalog, filed in one run.
289
350
  *
@@ -482,6 +543,30 @@ export type JsonStep = Readonly<{
482
543
  changed: boolean;
483
544
  exitCode: number;
484
545
  }>
546
+ /**
547
+ * The working session an agent reads and commits under.
548
+ *
549
+ * `minted` is the difference between "here is a new session" and "you already
550
+ * have one open": an agent that asked twice must reuse the second answer
551
+ * rather than treat it as a second session, or its reads and its commit end
552
+ * up under two ids and the join breaks by the act of asking for it.
553
+ */
554
+ | Readonly<{
555
+ step: "session";
556
+ sessionId: string;
557
+ startedAt: string;
558
+ minted: boolean;
559
+ /** The exact line to write into the commit or the pull-request body. */
560
+ trailer: string;
561
+ changed: boolean;
562
+ }>
563
+ /** One commit recorded as written by one session. */
564
+ | Readonly<{
565
+ step: "session_commit";
566
+ sessionId: string;
567
+ commitSha: string;
568
+ changed: boolean;
569
+ }>
485
570
  /**
486
571
  * One invited address, one line, whatever happened to it.
487
572
  *
@@ -582,6 +667,7 @@ export type JsonStep = Readonly<{
582
667
  previousConventionsVersion?: number;
583
668
  reason?: string;
584
669
  message?: string;
670
+ claudeDesktop?: ClaudeDesktopStep;
585
671
  }> | Readonly<{
586
672
  step: "whoami";
587
673
  session: PairSessionSummary;
package/dist/wire.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export const CLI_VERSION = "1.0.0";
8
+ export const CLI_VERSION = "1.0.1";
9
9
  export const CLIENT_HEADER = "x-balladeer-client";
10
10
  export const CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
11
11
  export const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Set up Balladeer from your terminal, or from a coding agent's.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,