@catalyst-cloud/cli 0.16.28 → 0.16.30

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 (38) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +3 -3
  3. package/dist/args.js +15 -0
  4. package/dist/capabilities.js +2 -0
  5. package/dist/cli-version.js +41 -0
  6. package/dist/cli.js +23 -0
  7. package/dist/lease.js +302 -0
  8. package/dist/onboard-existing.js +12 -3
  9. package/dist/onboard-next.js +39 -0
  10. package/dist/onboard-projects.js +3 -3
  11. package/dist/onboard-repositories.js +10 -3
  12. package/dist/onboard-runner-disk.js +345 -0
  13. package/dist/onboard-runner.js +52 -0
  14. package/dist/onboard-ui.js +5 -1
  15. package/dist/onboard.js +11 -0
  16. package/dist/ready.js +47 -1
  17. package/dist/runner.js +19 -2
  18. package/dist/setup-onboard-copy.js +41 -2
  19. package/dist/watch/consumer.js +1 -0
  20. package/dist/watch.js +2 -1
  21. package/package.json +1 -1
  22. package/skills/catalyst-github/SKILL.md +1 -1
  23. package/skills/catalyst-linear/SKILL.md +1 -1
  24. package/skills/catalyst-onboard/SKILL.md +1 -1
  25. package/skills/catalyst-onboard/references/reading-ready.md +1 -0
  26. package/skills/run-this-project/SKILL.md +1 -1
  27. package/skills/run-this-project/references/stalls-and-escalation.md +1 -1
  28. package/skills/unstick/SKILL.md +1 -1
  29. package/skills/what-needs-me/SKILL.md +1 -1
  30. package/skills/what-this-repo-needs/SKILL.md +1 -1
  31. package/skills/whats-happening/SKILL.md +1 -1
  32. package/skills/whats-happening/references/what-runs-next.md +1 -1
  33. package/skills/whats-happening/references/why-is-it-stuck.md +1 -1
  34. package/vendor/self-host/darwin-thoughts-custody/manifest.json +2 -2
  35. package/vendor/self-host/darwin-thoughts-custody/verifier.mjs +90 -31
  36. package/vendor/self-host/host-disk-sample.py +57 -0
  37. package/vendor/self-host/launchd/dev.catalystcloud.host-disk.plist +18 -0
  38. package/vendor/self-host/provenance.json +6 -4
@@ -1,7 +1,7 @@
1
1
  import { isRepositoryId } from "./repository-id.js";
2
2
  import { loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
3
3
  import { contractVersionInRange } from "./contract.js";
4
- import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
4
+ import { choiceEvidence, readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
5
5
  const part = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
6
6
  const id = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
7
7
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
@@ -12,6 +12,13 @@ const waiting = (reason) => ({
12
12
  state: "waiting",
13
13
  reason,
14
14
  });
15
+ /** CTC-5932: a choice no one was there to make, with the repositories it could have been. */
16
+ export const repositoryChoiceWait = (rows) => rows.length
17
+ ? {
18
+ ...waiting("repository_choice_required"),
19
+ evidence: choiceEvidence(rows.map((row) => `${row.owner}/${row.name}`)),
20
+ }
21
+ : waiting("repository_choice_required");
15
22
  const valid = (value) => {
16
23
  const row = object(value);
17
24
  return (!!row &&
@@ -381,7 +388,7 @@ export function existingRepositoryAdapter(args, choose) {
381
388
  ? waiting("repository_selection_unverified")
382
389
  : choose
383
390
  ? { state: "pending" }
384
- : waiting("repository_choice_required");
391
+ : repositoryChoiceWait(list.repositories);
385
392
  return {
386
393
  state: "done",
387
394
  evidence: {
@@ -400,7 +407,7 @@ export function existingRepositoryAdapter(args, choose) {
400
407
  if (!list.repositories.length)
401
408
  return waiting("repository_inventory_empty");
402
409
  if (!choose)
403
- return waiting("repository_choice_required");
410
+ return repositoryChoiceWait(list.repositories);
404
411
  const teamId = list.repositories[0].teamId;
405
412
  const offers = list.canRegister
406
413
  ? await offeredRepositories(ctx, journal, teamId, list.repositories, signal)
@@ -0,0 +1,345 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statfsSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { packageRoot } from "./config.js";
5
+ // CTC-5929: a supervisor in a Docker VM cannot measure the Mac's disk, so it refuses all work
6
+ // (`host_disk_unmeasured: physical_host_disk_sample_required`) until a native publisher writes a
7
+ // fresh sample into the read-only `host-disk` bind. Setup installs catalyst-cloud's own publisher
8
+ // (vendor/self-host, byte for byte) as a LaunchAgent, and names a zero-slot disk reason instead of
9
+ // leaving it in the supervisor log. The admission rule itself stays the supervisor's.
10
+ export const HOST_DISK_LABEL = "dev.catalystcloud.host-disk";
11
+ /** The sample path inside the supervisor; compose binds the runner's `host-disk` directory here. */
12
+ export const HOST_DISK_MOUNT = "/var/lib/catalyst/host-disk";
13
+ export const HOST_DISK_SAMPLE_FILE = `${HOST_DISK_MOUNT}/space.json`;
14
+ /** catalyst-cloud apps/host-agent/src/supervisor/disk/host-space.ts and the compose default. */
15
+ const SAMPLE_MAX_AGE_MS = 30_000;
16
+ const SAMPLE_FUTURE_MS = 5_000;
17
+ const MAX_SAMPLE_BYTES = 4_096;
18
+ export const DEFAULT_HOST_FREE_FLOOR_GIB = 25;
19
+ const GIB = 1024 ** 3;
20
+ const gib = (bytes) => Math.floor((bytes / GIB) * 100) / 100;
21
+ function floorGiB(saved) {
22
+ const value = Number(saved?.CATALYST_HOST_FREE_FLOOR_GIB?.trim() || DEFAULT_HOST_FREE_FLOOR_GIB);
23
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_HOST_FREE_FLOOR_GIB;
24
+ }
25
+ /** The supervisor's sample rules: schema 1, at most 30 seconds old, never ahead of the clock. */
26
+ export function hostDiskSampleBytes(text, nowMs) {
27
+ let sample;
28
+ try {
29
+ if (Buffer.byteLength(text) > MAX_SAMPLE_BYTES)
30
+ return "host_disk_sample_invalid";
31
+ const value = JSON.parse(text);
32
+ if (typeof value !== "object" || value === null || Array.isArray(value))
33
+ return "host_disk_sample_invalid";
34
+ sample = value;
35
+ }
36
+ catch {
37
+ return "host_disk_sample_invalid";
38
+ }
39
+ const { observedAtMs, availableBytes, totalBytes, path } = sample;
40
+ if (sample.schemaVersion !== 1 || typeof path !== "string" || !path.startsWith("/") ||
41
+ !Number.isSafeInteger(observedAtMs) || observedAtMs <= 0 ||
42
+ !Number.isSafeInteger(availableBytes) || availableBytes < 0 ||
43
+ !Number.isSafeInteger(totalBytes) || totalBytes <= 0 ||
44
+ availableBytes > totalBytes)
45
+ return "host_disk_sample_invalid";
46
+ if (nowMs - observedAtMs > SAMPLE_MAX_AGE_MS || observedAtMs > nowMs + SAMPLE_FUTURE_MS)
47
+ return "host_disk_sample_stale";
48
+ return availableBytes;
49
+ }
50
+ /** Where the supervisor's sample file lives on this machine, or null when it is not in the bind. */
51
+ export function hostDiskSamplePath(dir, saved) {
52
+ const file = saved?.CATALYST_HOST_DISK_SAMPLE_FILE?.trim();
53
+ if (!file || !file.startsWith(`${HOST_DISK_MOUNT}/`) || file.split("/").some(part => part === ".."))
54
+ return null;
55
+ const source = saved?.CATALYST_HOST_DISK_SAMPLE_DIR?.trim() || "./host-disk";
56
+ return join(isAbsolute(source) ? source : resolve(dir, source), file.slice(HOST_DISK_MOUNT.length + 1));
57
+ }
58
+ /** The same verdict the supervisor reaches, read from this machine. Never an admission decision. */
59
+ export function runnerDiskVerdict(input) {
60
+ const floor = floorGiB(input.saved);
61
+ const unmeasured = (detail) => ({ state: "unmeasured", detail, floorGiB: floor });
62
+ let bytes;
63
+ if (input.saved?.CATALYST_HOST_DISK_SAMPLE_FILE?.trim()) {
64
+ const path = hostDiskSamplePath(input.dir, input.saved);
65
+ if (!path)
66
+ return unmeasured("host_disk_unreadable");
67
+ let text;
68
+ try {
69
+ text = (input.readSample ?? ((file) => readFileSync(file, "utf8")))(path);
70
+ }
71
+ catch {
72
+ return unmeasured("host_disk_sample_missing");
73
+ }
74
+ const read = hostDiskSampleBytes(text, input.nowMs);
75
+ if (typeof read === "string")
76
+ return unmeasured(read);
77
+ bytes = read;
78
+ }
79
+ else if (input.vm) {
80
+ return unmeasured("physical_host_disk_sample_required");
81
+ }
82
+ else {
83
+ try {
84
+ bytes = input.nativeAvailableBytes?.() ?? null;
85
+ }
86
+ catch {
87
+ bytes = null;
88
+ }
89
+ if (bytes === null || !Number.isSafeInteger(bytes) || bytes < 0)
90
+ return unmeasured("host_disk_unreadable");
91
+ }
92
+ return { state: bytes >= floor * GIB ? "measured" : "below_floor", availableGiB: gib(bytes), floorGiB: floor };
93
+ }
94
+ /** Docker's data root, where the supervisor's named state volume lives on native Linux. */
95
+ export function dockerRootAvailableBytes(env) {
96
+ const read = spawnSync("docker", ["info", "--format", "{{.DockerRootDir}}"], { encoding: "utf8", timeout: 10_000, env });
97
+ const root = read.status === 0 ? read.stdout.trim() : "";
98
+ if (!isAbsolute(root))
99
+ return null;
100
+ const space = statfsSync(root);
101
+ return space.bavail * space.bsize;
102
+ }
103
+ const defaultRun = (cmd, args, timeoutMs) => {
104
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs });
105
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
106
+ };
107
+ const within = (path, root) => {
108
+ const rel = relative(root, path);
109
+ return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
110
+ };
111
+ /** Docker Desktop's disk image folder: the relocated one from its settings, else the default. */
112
+ function desktopData(deps, exists) {
113
+ const read = deps.readText ?? ((path) => readFileSync(path, "utf8"));
114
+ for (const name of ["settings-store.json", "settings.json"]) {
115
+ let settings;
116
+ try {
117
+ settings = JSON.parse(read(join(deps.home, "Library/Group Containers/group.com.docker", name)));
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ const folder = settings.DataFolder ?? settings.dataFolder;
123
+ if (folder !== undefined)
124
+ return typeof folder === "string" && isAbsolute(folder) && exists(folder) ? folder : null;
125
+ }
126
+ const fallback = join(deps.home, "Library/Containers/com.docker.docker/Data/vms/0/data");
127
+ return exists(fallback) ? fallback : null;
128
+ }
129
+ /** The filesystem backing the ACTIVE engine's VM disk, from its socket; null when it cannot be
130
+ * verified, so setup asks for CATALYST_HOST_DISK_PATH instead of measuring the wrong disk. */
131
+ export function engineDiskPath(deps) {
132
+ const exists = deps.exists ?? existsSync;
133
+ if (deps.explicitPath !== undefined && deps.explicitPath.trim() !== "")
134
+ return isAbsolute(deps.explicitPath) && exists(deps.explicitPath) ? deps.explicitPath : null;
135
+ if (!deps.dockerHost?.startsWith("unix://"))
136
+ return null;
137
+ let socket;
138
+ try {
139
+ socket = (deps.realpath ?? realpathSync)(deps.dockerHost.slice("unix://".length));
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ const home = deps.home;
145
+ if (within(socket, join(home, ".docker")) || within(socket, join(home, "Library/Containers/com.docker.docker")))
146
+ return desktopData(deps, exists);
147
+ if (within(socket, join(home, ".orbstack"))) {
148
+ const data = join(home, "Library/Group Containers/HUAQ24HBR6.dev.orbstack/data");
149
+ return exists(data) ? data : null;
150
+ }
151
+ // Lima and Colima keep each instance's disk in the instance directory that holds its socket.
152
+ const lima = /^(.*)\/sock\/docker\.sock$/.exec(socket)?.[1];
153
+ if (lima && within(lima, join(home, ".lima")) && exists(lima))
154
+ return lima;
155
+ const colima = /^(.*)\/docker\.sock$/.exec(socket)?.[1];
156
+ if (colima && within(colima, join(home, ".colima"))) {
157
+ const instance = join(home, ".colima", "_lima", `colima${colima.endsWith(`${sep}default`) ? "" : `-${colima.split(sep).at(-1)}`}`);
158
+ return exists(instance) ? instance : null;
159
+ }
160
+ return null;
161
+ }
162
+ /** A stable interpreter. /usr/bin/python3 is a stub that opens an install dialog until the
163
+ * Command Line Tools exist, so it is used only after xcode-select confirms them. */
164
+ function python(run, exists) {
165
+ if (exists("/usr/bin/python3") && run("/usr/bin/xcode-select", ["-p"], 5_000).status === 0)
166
+ return "/usr/bin/python3";
167
+ return ["/opt/homebrew/bin/python3", "/usr/local/bin/python3"].find(exists) ?? null;
168
+ }
169
+ const xml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
170
+ function plan(dir, deps) {
171
+ const run = deps.run ?? defaultRun;
172
+ const exists = deps.exists ?? existsSync;
173
+ const measured = engineDiskPath(deps);
174
+ if (!measured)
175
+ return "runner_host_disk_location_unverified";
176
+ const interpreter = python(run, exists);
177
+ if (!interpreter)
178
+ return "runner_host_disk_sampler_python_missing";
179
+ const vendor = join(packageRoot(), "vendor", "self-host");
180
+ const script = join(dir, "host-disk-sampler", "host-disk-sample.py");
181
+ const output = join(dir, "host-disk", "space.json");
182
+ const values = {
183
+ __PYTHON__: interpreter, __SCRIPT__: script, __PATH__: measured, __OUTPUT__: output,
184
+ };
185
+ const template = readFileSync(join(vendor, "launchd", `${HOST_DISK_LABEL}.plist`), "utf8");
186
+ const plistText = template.replace(/<string>(__[A-Z]+__)<\/string>/g, (whole, key) => key in values ? `<string>${xml(values[key])}</string>` : whole);
187
+ const program = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(template)?.[1] ?? "";
188
+ const command = [...program.matchAll(/<string>([^<]*)<\/string>/g)].map(m => values[m[1]] ?? m[1]);
189
+ const at = command.indexOf("--interval");
190
+ return {
191
+ script, output, plistText, command, interval: Number(command[at + 1]),
192
+ plist: join(deps.home, "Library", "LaunchAgents", `${HOST_DISK_LABEL}.plist`),
193
+ scriptBytes: readFileSync(join(vendor, "host-disk-sample.py")),
194
+ };
195
+ }
196
+ const readOr = (path) => {
197
+ try {
198
+ return readFileSync(path);
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ };
204
+ /** The file a host-disk agent's plist publishes to (its `--output`), unescaped; null when it names none. */
205
+ export function hostDiskAgentOutput(text) {
206
+ const raw = /<string>--output<\/string>\s*<string>([^<]*)<\/string>/.exec(text)?.[1];
207
+ return raw === undefined ? null : raw.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
208
+ }
209
+ /** A plist with this label that publishes somewhere else belongs to its operator. */
210
+ function foreign(existing, output) {
211
+ return hostDiskAgentOutput(existing.toString("utf8")) !== output;
212
+ }
213
+ /** Where a same-label agent this setup does not own publishes, as the two settings that would
214
+ * point the runner at it: the bind directory and the in-container file name. */
215
+ export function foreignHostDiskSample(home) {
216
+ const text = readOr(join(home, "Library", "LaunchAgents", `${HOST_DISK_LABEL}.plist`))?.toString("utf8");
217
+ const output = text && hostDiskAgentOutput(text);
218
+ if (!output || !isAbsolute(output) || !/^[^\x00-\x1f'"]+$/.test(output))
219
+ return {};
220
+ return { sampleDir: dirname(output), sampleFile: `${HOST_DISK_MOUNT}/${output.split("/").at(-1)}` };
221
+ }
222
+ /** The launchd domain holding the agent: gui when someone is logged in at the console, else user. */
223
+ function loadedIn(run, uid) {
224
+ for (const domain of [`gui/${uid}`, `user/${uid}`])
225
+ if (run("/bin/launchctl", ["print", `${domain}/${HOST_DISK_LABEL}`], 5_000).status === 0)
226
+ return domain;
227
+ return null;
228
+ }
229
+ function writeAtomic(path, bytes, mode) {
230
+ mkdirSync(dirname(path), { recursive: true, mode: 0o755 });
231
+ const temporary = `${path}.${process.pid}.tmp`;
232
+ writeFileSync(temporary, bytes, { mode });
233
+ chmodSync(temporary, mode);
234
+ renameSync(temporary, path);
235
+ }
236
+ function observedAt(output) {
237
+ try {
238
+ const value = JSON.parse(readOr(output)?.toString("utf8") ?? "");
239
+ const at = value?.observedAtMs;
240
+ return Number.isSafeInteger(at) ? at : null;
241
+ }
242
+ catch {
243
+ return null;
244
+ }
245
+ }
246
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
247
+ /** The positive cadence check: the agent itself publishes two new samples, one interval apart.
248
+ * Loaded is not running: launchd held every launch of an agent bootstrapped over SSH (CTC-5929). */
249
+ function repeats(p, deps) {
250
+ const sleep = deps.sleep ?? sleepSync;
251
+ const seen = new Set();
252
+ const before = observedAt(p.output);
253
+ const deadline = deps.now() + (2 * p.interval + 5) * 1_000;
254
+ while (deps.now() < deadline) {
255
+ const at = observedAt(p.output);
256
+ if (at !== null && at !== before && sampleFresh(p.output, deps.now()))
257
+ seen.add(at);
258
+ if (seen.size >= 2)
259
+ return true;
260
+ sleep(1_000);
261
+ }
262
+ return false;
263
+ }
264
+ function sampleFresh(output, nowMs) {
265
+ const text = readOr(output);
266
+ return text !== null && typeof hostDiskSampleBytes(text.toString("utf8"), nowMs) === "number";
267
+ }
268
+ export function hostDiskSamplerStatus(dir, deps) {
269
+ const p = plan(dir, deps);
270
+ if (typeof p === "string")
271
+ return p;
272
+ const plist = readOr(p.plist);
273
+ if (plist && !plist.equals(Buffer.from(p.plistText)) && foreign(plist, p.output))
274
+ return "runner_host_disk_sampler_conflict";
275
+ return plist?.equals(Buffer.from(p.plistText)) && readOr(p.script)?.equals(p.scriptBytes) &&
276
+ loadedIn(deps.run ?? defaultRun, deps.uid) && sampleFresh(p.output, deps.now()) ? "installed" : "missing";
277
+ }
278
+ /** Installs or repairs the agent in place. Idempotent: an unchanged, loaded agent is left running.
279
+ * A same-label agent publishing elsewhere is never replaced. */
280
+ export function installHostDiskSampler(dir, deps) {
281
+ const run = deps.run ?? defaultRun;
282
+ const p = plan(dir, deps);
283
+ if (typeof p === "string")
284
+ return p;
285
+ const existing = readOr(p.plist);
286
+ const plistChanged = !existing?.equals(Buffer.from(p.plistText));
287
+ if (existing && plistChanged && foreign(existing, p.output))
288
+ return "runner_host_disk_sampler_conflict";
289
+ try {
290
+ // The plist does not carry the script's bytes, so a changed script needs its own reload: a
291
+ // running agent keeps the code it started with until launchd starts it again.
292
+ const scriptChanged = !readOr(p.script)?.equals(p.scriptBytes);
293
+ if (scriptChanged)
294
+ writeAtomic(p.script, p.scriptBytes, 0o644);
295
+ // One synchronous sample proves the interpreter and the measured path before launchd owns it.
296
+ // Without --interval the script publishes once and exits; with it, it loops.
297
+ const loop = p.command.indexOf("--interval");
298
+ const oneShot = loop < 0 ? p.command : [...p.command.slice(0, loop), ...p.command.slice(loop + 2)];
299
+ const once = run(oneShot[0], oneShot.slice(1), 15_000);
300
+ if (once.status !== 0 || !sampleFresh(p.output, deps.now()))
301
+ return "runner_host_disk_sampler_failed";
302
+ if (plistChanged)
303
+ writeAtomic(p.plist, p.plistText, 0o644);
304
+ let domain = loadedIn(run, deps.uid);
305
+ if (domain && (plistChanged || scriptChanged)) {
306
+ run("/bin/launchctl", ["bootout", `${domain}/${HOST_DISK_LABEL}`], 15_000);
307
+ domain = null;
308
+ }
309
+ if (!domain) {
310
+ const gui = run("/bin/launchctl", ["bootstrap", `gui/${deps.uid}`, p.plist], 15_000).status === 0;
311
+ if (!gui && run("/bin/launchctl", ["bootstrap", `user/${deps.uid}`, p.plist], 15_000).status !== 0)
312
+ return "runner_host_disk_sampler_failed";
313
+ }
314
+ domain = loadedIn(run, deps.uid);
315
+ if (!domain)
316
+ return "runner_host_disk_sampler_failed";
317
+ // A demand start: launchd ran a kickstarted agent on mini-2 while it held RunAtLoad and the
318
+ // timer. The script loops by itself once started, and a running agent is left as it is.
319
+ run("/bin/launchctl", ["kickstart", `${domain}/${HOST_DISK_LABEL}`], 15_000);
320
+ return repeats(p, deps) ? "ready" : "runner_host_disk_sampler_not_repeating";
321
+ }
322
+ catch {
323
+ return "runner_host_disk_sampler_failed";
324
+ }
325
+ }
326
+ export const isRunnerDiskUnmeasured = (value) => value in UNMEASURED_WHY;
327
+ const UNMEASURED_WHY = {
328
+ physical_host_disk_sample_required: "no disk sampler is set up for it",
329
+ host_disk_sample_missing: "the disk sample file is missing",
330
+ host_disk_sample_stale: "the last disk sample is more than 30 seconds old",
331
+ host_disk_sample_invalid: "the disk sample cannot be read",
332
+ host_disk_unreadable: "setup cannot read the disk it measures",
333
+ };
334
+ /** One wording for setup and `catalyst ready`: the reason for zero free slots, then the fix. */
335
+ export function runnerDiskCopy(verdict, vm) {
336
+ if (verdict.state === "below_floor")
337
+ return {
338
+ line: `The runner advertises zero free slots: ${verdict.availableGiB} GiB of disk is free and it needs ${verdict.floorGiB} GiB.`,
339
+ fix: `Free at least ${Math.ceil(verdict.floorGiB - verdict.availableGiB)} GiB on ${vm ? "this Mac" : "the disk that holds Docker's data"}, for example with docker system prune. The runner takes work again by itself; run catalyst ready to confirm.`,
340
+ };
341
+ const line = `The runner advertises zero free slots: it has no fresh measurement of ${vm ? "this Mac's" : "this machine's"} disk, because ${UNMEASURED_WHY[verdict.detail]}.`;
342
+ return vm
343
+ ? { line, fix: `Run catalyst setup --runner. It installs and starts the ${HOST_DISK_LABEL} LaunchAgent, which measures the disk every 10 seconds. If it is already installed, run launchctl print gui/$(id -u)/${HOST_DISK_LABEL}, or user/$(id -u)/${HOST_DISK_LABEL} when no one is logged in at the console, to see why it stopped. If you publish your own sample with CATALYST_HOST_DISK_SAMPLE_DIR and CATALYST_HOST_DISK_SAMPLE_FILE, restart that publisher.` }
344
+ : { line, fix: "Run docker compose -p catalyst-host logs supervisor and look for host_disk_admission_refused, then run catalyst ready." };
345
+ }
@@ -8,6 +8,7 @@ import { runnerAdmission, issueRunnerOrgKey, verifyRunnerRoutes } from "./onboar
8
8
  import { verifyOnboardRoutes } from "./onboard-capabilities.js";
9
9
  import { liveTeamKey, onboardTeamAdmission } from "./onboard-capacity.js";
10
10
  import { runnerVmCapabilities } from "./onboard-runner-probes.js";
11
+ import { dockerRootAvailableBytes, foreignHostDiskSample, hostDiskSamplerStatus, installHostDiskSampler, runnerDiskVerdict, HOST_DISK_SAMPLE_FILE, } from "./onboard-runner-disk.js";
11
12
  import { darwinThoughtsInstallAdvice, darwinThoughtsInstallException, darwinThoughtsInstallRefusal, installDarwinThoughtsCustody } from "./onboard-runner-custody.js";
12
13
  import { emitCustodyFailure } from "./onboard-runner-telemetry.js";
13
14
  import { canonicalDarwinThoughtsJson, parseDarwinThoughtsAuthority } from "../vendor/self-host/darwin-thoughts-custody/verifier.mjs";
@@ -69,6 +70,8 @@ const DISK_POLICY_KEYS = [
69
70
  "CATALYST_SLOT_DISK_MODE", "CATALYST_SLOT_DISK_BUDGET_GIB", "CATALYST_DISK_BUDGET_GIB",
70
71
  "CATALYST_WORKSPACE_STORAGE", "CATALYST_WORKSPACE_RETENTION_HOURS",
71
72
  "CATALYST_HOST_FREE_FLOOR_GIB", "CATALYST_HOST_DISK_SAMPLE_DIR", "CATALYST_HOST_DISK_SAMPLE_FILE",
73
+ // Not read by Compose: where the Mac sampler measures, kept so unattended checks plan the same path.
74
+ "CATALYST_HOST_DISK_PATH",
72
75
  ];
73
76
  function isUnenrolled(value) {
74
77
  return value !== null && "unenrolled" in value;
@@ -549,6 +552,20 @@ export function dockerRunnerEngine(deps = {}) {
549
552
  return (read.code === 0 &&
550
553
  read.stdout.split("\n").some((line) => line.trim() === "supervisor"));
551
554
  },
555
+ async hostDisk(dir, vm) {
556
+ return runnerDiskVerdict({ dir, saved: readEnvFile(join(dir, ".env")), vm, nowMs: Date.now(),
557
+ nativeAvailableBytes: () => dockerRootAvailableBytes(env) });
558
+ },
559
+ ...((deps.platform ?? process.platform) === "darwin" ? {
560
+ async hostDiskSamplerStatus(dir, options) {
561
+ return hostDiskSamplerStatus(dir, { home: deps.env?.HOME ?? process.env.HOME ?? "", uid: process.getuid?.() ?? 0,
562
+ now: Date.now, dockerHost: await endpoint(), ...options });
563
+ },
564
+ async installHostDiskSampler(dir, options) {
565
+ return installHostDiskSampler(dir, { home: deps.env?.HOME ?? process.env.HOME ?? "", uid: process.getuid?.() ?? 0,
566
+ now: Date.now, dockerHost: await endpoint(), ...options });
567
+ },
568
+ } : {}),
552
569
  };
553
570
  }
554
571
  const serves = (host, teamKey) => host.teams.includes(teamKey);
@@ -590,6 +607,7 @@ function parseHosts(body) {
590
607
  capacity: capability ? capability.runtimeLive
591
608
  ? capability.placeableCapacity
592
609
  : 0 : null,
610
+ live: capability?.runtimeLive === true,
593
611
  failing: capability ? capability.failingRequired : [],
594
612
  });
595
613
  }
@@ -790,6 +808,7 @@ export function onboardRunnerAdapter(input = {}) {
790
808
  const gid = p.info.vm ? 0 : await engine.socketGid();
791
809
  if (!HOST_NAME.test(name) || gid === null)
792
810
  return null;
811
+ const sampler = samplerManaged(p, ctx, saved);
793
812
  return {
794
813
  DOCKER_SOCKET_PATH: "/var/run/docker.sock",
795
814
  DOCKER_SOCKET_GID: String(gid),
@@ -808,10 +827,22 @@ export function onboardRunnerAdapter(input = {}) {
808
827
  CATALYST_THOUGHTS_CUSTODY_RESPONSES_DIR: receivedCustody.result.responsesRoot,
809
828
  } : {}),
810
829
  CATALYST_SESSION_EGRESS_DIR: p.info.vm ? join(p.dir, "session-egress") : NATIVE_EGRESS_DIR,
830
+ ...(sampler ? { CATALYST_HOST_DISK_SAMPLE_FILE: HOST_DISK_SAMPLE_FILE } : {}),
811
831
  // Saved policy stays in merged(). An explicit value in this run overrides it.
812
832
  ...Object.fromEntries(DISK_POLICY_KEYS.flatMap(key => ctx.env[key] === undefined ? [] : [[key, ctx.env[key]]])),
813
833
  };
814
834
  }
835
+ /** CTC-5929: setup publishes the Mac's disk sample into the install's own `host-disk` bind. A
836
+ * custom sample directory or file belongs to its operator, who also runs its publisher. */
837
+ function samplerManaged(p, ctx, saved) {
838
+ const custom = (key) => (ctx.env[key] ?? saved?.[key] ?? "").trim();
839
+ return p.info.vm && engine.installHostDiskSampler !== undefined && custom("CATALYST_HOST_DISK_SAMPLE_DIR") === "" &&
840
+ ["", HOST_DISK_SAMPLE_FILE].includes(custom("CATALYST_HOST_DISK_SAMPLE_FILE"));
841
+ }
842
+ const samplerOptions = (ctx, saved) => {
843
+ const explicitPath = ctx.env.CATALYST_HOST_DISK_PATH ?? saved?.CATALYST_HOST_DISK_PATH;
844
+ return explicitPath ? { explicitPath } : {};
845
+ };
815
846
  /** The step owns its own keys only; a person's tuning (CATALYST_SLOTS and the rest) is kept. */
816
847
  const merged = (saved, want) => ({
817
848
  ...want,
@@ -947,6 +978,12 @@ export function onboardRunnerAdapter(input = {}) {
947
978
  const name = want.CATALYST_HOST_NAME;
948
979
  if (p.info.vm && persistedCustody(p.dir, saved) !== "installed")
949
980
  return mayAct && !afterAct ? { state: "pending" } : waiting(mayAct ? "runner_thoughts_custody_unavailable" : "runner_needs_runner_flag", { ...selected, hostName: name });
981
+ if (samplerManaged(p, ctx, saved) && engine.hostDiskSamplerStatus) {
982
+ const sampler = await engine.hostDiskSamplerStatus(p.dir, samplerOptions(ctx, saved));
983
+ if (sampler !== "installed")
984
+ return mayAct && !afterAct ? { state: "pending" } : waiting(!mayAct ? "runner_needs_runner_flag" : sampler === "missing" ? "runner_host_disk_sampler_failed" : sampler, { ...selected, hostName: name,
985
+ ...(sampler === "runner_host_disk_sampler_conflict" ? foreignHostDiskSample(ctx.home) : {}) });
986
+ }
950
987
  let enrolled = null;
951
988
  const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && serves(host, p.teamKey) && !host.revoked);
952
989
  let hosts = await listHosts(ctx, p, signal);
@@ -1023,6 +1060,15 @@ export function onboardRunnerAdapter(input = {}) {
1023
1060
  return pending();
1024
1061
  if (keyStatus !== "valid")
1025
1062
  return waiting(keyStatus === "missing" ? "runner_org_key_missing" : keyStatus === "invalid" ? "runner_org_key_invalid" : "runner_org_key_unavailable", evidence);
1063
+ if (host.capacity === 0 && host.live && host.failing.length === 0) {
1064
+ // The supervisor advertises zero slots when disk admission refuses; its reason stays in its log.
1065
+ const disk = await engine.hostDisk?.(p.dir, p.info.vm);
1066
+ if (disk && disk.state !== "measured")
1067
+ return waiting(disk.state === "below_floor" ? "runner_host_disk_below_floor" : "runner_host_disk_unmeasured", {
1068
+ ...evidence, floorGiB: disk.floorGiB, vm: p.info.vm,
1069
+ ...(disk.state === "below_floor" ? { availableGiB: disk.availableGiB } : { diskDetail: disk.detail }),
1070
+ });
1071
+ }
1026
1072
  if (host.capacity === 0 || host.failing.length > 0)
1027
1073
  return waiting("runner_host_not_ready", { ...evidence, failing: host.failing.join(",") });
1028
1074
  if (p.cloud) {
@@ -1166,6 +1212,12 @@ export function onboardRunnerAdapter(input = {}) {
1166
1212
  (advice ? " " + advice : ""), details, signal);
1167
1213
  }
1168
1214
  }
1215
+ if (samplerManaged(p, ctx, readEnvFile(join(p.dir, ".env"))) && engine.installHostDiskSampler) {
1216
+ const sampler = await engine.installHostDiskSampler(p.dir, samplerOptions(ctx, readEnvFile(join(p.dir, ".env"))));
1217
+ if (sampler !== "ready")
1218
+ return waiting(sampler, sampler === "runner_host_disk_sampler_conflict"
1219
+ ? { ...selected, ...foreignHostDiskSample(ctx.home) } : selected);
1220
+ }
1169
1221
  if (!writeEnv(want))
1170
1222
  return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
1171
1223
  const network = await engine.network(RUNNER_SESSION_NETWORK, signal);
@@ -2,7 +2,7 @@ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
2
2
  import { COMPUTER_CHECKS, standalonePlan, standalonePlanNotes, pendingContinuation, } from "./onboard-standalone-copy.js";
3
3
  import { trackedPrompt } from "./setup-prompt-frame.js";
4
4
  import { createOnboardInterrupts } from "./onboard-interrupts.js";
5
- import { CLOUD_WORK, setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
5
+ import { CLOUD_WORK, setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, unattendedSkipLines, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
6
6
  import { createOnboardProgress, } from "./onboard-progress.js";
7
7
  import { CREATE_TEAM_CHOICE, suggestTeamKey, teamKeyProblem, teamNameProblem, } from "./onboard-team-create.js";
8
8
  import { FIRST_TICKET_SAMPLE, FIRST_TICKET_SKIP, } from "./onboard-first-ticket.js";
@@ -297,6 +297,10 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
297
297
  if (screen.paused)
298
298
  renderer.line("Your progress is saved.");
299
299
  renderer.tracker(setupTrackerRows(setupPartProgress(journal, planned.scope), "end"));
300
+ // CTC-5932: with no one to ask, setup says what it left out instead of letting it pass unsaid.
301
+ if (!interactive && !screen.paused)
302
+ for (const line of unattendedSkipLines(journal, planned))
303
+ renderer.line(line);
300
304
  if (screen.readinessCause)
301
305
  renderer.line(say(screen.readinessCause));
302
306
  if (mine.length) {
package/dist/onboard.js CHANGED
@@ -298,6 +298,9 @@ function normalizeStep(value, fallbackAt) {
298
298
  "passed",
299
299
  "team",
300
300
  "teamKey",
301
+ // CTC-5932: the comma-joined choices a run with no one to ask names in its flag advice.
302
+ "choices",
303
+ "choiceTotal",
301
304
  "selected",
302
305
  "hostName",
303
306
  "supervisorImage",
@@ -324,6 +327,14 @@ function normalizeStep(value, fallbackAt) {
324
327
  "custodyResultInstalled",
325
328
  "custodyAuthorityMatched",
326
329
  "custodyTelemetry",
330
+ // CTC-5929: the runner's disk reason and a foreign sampler's paths. Without them the saved
331
+ // step read back as "no disk sampler" on a Linux host that was below its floor.
332
+ "floorGiB",
333
+ "availableGiB",
334
+ "vm",
335
+ "diskDetail",
336
+ "sampleDir",
337
+ "sampleFile",
327
338
  ]) {
328
339
  const item = evidence?.[key];
329
340
  if (item === null ||
package/dist/ready.js CHANGED
@@ -16,7 +16,9 @@ import { installedBundleVersion } from "./skills.js";
16
16
  import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
17
17
  import { selectedOnboardTeam } from "./onboard-existing.js";
18
18
  import { dispatchGateCopy, teamCheckCopy, valuesFix, whoFixes } from "./ready-copy.js";
19
- import { onboardStatePath, readOnboardJournal } from "./onboard.js";
19
+ import { onboardStatePath, onboardStateRoot, readOnboardJournal } from "./onboard.js";
20
+ import { dockerRunnerEngine, readEnvFile, RUNNER_HOST_IMAGES } from "./onboard-runner.js";
21
+ import { dockerRootAvailableBytes, runnerDiskCopy, runnerDiskVerdict } from "./onboard-runner-disk.js";
20
22
  export { semverOlder };
21
23
  /** `ready`'s own wording for the replica. It never recommends adopting the replica — until the
22
24
  * mirror's snapshot path is safe for a large tenant, a customer following that advice is the
@@ -112,6 +114,35 @@ export function catalystCommandCheck(env, platform = process.platform) {
112
114
  "Keep using catalyst-skills on this machine, or put the npm global bin directory ahead of that one on PATH.",
113
115
  };
114
116
  }
117
+ /** CTC-5929: the disk admission the supervisor applies, read from the runner installed here. The
118
+ * running catalyst-host project names its own directory, so a moved state root is still found; the
119
+ * state root is only the fallback when Docker shows no such project or cannot be listed.
120
+ * A Mac runs the engine in a VM, so only the native sample counts; Linux measures Docker's data root. */
121
+ async function localRunnerDisk(ctx, engine) {
122
+ let dir = null;
123
+ const containers = await engine.projectContainers?.().catch(() => null);
124
+ if (containers?.some(row => row.service === "supervisor")) {
125
+ const installed = await engine.installation?.(ctx.home, ctx.env.CATALYST_SUPERVISOR_IMAGE ?? RUNNER_HOST_IMAGES.supervisor)
126
+ .catch(() => "unverified");
127
+ if (!installed || typeof installed === "string")
128
+ return { unverified: true };
129
+ dir = installed.dir;
130
+ }
131
+ else {
132
+ try {
133
+ dir = join(onboardStateRoot(ctx.home, ctx.env), "runner");
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ const saved = readEnvFile(join(dir, ".env"));
140
+ if (!saved?.CATALYST_SUPERVISOR_IMAGE)
141
+ return containers?.length ? { unverified: true } : null;
142
+ const vm = process.platform === "darwin";
143
+ return { vm, verdict: runnerDiskVerdict({ dir, saved, vm, nowMs: ctx.now().getTime(),
144
+ nativeAvailableBytes: () => dockerRootAvailableBytes(ctx.env) }) };
145
+ }
115
146
  export async function readyReport(ctx, deps) {
116
147
  const checks = [];
117
148
  const facts = deps.runtime ?? detectRuntime();
@@ -311,6 +342,21 @@ export async function readyReport(ctx, deps) {
311
342
  who: "you",
312
343
  });
313
344
  }
345
+ const disk = await (deps.runnerDisk ?? (() => localRunnerDisk(ctx, deps.runnerEngine ?? dockerRunnerEngine({ env: ctx.env }))))();
346
+ if (disk && "unverified" in disk) {
347
+ checks.push({ id: "runner:disk", ok: false, note: true,
348
+ line: "runner disk: a catalyst-host runner is installed here, but ready could not verify its installation to read its disk measurement",
349
+ fix: "run catalyst setup --runner, which checks the installation and names what is wrong" });
350
+ }
351
+ else if (disk) {
352
+ if (disk.verdict.state === "measured")
353
+ checks.push({ id: "runner:disk", ok: true,
354
+ line: `runner disk: ${disk.verdict.availableGiB} GiB free, at least ${disk.verdict.floorGiB} GiB needed` });
355
+ else {
356
+ const copy = runnerDiskCopy(disk.verdict, disk.vm);
357
+ checks.push({ id: "runner:disk", ok: false, line: `runner disk: ${copy.line}`, fix: copy.fix, who: "you" });
358
+ }
359
+ }
314
360
  const replica = replicaStatus(ctx, cfg);
315
361
  checks.push({
316
362
  id: "replica",