balladeer 1.0.18 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/checkout-form.d.ts +11 -0
- package/dist/checkout-form.js +14 -0
- package/dist/cli.js +4 -3
- package/dist/commands/guidance.js +23 -8
- package/dist/commands/mcp.js +5 -9
- package/dist/commands/setup.js +5 -23
- package/dist/currency.d.ts +2 -0
- package/dist/currency.js +2 -1
- package/dist/guidance-hook.mjs +162 -56
- package/dist/guidance-runtime.d.ts +26 -0
- package/dist/guidance-runtime.js +71 -1
- package/dist/self-update.d.ts +3 -3
- package/dist/self-update.js +38 -8
- package/dist/wire.d.ts +2 -2
- package/dist/wire.js +1 -1
- package/package.json +1 -1
package/dist/checkout-form.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an install may take the older form out of the checkout it runs in:
|
|
3
|
+
* only when a person ran setup or install and the user-scope form is fully in
|
|
4
|
+
* place. The background update a session hook starts leaves the repository's
|
|
5
|
+
* files alone, because an edit to a file the team commits is a diff nobody
|
|
6
|
+
* asked for, and the user-scope hook already switches the older hook off from
|
|
7
|
+
* the laptop side (`retireCheckoutRuntime`), so nothing is lost.
|
|
8
|
+
*/
|
|
9
|
+
export declare function checkoutFormRemovalAllowed(environment: NodeJS.ProcessEnv, writes: readonly {
|
|
10
|
+
status: string;
|
|
11
|
+
}[]): boolean;
|
|
1
12
|
/**
|
|
2
13
|
* Taking this product's own older, per-repository form out of a checkout once
|
|
3
14
|
* the user-scope form is in place, so one hook speaks per session.
|
package/dist/checkout-form.js
CHANGED
|
@@ -2,6 +2,20 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { copyFileSync, existsSync, lstatSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { isOurEntry, writeJsonAtomically } from "./mcp-config.js";
|
|
5
|
+
import { SELF_UPDATE_ENVIRONMENT } from "./self-update.js";
|
|
6
|
+
/**
|
|
7
|
+
* Whether an install may take the older form out of the checkout it runs in:
|
|
8
|
+
* only when a person ran setup or install and the user-scope form is fully in
|
|
9
|
+
* place. The background update a session hook starts leaves the repository's
|
|
10
|
+
* files alone, because an edit to a file the team commits is a diff nobody
|
|
11
|
+
* asked for, and the user-scope hook already switches the older hook off from
|
|
12
|
+
* the laptop side (`retireCheckoutRuntime`), so nothing is lost.
|
|
13
|
+
*/
|
|
14
|
+
export function checkoutFormRemovalAllowed(environment, writes) {
|
|
15
|
+
return (environment[SELF_UPDATE_ENVIRONMENT] !== "1" &&
|
|
16
|
+
writes.length > 0 &&
|
|
17
|
+
!writes.some((w) => w.status === "refused"));
|
|
18
|
+
}
|
|
5
19
|
const PROJECT_OWNER = "Balladeer current guidance (loader 1)";
|
|
6
20
|
const CODEX_BLOCKS = [
|
|
7
21
|
["# balladeer:guidance:start", "# balladeer:guidance:end"],
|
package/dist/cli.js
CHANGED
|
@@ -31,7 +31,7 @@ import { updateNotice } from "./currency.js";
|
|
|
31
31
|
import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
|
|
32
32
|
import { recordSelfUpdateState, SELF_UPDATE_ENVIRONMENT } from "./self-update.js";
|
|
33
33
|
import { hooksAwaitingTrust } from "./hook-trust.js";
|
|
34
|
-
import { describeCheckoutFormRemoval, findCheckoutForm, removeCheckoutForm, } from "./checkout-form.js";
|
|
34
|
+
import { checkoutFormRemovalAllowed, describeCheckoutFormRemoval, findCheckoutForm, removeCheckoutForm, } from "./checkout-form.js";
|
|
35
35
|
import { StoreError, normalizeControlPlane } from "./store.js";
|
|
36
36
|
import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
37
37
|
const USAGE = `balladeer ${CLI_VERSION}
|
|
@@ -545,8 +545,9 @@ function installEverything(options) {
|
|
|
545
545
|
}
|
|
546
546
|
// With the user-scope form in place, the older per-repository form in this
|
|
547
547
|
// checkout only makes the session hear the guidance twice. Out it goes, with
|
|
548
|
-
// a copy beside any file git does not already keep
|
|
549
|
-
|
|
548
|
+
// a copy beside any file git does not already keep, and never from the
|
|
549
|
+
// background update (see `checkoutFormRemovalAllowed`).
|
|
550
|
+
if (checkoutFormRemovalAllowed(process.env, writes ?? [])) {
|
|
550
551
|
const checkoutForm = findCheckoutForm({
|
|
551
552
|
cwd: process.cwd(),
|
|
552
553
|
controlPlane: options.controlPlane,
|
|
@@ -7,6 +7,7 @@ import { repositoryHints } from "../repository.js";
|
|
|
7
7
|
import { newerVersionPublished } from "../currency.js";
|
|
8
8
|
import { startSelfUpdateIfDue } from "../self-update.js";
|
|
9
9
|
import { noteHookFired } from "../hook-trust.js";
|
|
10
|
+
import { retireCheckoutRuntime } from "../guidance-runtime.js";
|
|
10
11
|
/** A repository-specific setup step for explicit status and empty-catalog responses. */
|
|
11
12
|
export function untrackedLine(remote) {
|
|
12
13
|
const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
|
|
@@ -158,6 +159,18 @@ export async function runGuidance(options) {
|
|
|
158
159
|
// trust are the ones this matters to.
|
|
159
160
|
if (options.hook === "codex")
|
|
160
161
|
noteHookFired(options.environment, "codex");
|
|
162
|
+
// This hook covers this checkout now, so the older per-repository hook
|
|
163
|
+
// here (releases before 1.0.8) only makes the session hear the guidance
|
|
164
|
+
// twice. Switch it off from the laptop side, once, before the fetch:
|
|
165
|
+
// nothing printed, no repository file touched, nobody asked to run a thing.
|
|
166
|
+
if (!projectScoped && event === "SessionStart")
|
|
167
|
+
retireCheckoutRuntime({
|
|
168
|
+
cwd: options.cwd,
|
|
169
|
+
host: options.hook,
|
|
170
|
+
repositoryId: agents[0].repositoryId,
|
|
171
|
+
controlPlane: agents[0].controlPlane,
|
|
172
|
+
environment: options.environment,
|
|
173
|
+
});
|
|
161
174
|
const loaded = await loadGuidance({
|
|
162
175
|
agent: agents[0],
|
|
163
176
|
environment: options.environment,
|
|
@@ -165,14 +178,16 @@ export async function runGuidance(options) {
|
|
|
165
178
|
hook: options.hook,
|
|
166
179
|
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
167
180
|
});
|
|
168
|
-
// Behind? Start the install in the background
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})
|
|
181
|
+
// Behind? Start the install in the background and carry on serving this
|
|
182
|
+
// session from the copy that is here. Any hook that loaded guidance may
|
|
183
|
+
// start it, not only a session start: sessions run for days, and a laptop
|
|
184
|
+
// whose sessions never restart would otherwise never update (Robert's own,
|
|
185
|
+
// 1 October 2026). The stamp keeps it to once a day per release.
|
|
186
|
+
startSelfUpdateIfDue(newerVersionPublished(), {
|
|
187
|
+
environment: options.environment,
|
|
188
|
+
...(options.startUpdate ? { run: options.startUpdate } : {}),
|
|
189
|
+
...(options.now ? { now: options.now } : {}),
|
|
190
|
+
});
|
|
176
191
|
const sessionId = identifier(record.session_id);
|
|
177
192
|
const agentId = identifier(record.agent_id);
|
|
178
193
|
recordGuidanceContext({
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
|
-
import { installGuidanceLoader } from "../guidance-install.js";
|
|
3
2
|
import { callAgentTool, forwarderHeaders, noAgentCredentialSentence, safeJson, selectAgent, structuredString, updateLine, } from "../agent.js";
|
|
4
3
|
import { noteServerVersion, updateNotice } from "../currency.js";
|
|
5
4
|
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
@@ -189,15 +188,12 @@ export async function runMcp(options) {
|
|
|
189
188
|
return 4;
|
|
190
189
|
return serveUntracked(options, untrackedLine(repositoryHint(options.cwd)));
|
|
191
190
|
}
|
|
191
|
+
// This forwarder used to install the per-repository guidance hook into any
|
|
192
|
+
// checkout whose project config named this repository. From 1.0.8 the hook
|
|
193
|
+
// lives at user scope, so doing that put a second hook beside it and every
|
|
194
|
+
// session heard the guidance twice; it also re-armed the older hook the
|
|
195
|
+
// user-scope hook had just switched off. It installs nothing now.
|
|
192
196
|
const { agent } = selection;
|
|
193
|
-
const guidanceInstall = installGuidanceLoader({
|
|
194
|
-
environment: options.environment,
|
|
195
|
-
cwd: options.cwd,
|
|
196
|
-
repositoryId: agent.repositoryId,
|
|
197
|
-
controlPlane: agent.controlPlane,
|
|
198
|
-
});
|
|
199
|
-
if (guidanceInstall.status !== "not_applicable")
|
|
200
|
-
options.error(`Balladeer guidance loader: ${guidanceInstall.status}${guidanceInstall.reason ? ` (${guidanceInstall.reason})` : ""}. New project hooks may need host trust; no trust is assumed.\n`);
|
|
201
197
|
let saidUpdate = false;
|
|
202
198
|
// Which host this session belongs to is known from its hello; whether its
|
|
203
199
|
// hooks are waiting for the person's trust is asked at the first tool call,
|
package/dist/commands/setup.js
CHANGED
|
@@ -11,7 +11,6 @@ import { CI_BRANCH, commitWorkflowOnBranch, repositoryRoot, workflowOnDefaultBra
|
|
|
11
11
|
import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, isOurEntry, mergeMcpConfig, readMcpConfig, stdioEntry, } from "../mcp-config.js";
|
|
12
12
|
import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
|
|
13
13
|
import { selectAgent } from "../agent.js";
|
|
14
|
-
import { installGuidanceLoader } from "../guidance-install.js";
|
|
15
14
|
import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "../remove-earlier.js";
|
|
16
15
|
import { userHome } from "../user-scope.js";
|
|
17
16
|
import { formatInstant } from "../local-time.js";
|
|
@@ -1008,28 +1007,11 @@ function mergeHostConfig(options, root, repositoryId, published) {
|
|
|
1008
1007
|
: mergeMcpConfig(root, entry, options.controlPlane);
|
|
1009
1008
|
}
|
|
1010
1009
|
function writeHostConventions(options, root) {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
const loader = installGuidanceLoader({
|
|
1017
|
-
environment: options.environment,
|
|
1018
|
-
cwd: root,
|
|
1019
|
-
repositoryId,
|
|
1020
|
-
controlPlane: options.controlPlane,
|
|
1021
|
-
host: options.client === "codex" ? "codex" : "claude",
|
|
1022
|
-
});
|
|
1023
|
-
emit(options, { step: "guidance_loader", ...loader });
|
|
1024
|
-
say(options, loader.status === "installed_needs_host_trust" ||
|
|
1025
|
-
loader.status === "installed_trust_unverified"
|
|
1026
|
-
? "Project guidance loader installed. Review new hooks in your coding host once; future policy updates do not change that hook definition. A fresh session is needed to observe it."
|
|
1027
|
-
: loader.status === "unavailable"
|
|
1028
|
-
? `Project guidance loader needs repair (${loader.reason}). No active hook is claimed.`
|
|
1029
|
-
: "No matching project guidance integration was found. No guidance hook was installed or activated.");
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
return result;
|
|
1010
|
+
// No per-repository guidance hook any more. The install step registers the
|
|
1011
|
+
// hook at user scope, where it runs in every checkout; adding the older
|
|
1012
|
+
// per-repository one here as well put a second hook in this checkout, and
|
|
1013
|
+
// every session heard the guidance twice (Didero, September 2026).
|
|
1014
|
+
return writeConventions(root, options.client === "codex" ? { file: "AGENTS.md" } : {});
|
|
1033
1015
|
}
|
|
1034
1016
|
function hostNextSteps(options) {
|
|
1035
1017
|
if (options.client === "codex")
|
package/dist/currency.d.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
export declare const CLI_NEWEST_HEADER = "x-balladeer-cli-newest";
|
|
16
16
|
export declare const CLI_FLOOR_HEADER = "x-balladeer-cli-minimum";
|
|
17
17
|
export declare const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
|
|
18
|
+
/** Whether `left` is an older release than `right` (both `x.y.z`). */
|
|
19
|
+
export declare function older(left: string, right: string): boolean;
|
|
18
20
|
/**
|
|
19
21
|
* Reads the version signals off one response.
|
|
20
22
|
*
|
package/dist/currency.js
CHANGED
|
@@ -25,7 +25,8 @@ const ADVICE_LIMIT = 300;
|
|
|
25
25
|
const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
|
|
26
26
|
let notice;
|
|
27
27
|
let newestSeen;
|
|
28
|
-
|
|
28
|
+
/** Whether `left` is an older release than `right` (both `x.y.z`). */
|
|
29
|
+
export function older(left, right) {
|
|
29
30
|
const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
|
|
30
31
|
for (let index = 0; index < 3; index += 1) {
|
|
31
32
|
const difference = a[index] - b[index];
|
package/dist/guidance-hook.mjs
CHANGED
|
@@ -2152,13 +2152,31 @@ function relayHook(input, directory) {
|
|
|
2152
2152
|
}
|
|
2153
2153
|
|
|
2154
2154
|
// src/wire.ts
|
|
2155
|
-
var CLI_VERSION = "1.0.
|
|
2155
|
+
var CLI_VERSION = "1.0.19";
|
|
2156
2156
|
var CLIENT_HEADER = "x-balladeer-client";
|
|
2157
2157
|
var UPDATE_STATE_HEADER = "x-balladeer-update-state";
|
|
2158
2158
|
var HOOK_HOST_HEADER = "x-balladeer-hook";
|
|
2159
2159
|
var CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
|
|
2160
2160
|
var DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
2161
2161
|
|
|
2162
|
+
// src/guidance-runtime.ts
|
|
2163
|
+
import { createHash as createHash3, randomBytes as randomBytes2 } from "node:crypto";
|
|
2164
|
+
import {
|
|
2165
|
+
constants as constants2,
|
|
2166
|
+
closeSync as closeSync3,
|
|
2167
|
+
fstatSync as fstatSync2,
|
|
2168
|
+
lstatSync as lstatSync3,
|
|
2169
|
+
mkdirSync as mkdirSync5,
|
|
2170
|
+
openSync as openSync3,
|
|
2171
|
+
readFileSync as readFileSync5,
|
|
2172
|
+
realpathSync,
|
|
2173
|
+
renameSync as renameSync4,
|
|
2174
|
+
unlinkSync as unlinkSync3,
|
|
2175
|
+
writeFileSync as writeFileSync4
|
|
2176
|
+
} from "node:fs";
|
|
2177
|
+
import { join as join5 } from "node:path";
|
|
2178
|
+
import { execFileSync } from "node:child_process";
|
|
2179
|
+
|
|
2162
2180
|
// src/store.ts
|
|
2163
2181
|
import {
|
|
2164
2182
|
closeSync,
|
|
@@ -2289,6 +2307,8 @@ var HOOK_SPECIFIER = "balladeer@1";
|
|
|
2289
2307
|
var STAMP = "self-update.json";
|
|
2290
2308
|
var STATE = "self-update-state.json";
|
|
2291
2309
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2310
|
+
var HOUR_MS = 60 * 60 * 1e3;
|
|
2311
|
+
var VERSION2 = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
|
|
2292
2312
|
var SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
|
|
2293
2313
|
var STATE_PATTERN = /^(?:started|unstartable|installed|failed):[A-Za-z0-9._-]{1,40}$/;
|
|
2294
2314
|
function stampPath(environment) {
|
|
@@ -2297,16 +2317,23 @@ function stampPath(environment) {
|
|
|
2297
2317
|
function statePath(environment) {
|
|
2298
2318
|
return join3(configHome(environment), STATE);
|
|
2299
2319
|
}
|
|
2300
|
-
function
|
|
2320
|
+
function lastAttempt(environment) {
|
|
2301
2321
|
const path = stampPath(environment);
|
|
2302
|
-
if (!existsSync(path)) return 0;
|
|
2322
|
+
if (!existsSync(path)) return { startedAt: 0 };
|
|
2303
2323
|
try {
|
|
2304
2324
|
const parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
2305
|
-
|
|
2325
|
+
const startedAt = typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
|
|
2326
|
+
return typeof parsed.toward === "string" && VERSION2.test(parsed.toward) ? { startedAt, toward: parsed.toward } : { startedAt };
|
|
2306
2327
|
} catch {
|
|
2307
|
-
return 0;
|
|
2328
|
+
return { startedAt: 0 };
|
|
2308
2329
|
}
|
|
2309
2330
|
}
|
|
2331
|
+
function due(newer, last, now) {
|
|
2332
|
+
const since = now - last.startedAt;
|
|
2333
|
+
if (since >= DAY_MS) return true;
|
|
2334
|
+
const newerTarget = last.toward === void 0 || older(last.toward, newer);
|
|
2335
|
+
return newerTarget && since >= HOUR_MS;
|
|
2336
|
+
}
|
|
2310
2337
|
function writePrivate(path, value, environment) {
|
|
2311
2338
|
mkdirSync3(configHome(environment), { recursive: true, mode: 448 });
|
|
2312
2339
|
writeFileSync2(path, JSON.stringify(value) + "\n", { mode: 384 });
|
|
@@ -2349,7 +2376,7 @@ function detached(command, args, environment) {
|
|
|
2349
2376
|
function startSelfUpdateIfDue(newer, deps) {
|
|
2350
2377
|
if (newer === void 0) return false;
|
|
2351
2378
|
const now = deps.now ?? Date.now;
|
|
2352
|
-
if (
|
|
2379
|
+
if (!due(newer, lastAttempt(deps.environment), now())) return false;
|
|
2353
2380
|
const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
|
|
2354
2381
|
let started;
|
|
2355
2382
|
try {
|
|
@@ -2628,52 +2655,124 @@ function recordGuidanceContext(input, write2) {
|
|
|
2628
2655
|
return true;
|
|
2629
2656
|
}
|
|
2630
2657
|
|
|
2658
|
+
// src/guidance-runtime.ts
|
|
2659
|
+
var OWNER = "balladeer-guidance-runtime/1";
|
|
2660
|
+
var sha2 = (value) => createHash3("sha256").update(value).digest("hex");
|
|
2661
|
+
function guidanceRuntimeKey(scope) {
|
|
2662
|
+
return sha2(
|
|
2663
|
+
JSON.stringify([
|
|
2664
|
+
realpathSync(scope.root),
|
|
2665
|
+
new URL(scope.controlPlane).origin,
|
|
2666
|
+
scope.repositoryId,
|
|
2667
|
+
scope.host
|
|
2668
|
+
])
|
|
2669
|
+
);
|
|
2670
|
+
}
|
|
2671
|
+
function checkDirectory(path) {
|
|
2672
|
+
const stat = lstatSync3(path);
|
|
2673
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 63) !== 0)
|
|
2674
|
+
throw new Error("guidance_runtime_directory_unsafe");
|
|
2675
|
+
}
|
|
2676
|
+
function readPrivate(path, max) {
|
|
2677
|
+
const fd = openSync3(path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
2678
|
+
try {
|
|
2679
|
+
const stat = fstatSync2(fd);
|
|
2680
|
+
if (!stat.isFile() || stat.size > max || (stat.mode & 63) !== 0)
|
|
2681
|
+
throw new Error("guidance_runtime_file_unsafe");
|
|
2682
|
+
return readFileSync5(fd);
|
|
2683
|
+
} finally {
|
|
2684
|
+
closeSync3(fd);
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
function retireCheckoutRuntime(input) {
|
|
2688
|
+
try {
|
|
2689
|
+
const base = join5(configHome(input.environment), "guidance-runtime-v1");
|
|
2690
|
+
try {
|
|
2691
|
+
lstatSync3(base);
|
|
2692
|
+
} catch {
|
|
2693
|
+
return "absent";
|
|
2694
|
+
}
|
|
2695
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
2696
|
+
cwd: input.cwd,
|
|
2697
|
+
encoding: "utf8",
|
|
2698
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2699
|
+
timeout: 500,
|
|
2700
|
+
maxBuffer: 8192
|
|
2701
|
+
}).trim();
|
|
2702
|
+
if (root.length === 0) return "absent";
|
|
2703
|
+
const key2 = guidanceRuntimeKey({
|
|
2704
|
+
root,
|
|
2705
|
+
host: input.host,
|
|
2706
|
+
repositoryId: input.repositoryId,
|
|
2707
|
+
controlPlane: input.controlPlane
|
|
2708
|
+
});
|
|
2709
|
+
const directory = join5(base, key2);
|
|
2710
|
+
const manifestPath = join5(directory, "current.json");
|
|
2711
|
+
try {
|
|
2712
|
+
checkDirectory(directory);
|
|
2713
|
+
} catch {
|
|
2714
|
+
return "absent";
|
|
2715
|
+
}
|
|
2716
|
+
let manifest;
|
|
2717
|
+
try {
|
|
2718
|
+
manifest = JSON.parse(readPrivate(manifestPath, 4096).toString("utf8"));
|
|
2719
|
+
} catch (error) {
|
|
2720
|
+
return error.code === "ENOENT" ? "absent" : "kept";
|
|
2721
|
+
}
|
|
2722
|
+
if (manifest?.owner !== OWNER || manifest.scopeKey !== key2) return "kept";
|
|
2723
|
+
renameSync4(manifestPath, join5(directory, "retired.json"));
|
|
2724
|
+
return "retired";
|
|
2725
|
+
} catch {
|
|
2726
|
+
return "kept";
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2631
2730
|
// src/guidance-install.ts
|
|
2632
|
-
import { execFileSync } from "node:child_process";
|
|
2731
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2633
2732
|
import {
|
|
2634
|
-
constants as
|
|
2635
|
-
closeSync as
|
|
2636
|
-
fstatSync as
|
|
2637
|
-
openSync as
|
|
2733
|
+
constants as constants3,
|
|
2734
|
+
closeSync as closeSync6,
|
|
2735
|
+
fstatSync as fstatSync3,
|
|
2736
|
+
openSync as openSync6,
|
|
2638
2737
|
existsSync as existsSync3,
|
|
2639
|
-
lstatSync as
|
|
2738
|
+
lstatSync as lstatSync5,
|
|
2640
2739
|
linkSync,
|
|
2641
|
-
mkdirSync as
|
|
2642
|
-
readFileSync as
|
|
2643
|
-
renameSync as
|
|
2644
|
-
unlinkSync as
|
|
2645
|
-
writeFileSync as
|
|
2740
|
+
mkdirSync as mkdirSync7,
|
|
2741
|
+
readFileSync as readFileSync8,
|
|
2742
|
+
renameSync as renameSync7,
|
|
2743
|
+
unlinkSync as unlinkSync6,
|
|
2744
|
+
writeFileSync as writeFileSync5
|
|
2646
2745
|
} from "node:fs";
|
|
2647
2746
|
var import_toml2 = __toESM(require_toml(), 1);
|
|
2648
|
-
import { dirname as dirname4, join as
|
|
2747
|
+
import { dirname as dirname4, join as join8, relative } from "node:path";
|
|
2649
2748
|
|
|
2650
2749
|
// src/codex-config.ts
|
|
2651
2750
|
var import_toml = __toESM(require_toml(), 1);
|
|
2652
2751
|
import {
|
|
2653
|
-
closeSync as
|
|
2752
|
+
closeSync as closeSync5,
|
|
2654
2753
|
existsSync as existsSync2,
|
|
2655
2754
|
fsyncSync as fsyncSync3,
|
|
2656
|
-
lstatSync as
|
|
2657
|
-
mkdirSync as
|
|
2658
|
-
openSync as
|
|
2659
|
-
readFileSync as
|
|
2660
|
-
renameSync as
|
|
2661
|
-
unlinkSync as
|
|
2755
|
+
lstatSync as lstatSync4,
|
|
2756
|
+
mkdirSync as mkdirSync6,
|
|
2757
|
+
openSync as openSync5,
|
|
2758
|
+
readFileSync as readFileSync7,
|
|
2759
|
+
renameSync as renameSync6,
|
|
2760
|
+
unlinkSync as unlinkSync5,
|
|
2662
2761
|
writeSync as writeSync3
|
|
2663
2762
|
} from "node:fs";
|
|
2664
|
-
import { join as
|
|
2763
|
+
import { join as join7 } from "node:path";
|
|
2665
2764
|
|
|
2666
2765
|
// src/mcp-config.ts
|
|
2667
2766
|
import {
|
|
2668
|
-
closeSync as
|
|
2767
|
+
closeSync as closeSync4,
|
|
2669
2768
|
fsyncSync as fsyncSync2,
|
|
2670
|
-
openSync as
|
|
2671
|
-
readFileSync as
|
|
2672
|
-
renameSync as
|
|
2673
|
-
unlinkSync as
|
|
2769
|
+
openSync as openSync4,
|
|
2770
|
+
readFileSync as readFileSync6,
|
|
2771
|
+
renameSync as renameSync5,
|
|
2772
|
+
unlinkSync as unlinkSync4,
|
|
2674
2773
|
writeSync as writeSync2
|
|
2675
2774
|
} from "node:fs";
|
|
2676
|
-
import { basename, dirname as dirname3, isAbsolute, join as
|
|
2775
|
+
import { basename, dirname as dirname3, isAbsolute, join as join6 } from "node:path";
|
|
2677
2776
|
var MCP_CONFIG_FILE = ".mcp.json";
|
|
2678
2777
|
function sameOrigin(left, right) {
|
|
2679
2778
|
try {
|
|
@@ -2716,7 +2815,7 @@ function currentEntry(parsed) {
|
|
|
2716
2815
|
}
|
|
2717
2816
|
function readMcpConfig(repositoryRoot) {
|
|
2718
2817
|
try {
|
|
2719
|
-
return JSON.parse(
|
|
2818
|
+
return JSON.parse(readFileSync6(join6(repositoryRoot, MCP_CONFIG_FILE), "utf8"));
|
|
2720
2819
|
} catch {
|
|
2721
2820
|
return void 0;
|
|
2722
2821
|
}
|
|
@@ -2726,7 +2825,7 @@ function readMcpConfig(repositoryRoot) {
|
|
|
2726
2825
|
var CODEX_CONFIG_FILE = ".codex/config.toml";
|
|
2727
2826
|
function readCodexEntry(root) {
|
|
2728
2827
|
try {
|
|
2729
|
-
const config = (0, import_toml.parse)(
|
|
2828
|
+
const config = (0, import_toml.parse)(readFileSync7(join7(root, CODEX_CONFIG_FILE), "utf8"));
|
|
2730
2829
|
const servers = config.mcp_servers;
|
|
2731
2830
|
return servers && typeof servers === "object" && !Array.isArray(servers) && !(servers instanceof Date) ? servers.balladeer : void 0;
|
|
2732
2831
|
} catch {
|
|
@@ -2735,7 +2834,7 @@ function readCodexEntry(root) {
|
|
|
2735
2834
|
}
|
|
2736
2835
|
function hasManagedCodexEntry(root, controlPlane) {
|
|
2737
2836
|
try {
|
|
2738
|
-
const text =
|
|
2837
|
+
const text = readFileSync7(join7(root, CODEX_CONFIG_FILE), "utf8");
|
|
2739
2838
|
const starts = [...text.matchAll(/^# balladeer:mcp:start\r?$/gm)];
|
|
2740
2839
|
const ends = [...text.matchAll(/^# balladeer:mcp:end\r?$/gm)];
|
|
2741
2840
|
if (starts.length !== 1 || ends.length !== 1 || !starts[0] || !ends[0] || starts[0].index >= ends[0].index)
|
|
@@ -3151,23 +3250,23 @@ var MAX_FILE = 262144;
|
|
|
3151
3250
|
function readRegular(path, allowMissing = false, writable = true) {
|
|
3152
3251
|
if (!existsSync3(path)) {
|
|
3153
3252
|
try {
|
|
3154
|
-
|
|
3253
|
+
lstatSync5(path);
|
|
3155
3254
|
} catch (e) {
|
|
3156
3255
|
if (e.code === "ENOENT" && allowMissing) return void 0;
|
|
3157
3256
|
}
|
|
3158
3257
|
throw new Error("file_unavailable");
|
|
3159
3258
|
}
|
|
3160
|
-
const stat =
|
|
3259
|
+
const stat = lstatSync5(path);
|
|
3161
3260
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_FILE || writable && (stat.mode & 146) === 0)
|
|
3162
3261
|
throw new Error("file_not_safely_writable");
|
|
3163
|
-
return
|
|
3262
|
+
return readFileSync8(path, "utf8");
|
|
3164
3263
|
}
|
|
3165
3264
|
function safeParent(root, path) {
|
|
3166
3265
|
let at = dirname4(path);
|
|
3167
3266
|
while (at !== root) {
|
|
3168
3267
|
if (relative(root, at).startsWith("..")) throw new Error("outside_repository");
|
|
3169
3268
|
try {
|
|
3170
|
-
const stat =
|
|
3269
|
+
const stat = lstatSync5(at);
|
|
3171
3270
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe_directory");
|
|
3172
3271
|
} catch (e) {
|
|
3173
3272
|
if (e.code !== "ENOENT") throw e;
|
|
@@ -3177,12 +3276,12 @@ function safeParent(root, path) {
|
|
|
3177
3276
|
}
|
|
3178
3277
|
function validateProjectGuidanceScope(options) {
|
|
3179
3278
|
try {
|
|
3180
|
-
const root =
|
|
3279
|
+
const root = execFileSync2("git", ["-C", options.cwd, "rev-parse", "--show-toplevel"], {
|
|
3181
3280
|
encoding: "utf8",
|
|
3182
3281
|
stdio: ["ignore", "pipe", "ignore"],
|
|
3183
3282
|
timeout: 1e3
|
|
3184
3283
|
}).trim();
|
|
3185
|
-
const path =
|
|
3284
|
+
const path = join8(root, options.hook === "codex" ? ".codex/config.toml" : ".mcp.json");
|
|
3186
3285
|
safeParent(root, path);
|
|
3187
3286
|
readRegular(path, false, false);
|
|
3188
3287
|
const entry = options.hook === "codex" ? readCodexEntry(root) : currentEntry(readMcpConfig(root));
|
|
@@ -3262,11 +3361,11 @@ function withSafeEndpoint(agent) {
|
|
|
3262
3361
|
}
|
|
3263
3362
|
|
|
3264
3363
|
// src/repository.ts
|
|
3265
|
-
import { execFileSync as
|
|
3364
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
3266
3365
|
var REPOSITORY_HINT_PATTERN = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
3267
3366
|
function git(cwd, args) {
|
|
3268
3367
|
try {
|
|
3269
|
-
return
|
|
3368
|
+
return execFileSync3("git", ["-C", cwd, ...args], {
|
|
3270
3369
|
encoding: "utf8",
|
|
3271
3370
|
stdio: ["ignore", "pipe", "ignore"]
|
|
3272
3371
|
}).trim();
|
|
@@ -3296,21 +3395,21 @@ function repositoryHints(cwd = process.cwd()) {
|
|
|
3296
3395
|
}
|
|
3297
3396
|
|
|
3298
3397
|
// src/hook-trust.ts
|
|
3299
|
-
import { mkdirSync as
|
|
3300
|
-
import { join as
|
|
3398
|
+
import { mkdirSync as mkdirSync8, readFileSync as readFileSync9, rmSync, writeFileSync as writeFileSync6 } from "node:fs";
|
|
3399
|
+
import { join as join9 } from "node:path";
|
|
3301
3400
|
var HOSTS_REQUIRING_TRUST = ["codex"];
|
|
3302
3401
|
var FILE = "hook-trust.json";
|
|
3303
3402
|
var key = (host, set) => set === "guidance" ? host : `${host}:${set}`;
|
|
3304
3403
|
function read(environment) {
|
|
3305
3404
|
try {
|
|
3306
|
-
const parsed = JSON.parse(
|
|
3405
|
+
const parsed = JSON.parse(readFileSync9(join9(configHome(environment), FILE), "utf8"));
|
|
3307
3406
|
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3308
3407
|
} catch {
|
|
3309
3408
|
return {};
|
|
3310
3409
|
}
|
|
3311
3410
|
}
|
|
3312
3411
|
function pushCheckAwaitingFile(environment, host) {
|
|
3313
|
-
return
|
|
3412
|
+
return join9(configHome(environment), `${host}-push-check-awaiting-first-run`);
|
|
3314
3413
|
}
|
|
3315
3414
|
function forget(path) {
|
|
3316
3415
|
try {
|
|
@@ -3320,13 +3419,13 @@ function forget(path) {
|
|
|
3320
3419
|
}
|
|
3321
3420
|
function write(environment, marks) {
|
|
3322
3421
|
try {
|
|
3323
|
-
|
|
3324
|
-
|
|
3422
|
+
mkdirSync8(configHome(environment), { recursive: true, mode: 448 });
|
|
3423
|
+
writeFileSync6(join9(configHome(environment), FILE), JSON.stringify(marks) + "\n", {
|
|
3325
3424
|
mode: 384
|
|
3326
3425
|
});
|
|
3327
3426
|
for (const host of HOSTS_REQUIRING_TRUST) {
|
|
3328
3427
|
const file = pushCheckAwaitingFile(environment, host);
|
|
3329
|
-
if (awaiting(marks[key(host, "push-check")]))
|
|
3428
|
+
if (awaiting(marks[key(host, "push-check")])) writeFileSync6(file, "", { mode: 384 });
|
|
3330
3429
|
else forget(file);
|
|
3331
3430
|
}
|
|
3332
3431
|
} catch {
|
|
@@ -3460,6 +3559,14 @@ async function runGuidance(options) {
|
|
|
3460
3559
|
return 0;
|
|
3461
3560
|
}
|
|
3462
3561
|
if (options.hook === "codex") noteHookFired(options.environment, "codex");
|
|
3562
|
+
if (!projectScoped && event === "SessionStart")
|
|
3563
|
+
retireCheckoutRuntime({
|
|
3564
|
+
cwd: options.cwd,
|
|
3565
|
+
host: options.hook,
|
|
3566
|
+
repositoryId: agents[0].repositoryId,
|
|
3567
|
+
controlPlane: agents[0].controlPlane,
|
|
3568
|
+
environment: options.environment
|
|
3569
|
+
});
|
|
3463
3570
|
const loaded = await loadGuidance({
|
|
3464
3571
|
agent: agents[0],
|
|
3465
3572
|
environment: options.environment,
|
|
@@ -3467,12 +3574,11 @@ async function runGuidance(options) {
|
|
|
3467
3574
|
hook: options.hook,
|
|
3468
3575
|
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
3469
3576
|
});
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
});
|
|
3577
|
+
startSelfUpdateIfDue(newerVersionPublished(), {
|
|
3578
|
+
environment: options.environment,
|
|
3579
|
+
...options.startUpdate ? { run: options.startUpdate } : {},
|
|
3580
|
+
...options.now ? { now: options.now } : {}
|
|
3581
|
+
});
|
|
3476
3582
|
const sessionId = identifier(record.session_id);
|
|
3477
3583
|
const agentId = identifier(record.agent_id);
|
|
3478
3584
|
recordGuidanceContext(
|
|
@@ -10,6 +10,32 @@ export declare function installGuidanceRuntime(options: Scope & {
|
|
|
10
10
|
/** Digest of the final, already-validated project MCP configuration. */
|
|
11
11
|
configurationDigest: string;
|
|
12
12
|
}): void;
|
|
13
|
+
export type RetireOutcome = "retired" | "absent" | "kept";
|
|
14
|
+
/**
|
|
15
|
+
* Switches off the older per-repository hook in one checkout, without touching
|
|
16
|
+
* any file in the repository.
|
|
17
|
+
*
|
|
18
|
+
* Releases before 1.0.8 put a hook in the checkout's `.claude/settings.json`
|
|
19
|
+
* or `.codex/config.toml` whose command is the wrapper below. That wrapper
|
|
20
|
+
* reads `current.json` from this checkout's runtime directory before anything
|
|
21
|
+
* else, outside its own try, and its outer catch swallows the failure: with
|
|
22
|
+
* the manifest gone it prints nothing and fetches nothing. So renaming the
|
|
23
|
+
* manifest aside silences it for every later session, while the tracked hook
|
|
24
|
+
* entry stays exactly as the team committed it.
|
|
25
|
+
*
|
|
26
|
+
* Called by the user-scope hook at session start, only once it has found this
|
|
27
|
+
* checkout's connection, so a checkout is switched off only where the
|
|
28
|
+
* user-scope hook is already serving the guidance. Only the directory for this
|
|
29
|
+
* checkout, connection and host is touched, and only when the manifest proves
|
|
30
|
+
* it is ours (owner and scope key). Never throws and never prints.
|
|
31
|
+
*/
|
|
32
|
+
export declare function retireCheckoutRuntime(input: {
|
|
33
|
+
cwd: string;
|
|
34
|
+
host: "codex" | "claude";
|
|
35
|
+
repositoryId: string;
|
|
36
|
+
controlPlane: string;
|
|
37
|
+
environment: NodeJS.ProcessEnv;
|
|
38
|
+
}): RetireOutcome;
|
|
13
39
|
/** Stable, dependency-free wrapper. Current policy exists only in the authenticated server response. */
|
|
14
40
|
export declare function guidanceRuntimeCommand(host: "codex" | "claude", repositoryId: string, controlPlane: string): string;
|
|
15
41
|
export {};
|
package/dist/guidance-runtime.js
CHANGED
|
@@ -2,7 +2,8 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import {
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { configHome, ensureStoreDirectory } from "./store.js";
|
|
6
7
|
import { GUIDANCE_UNAVAILABLE } from "./guidance.js";
|
|
7
8
|
const OWNER = "balladeer-guidance-runtime/1";
|
|
8
9
|
const HEX = /^[a-f0-9]{64}$/;
|
|
@@ -114,6 +115,75 @@ export function installGuidanceRuntime(options) {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Switches off the older per-repository hook in one checkout, without touching
|
|
120
|
+
* any file in the repository.
|
|
121
|
+
*
|
|
122
|
+
* Releases before 1.0.8 put a hook in the checkout's `.claude/settings.json`
|
|
123
|
+
* or `.codex/config.toml` whose command is the wrapper below. That wrapper
|
|
124
|
+
* reads `current.json` from this checkout's runtime directory before anything
|
|
125
|
+
* else, outside its own try, and its outer catch swallows the failure: with
|
|
126
|
+
* the manifest gone it prints nothing and fetches nothing. So renaming the
|
|
127
|
+
* manifest aside silences it for every later session, while the tracked hook
|
|
128
|
+
* entry stays exactly as the team committed it.
|
|
129
|
+
*
|
|
130
|
+
* Called by the user-scope hook at session start, only once it has found this
|
|
131
|
+
* checkout's connection, so a checkout is switched off only where the
|
|
132
|
+
* user-scope hook is already serving the guidance. Only the directory for this
|
|
133
|
+
* checkout, connection and host is touched, and only when the manifest proves
|
|
134
|
+
* it is ours (owner and scope key). Never throws and never prints.
|
|
135
|
+
*/
|
|
136
|
+
export function retireCheckoutRuntime(input) {
|
|
137
|
+
try {
|
|
138
|
+
const base = join(configHome(input.environment), "guidance-runtime-v1");
|
|
139
|
+
// Most laptops never had the older form: one stat and nothing else.
|
|
140
|
+
try {
|
|
141
|
+
lstatSync(base);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return "absent";
|
|
145
|
+
}
|
|
146
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
147
|
+
cwd: input.cwd,
|
|
148
|
+
encoding: "utf8",
|
|
149
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
150
|
+
timeout: 500,
|
|
151
|
+
maxBuffer: 8192,
|
|
152
|
+
}).trim();
|
|
153
|
+
if (root.length === 0)
|
|
154
|
+
return "absent";
|
|
155
|
+
const key = guidanceRuntimeKey({
|
|
156
|
+
root,
|
|
157
|
+
host: input.host,
|
|
158
|
+
repositoryId: input.repositoryId,
|
|
159
|
+
controlPlane: input.controlPlane,
|
|
160
|
+
});
|
|
161
|
+
const directory = join(base, key);
|
|
162
|
+
const manifestPath = join(directory, "current.json");
|
|
163
|
+
try {
|
|
164
|
+
checkDirectory(directory);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return "absent";
|
|
168
|
+
}
|
|
169
|
+
let manifest;
|
|
170
|
+
try {
|
|
171
|
+
manifest = JSON.parse(readPrivate(manifestPath, 4096).toString("utf8"));
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
return error.code === "ENOENT" ? "absent" : "kept";
|
|
175
|
+
}
|
|
176
|
+
if (manifest?.owner !== OWNER || manifest.scopeKey !== key)
|
|
177
|
+
return "kept";
|
|
178
|
+
// Renamed, not deleted: the pinned runtime stays beside it, and an
|
|
179
|
+
// explicit install of the older form would simply write a new manifest.
|
|
180
|
+
renameSync(manifestPath, join(directory, "retired.json"));
|
|
181
|
+
return "retired";
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return "kept";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
117
187
|
/** Stable, dependency-free wrapper. Current policy exists only in the authenticated server response. */
|
|
118
188
|
export function guidanceRuntimeCommand(host, repositoryId, controlPlane) {
|
|
119
189
|
// Keep this readable: it is the exact code the host asks a person to trust once.
|
package/dist/self-update.d.ts
CHANGED
|
@@ -29,10 +29,10 @@ export declare function selfUpdateState(environment: NodeJS.ProcessEnv): SelfUpd
|
|
|
29
29
|
*/
|
|
30
30
|
export declare function resolveLaunch(specifier: string, environment: NodeJS.ProcessEnv, execPath?: string, exists?: (path: string) => boolean): Launch;
|
|
31
31
|
/**
|
|
32
|
-
* Start the update when the server named a newer release and
|
|
33
|
-
*
|
|
32
|
+
* Start the update when the server named a newer release and an attempt
|
|
33
|
+
* toward it is due (`due`). Answers whether one was started, for the caller's own
|
|
34
34
|
* record; the session hears nothing either way. A launch that could not start
|
|
35
|
-
* leaves no stamp, so the next
|
|
35
|
+
* leaves no stamp, so the next hook fire tries again rather than waiting
|
|
36
36
|
* a day for the same failure.
|
|
37
37
|
*/
|
|
38
38
|
export declare function startSelfUpdateIfDue(newer: string | undefined, deps: SelfUpdateDeps): boolean;
|
package/dist/self-update.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { delimiter, dirname, join } from "node:path";
|
|
4
|
+
import { older } from "./currency.js";
|
|
4
5
|
import { HOOK_SPECIFIER } from "./release.js";
|
|
5
6
|
import { configHome } from "./store.js";
|
|
6
7
|
/**
|
|
@@ -24,10 +25,22 @@ import { configHome } from "./store.js";
|
|
|
24
25
|
* and what happened is kept in a small state file the next hook fire reports
|
|
25
26
|
* to the server, so a laptop that cannot update is visible on the scorecard
|
|
26
27
|
* instead of looking merely behind.
|
|
28
|
+
*
|
|
29
|
+
* What changed on 1 October 2026, from Robert's own laptop: it stayed on
|
|
30
|
+
* 1.0.16 through two releases while in use all day. Two rules held it. Only a
|
|
31
|
+
* session start began an install, and his sessions run for days, so the hooks
|
|
32
|
+
* that did fire (a prompt, a subagent) never asked. And the day's stamp was
|
|
33
|
+
* one stamp for every version: an attempt toward 1.0.15 at 03:20 held back
|
|
34
|
+
* 1.0.17 until 03:20 the next day, though a manual update had already passed
|
|
35
|
+
* 1.0.15. Now any guidance hook fire can begin the install, and a release
|
|
36
|
+
* newer than the one the last attempt aimed at may begin one an hour after
|
|
37
|
+
* it, rather than a day.
|
|
27
38
|
*/
|
|
28
39
|
const STAMP = "self-update.json";
|
|
29
40
|
const STATE = "self-update-state.json";
|
|
30
41
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
43
|
+
const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
|
|
31
44
|
/** Set on the child so the install it runs can record how it ended. */
|
|
32
45
|
export const SELF_UPDATE_ENVIRONMENT = "BALLADEER_SELF_UPDATE";
|
|
33
46
|
const STATE_PATTERN = /^(?:started|unstartable|installed|failed):[A-Za-z0-9._-]{1,40}$/;
|
|
@@ -37,18 +50,35 @@ function stampPath(environment) {
|
|
|
37
50
|
function statePath(environment) {
|
|
38
51
|
return join(configHome(environment), STATE);
|
|
39
52
|
}
|
|
40
|
-
|
|
53
|
+
/** The last attempt: when it started and which release it aimed at, when it said. */
|
|
54
|
+
function lastAttempt(environment) {
|
|
41
55
|
const path = stampPath(environment);
|
|
42
56
|
if (!existsSync(path))
|
|
43
|
-
return 0;
|
|
57
|
+
return { startedAt: 0 };
|
|
44
58
|
try {
|
|
45
59
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
-
|
|
60
|
+
const startedAt = typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
|
|
61
|
+
return typeof parsed.toward === "string" && VERSION.test(parsed.toward)
|
|
62
|
+
? { startedAt, toward: parsed.toward }
|
|
63
|
+
: { startedAt };
|
|
47
64
|
}
|
|
48
65
|
catch {
|
|
49
|
-
return 0;
|
|
66
|
+
return { startedAt: 0 };
|
|
50
67
|
}
|
|
51
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether an attempt toward `newer` may start now. Once a day toward the same
|
|
71
|
+
* release, so a laptop whose install keeps failing is not hammered; but a
|
|
72
|
+
* release newer than the last attempt's target is a new reason, and waits only
|
|
73
|
+
* an hour, so one stale attempt cannot hold back the next release for a day.
|
|
74
|
+
*/
|
|
75
|
+
function due(newer, last, now) {
|
|
76
|
+
const since = now - last.startedAt;
|
|
77
|
+
if (since >= DAY_MS)
|
|
78
|
+
return true;
|
|
79
|
+
const newerTarget = last.toward === undefined || older(last.toward, newer);
|
|
80
|
+
return newerTarget && since >= HOUR_MS;
|
|
81
|
+
}
|
|
52
82
|
function writePrivate(path, value, environment) {
|
|
53
83
|
mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
|
|
54
84
|
writeFileSync(path, JSON.stringify(value) + "\n", { mode: 0o600 });
|
|
@@ -109,17 +139,17 @@ function detached(command, args, environment) {
|
|
|
109
139
|
return child.pid !== undefined;
|
|
110
140
|
}
|
|
111
141
|
/**
|
|
112
|
-
* Start the update when the server named a newer release and
|
|
113
|
-
*
|
|
142
|
+
* Start the update when the server named a newer release and an attempt
|
|
143
|
+
* toward it is due (`due`). Answers whether one was started, for the caller's own
|
|
114
144
|
* record; the session hears nothing either way. A launch that could not start
|
|
115
|
-
* leaves no stamp, so the next
|
|
145
|
+
* leaves no stamp, so the next hook fire tries again rather than waiting
|
|
116
146
|
* a day for the same failure.
|
|
117
147
|
*/
|
|
118
148
|
export function startSelfUpdateIfDue(newer, deps) {
|
|
119
149
|
if (newer === undefined)
|
|
120
150
|
return false;
|
|
121
151
|
const now = deps.now ?? Date.now;
|
|
122
|
-
if (
|
|
152
|
+
if (!due(newer, lastAttempt(deps.environment), now()))
|
|
123
153
|
return false;
|
|
124
154
|
const launch = resolveLaunch(HOOK_SPECIFIER, deps.environment, deps.execPath, deps.exists);
|
|
125
155
|
let started;
|
package/dist/wire.d.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
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.
|
|
8
|
+
export declare const CLI_VERSION = "1.0.19";
|
|
9
9
|
export declare const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export declare const CLIENT_HEADER = "x-balladeer-client";
|
|
11
11
|
/** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
|
|
12
12
|
export declare const UPDATE_STATE_HEADER = "x-balladeer-update-state";
|
|
13
13
|
/** Which host's hook made this guidance fetch: `claude` or `codex`. */
|
|
14
14
|
export declare const HOOK_HOST_HEADER = "x-balladeer-hook";
|
|
15
|
-
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.
|
|
15
|
+
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.19";
|
|
16
16
|
export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
17
17
|
export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
|
|
18
18
|
export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
|
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.
|
|
8
|
+
export const CLI_VERSION = "1.0.19";
|
|
9
9
|
export const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export const CLIENT_HEADER = "x-balladeer-client";
|
|
11
11
|
/** What the last background update came to: `started:1.0.17`, `installed:1.0.17`, `failed:exit-1`, `unstartable:launcher`. */
|