@catalyst-cloud/cli 0.16.27 → 0.16.29

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.29
4
+
5
+ The CLI now tells Catalyst Cloud which release you have installed: each request to Catalyst Cloud carries the installed version, and so does `catalyst watch` when it connects. Catalyst Cloud can then tell an out-of-date CLI from a current one. Nothing about your requests changes otherwise. On a Mac self-hosted machine, setup now installs a small agent that measures the Mac's disk, so the machine can take work instead of showing zero free slots. When a machine still shows zero free slots, setup and `catalyst ready` now say why: its disk has not been measured yet, or its free space is below the floor (25 GiB by default), with the numbers and the fix. An updated disk sampler also takes effect straight away: setup reloads the running disk agent whenever the sampler script changes, not only when its schedule file does.
6
+
7
+ ## 0.16.28
8
+
9
+ `catalyst events wait-for` can now wait on a repository (`--repo owner/name`), a branch (`--ref owner/name:branch`), a pull request (`--pr owner/name#n`), a work session (`--session KEY-123/phase#n`), events addressed to you (`--to me`) or a family of event types (`--type pr.*`). An agent can wait hours for a review or a conflict on its own branch: the wait holds one connection, prints each matching event as one JSON line, and after a dropped connection picks up where it left off without missing or repeating an event. Two new exit codes say why a wait ended without a match. 2 means the cloud refused the wait, for example on a ticket outside your teams, or the wait failed. 3 means `--from-cache` found no local event cache. A wait still exits 1 on a timeout and 4 on an outage, as before, and on a timeout it names the `--after` to resume from. New: `catalyst messages read --ticket KEY-123` prints the messages sent to a ticket on your teams, with each sender and time.
10
+
3
11
  ## 0.16.27
4
12
 
5
13
  A large workspace's local replica now finishes its first download instead of stopping after 30 seconds: it waits as long as data keeps arriving, and gives up only after 150 seconds with no progress. New commands: `catalyst repo status` lists your repositories and whether each is paused, and `catalyst repo pause|resume` pauses or resumes one (owners and admins). `catalyst runner status|stop|start|uninstall` stops a self-hosted machine taking work, starts it again with the same registration, or removes it and revokes its registration. Setup with local sync off now lists an old local replica's files with their sizes and removes them only after you say yes. Setup with local sync on keeps using the replica you already have. On a Mac where Docker Desktop does not share your home folder, setup now says which setting to change (Settings, then Resources, then File sharing).
package/README.md CHANGED
@@ -164,7 +164,7 @@ Before any network call, the run checks that every input is present. Each missin
164
164
 
165
165
  With a person at the terminal, `catalyst onboard` starts each of these approvals itself and prints the exact link to approve.
166
166
 
167
- With `--runner yes`, setup starts the self-hosted runner and verifies enrollment, advertised capacity and team admission. This needs Docker with Compose, pinned host images and an organization key file (`CATALYST_RUNNER_ORG_KEY_FILE`). When images, a key or admission are missing, exit 11 names that gate. A failed setup step exits 10. Use `--runner no` to skip the machine's runner. Unlike the interactive optional runner step, an explicitly requested headless runner must finish before exit 0.
167
+ With `--runner yes`, setup starts the self-hosted runner and verifies enrollment, advertised capacity and team admission. This needs Docker with Compose, pinned host images and an organization key file (`CATALYST_RUNNER_ORG_KEY_FILE`). When images, a key or admission are missing, exit 11 names that gate. A failed setup step exits 10. Use `--runner no` to skip the machine's runner. Unlike the interactive optional runner step, an explicitly requested headless runner must finish before exit 0. On a Mac, setup also installs the `dev.catalystcloud.host-disk` LaunchAgent, which measures the Mac's free disk space every 10 seconds, because Docker's virtual machine cannot see it. It needs Python 3 from Apple's Command Line Tools or Homebrew. When the machine takes no work because the disk Docker uses has less than 25 GiB free (`CATALYST_HOST_FREE_FLOOR_GIB` changes the amount) or has no fresh measurement, exit 11 says which, with the fix, and `catalyst ready` fails its `runner:disk` check the same way. If setup cannot tell which folder holds your Docker engine's disk image, set `CATALYST_HOST_DISK_PATH` to that folder.
168
168
 
169
169
  ### Stopping or removing this machine's runner
170
170
 
@@ -175,9 +175,9 @@ Run these on the machine itself:
175
175
  | `catalyst runner stop` | The machine takes no new work. Work it already started finishes or reaches its deadline. Its registration, settings and checkouts are kept, and `catalyst hosts` shows it as `stopped`. |
176
176
  | `catalyst runner start` | Starts it again with the same registration. |
177
177
  | `catalyst runner status` | Says whether it is running, stopped or not installed. |
178
- | `catalyst runner uninstall` | Asks first (`--yes` confirms without a terminal; an owner or admin login is needed). It stops new work, revokes the machine's registration and the runner key setup issued, and switches the project's runner admission off when no other machine runs it. Then it removes the runner's containers, their volumes and the session network, and the setup files in the runner folder. On macOS it also removes the storage LaunchAgent and its folders. On Linux it also removes the session-egress units, their script and state, and the `inet catalyst_session` nftables table, through `sudo`. |
178
+ | `catalyst runner uninstall` | Asks first (`--yes` confirms without a terminal; an owner or admin login is needed). It stops new work, revokes the machine's registration and the runner key setup issued, and switches the project's runner admission off when no other machine runs it. Then it removes the runner's containers, their volumes and the session network, and the setup files in the runner folder. On macOS it also removes the storage LaunchAgent and its folders, and the disk-measuring LaunchAgent setup installed. On Linux it also removes the session-egress units, their script and state, and the `inet catalyst_session` nftables table, through `sudo`. |
179
179
 
180
- Uninstall removes only what setup created. Anything that does not match what setup wrote, such as a network with another label or a changed unit file, is left in place. Your repository and thoughts checkouts are kept unless you add `--delete-checkouts`. The command ends with a "Left behind" list naming everything it did not remove. When a step fails, such as a cloud call while you are offline, it says which one and exits 1. Run it again: it picks up where the last run stopped. Do not delete the runner folder by hand while the runner is installed. That deletes its settings while its containers keep running.
180
+ Uninstall removes only what setup created. Anything that does not match what setup wrote, such as a network with another label, a changed unit file or a disk-measuring LaunchAgent that writes somewhere else, is left in place. Your repository and thoughts checkouts are kept unless you add `--delete-checkouts`. The command ends with a "Left behind" list naming everything it did not remove. When a step fails, such as a cloud call while you are offline, it says which one and exits 1. Run it again: it picks up where the last run stopped. Do not delete the runner folder by hand while the runner is installed. That deletes its settings while its containers keep running.
181
181
 
182
182
  With `--json`, stdout carries exactly one document: the onboarding receipt plus a `headless` block (`schema: "catalyst-onboard-headless/1"`). The block holds the resolved `inputs` (the key appears only as its source: `env`, `file` or `saved`), and lists of items under `missing`, `refused`, `failed`, `deferred` and `warnings`. Each item has `id`, `kind` (`input`, `grant` or `step`), `reason` and a one-line `text`, plus `flag`, `env` and `url` where they apply. Everything else goes to stderr.
183
183
 
@@ -246,7 +246,22 @@ CLI and skill reads use the cloud API by default, including on a machine with a
246
246
 
247
247
  For a local view, `catalyst replica status` checks the writer, heartbeat and cursor. It exits `0` for fresh, `1` for present but stale, `2` for not connected, `3` for absent. `replica status --probe` compares its cursor with the cloud head. These diagnostics do not change the default read source, and an absent replica does not make `catalyst ready` fail.
248
248
 
249
- `catalyst events tail`, `wait-for`, `query` and `status` read from the cloud by default, with no local file. `events tail` prints one JSON line per new event and stays in the foreground; `events wait-for --ticket ... --timeout ...` prints the first match and exits 0, or exits 1 when none arrives in time; `events query` reads one page of filtered history (`--ticket`, `--type`, `--limit` up to 200, `--order desc|asc`, `--before`, `--after`); `events status` names the cloud head. Add `--from-cache` to read this machine's local event cache instead, which pays off when several agents on one machine share one cloud connection. `catalyst events status --from-cache --probe` checks the cache's own cursor against the cloud head; `replica status --probe` checks the separate entity-replica cursor. A fresh replica does not prove event freshness. The replica process is the single writer and reports an event-sync failure without terminating a healthy entity replica. The event cache keeps closed daily segments for at most seven days or 256 MiB per account and reports an explicit gap when a requested sequence has retired.
249
+ `catalyst events tail`, `wait-for`, `query` and `status` read from the cloud by default, with no local file. `events tail` prints one JSON line per new event and stays in the foreground; `events wait-for --ticket ... --timeout ...` prints the first match and exits 0, or exits 1 when none arrives in time; `events query` reads one page of filtered history (`--ticket`, `--type`, `--limit` up to 200, `--order desc|asc`, `--before`, `--after`); `events status` names the cloud head.
250
+
251
+ `events wait-for` can also wait on a repository (`--repo owner/name`), a branch (`--ref owner/name:branch`), a pull request (`--pr owner/name#n`), a phase session (`--session TICKET/phase#n`), events addressed to you (`--to me`), or a family of types (`--type pr.*`). Values of one flag are OR'ed and different flags are AND'ed, and `--repo`, `--ref`, `--pr` and `--session` can repeat. Such a wait holds one connection and prints nothing until a match, so a long `--timeout` such as 14400 costs nothing while it waits. It prints every matching event as one JSON line. After a dropped connection it replays from its cursor, so no event is missed or printed twice.
252
+
253
+ | `wait-for` exit | Meaning |
254
+ | --- | --- |
255
+ | 0 | a match, printed on stdout |
256
+ | 1 | none arrived in time; with a scope flag, stderr names the `--after` to resume from |
257
+ | 2 | the cloud refused the wait (for example a ticket outside your teams), or the wait failed; stderr gives the reason |
258
+ | 3 | `--from-cache` and the local event cache is absent or its writer stopped |
259
+ | 4 | the cloud was unreachable for five attempts in a row: an outage, not a timeout |
260
+ | 130 | interrupted |
261
+
262
+ `catalyst messages read --ticket KEY-123 [--since SEQUENCE]` prints the messages sent to a ticket on your teams, with each sender and time, for example after a wait on `--type comms.message.posted` returns.
263
+
264
+ Add `--from-cache` to read this machine's local event cache instead, which pays off when several agents on one machine share one cloud connection. `catalyst events status --from-cache --probe` checks the cache's own cursor against the cloud head; `replica status --probe` checks the separate entity-replica cursor. A fresh replica does not prove event freshness. The replica process is the single writer and reports an event-sync failure without terminating a healthy entity replica. The event cache keeps closed daily segments for at most seven days or 256 MiB per account and reports an explicit gap when a requested sequence has retired.
250
265
 
251
266
  The replica is a Node process, not a service: the supported path is the plain command, and `skills/catalyst-onboard/references/local-sync.md` gives launchd and systemd examples for people who want the writer to survive a reboot.
252
267
 
package/dist/args.js CHANGED
@@ -158,11 +158,16 @@ export const FLAG_TABLES = {
158
158
  value: false,
159
159
  help: "status --from-cache: compare the local event cursor with the cloud event head",
160
160
  },
161
- type: { value: true, help: "exact event type" },
161
+ type: { value: true, help: "exact event type, or for wait-for a family such as pr.*" },
162
162
  ticket: {
163
163
  value: true,
164
164
  help: "ticket identifier found in the event",
165
165
  },
166
+ repo: { value: true, repeat: true, help: "wait-for: repository as owner/name (repeatable)" },
167
+ ref: { value: true, repeat: true, help: "wait-for: branch as owner/name:branch (repeatable)" },
168
+ pr: { value: true, repeat: true, help: "wait-for: pull request as owner/name#number (repeatable)" },
169
+ session: { value: true, repeat: true, help: "wait-for: phase session as TICKET/phase#attempt (repeatable)" },
170
+ to: { value: true, help: "wait-for: me, for events addressed to this login" },
166
171
  after: {
167
172
  value: true,
168
173
  help: "event sequence to read after (tail and wait-for default to new events only)",
@@ -478,6 +483,10 @@ export const FLAG_TABLES = {
478
483
  },
479
484
  },
480
485
  identity: {},
486
+ messages: {
487
+ ticket: { value: true, help: "read: the ticket whose messages to read" },
488
+ since: { value: true, help: "read: the first message sequence to read (inclusive)" },
489
+ },
481
490
  capabilities: {},
482
491
  project: {
483
492
  team: {
@@ -564,7 +573,7 @@ export const VERB_USAGE = {
564
573
  query: "query <issues|issue <id>|pulls|pull <id>|projects|cycles|search <terms>|changes --since <cursor|head>> [--team K] [--project P] [--state S] [--limit N] [--all] [--source replica|api] [--json]",
565
574
  replica: 'replica <start [--detach]|stop|status [--probe] [--json]|sql "<select>"|schema [table]> [--db <path>]',
566
575
  runtime: "runtime <status [--json]|install|path|uninstall>",
567
- events: "events <tail|wait-for|query|status [--probe] [--json]> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
576
+ events: "events <tail|wait-for|query|status [--probe] [--json]> [--type NAME] [--ticket CTC-N] [--repo O/N] [--ref O/N:BRANCH] [--pr O/N#N] [--session T/PHASE#N] [--to me] [--after SEQUENCE] [--limit N] [--timeout SECONDS] [--directory PATH]",
568
577
  explain: "explain <ticket> [--history] [--json]",
569
578
  history: "history <ticket> [--json]",
570
579
  running: "running [--ticket T --phase P] [--json]",
@@ -582,6 +591,7 @@ export const VERB_USAGE = {
582
591
  secret: "secret set <NAME> --repo <owner/name> [--command '<cmd>'] [--json] (value from --command, stdin, or a hidden prompt) | secret import <file> --repo <owner/name> [--names NAME[,NAME...]] [--rotate NAME]... [--json]",
583
592
  var: "var <set NAME|import <file>> [--repo <owner/name>] [--names NAME[,NAME...]] [--json] (plain environment variables; set reads stdin or a hidden prompt)",
584
593
  identity: "identity linear <status|options|set> [<linearUserId>] [--json]",
594
+ messages: "messages read --ticket CTC-N [--since SEQUENCE] [--json]",
585
595
  capabilities: "capabilities [--json]",
586
596
  project: "project <list [--json] | wip-limit <get|set <n>|set default> [--team K] [--json]>",
587
597
  repo: "repo <status|pause <owner/name> --reason <text>|resume <owner/name> [--reason <text>]|agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> [--json] (status: visible workspace repositories; agents-block/agent-setup: local checkout)",
@@ -0,0 +1,41 @@
1
+ const HEADER = "x-catalyst-cli-version";
2
+ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
3
+ /** Public package metadata on the selected cloud API transport. All fetch semantics stay intact. */
4
+ export function reportCliVersion(transport, version, origins) {
5
+ if (version.length > 80 || !VERSION.test(version))
6
+ return transport;
7
+ return (input, init) => {
8
+ let url;
9
+ try {
10
+ url = new URL(input instanceof Request ? input.url : String(input));
11
+ const selected = origins().some(origin => {
12
+ try {
13
+ return new URL(origin).origin === url.origin;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ });
19
+ if (!selected || !(url.pathname.startsWith("/api/v1/") || ["/snapshot", "/changes", "/events"].includes(url.pathname)))
20
+ return transport(input, init);
21
+ }
22
+ catch {
23
+ return transport(input, init);
24
+ }
25
+ return transport(input, { ...init, headers: withVersion(init?.headers ?? (input instanceof Request ? input.headers : undefined), version) });
26
+ };
27
+ }
28
+ function withVersion(headers, version) {
29
+ // A Headers from another realm or library fails instanceof, and Object.entries would read it as
30
+ // empty and drop every header the caller sent. Anything iterable that is not an array is copied.
31
+ if (headers instanceof Headers || (!Array.isArray(headers) && typeof headers?.forEach === "function")) {
32
+ const copy = new Headers(headers);
33
+ copy.set(HEADER, version);
34
+ return copy;
35
+ }
36
+ if (Array.isArray(headers))
37
+ return [...headers.filter(([name]) => name.toLowerCase() !== HEADER), [HEADER, version]];
38
+ const record = Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => name.toLowerCase() !== HEADER));
39
+ record[HEADER] = version;
40
+ return record;
41
+ }
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { existsSync, openSync, closeSync } from "node:fs";
8
8
  import { ReadStream as TerminalInput } from "node:tty";
9
9
  import { Writable } from "node:stream";
10
10
  import { parseArgs, positionals, verbHelp } from "./args.js";
11
+ import { reportCliVersion } from "./cli-version.js";
11
12
  import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, installerOwnsSkills, INSTALL_COMMAND, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
12
13
  import { loadContract, pickPath } from "./contract.js";
13
14
  import { CliError, MeError, UsageError } from "./errors.js";
@@ -47,6 +48,7 @@ import { cmdProject } from "./project.js";
47
48
  import { cmdRepo } from "./repo.js";
48
49
  import { cmdLegacy } from "./legacy.js";
49
50
  import { cmdIdentity } from "./identity.js";
51
+ import { cmdMessages } from "./messages.js";
50
52
  import { cmdConnections } from "./connections.js";
51
53
  import { cmdOnboard, onboardErrorJournal, onboardStatePath, readOnboardJournal, } from "./onboard.js";
52
54
  import { createClackOnboardUi, shouldUseOnboardUi, } from "./onboard-ui.js";
@@ -93,6 +95,7 @@ export function usageText() {
93
95
  ' catalyst replica <start [--detach]|stop|status [--probe]|sql "<select>"|schema [table]>',
94
96
  " catalyst runtime <status [--json]|install|path|uninstall> (a pinned Node this CLI manages itself)",
95
97
  " catalyst events <tail|wait-for|query|status> [--type NAME] [--ticket CTC-N] [--after SEQUENCE] [--from-cache]",
98
+ " catalyst events wait-for [--repo O/N] [--ref O/N:BRANCH] [--pr O/N#N] [--session T/PHASE#N] [--to me] [--type pr.*] [--timeout SECONDS] (one connection; nothing printed until a match)",
96
99
  " catalyst explain <ticket> | history <ticket> | running [--ticket T --phase P] | queue [--team K]",
97
100
  " catalyst watch [--team K] [--ticket T]... [--project P] [--exec CMD]",
98
101
  " catalyst write <comment|state|label|create|reaction|attachment|session> ...",
@@ -112,6 +115,7 @@ export function usageText() {
112
115
  " catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
113
116
  " catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
114
117
  " catalyst identity linear <status|options|set> [<linearUserId>] [--json]",
118
+ " catalyst messages read --ticket CTC-N [--since SEQUENCE] [--json] (messages sent to a ticket on your teams)",
115
119
  " catalyst connections personal <linear|github> <start|status> [--wait <seconds>] [--json]",
116
120
  "",
117
121
  "Every verb takes --help. --json makes the output machine-readable.",
@@ -277,6 +281,19 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
277
281
  command: args.command,
278
282
  version: manifest.version,
279
283
  });
284
+ ctx = {
285
+ ...ctx,
286
+ fetch: reportCliVersion(ctx.fetch, manifest.version, () => {
287
+ let configured;
288
+ try {
289
+ configured = loadConfig(ctx.home)?.baseUrl;
290
+ }
291
+ catch {
292
+ // The command reports its existing config error.
293
+ }
294
+ return [args.baseUrl, ctx.env.CATALYST_CLOUD_BASE_URL, configured, DEFAULT_BASE_URL].filter((origin) => typeof origin === "string");
295
+ }),
296
+ };
280
297
  try {
281
298
  if (args.command !== "onboard") {
282
299
  migrateLegacyCliPath(ctx);
@@ -535,6 +552,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
535
552
  return await cmdLegacy(args, ctx, { localSyncSelected: savedLocalSync(ctx), ...deps.legacy });
536
553
  case "identity":
537
554
  return await cmdIdentity(args, ctx, deps.identity ?? {});
555
+ case "messages":
556
+ return await cmdMessages(args, ctx, deps.messages ?? {});
538
557
  case "hosts":
539
558
  return await cmdHosts(args, ctx, deps.hosts);
540
559
  case "runner":
@@ -599,6 +618,7 @@ const VERB_HELP_KNOWN = Object.fromEntries([
599
618
  "environment",
600
619
  "connections",
601
620
  "identity",
621
+ "messages",
602
622
  "mcp",
603
623
  "team",
604
624
  "capabilities",
package/dist/events.js CHANGED
@@ -1,4 +1,4 @@
1
- import { flagInt, flagString, positionals } from "./args.js";
1
+ import { flagInt, flagList, flagString, positionals } from "./args.js";
2
2
  import { normalizeBaseUrl, requireConfig } from "./config.js";
3
3
  import { CliError, UsageError } from "./errors.js";
4
4
  import { authStrategyFor } from "./oauth.js";
@@ -39,6 +39,13 @@ export async function cmdEvents(args, ctx, deps = {}) {
39
39
  throw new UsageError("events needs a subcommand: tail | wait-for | query | status");
40
40
  if (!["tail", "wait-for", "query", "status"].includes(sub))
41
41
  throw new UsageError(`unknown events subcommand: ${sub}`);
42
+ // CTC-6650 — a scope key or a type family runs on the SDK's wait; the --type/--ticket path is unchanged.
43
+ const scope = scopeFilter(args);
44
+ if (scope) {
45
+ if (sub !== "wait-for")
46
+ throw new UsageError("--repo, --ref, --pr, --session, --to and a type family such as pr.* work with wait-for only");
47
+ return cmdScopedWait(args, ctx, deps, scope);
48
+ }
42
49
  // CTC-4554 — the cloud is the default source; the local event file is the opt-in.
43
50
  if (args.flags["from-cache"] !== true) {
44
51
  if (flagString(args, "directory") !== undefined)
@@ -118,6 +125,144 @@ function startingCursor(args, name = "after") {
118
125
  throw new UsageError(`--${name} must be a non-negative event sequence`);
119
126
  return parsed;
120
127
  }
128
+ const SCOPE_KEYS = ["repo", "ref", "pr", "session"];
129
+ /** The SDK filter when the wait names a scope key or a type family, or null for the original path. */
130
+ function scopeFilter(args) {
131
+ const filter = {};
132
+ for (const key of SCOPE_KEYS) {
133
+ const values = flagList(args, key);
134
+ if (values.length)
135
+ filter[key] = values;
136
+ }
137
+ const to = flagString(args, "to");
138
+ if (to !== undefined) {
139
+ if (to !== "me")
140
+ throw new UsageError("--to accepts only me");
141
+ filter.to = "me";
142
+ }
143
+ const type = flagString(args, "type");
144
+ if (Object.keys(filter).length === 0 && !type?.endsWith(".*"))
145
+ return null;
146
+ const ticket = flagString(args, "ticket");
147
+ return { ...filter, ...(type ? { type } : {}), ...(ticket ? { ticket } : {}) };
148
+ }
149
+ /** Counts consecutive failed requests, so a wait that cannot reach the cloud ends as an outage. */
150
+ function outageFetch(base, signal, onOutage, now) {
151
+ let failures = 0;
152
+ let sinceMs = 0;
153
+ const trouble = () => {
154
+ if (failures === 0)
155
+ sinceMs = now();
156
+ failures += 1;
157
+ if (failures >= OUTAGE_ATTEMPTS)
158
+ onOutage(failures, sinceMs);
159
+ };
160
+ return async (input, init) => {
161
+ try {
162
+ const response = await base(input, init);
163
+ if (response.status >= 500)
164
+ trouble();
165
+ else
166
+ failures = 0;
167
+ return response;
168
+ }
169
+ catch (error) {
170
+ // A request the SDK timed out counts; one cancelled because this wait ended does not.
171
+ if (!signal.aborted)
172
+ trouble();
173
+ throw error;
174
+ }
175
+ };
176
+ }
177
+ /** `wait-for` with scope keys: the SDK's wait, under the CLI's documented exit codes. */
178
+ async function cmdScopedWait(args, ctx, deps, filter) {
179
+ const cfg = requireConfig(ctx);
180
+ const timeout = flagInt(args, "timeout", 300);
181
+ if (timeout < 0)
182
+ throw new UsageError("--timeout must be zero or more seconds");
183
+ const after = startingCursor(args);
184
+ const fromCache = args.flags["from-cache"] === true;
185
+ if (!fromCache && flagString(args, "directory") !== undefined)
186
+ throw new UsageError("--directory names the local event cache; add --from-cache to read it");
187
+ const sdk = await (deps.loadSdk ?? loadEventsSdk)();
188
+ const { waitForEvents, waitForCachedEvents } = sdk;
189
+ if (!waitForEvents || !waitForCachedEvents)
190
+ throw new CliError("the installed SDK cannot wait on scope keys; upgrade the catalyst CLI", "sdk-upgrade-required");
191
+ const controller = new AbortController();
192
+ const abort = () => controller.abort(deps.signal?.reason);
193
+ if (deps.signal?.aborted)
194
+ abort();
195
+ else
196
+ deps.signal?.addEventListener("abort", abort, { once: true });
197
+ let interrupted = false;
198
+ let outage;
199
+ const removeInterrupt = (deps.onInterrupt ?? processInterrupts)(() => {
200
+ interrupted = true;
201
+ controller.abort(new Error("interrupted"));
202
+ });
203
+ try {
204
+ let result;
205
+ if (fromCache) {
206
+ result = await waitForCachedEvents({
207
+ tenantId: cfg.account,
208
+ directory: flagString(args, "directory") ?? sdk.defaultEventCacheDirectory(cfg.account),
209
+ filter,
210
+ after,
211
+ timeoutMs: timeout * 1_000,
212
+ signal: controller.signal,
213
+ });
214
+ }
215
+ else {
216
+ const auth = authStrategyFor(ctx, cfg);
217
+ if (auth.kind === "cookie")
218
+ throw new CliError("event waits require a token or OAuth session", "events-auth");
219
+ result = await waitForEvents({
220
+ baseUrl: normalizeBaseUrl(cfg.baseUrl),
221
+ auth,
222
+ tenantId: cfg.account,
223
+ filter,
224
+ after,
225
+ timeoutMs: timeout * 1_000,
226
+ signal: controller.signal,
227
+ fetch: outageFetch(ctx.fetch, controller.signal, (failures, sinceMs) => {
228
+ if (outage)
229
+ return;
230
+ outage = { failures, sinceMs };
231
+ controller.abort(new Error("cloud unreachable"));
232
+ }, () => ctx.now().getTime()),
233
+ });
234
+ }
235
+ const resume = (cursor) => (cursor === null || cursor === undefined ? "" : `; resume with --after ${cursor}`);
236
+ switch (result.outcome) {
237
+ case "matched":
238
+ for (const event of result.events)
239
+ ctx.stdout(JSON.stringify(event));
240
+ return 0;
241
+ case "timeout":
242
+ ctx.stderr(`events: no matching event within ${timeout} s${resume(result.cursor)}`);
243
+ return 1;
244
+ case "aborted":
245
+ if (interrupted)
246
+ return 130;
247
+ if (outage) {
248
+ ctx.stderr(`events: the cloud event service was unreachable for ${outage.failures} attempts over ${Math.round((ctx.now().getTime() - outage.sinceMs) / 1_000)} s; this is an outage, not a timeout${resume(result.cursor)}`);
249
+ return 4;
250
+ }
251
+ return 1;
252
+ case "refused":
253
+ throw new CliError(`events: ${result.source === "filter" ? "the filter was refused" : `the cloud refused the wait${result.status === null ? "" : ` (${result.status})`}`}${result.key ? ` on --${result.key}${result.value === null ? "" : ` ${result.value}`}` : ""}: ${result.reason}`, "events-refused");
254
+ default:
255
+ // The remaining outcome is "failed"; a default keeps every path ending in a return or throw.
256
+ if (result.code === "cache-missing" || result.code === "cache-writer-stopped")
257
+ throw new CliError(`events: ${result.reason}. Start the local writer with: catalyst replica start --detach, or drop --from-cache to read from the cloud`, "events-absent", 3);
258
+ throw new CliError(`events: the wait failed (${result.code}): ${result.reason}${resume(result.resumeFrom ?? result.cursor)}`, "events-failed");
259
+ }
260
+ }
261
+ finally {
262
+ removeInterrupt();
263
+ deps.signal?.removeEventListener("abort", abort);
264
+ }
265
+ }
121
266
  function eventFilter(args) {
122
267
  const type = flagString(args, "type");
123
268
  const ticket = flagString(args, "ticket")?.toUpperCase();
@@ -0,0 +1,47 @@
1
+ import { flagString, positionals } from "./args.js";
2
+ import { requireConfig } from "./config.js";
3
+ import { CliError, UsageError } from "./errors.js";
4
+ import { bearerFor } from "./oauth.js";
5
+ import { loadHttpSdk } from "./sdk.js";
6
+ function failure(result) {
7
+ const reason = "reason" in result && typeof result.reason === "string" ? `: ${result.reason}` : "";
8
+ return `messages: ${result.outcome}${"status" in result && result.status ? ` (${result.status})` : ""}${reason}`;
9
+ }
10
+ export async function cmdMessages(args, ctx, deps = {}) {
11
+ const [action, ...extra] = positionals(args);
12
+ if (action !== "read" || extra.length)
13
+ throw new UsageError("messages takes: read --ticket CTC-N [--since SEQUENCE]");
14
+ const ticket = flagString(args, "ticket")?.trim().toUpperCase();
15
+ if (!ticket)
16
+ throw new UsageError("messages read needs --ticket CTC-N");
17
+ const rawSince = flagString(args, "since");
18
+ const since = rawSince === undefined ? undefined : Number(rawSince);
19
+ if (since !== undefined && (!Number.isSafeInteger(since) || since < 0))
20
+ throw new UsageError("--since must be a non-negative message sequence");
21
+ const cfg = requireConfig(ctx);
22
+ const createClient = deps.createClient ?? (async (options) => (await loadHttpSdk()).createTenantClient(options));
23
+ const client = await createClient({ key: await bearerFor(ctx, cfg), baseUrl: cfg.baseUrl, fetch: ctx.fetch });
24
+ if (!client.messages)
25
+ throw new CliError("the installed SDK cannot read messages; upgrade the catalyst CLI", "sdk-upgrade-required");
26
+ const result = await client.messages.read({ ticket, ...(since === undefined ? {} : { since }) });
27
+ if (result.outcome !== "ok") {
28
+ if (args.json)
29
+ ctx.stdout(JSON.stringify(result));
30
+ else
31
+ ctx.stderr(failure(result));
32
+ return 1;
33
+ }
34
+ if (args.json)
35
+ ctx.stdout(JSON.stringify({ ticket: result.ticket, messages: result.messages, next: result.next }));
36
+ else if (result.messages.length === 0)
37
+ ctx.stdout(`${result.ticket}: no messages${since === undefined ? "" : ` at or after ${since}`}`);
38
+ else
39
+ for (const message of result.messages) {
40
+ ctx.stdout(`#${message.seq} ${message.sentAt} ${message.from.label} (${message.kind})`);
41
+ for (const line of message.body.split("\n"))
42
+ ctx.stdout(` ${line}`);
43
+ }
44
+ if (result.next !== null)
45
+ ctx.stderr(`more: re-run with --since ${result.next}`);
46
+ return 0;
47
+ }
@@ -3,6 +3,7 @@ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
3
3
  import { INSTALL_COMMAND, loadConfig, normalizeBaseUrl } from "./config.js";
4
4
  import { githubInstallationPage } from "./onboard-permissions.js";
5
5
  import { valuesFacts, valuesFix } from "./ready-copy.js";
6
+ import { DEFAULT_HOST_FREE_FLOOR_GIB, isRunnerDiskUnmeasured, runnerDiskCopy } from "./onboard-runner-disk.js";
6
7
  /** What a person reads for a step that is not done. Each line ends in an action they can take. */
7
8
  const REASONS = {
8
9
  legacy_cleanup_failed: "An earlier Catalyst installation could not be removed. Run catalyst legacy --remove to see the exact item and its blocker, fix that blocker, then run catalyst setup again. Shared data folders are kept.",
@@ -158,6 +159,10 @@ const REASONS = {
158
159
  runner_compose_not_running: "The runner's supervisor is not running. Run docker compose -p catalyst-host logs supervisor to see why, then run catalyst onboard again.",
159
160
  runner_enrollment_unverified: "The runner started but has not enrolled yet. Run docker compose -p catalyst-host logs supervisor to see why, or run catalyst onboard again in a minute.",
160
161
  runner_needs_runner_flag: "This machine was set up to run Catalyst's work, but its runner is not running or not enrolled. To bring it up, run catalyst onboard --runner.",
162
+ runner_host_disk_sampler_python_missing: "Setup needs Python 3 to measure this Mac's disk for the runner. Install Apple's Command Line Tools with xcode-select --install, or python3 from Homebrew, then run catalyst setup --runner.",
163
+ runner_host_disk_location_unverified: "Setup could not tell which disk holds your Docker engine's virtual machine, so it cannot measure this Mac's free space for the runner. Set CATALYST_HOST_DISK_PATH to that folder (for a relocated Docker Desktop disk image, the folder chosen in Settings, Resources, Advanced), then run catalyst setup --runner.",
164
+ runner_host_disk_sampler_failed: "Setup could not start the dev.catalystcloud.host-disk LaunchAgent that measures this Mac's disk, so the runner will advertise zero free slots. Run launchctl print gui/$(id -u)/dev.catalystcloud.host-disk, or user/$(id -u)/dev.catalystcloud.host-disk when no one is logged in at the console, to see why, then run catalyst setup --runner.",
165
+ runner_host_disk_sampler_not_repeating: "Setup loaded the dev.catalystcloud.host-disk LaunchAgent, but launchd did not keep it measuring this Mac's disk: it published no new sample within 25 seconds, so the runner will advertise zero free slots. launchd can hold back an agent loaded from an SSH session. Log in to this Mac's desktop, at the console or over Screen Sharing, and run catalyst setup --runner in Terminal there. To see what launchd did, run launchctl print gui/$(id -u)/dev.catalystcloud.host-disk and read its runs and pended lines.",
161
166
  runner_capability_pending: "The runner enrolled but has not reported its capacity yet. Run catalyst onboard again in a minute.",
162
167
  runner_admission_unverified: "Setup could not read whether the team's pool admits runner hosts. Run catalyst onboard again to retry.",
163
168
  github_app_permissions_outdated: `The existing GitHub App installation needs updated permissions accepted. ${GITHUB_CONFIRM_ACCESS} An organization owner on GitHub opens the App's installation page, chooses Review permissions, then Accept new permissions. Run catalyst onboard afterward.`,
@@ -454,6 +459,26 @@ export function onboardReasonText(step, context = {}) {
454
459
  : "on the web app's Account API keys page";
455
460
  return `The runner is enrolled, but it takes no work without an organization key. A workspace owner or administrator runs catalyst onboard --runner on this machine; it mints the key and stores it for the runner. If their cloud cannot mint one yet, they create one with the mirror:read, mirror:write and mirror:feed scopes ${where}, save it in a file only they can read, and run catalyst onboard --runner with CATALYST_RUNNER_ORG_KEY_FILE set to that file.`;
456
461
  }
462
+ if (reason === "runner_host_disk_sampler_conflict") {
463
+ // Both settings are needed: a custom directory turns off setup's sampler, and the sample file
464
+ // name is then empty unless it is set too.
465
+ const safe = (value) => typeof value === "string" && /^\/[^\x00-\x1f'"]{0,500}$/.test(value) ? value : null;
466
+ const dir = safe(step.evidence?.sampleDir) ?? "<directory the other agent writes to>";
467
+ const file = safe(step.evidence?.sampleFile) ?? "/var/lib/catalyst/host-disk/<its file name>";
468
+ return `Another dev.catalystcloud.host-disk LaunchAgent on this Mac writes its disk sample somewhere else, so setup left it alone and the runner has no disk measurement. To use that agent's sample, set both CATALYST_HOST_DISK_SAMPLE_DIR=${dir} and CATALYST_HOST_DISK_SAMPLE_FILE=${file}, then run catalyst setup --runner; the directory alone leaves the runner without a sample file. Or remove the other agent: run launchctl bootout gui/$(id -u)/dev.catalystcloud.host-disk and launchctl bootout user/$(id -u)/dev.catalystcloud.host-disk (one of them reports it is not loaded), delete ~/Library/LaunchAgents/dev.catalystcloud.host-disk.plist, then run catalyst setup --runner.`;
469
+ }
470
+ if (reason === "runner_host_disk_below_floor" || reason === "runner_host_disk_unmeasured") {
471
+ const e = step.evidence ?? {};
472
+ const num = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
473
+ const floorGiB = num(e.floorGiB) ?? DEFAULT_HOST_FREE_FLOOR_GIB, availableGiB = num(e.availableGiB);
474
+ if (reason === "runner_host_disk_below_floor" && availableGiB === null)
475
+ return `The runner advertises zero free slots: the disk it measures has less than ${floorGiB} GiB free. Free disk space, then run catalyst ready to see how much is free.`;
476
+ const detail = typeof e.diskDetail === "string" && isRunnerDiskUnmeasured(e.diskDetail) ? e.diskDetail : "physical_host_disk_sample_required";
477
+ const copy = runnerDiskCopy(reason === "runner_host_disk_below_floor" && availableGiB !== null
478
+ ? { state: "below_floor", availableGiB, floorGiB }
479
+ : { state: "unmeasured", detail, floorGiB }, e.vm === true);
480
+ return `${copy.line} ${copy.fix}`;
481
+ }
457
482
  if (reason === "runner_host_not_ready") {
458
483
  const failing = step.evidence?.failing;
459
484
  const checks = typeof failing === "string" && /^[a-z0-9.,_-]{1,500}$/.test(failing)