@bli-cockpit/cli 0.2.222 → 0.2.224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Runs the host commands that put the company plugin on a machine (BLI-18200),
3
+ * and remembers what happened so the 15-minute tick never hammers a failure.
4
+ *
5
+ * Claude Code claude plugin marketplace add BLI-INC/bli-agents-marketplace
6
+ * claude plugin install engineering-standards@bli-agents
7
+ * daily: claude plugin marketplace update bli-agents
8
+ * claude plugin update engineering-standards@bli-agents
9
+ * Codex codex plugin marketplace add BLI-INC/bli-agents-marketplace
10
+ * codex plugin add engineering-standards@bli-agents
11
+ * daily: codex plugin marketplace upgrade bli-agents
12
+ *
13
+ * The marketplace follows the repository default branch (no ref is passed).
14
+ * These are the host programs the machine already runs (agent-setup.ts spawns
15
+ * them for `--version` the same way), never a person's own program, so this does
16
+ * not belong in `commands/editor.ts`.
17
+ *
18
+ * Never throws and never blocks: a missing program, a private-repo sign-in gap,
19
+ * a dead network or a program too old for `plugin` is a named reason in the
20
+ * returned outcome, the log and `agent-plugins.json`. The tick retries a failed
21
+ * host only after six hours; a person running `cockpit doctor` always retries.
22
+ * Logs carry reason labels, exit codes and timings only, never command output.
23
+ */
24
+ import fs from "node:fs/promises";
25
+ import path from "node:path";
26
+ import { AGENT_PLUGIN_ID, AGENT_PLUGIN_MARKETPLACE, AGENT_PLUGIN_SOURCE, PLUGIN_HOSTS, classifyPluginFailure, inspectAgentPlugins, locateHostBinary, } from "./agent-plugins.js";
27
+ import { envWithNodeRuntimeOnPath } from "./scheduled-self-update.js";
28
+ export const AGENT_PLUGINS_STATE_FILE = "agent-plugins.json";
29
+ export const PLUGIN_RETRY_AFTER_FAILURE_MS = 6 * 60 * 60 * 1000;
30
+ export const PLUGIN_REFRESH_EVERY_MS = 24 * 60 * 60 * 1000;
31
+ /** A first marketplace clone is small; anything slower is a stuck prompt or a dead link. */
32
+ export const PLUGIN_COMMAND_TIMEOUT_MS = 120_000;
33
+ export async function readPluginState(stateDir) {
34
+ try {
35
+ const parsed = JSON.parse(await fs.readFile(path.join(stateDir, AGENT_PLUGINS_STATE_FILE), "utf8"));
36
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
37
+ }
38
+ catch {
39
+ return {};
40
+ }
41
+ }
42
+ async function writePluginState(stateDir, state) {
43
+ try {
44
+ await fs.mkdir(stateDir, { recursive: true });
45
+ await fs.writeFile(path.join(stateDir, AGENT_PLUGINS_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
46
+ }
47
+ catch (error) {
48
+ console.error("[agent-plugins] state not saved", JSON.stringify({ reason: "state_write_failed", error_code: error.code ?? null }));
49
+ }
50
+ }
51
+ const msSince = (iso, now) => {
52
+ const at = iso ? Date.parse(iso) : Number.NaN;
53
+ return Number.isFinite(at) ? now.getTime() - at : Number.POSITIVE_INFINITY;
54
+ };
55
+ function spawnEnv(options) {
56
+ const env = envWithNodeRuntimeOnPath({ ...(options.env ?? process.env) }, options.nodeExecutable);
57
+ return {
58
+ ...env,
59
+ // git must fail, not ask: a prompt on a scheduled tick hangs until the timeout.
60
+ GIT_TERMINAL_PROMPT: "0",
61
+ GCM_INTERACTIVE: "never",
62
+ ...(options.unattended && !env["GIT_SSH_COMMAND"] ? { GIT_SSH_COMMAND: "ssh -o BatchMode=yes" } : {}),
63
+ };
64
+ }
65
+ function stepsFor(host, reading, refresh) {
66
+ const steps = [];
67
+ if (!reading.marketplace)
68
+ steps.push({ name: "marketplace_add", args: ["plugin", "marketplace", "add", AGENT_PLUGIN_SOURCE] });
69
+ if (!reading.installed)
70
+ steps.push({ name: "plugin_install", args: host === "claude" ? ["plugin", "install", AGENT_PLUGIN_ID] : ["plugin", "add", AGENT_PLUGIN_ID] });
71
+ if (reading.installed && refresh) {
72
+ steps.push(host === "claude"
73
+ ? { name: "marketplace_update", args: ["plugin", "marketplace", "update", AGENT_PLUGIN_MARKETPLACE] }
74
+ : { name: "marketplace_update", args: ["plugin", "marketplace", "upgrade", AGENT_PLUGIN_MARKETPLACE] });
75
+ if (host === "claude")
76
+ steps.push({ name: "plugin_update", args: ["plugin", "update", AGENT_PLUGIN_ID] });
77
+ }
78
+ return steps;
79
+ }
80
+ export async function convergeAgentPlugins(options) {
81
+ const now = options.now ?? new Date();
82
+ const state = await readPluginState(options.stateDir);
83
+ const before = JSON.stringify(state);
84
+ const outcomes = [];
85
+ let readings;
86
+ try {
87
+ readings = await inspectAgentPlugins(options.homeDir, options);
88
+ }
89
+ catch (error) {
90
+ console.error("[agent-plugins] host state unreadable", JSON.stringify({ reason: "inspect_threw", error_name: error instanceof Error ? error.name : typeof error }));
91
+ return PLUGIN_HOSTS.map((host) => ({ host, status: "failed", reason: "command_failed", quiet: false }));
92
+ }
93
+ for (const host of PLUGIN_HOSTS) {
94
+ const reading = readings.find((entry) => entry.host === host);
95
+ const entry = { ...state[host] };
96
+ const outcome = await convergeHost(host, reading, entry, options, now).catch((error) => {
97
+ console.error("[agent-plugins] host step threw", JSON.stringify({ reason: "unexpected_error", host, error_name: error instanceof Error ? error.name : typeof error }));
98
+ return { host, status: "failed", reason: "command_failed", quiet: false };
99
+ });
100
+ if (Object.keys(entry).length > 0)
101
+ state[host] = entry;
102
+ else
103
+ delete state[host];
104
+ outcomes.push(outcome);
105
+ }
106
+ if (JSON.stringify(state) !== before)
107
+ await writePluginState(options.stateDir, state);
108
+ return outcomes;
109
+ }
110
+ async function convergeHost(host, reading, entry, options, now) {
111
+ if (!reading.present)
112
+ return { host, status: "skipped", reason: "host_not_present", quiet: true };
113
+ if (reading.installed) {
114
+ // Installed by us or by hand: a failure remembered from before is spent.
115
+ delete entry.last_reason;
116
+ const due = options.refresh && msSince(entry.last_refresh_at, now) >= PLUGIN_REFRESH_EVERY_MS;
117
+ if (!due)
118
+ return { host, status: "already_installed", reason: "installed", quiet: true };
119
+ return refreshHost(host, reading, entry, options, now);
120
+ }
121
+ if (options.unattended && entry.last_reason !== undefined && msSince(entry.last_attempt_at, now) < PLUGIN_RETRY_AFTER_FAILURE_MS) {
122
+ console.error("[agent-plugins] retry waits; the last attempt failed under six hours ago", JSON.stringify({ reason: "retry_throttled", host, last_reason: entry.last_reason }));
123
+ return { host, status: entry.last_reason === "binary_missing" ? "skipped" : "failed", reason: entry.last_reason, quiet: true };
124
+ }
125
+ return installHost(host, reading, entry, options, now);
126
+ }
127
+ async function runSteps(host, binary, steps, options) {
128
+ const env = spawnEnv(options);
129
+ for (const step of steps) {
130
+ const started = Date.now();
131
+ // The runner contract never rejects; a runner that does is still just a failed command.
132
+ const result = await options.exec(binary, step.args, { env, timeoutMs: options.timeoutMs ?? PLUGIN_COMMAND_TIMEOUT_MS })
133
+ .catch(() => ({ code: 1, stdout: "", stderr: "" }));
134
+ const elapsed_ms = Date.now() - started;
135
+ if (result.code !== 0) {
136
+ const reason = classifyPluginFailure(result);
137
+ console.error("[agent-plugins] host command failed", JSON.stringify({ reason, host, step: step.name, exit_code: result.code, elapsed_ms }));
138
+ return reason;
139
+ }
140
+ console.error("[agent-plugins] host command ran", JSON.stringify({ reason: "step_ok", host, step: step.name, exit_code: 0, elapsed_ms }));
141
+ }
142
+ return null;
143
+ }
144
+ async function installHost(host, reading, entry, options, now) {
145
+ entry.last_attempt_at = now.toISOString();
146
+ const binary = await locateHostBinary(host, options.homeDir, options);
147
+ if (!binary) {
148
+ entry.last_reason = "binary_missing";
149
+ console.error("[agent-plugins] host program not found", JSON.stringify({ reason: "binary_missing", host }));
150
+ return { host, status: "skipped", reason: "binary_missing", quiet: false };
151
+ }
152
+ const failure = await runSteps(host, binary, stepsFor(host, reading, false), options);
153
+ if (failure) {
154
+ entry.last_reason = failure;
155
+ return { host, status: "failed", reason: failure, quiet: false };
156
+ }
157
+ // Verify against what the host recorded, not against the exit code we got.
158
+ const after = (await inspectAgentPlugins(options.homeDir, options)).find((candidate) => candidate.host === host);
159
+ if (!after?.installed) {
160
+ entry.last_reason = "install_not_recorded";
161
+ console.error("[agent-plugins] install ran but the host did not record it", JSON.stringify({ reason: "install_not_recorded", host }));
162
+ return { host, status: "failed", reason: "install_not_recorded", quiet: false };
163
+ }
164
+ delete entry.last_reason;
165
+ entry.last_refresh_at = now.toISOString();
166
+ console.error("[agent-plugins] plugin installed", JSON.stringify({ reason: "installed", host }));
167
+ return { host, status: "installed", reason: "installed", quiet: false };
168
+ }
169
+ /** The daily refresh. A failure here never un-installs anything; the next try is tomorrow's. */
170
+ async function refreshHost(host, reading, entry, options, now) {
171
+ entry.last_refresh_at = now.toISOString();
172
+ const binary = await locateHostBinary(host, options.homeDir, options);
173
+ if (!binary) {
174
+ console.error("[agent-plugins] host program not found for the daily refresh", JSON.stringify({ reason: "binary_missing", host }));
175
+ return { host, status: "already_installed", reason: "refresh_skipped_binary_missing", quiet: true };
176
+ }
177
+ const failure = await runSteps(host, binary, stepsFor(host, reading, true), options);
178
+ if (failure)
179
+ return { host, status: "already_installed", reason: `refresh_failed_${failure}`, quiet: false };
180
+ console.error("[agent-plugins] plugin refreshed", JSON.stringify({ reason: "refreshed", host }));
181
+ return { host, status: "refreshed", reason: "refreshed", quiet: false };
182
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The company `engineering-standards` plugin on an agent machine (BLI-18200).
3
+ *
4
+ * Every intern machine should carry the plugin from the private marketplace
5
+ * `BLI-INC/bli-agents-marketplace` in BOTH Claude Code and Codex. This module is
6
+ * the reading half: what the two hosts' own state files say, where each host's
7
+ * program lives, and how a failed host command is named. It spawns nothing;
8
+ * `agent-plugins-converge.ts` runs the commands, `commands/doctor-agent-plugins.ts`
9
+ * is the doctor row, `commands/sync-heal-agent-plugins.ts` is the 15-minute tick.
10
+ *
11
+ * Reads only the hosts' own files, rooted at the person's home, so the same code
12
+ * answers on macOS and native Windows:
13
+ * Claude Code ~/.claude/plugins/known_marketplaces.json (marketplace added)
14
+ * ~/.claude/plugins/installed_plugins.json (plugin installed)
15
+ * Codex ~/.codex/config.toml [marketplaces.bli-agents] / [plugins."<id>"]
16
+ *
17
+ * A host counts as PRESENT when its config directory exists (`~/.claude`,
18
+ * `~/.codex`). That is a file read, so a doctor check never spawns a program, and
19
+ * a machine whose agent was never started has nothing to install into yet.
20
+ */
21
+ import fs from "node:fs/promises";
22
+ import path from "node:path";
23
+ export const AGENT_PLUGIN_SOURCE = "BLI-INC/bli-agents-marketplace";
24
+ export const AGENT_PLUGIN_MARKETPLACE = "bli-agents";
25
+ export const AGENT_PLUGIN_NAME = "engineering-standards";
26
+ export const AGENT_PLUGIN_ID = `${AGENT_PLUGIN_NAME}@${AGENT_PLUGIN_MARKETPLACE}`;
27
+ export const PLUGIN_HOSTS = ["claude", "codex"];
28
+ export const realPluginFiles = {
29
+ read: (file) => fs.readFile(file, "utf8").catch(() => null),
30
+ exists: (file) => fs.access(file).then(() => true, () => false),
31
+ };
32
+ const pathApi = (platform) => (platform === "win32" ? path.win32 : path.posix);
33
+ function parseObject(text) {
34
+ if (text === null)
35
+ return null;
36
+ try {
37
+ const value = JSON.parse(text);
38
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ export async function inspectAgentPlugins(homeDir, runtime = {}) {
45
+ const platform = runtime.platform ?? process.platform;
46
+ const files = runtime.files ?? realPluginFiles;
47
+ const join = (...parts) => pathApi(platform).join(homeDir, ...parts);
48
+ const claudePresent = await files.exists(join(".claude"));
49
+ const marketplaces = parseObject(await files.read(join(".claude", "plugins", "known_marketplaces.json")));
50
+ const installedFile = parseObject(await files.read(join(".claude", "plugins", "installed_plugins.json")));
51
+ const installations = installedFile?.["plugins"]?.[AGENT_PLUGIN_ID];
52
+ const codexPresent = await files.exists(join(".codex"));
53
+ const toml = (await files.read(join(".codex", "config.toml"))) ?? "";
54
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
55
+ const marketplace = escapeRegExp(AGENT_PLUGIN_MARKETPLACE);
56
+ return [
57
+ {
58
+ host: "claude",
59
+ present: claudePresent,
60
+ marketplace: Boolean(marketplaces && Object.keys(marketplaces).includes(AGENT_PLUGIN_MARKETPLACE)),
61
+ // A plugin the person turned off by hand is still installed; it is not ours to switch back on.
62
+ installed: Array.isArray(installations) && installations.length > 0,
63
+ },
64
+ {
65
+ host: "codex",
66
+ present: codexPresent,
67
+ marketplace: new RegExp(`^\\s*\\[marketplaces\\.(?:"${marketplace}"|${marketplace})\\]\\s*$`, "mu").test(toml),
68
+ installed: new RegExp(`^\\s*\\[plugins\\."${escapeRegExp(AGENT_PLUGIN_ID)}"\\]\\s*$`, "mu").test(toml),
69
+ },
70
+ ];
71
+ }
72
+ /**
73
+ * Where a host's program lives, as an absolute path or null. A scheduled tick
74
+ * runs with a stripped PATH, so the person's PATH is tried first and then the
75
+ * places the host's installers use on each family. No exec: a file check.
76
+ */
77
+ export async function locateHostBinary(host, homeDir, runtime = {}) {
78
+ const platform = runtime.platform ?? process.platform;
79
+ const files = runtime.files ?? realPluginFiles;
80
+ const api = pathApi(platform);
81
+ const env = runtime.env ?? process.env;
82
+ const windows = platform === "win32";
83
+ const fromPath = (env["PATH"] ?? env["Path"] ?? "").split(windows ? ";" : ":").filter(Boolean);
84
+ const known = windows
85
+ ? [api.join(homeDir, ".local", "bin"), api.join(homeDir, "AppData", "Roaming", "npm"), ...(host === "claude" ? [api.join(homeDir, ".claude", "local")] : [])]
86
+ : [api.join(homeDir, ".local", "bin"), api.join(homeDir, ".npm-global", "bin"), api.join(homeDir, ".bun", "bin"), "/opt/homebrew/bin", "/usr/local/bin", ...(host === "claude" ? [api.join(homeDir, ".claude", "local")] : [])];
87
+ const nodeDir = api.dirname(runtime.nodeExecutable ?? process.execPath);
88
+ const names = windows ? [`${host}.exe`, `${host}.cmd`] : [host];
89
+ for (const directory of [...new Set([...fromPath, ...known, nodeDir])]) {
90
+ for (const name of names) {
91
+ const candidate = api.join(directory, name);
92
+ if (await files.exists(candidate))
93
+ return candidate;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ /**
99
+ * Names a failed host command from its exit code and output. The output is read
100
+ * for the label and then dropped: only the label ever leaves this function, so a
101
+ * git error that echoed a URL with a credential in it cannot reach a log.
102
+ */
103
+ export function classifyPluginFailure(result) {
104
+ if (result.code === 124)
105
+ return "timed_out";
106
+ const text = `${result.stderr}\n${result.stdout}`.toLowerCase();
107
+ if (/unknown (sub)?command|unrecognized (sub)?command|unexpected argument|no such (sub)?command|invalid subcommand|unknown option/u.test(text))
108
+ return "cli_too_old";
109
+ if (/authentication failed|could not read (username|password)|permission denied \(publickey|repository not found|terminal prompts disabled|invalid username or password|bad credentials|not logged in|returned error: (401|403|404)|access denied|saml|single sign-on/u.test(text))
110
+ return "private_repo_auth";
111
+ if (/could not resolve host|network is unreachable|failed to connect|connection (timed out|refused|reset)|econn(reset|refused)|enotfound|etimedout|eai_again|temporary failure in name resolution|ssl|tls|early eof|remote end hung up/u.test(text))
112
+ return "network_unreachable";
113
+ if (/plugin .*(not found|does not exist)|not found in (any )?marketplace|no plugin named|marketplace .*not found/u.test(text))
114
+ return "plugin_not_in_marketplace";
115
+ return "command_failed";
116
+ }
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
4
+ import { isDefaultDashboardUrl, getCollectorRuntimePaths } from "./local-state.js";
5
5
  import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
6
6
  import { DARWIN_LOGIN_SHELL } from "./autostart-node-path.js";
7
7
  import { AUTOSTART_LABEL, fileExists, plistPathFor, resolveAutostartSettings, schedulerNodeExecutable, } from "./autostart-contract.js";
@@ -211,7 +211,7 @@ function watchPathsBlock(watchPaths) {
211
211
  ];
212
212
  }
213
213
  function renderDarwinSyncCommand(options) {
214
- const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
214
+ const dashboardArg = isDefaultDashboardUrl(options.dashboardUrl)
215
215
  ? ""
216
216
  : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
217
217
  const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
4
+ import { isDefaultDashboardUrl, getCollectorRuntimePaths } from "./local-state.js";
5
5
  import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
6
6
  import { fileExists, resolveAutostartSettings, schedulerNodeExecutable, WINDOWS_AUTOSTART_TASK_NAME, } from "./autostart-contract.js";
7
7
  /**
@@ -271,7 +271,7 @@ function renderWindowsLauncherScript(options) {
271
271
  ].join("\r\n");
272
272
  }
273
273
  function renderWindowsSyncScript(options) {
274
- const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
274
+ const dashboardArgs = isDefaultDashboardUrl(options.dashboardUrl)
275
275
  ? ""
276
276
  : ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
277
277
  const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
@@ -4,7 +4,7 @@ import { scanWorkDirectories } from "./local-discovery.js";
4
4
  import { sendCollectorHeartbeatBestEffort } from "./heartbeat.js";
5
5
  import { isInteractiveDoctorFix } from "./doctor-report.js";
6
6
  import { describeError } from "../health-detail.js";
7
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
7
+ import { isDefaultDashboardUrl, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
8
8
  import { normalizeCollectionRoots } from "../root-normalization.js";
9
9
  import { detectSecondCockpitInstall } from "../second-install.js";
10
10
  import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
@@ -140,7 +140,7 @@ export async function savedRoots(homeDir) {
140
140
  }
141
141
  function onboardOneLiner(command) {
142
142
  const workspace = command.repoRoot ?? "$PWD";
143
- const dashboard = command.dashboardUrl === DEFAULT_DASHBOARD_URL
143
+ const dashboard = isDefaultDashboardUrl(command.dashboardUrl)
144
144
  ? ""
145
145
  : ` --dashboard-url ${shellQuote(command.dashboardUrl)}`;
146
146
  return `cockpit doctor --workspace ${shellQuote(workspace)}${dashboard}`;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The `agent-plugins` doctor row (BLI-18200): does this machine carry the
3
+ * company `engineering-standards` plugin in Claude Code and in Codex?
4
+ *
5
+ * The CHECK reads the two hosts' own files and the last attempt's remembered
6
+ * reason; it never starts a program, so `cockpit doctor --check` is free. The
7
+ * FIX runs the host commands through `agent-plugins-converge.ts` and answers by
8
+ * re-reading what the hosts recorded. A tick runs the fix too (the doctor the
9
+ * sync runs after collection), throttled to one retry per six hours; a person
10
+ * running `cockpit doctor` always retries.
11
+ *
12
+ * Never blocks anything else: a missing program is `skipped`, every other
13
+ * failure is a named reason on this row alone.
14
+ */
15
+ import os from "node:os";
16
+ import { AGENT_PLUGIN_NAME, AGENT_PLUGIN_SOURCE, inspectAgentPlugins, locateHostBinary, } from "../agent-plugins.js";
17
+ import { convergeAgentPlugins, readPluginState } from "../agent-plugins-converge.js";
18
+ import { getCollectorRuntimePaths } from "../local-state.js";
19
+ import { needsFix, ok, skipped } from "./doctor-report.js";
20
+ const ID = "agent-plugins";
21
+ /** Worst first: the row names the reason a person can act on before the one that heals itself. */
22
+ const REASON_PRIORITY = ["private_repo_auth", "cli_too_old", "plugin_not_in_marketplace", "install_not_recorded", "command_failed", "timed_out", "network_unreachable", "missing"];
23
+ const SENTENCES = {
24
+ missing: { say: (hosts) => `the company ${AGENT_PLUGIN_NAME} plugin is not installed for ${hosts}; \`cockpit doctor\` installs it`, next: "cockpit doctor" },
25
+ private_repo_auth: {
26
+ say: (hosts) => `Tower could not read the company plugin repository ${AGENT_PLUGIN_SOURCE} for ${hosts}: GitHub sign-in or BLI-INC access is missing. Run \`gh auth login\` with your company account, then \`cockpit doctor\``,
27
+ next: "gh auth login",
28
+ },
29
+ cli_too_old: { say: (hosts) => `the installed ${hosts} program is too old to manage plugins; update it, then run \`cockpit doctor\``, next: "cockpit doctor" },
30
+ plugin_not_in_marketplace: { say: (hosts) => `the company marketplace did not offer ${AGENT_PLUGIN_NAME} for ${hosts}; ask in the engineering channel, then run \`cockpit doctor\``, next: "cockpit doctor" },
31
+ install_not_recorded: { say: (hosts) => `the install for ${hosts} ran but the program did not record it; run \`cockpit doctor\` again`, next: "cockpit doctor" },
32
+ command_failed: { say: (hosts) => `the plugin install for ${hosts} failed; run \`cockpit doctor\` to retry`, next: "cockpit doctor" },
33
+ timed_out: { say: (hosts) => `the plugin install for ${hosts} timed out; Tower retries on its own, or run \`cockpit doctor\``, next: "cockpit doctor" },
34
+ network_unreachable: { say: (hosts) => `the plugin install for ${hosts} could not reach GitHub; Tower retries on its own, or run \`cockpit doctor\``, next: "cockpit doctor" },
35
+ };
36
+ function homeOf(context) {
37
+ return context.command.homeDir ?? os.homedir();
38
+ }
39
+ /** `runtime` is for tests: a Windows layout, a fake file system. */
40
+ export async function checkAgentPluginsState(context, runtime = {}) {
41
+ const homeDir = homeOf(context);
42
+ const present = (await inspectAgentPlugins(homeDir, runtime)).filter((reading) => reading.present);
43
+ if (present.length === 0) {
44
+ return skipped(ID, "no_agent_hosts", "neither Claude Code nor Codex is set up on this machine yet; Tower installs the company plugin once one is");
45
+ }
46
+ const missing = present.filter((reading) => !reading.installed);
47
+ if (missing.length === 0) {
48
+ return ok(ID, "installed", `the company ${AGENT_PLUGIN_NAME} plugin is installed for ${present.map((reading) => reading.host).join(", ")}`);
49
+ }
50
+ const remembered = await readPluginState(getCollectorRuntimePaths(context.command.homeDir).state_dir);
51
+ const reasons = new Map();
52
+ for (const reading of missing) {
53
+ const binary = await locateHostBinary(reading.host, homeDir, runtime);
54
+ reasons.set(reading.host, binary ? remembered[reading.host]?.last_reason ?? "missing" : "binary_missing");
55
+ }
56
+ // A remembered label this table does not know reads as a plain command failure.
57
+ const actionable = [...reasons].filter(([, reason]) => reason !== "binary_missing").map(([host, reason]) => ({ host, reason: REASON_PRIORITY.includes(reason) ? reason : "command_failed" }));
58
+ if (actionable.length === 0) {
59
+ return skipped(ID, "binary_missing", `${[...reasons.keys()].join(", ")} is set up but its program was not found; the plugin installs once it is`);
60
+ }
61
+ const code = REASON_PRIORITY.find((reason) => actionable.some((entry) => entry.reason === reason));
62
+ const hosts = actionable.filter((entry) => entry.reason === code).map((entry) => entry.host).join(" and ");
63
+ const sentence = SENTENCES[code];
64
+ return { ...needsFix(ID, code, sentence.say(hosts)), nextAction: sentence.next };
65
+ }
66
+ export async function fixAgentPluginsState(context, runtime = {}) {
67
+ const exec = context.io.exec;
68
+ if (!exec) {
69
+ console.error("[agent-plugins] no process runner on this io; the install could not be attempted", JSON.stringify({ reason: "runner_unavailable" }));
70
+ return needsFix(ID, "runner_unavailable", "this CLI has no process runner; reinstall it with `npm i -g @bli-cockpit/cli`, then run `cockpit doctor`");
71
+ }
72
+ const outcomes = await convergeAgentPlugins({
73
+ ...runtime,
74
+ homeDir: homeOf(context),
75
+ stateDir: getCollectorRuntimePaths(context.command.homeDir).state_dir,
76
+ exec,
77
+ unattended: Boolean(context.command.automaticTick),
78
+ refresh: false,
79
+ });
80
+ console.error("[agent-plugins] doctor repair done", JSON.stringify({ reason: "repair_done", outcomes: outcomes.map((outcome) => `${outcome.host}:${outcome.reason}`) }));
81
+ return checkAgentPluginsState(context, runtime);
82
+ }
@@ -5,7 +5,7 @@ import { retentionOptionsFromEnv } from "../disk-retention.js";
5
5
  import { readStagingGate, stagingGateLimitsFromEnv } from "../staging-gate.js";
6
6
  import { readDiskFootprint } from "../disk-usage.js";
7
7
  import { runEvidenceRedelivery } from "../evidence-redelivery.js";
8
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "../local-state.js";
8
+ import { isDefaultDashboardUrl, getCollectorRuntimePaths } from "../local-state.js";
9
9
  import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
10
10
  import { FOOTPRINT_NEEDS_FIX_BYTES, stagingPausedWords, diskRowMessage, footprintHolder, footprintTotalBytes, mib, redeliveryLine, } from "./doctor-disk-words.js";
11
11
  import { doctorRoots } from "./doctor-access.js";
@@ -185,7 +185,7 @@ async function runSyncRepair(context) {
185
185
  const args = ["sync", "--json", "--workspace", repoRoot, ...discoveryArgs];
186
186
  if (context.command.homeDir)
187
187
  args.push("--home", context.command.homeDir);
188
- if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
188
+ if (!isDefaultDashboardUrl(context.command.dashboardUrl)) {
189
189
  args.push("--dashboard-url", context.command.dashboardUrl);
190
190
  }
191
191
  // One line every 30 s while doctor's own sync runs; the exec has nothing
@@ -174,6 +174,7 @@ export function doctorNeedsPerson(rows, receipt) {
174
174
  "usage-telemetry": "cockpit doctor",
175
175
  "memory-registered": "cockpit doctor",
176
176
  "agent-rules": "cockpit doctor",
177
+ "agent-plugins": "cockpit doctor",
177
178
  "mcp-answers": "cockpit doctor",
178
179
  "memory-daemon": "cockpit doctor",
179
180
  "backfill-complete": "cockpit doctor",
@@ -1,5 +1,5 @@
1
1
  import { isSemverBelow } from "../scheduled-self-update.js";
2
- import { DEFAULT_DASHBOARD_URL, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
2
+ import { isDefaultDashboardUrl, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
3
3
  import { createInteractiveExecRunner } from "../process-runner.js";
4
4
  import { asRecord, fail, needsFix, ok } from "./doctor-report.js";
5
5
  /**
@@ -89,7 +89,7 @@ export function reexecDoctor(command, io) {
89
89
  args.push("--max-repos", String(command.maxRepos));
90
90
  for (const root of command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []))
91
91
  args.push("--workspace", root);
92
- if (command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
92
+ if (!isDefaultDashboardUrl(command.dashboardUrl)) {
93
93
  args.push("--dashboard-url", command.dashboardUrl);
94
94
  }
95
95
  if (command.updateTag)
@@ -10,6 +10,7 @@ import { gateRow, readCollectionGate } from "./doctor-collection-lock.js";
10
10
  import { checkMcpAnswersState, fixMcpAnswersState } from "./doctor-mcp.js";
11
11
  import { budgetSpentRow, doctorRunDeadline, DOCTOR_JSON_BUDGET_SECONDS } from "./doctor-budget.js";
12
12
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
13
+ import { checkAgentPluginsState, fixAgentPluginsState } from "./doctor-agent-plugins.js";
13
14
  import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
14
15
  import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, finalizeDoctorRepairs, } from "./doctor-report.js";
15
16
  import { buildSetupReceipt, refreshSetupReceipt } from "./setup-receipt.js";
@@ -189,6 +190,9 @@ function doctorInvariants() {
189
190
  },
190
191
  { id: "usage-telemetry", check: context => context.deps.checkUsageTelemetry(context), fix: context => context.deps.fixUsageTelemetry(context) },
191
192
  { id: "agent-rules", check: (context) => context.deps.checkAgentRules(context), fix: (context) => context.deps.fixAgentRules(context) },
193
+ // BLI-18200. Right after the rules row: same family, what the two agent
194
+ // hosts carry. The check reads the hosts' files; only the fix starts their CLIs.
195
+ { id: "agent-plugins", check: (context) => context.deps.checkAgentPlugins(context), fix: (context) => context.deps.fixAgentPlugins(context) },
192
196
  // BLI-3804. Right after the registration row, because it asks the second
193
197
  // half of the same question: `memory-registered` proves the entry exists
194
198
  // and names a bin, this one proves the server behind it starts, speaks the
@@ -240,6 +244,8 @@ function defaultDoctorDeps(hooks) {
240
244
  onReceipt: hooks.onReceipt,
241
245
  checkAgentRules: checkDoctorAgentRules,
242
246
  fixAgentRules: fixDoctorAgentRules,
247
+ checkAgentPlugins: (context) => checkAgentPluginsState(context),
248
+ fixAgentPlugins: (context) => fixAgentPluginsState(context),
243
249
  latestCliVersion: latestCliVersionFromNpm,
244
250
  selfUpdate: hooks.selfUpdate,
245
251
  reexecDoctor: reexecDoctor,
@@ -10,7 +10,7 @@
10
10
  import { execFile } from "node:child_process";
11
11
  import { errorMessage, isInteractiveStdin, readLine, writeLine, settleYesNo, } from "./cli-io.js";
12
12
  import { normalizeUrl } from "./local-args.js";
13
- import { getCollectorRuntimePaths, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, } from "../local-state.js";
13
+ import { getCollectorRuntimePaths, pairLocalCollector, readLocalCollectorSessionFile, sameDashboardUrl, readLocalSessionReference, } from "../local-state.js";
14
14
  const OTP_CODE_PROMPT = [
15
15
  "Email code needed:",
16
16
  " Check your latest Tower email for a 6- to 10-digit code.",
@@ -254,8 +254,11 @@ export function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl)
254
254
  return false;
255
255
  }
256
256
  const expectedDashboardUrl = normalizeUrlForComparison(dashboardUrl);
257
+ // The two production addresses are one deployment: a session paired on the
258
+ // old vercel.app address stays reusable against the new default.
259
+ const sessionDashboardUrl = normalizeUrlForComparison(session.dashboard_url);
257
260
  if (expectedDashboardUrl &&
258
- normalizeUrlForComparison(session.dashboard_url) !== expectedDashboardUrl) {
261
+ !(sessionDashboardUrl && sameDashboardUrl(sessionDashboardUrl, expectedDashboardUrl))) {
259
262
  return false;
260
263
  }
261
264
  return true;
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.222");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.224");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The tick's half of the company plugin install (BLI-18200). Doctor's
3
+ * `agent-plugins` row converges a machine when a person runs `cockpit doctor`;
4
+ * this puts the same plugin on a machine nobody is looking at, every 15 minutes
5
+ * and cheaply: a steady machine costs four file reads, a failed host waits six
6
+ * hours, and an installed plugin is refreshed once a day.
7
+ *
8
+ * It reports through the same install-event channel as the agent-rules heal
9
+ * (`agent_plugins_install`), once per real attempt, with reason labels only.
10
+ */
11
+ import os from "node:os";
12
+ import { convergeAgentPlugins } from "../agent-plugins-converge.js";
13
+ export async function healAgentPlugins(command, io, paths, now) {
14
+ const exec = io.exec;
15
+ if (!exec) {
16
+ console.error("[sync-heal] agent plugins not checked; this io has no process runner", JSON.stringify({ reason: "runner_unavailable" }));
17
+ return null;
18
+ }
19
+ const outcomes = await convergeAgentPlugins({
20
+ homeDir: command.homeDir ?? os.homedir(),
21
+ stateDir: paths.state_dir,
22
+ exec,
23
+ unattended: true,
24
+ refresh: true,
25
+ now,
26
+ });
27
+ return eventFor(outcomes);
28
+ }
29
+ /** One event per real attempt; steady state, throttled retries and absent hosts report nothing. */
30
+ export function eventFor(outcomes) {
31
+ const acted = outcomes.filter((outcome) => !outcome.quiet);
32
+ if (acted.length === 0)
33
+ return null;
34
+ const detail = acted.map((outcome) => `${outcome.host}=${outcome.reason}`).join("; ");
35
+ const failed = acted.find((outcome) => outcome.status === "failed");
36
+ if (failed)
37
+ return { step: "agent_plugins_install", status: "fail", error_code: failed.reason, error_detail: detail };
38
+ if (acted.every((outcome) => outcome.status === "skipped"))
39
+ return { step: "agent_plugins_install", status: "skipped", error_code: acted[0].reason, error_detail: detail };
40
+ const refreshOnly = acted.every((outcome) => outcome.status === "refreshed");
41
+ // A failed daily refresh keeps the plugin; it is still worth one line, as a skipped step.
42
+ const refreshFailed = acted.find((outcome) => outcome.reason.startsWith("refresh_failed_"));
43
+ if (refreshFailed)
44
+ return { step: "agent_plugins_install", status: "skipped", error_code: refreshFailed.reason, error_detail: detail };
45
+ return { step: refreshOnly ? "agent_plugins_refresh" : "agent_plugins_install", status: "ok", error_detail: detail };
46
+ }
@@ -8,6 +8,10 @@
8
8
  * failed install retried hourly
9
9
  * sign-in lapsed record the ONE human written when the heartbeat
10
10
  * action, stop nagging could not renew, reported daily
11
+ * company plugin missing install it for Claude inspected every tick, a failed
12
+ * (BLI-18200) Code and Codex host retried every six hours,
13
+ * (sync-heal-agent- an installed one refreshed daily
14
+ * plugins.ts)
11
15
  *
12
16
  * The rest of the table is already on the tick elsewhere: the CLI floor
13
17
  * (`sync-followups-self-update.ts`), the scheduler incl. an ABSENT one
@@ -23,6 +27,7 @@ import path from "node:path";
23
27
  import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
24
28
  import { heartbeatRefusalStatus } from "./heartbeat-token.js";
25
29
  import { SIGN_IN_ACTION } from "../sign-in-action.js";
30
+ import { healAgentPlugins } from "./sync-heal-agent-plugins.js";
26
31
  import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
27
32
  import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
28
33
  import { normalizeCollectionRoots } from "../root-normalization.js";
@@ -37,6 +42,7 @@ export async function runMachineHealAfterSync(command, io, dashboardUrl, options
37
42
  const events = [];
38
43
  for (const heal of [
39
44
  () => healAgentRules(command, paths, now),
45
+ () => healAgentPlugins(command, io, paths, now),
40
46
  () => noteSessionState(paths, heartbeatRefusalStatus(), now),
41
47
  ]) {
42
48
  try {
@@ -11,7 +11,27 @@ const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json",
11
11
  export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
12
12
  ? localCollectorPackage.version
13
13
  : "0.0.0";
14
- export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
14
+ export const DEFAULT_DASHBOARD_URL = "https://tower.buildlaunchiterate.ca";
15
+ /**
16
+ * The address every machine paired before 2026-10-14 saved. It stays live as an
17
+ * alias of the same deployment forever, so a saved config naming it keeps
18
+ * working untouched and counts as the default wherever the default is decided.
19
+ */
20
+ export const LEGACY_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
21
+ function dashboardUrlKey(value) {
22
+ return value.trim().replace(/\/+$/, "").toLowerCase();
23
+ }
24
+ /** True for either address of the production Tower; a staging or local URL is false. */
25
+ export function isDefaultDashboardUrl(value) {
26
+ const key = dashboardUrlKey(value);
27
+ return key === dashboardUrlKey(DEFAULT_DASHBOARD_URL) || key === dashboardUrlKey(LEGACY_DASHBOARD_URL);
28
+ }
29
+ /** Same dashboard, the two production addresses counted as one. */
30
+ export function sameDashboardUrl(a, b) {
31
+ if (isDefaultDashboardUrl(a) && isDefaultDashboardUrl(b))
32
+ return true;
33
+ return dashboardUrlKey(a) === dashboardUrlKey(b);
34
+ }
15
35
  /**
16
36
  * `cockpit install` — the one write that registers collection roots, because
17
37
  * widening an operator's boundary is a consent decision and nothing else may
@@ -36,7 +36,7 @@
36
36
  * `runtimeFiles`, or the repo tests stay green while the packed CLI breaks.
37
37
  */
38
38
  export { getCollectorRuntimePaths } from "./local-state-paths.js";
39
- export { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, installLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
39
+ export { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, installLocalCollector, isDefaultDashboardUrl, LEGACY_DASHBOARD_URL, LOCAL_COLLECTOR_VERSION, sameDashboardUrl, readLocalCollectorConfig, } from "./local-state-config.js";
40
40
  export { readLocalCollectorSessionFile, readLocalSessionReference, toSessionReference, } from "./local-state-session.js";
41
41
  export { logoutLocalCollector, pairLocalCollector, recordDeviceTokenExpiry, } from "./local-state-pairing.js";
42
42
  export { PairingCodeError, pairLocalCollectorViaLink, } from "./local-state-pairing-code.js";
@@ -35,12 +35,16 @@ export function createCapturedExecRunner(options = {}) {
35
35
  encoding: "utf8",
36
36
  env,
37
37
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
38
+ ...(runOptions?.timeoutMs ? { timeout: runOptions.timeoutMs } : {}),
38
39
  }, (error, stdout, stderr) => {
40
+ // A child ended by `timeoutMs` reports code 124, never a bare 1.
39
41
  const code = error == null
40
42
  ? 0
41
- : typeof error.code === "number"
42
- ? error.code
43
- : 1;
43
+ : error.killed === true && runOptions?.timeoutMs
44
+ ? 124
45
+ : typeof error.code === "number"
46
+ ? error.code
47
+ : 1;
44
48
  resolve({
45
49
  code,
46
50
  stdout: stdout ?? "",
@@ -55,6 +55,7 @@ export const STATE_DIR_RULES = [
55
55
  // tiny by construction; naming them keeps the unrecognised bucket meaning
56
56
  // "something nobody registered", not "something small we forgot".
57
57
  { name: "session-needs-login.json", match: "exact", class: "state", bound: "one small note, overwritten at most daily and removed when the sign-in is valid again (sync-heal.ts)" },
58
+ { name: "agent-plugins.json", match: "exact", class: "state", bound: "one small file, overwritten: per host the last install attempt, its failure label and the last daily refresh (agent-plugins-converge.ts, BLI-18200)" },
58
59
  { name: "autostart-uninstalled", match: "exact", class: "state", bound: "a zero-byte marker written by `cockpit autostart uninstall` and removed by install or doctor" },
59
60
  { name: "last-turn-trace.json", match: "exact", class: "state", bound: "one file, overwritten" },
60
61
  { name: "sync.lock", match: "exact", class: "state", bound: "lock file, removed when the tick ends" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.222",
3
+ "version": "0.2.224",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.66",
31
- "@bli-cockpit/mcp": "0.1.129",
30
+ "@bli-cockpit/memory-mcp": "0.1.67",
31
+ "@bli-cockpit/mcp": "0.1.130",
32
32
  "@bli-cockpit/telemetry-core": "0.1.75"
33
33
  },
34
34
  "repository": {