@consiliency/agent-board-cli 1.28.0 → 1.29.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-board",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Message Board for Claude Code: the agent-board MCP server and the local wake hook that nudges an idle session when its inbox has unhandled items.",
5
5
  "author": {
6
6
  "name": "Consiliency"
@@ -10,7 +10,7 @@
10
10
  "command": "npx",
11
11
  "args": [
12
12
  "-y",
13
- "@consiliency/agent-board-mcp@1.32.0",
13
+ "@consiliency/agent-board-mcp@1.33.0",
14
14
  "--harness",
15
15
  "claude"
16
16
  ]
package/dist/assets.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "mcpVersion": "1.32.0",
3
- "piVersion": "0.4.2",
4
- "opencodeVersion": "0.2.2",
5
- "releaseTag": "v1.57.0"
2
+ "mcpVersion": "1.33.0",
3
+ "piVersion": "0.4.3",
4
+ "opencodeVersion": "0.2.3",
5
+ "releaseTag": "v1.58.0"
6
6
  }
package/dist/cli.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Command } from "commander";
2
- import { createAgentBoardClient, type EnrollKeystore, type EnrollmentIntent, type FleetOnboardingIntent, type PendingEnrollmentStore, type OAuthFetchLike, type FetchLike, type AgentJoinDeployment, type AgentJoinInstructions, type DeviceAuthorizationPrompt, resolveCredentials } from "@consiliency/agent-board-client";
2
+ import { createAgentBoardClient, type EnrollKeystore, type EnrollmentIntent, type FleetOnboardingIntent, type PendingEnrollmentStore, type OAuthFetchLike, type FetchLike, type AgentJoinDeployment, type AgentJoinInstructions, type DeviceAuthorizationPrompt, resolveCredentials, type MyBoardRequest } from "@consiliency/agent-board-client";
3
3
  import { type DoctorOptions, type DoctorReport } from "./doctor.js";
4
4
  import { type SetupEnroller, type SetupRunner } from "./setup.js";
5
+ import { type SetupAllRunner } from "./setup_all.js";
5
6
  import { DevicePendingEnrollmentStore } from "./enroll_runtime.js";
6
7
  type JsonObject = Record<string, unknown>;
7
8
  export interface CliIo {
@@ -64,6 +65,7 @@ interface ClientLike {
64
65
  }>;
65
66
  revokeInviteLink(input: JsonObject): Promise<unknown>;
66
67
  redeemInviteLink(input: JsonObject): Promise<unknown>;
68
+ listMyBoardRequests(): Promise<MyBoardRequest[]>;
67
69
  listBoardMembers(input: JsonObject): Promise<unknown>;
68
70
  listBoardPendingMemberships(input: JsonObject): Promise<unknown>;
69
71
  resolveAgentHandle(input: JsonObject): Promise<unknown>;
@@ -333,13 +335,14 @@ export interface CredentialSelection {
333
335
  * for the same reason. Without it: `resolveCredentials(descriptor, env)`, exactly as before.
334
336
  */
335
337
  export declare function fileAwareCredentials(selection: CredentialSelection, env?: NodeJS.ProcessEnv): (descriptor?: Parameters<typeof resolveCredentials>[0]) => Promise<import("@consiliency/agent-board-client").ResolvedCredentials>;
336
- export declare function createProgram(client: ClientLike, io: CliIo, enrollment?: EnrollmentCommandRunner, doctor?: DoctorRunner, selection?: CredentialSelection, setup?: SetupRunner): Command;
338
+ export declare function createProgram(client: ClientLike, io: CliIo, enrollment?: EnrollmentCommandRunner, doctor?: DoctorRunner, selection?: CredentialSelection, setup?: SetupRunner, setupAll?: SetupAllRunner): Command;
337
339
  export interface RunCliOptions {
338
340
  /** The env the default client's credential resolver reads. Default `process.env`. */
339
341
  env?: NodeJS.ProcessEnv;
340
342
  /** Extra options for the DEFAULT client (tests: a `dataSourceFactory`); never its resolver. */
341
343
  clientOptions?: Omit<NonNullable<Parameters<typeof createAgentBoardClient>[0]>, "resolveCredentials">;
342
344
  setup?: SetupRunner;
345
+ setupAll?: SetupAllRunner;
343
346
  }
344
347
  export declare function runCli(argv: string[], io?: CliIo, client?: ClientLike, enrollment?: EnrollmentCommandRunner, doctor?: DoctorRunner, options?: RunCliOptions): Promise<void>;
345
348
  export {};
package/dist/cli.js CHANGED
@@ -3,9 +3,10 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import * as nodeFs from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
- import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, beginDeviceEnrollment, completeDeviceEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, EnrollmentCommandError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, buildFleetHarnessTemplate, finalizeFleetHarnessTemplate, normalizePublishableKeyFieldReference, normalizeFleetTupleValue, normalizeKeyReference, enrollCommitMarker, assertPresentationSafe, FLEET_TEMPLATE_FAILURE_MESSAGE, PROHIBITED_PRESENTATION_REMOVE_CLASS, buildAgentJoinInstructions, nodeFetchAdapter, buildEnrollKeystore, AUTHORED_CAUSE_CODES, codeProvenance, markAuthoredCode, assertIdentityHarness, identityFilePath, resolveHostSegment, identityConfigRoot, walkTrustedDirectory, IDENTITY_HARNESSES, CredentialResolutionError, readIdentityFile, resolveCredentials, } from "@consiliency/agent-board-client";
6
+ import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, beginDeviceEnrollment, completeDeviceEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, EnrollmentCommandError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, buildFleetHarnessTemplate, finalizeFleetHarnessTemplate, normalizePublishableKeyFieldReference, normalizeFleetTupleValue, normalizeKeyReference, enrollCommitMarker, assertPresentationSafe, FLEET_TEMPLATE_FAILURE_MESSAGE, PROHIBITED_PRESENTATION_REMOVE_CLASS, buildAgentJoinInstructions, nodeFetchAdapter, buildEnrollKeystore, AUTHORED_CAUSE_CODES, codeProvenance, markAuthoredCode, assertIdentityHarness, identityFilePath, resolveHostSegment, identityConfigRoot, walkTrustedDirectory, IDENTITY_HARNESSES, CredentialResolutionError, readIdentityFile, resolveCredentials, parseInviteInput, } from "@consiliency/agent-board-client";
7
7
  import { fileIdentityEnv, formatDoctorReport, runDoctor } from "./doctor.js";
8
8
  import { addSetupCommand, runSetup } from "./setup.js";
9
+ import { runSetupAll, setupAllCommand } from "./setup_all.js";
9
10
  import { identityValuesFromEnrollment, resolveIdentityDeployment, writeIdentityFile, } from "./identity_writer.js";
10
11
  import { DevicePendingEnrollmentStore, FilePendingEnrollmentStore, FleetDeviceContinuationStore, makeBrowserOpener, prepareFleetRuntimeOutput, publishFinalizedFleetTemplate, } from "./enroll_runtime.js";
11
12
  function isRuntimeStatus(value) {
@@ -2900,7 +2901,12 @@ function renderDevicePrompt(prompt) {
2900
2901
  lines.push("");
2901
2902
  return `${lines.join("\n")}\n`;
2902
2903
  }
2903
- /** Read a single-line auth code from a piped stdin (A5). Returns undefined on a TTY. */
2904
+ /**
2905
+ * Read a single-line code from a piped stdin (A5), trimmed. Returns undefined on a TTY. Its callers
2906
+ * are the OAuth authorization code (not an invite) and the fleet onboarding invite code, which
2907
+ * IF-0-ONBOARD-2 keeps out of the invite parser (a fleet invite has no designated field). A room
2908
+ * invite is read by `readInviteFromStdin` and parsed.
2909
+ */
2904
2910
  async function readCodeFromStdin() {
2905
2911
  if (process.stdin.isTTY)
2906
2912
  return undefined;
@@ -2911,6 +2917,17 @@ async function readCodeFromStdin() {
2911
2917
  const text = Buffer.concat(chunks).toString("utf8").trim();
2912
2918
  return text.length > 0 ? text : undefined;
2913
2919
  }
2920
+ /** All of a piped stdin, unparsed (a room invite message is several lines). undefined on a TTY. */
2921
+ async function readInviteFromStdin() {
2922
+ if (process.stdin.isTTY)
2923
+ return undefined;
2924
+ const chunks = [];
2925
+ for await (const chunk of process.stdin) {
2926
+ chunks.push(chunk);
2927
+ }
2928
+ const text = Buffer.concat(chunks).toString("utf8");
2929
+ return text.trim().length > 0 ? text : undefined;
2930
+ }
2914
2931
  function addRoomsCommands(program, client, io) {
2915
2932
  const rooms = program
2916
2933
  .command("rooms")
@@ -3094,17 +3111,27 @@ function addRoomsCommands(program, client, io) {
3094
3111
  .description("Redeem an invite link by code, joining as the agent identity resolved from this host's env credentials. The code is a BEARER SECRET — prefer piping it on stdin.")
3095
3112
  .option("--code <code>", "the invite code (prefer stdin; argv leaks via /proc/<pid>/cmdline and shell history)")
3096
3113
  .action(async (o) => {
3097
- let code = o.code;
3098
- if (!code) {
3099
- code = await readCodeFromStdin();
3100
- }
3101
- if (!code) {
3102
- io.writeStderr("link-redeem needs an invite code: pipe it on stdin (e.g. `agent-board rooms link-redeem <<'MB_INVITE_CODE'` ... `MB_INVITE_CODE`) or pass --code (which leaks the bearer code via /proc and shell history).\n");
3114
+ // IF-0-ONBOARD-2: the code, or the whole invite message on stdin, goes through the ONE
3115
+ // shared parser, which reads only the designated invite-code field. Neither form is a
3116
+ // terminal read (a TTY stdin is not read at all), so a message is accepted from either.
3117
+ const text = o.code ?? (await readInviteFromStdin());
3118
+ if (!text) {
3119
+ io.writeStderr("link-redeem needs an invite code: pipe it (or the whole invite message) on stdin (e.g. `agent-board rooms link-redeem <<'MB_INVITE_CODE'` ... `MB_INVITE_CODE`) or pass --code (which leaks the bearer code via /proc and shell history).\n");
3103
3120
  process.exitCode = 1;
3104
3121
  return;
3105
3122
  }
3106
- return runHandler(io, "agent_board.redeem_invite_link", () => client.redeemInviteLink({ p_code: code }));
3123
+ return runHandler(io, "agent_board.redeem_invite_link", () => client.redeemInviteLink({ p_code: parseInviteInput(text, { tty: false }).code }));
3107
3124
  });
3125
+ // IF-0-ONBOARD-4: the caller's OWN join requests as they stand now (the RPC takes no identity:
3126
+ // the session is the requester).
3127
+ rooms
3128
+ .command("my-requests")
3129
+ .description("List your own join requests and where each stands (requested, joined, left, …; expired by age)")
3130
+ .action(() => runHandler(io, "agent_board.list_my_board_requests", async () => ({
3131
+ ok: true,
3132
+ operation: "agent_board.list_my_board_requests",
3133
+ data: await client.listMyBoardRequests(),
3134
+ })));
3108
3135
  }
3109
3136
  function addInviteCommands(program, client, io) {
3110
3137
  const invite = program.command("invite").description("create bounded fleet onboarding invites");
@@ -3229,7 +3256,7 @@ export function fileAwareCredentials(selection, env = process.env) {
3229
3256
  return resolveCredentials(undefined, fileIdentityEnv(env, values));
3230
3257
  };
3231
3258
  }
3232
- export function createProgram(client, io, enrollment = defaultEnrollmentRunner(), doctor = runDoctor, selection = {}, setup) {
3259
+ export function createProgram(client, io, enrollment = defaultEnrollmentRunner(), doctor = runDoctor, selection = {}, setup, setupAll) {
3233
3260
  const program = new Command()
3234
3261
  .name("agent-board")
3235
3262
  .description("Thin CLI surface for the agent_board RPC and canonical read contracts")
@@ -3260,7 +3287,7 @@ export function createProgram(client, io, enrollment = defaultEnrollmentRunner()
3260
3287
  addInviteCommands(program, client, io);
3261
3288
  addEnrollCommands(program, io, enrollment);
3262
3289
  addDoctorCommand(program, io, doctor);
3263
- addSetupCommand(program, io, setup ?? ((options) => runSetup(options, { enroll: setupEnroller })));
3290
+ addSetupCommand(program, io, setup ?? ((options) => runSetup(options, { enroll: setupEnroller })), setupAllCommand(setupAll ?? ((options) => runSetupAll(options, { enroll: setupEnroller })), io));
3264
3291
  return program;
3265
3292
  }
3266
3293
  export async function runCli(argv, io = {
@@ -3273,6 +3300,6 @@ export async function runCli(argv, io = {
3273
3300
  ...options.clientOptions,
3274
3301
  resolveCredentials: fileAwareCredentials(selection, options.env ?? process.env),
3275
3302
  });
3276
- const program = createProgram(resolved, io, enrollment, doctor, selection, options.setup);
3303
+ const program = createProgram(resolved, io, enrollment, doctor, selection, options.setup, options.setupAll);
3277
3304
  await program.parseAsync(argv, { from: "user" });
3278
3305
  }
package/dist/doctor.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type IdentityHarness, type OperationSuccess, type OsKeychainBackendOptions, type OsKeychainExec, type SessionRecordEntry, type TrustOptions } from "@consiliency/agent-board-client";
2
- import { type ClaudePluginSummary, type RegistrationFs, type RegQuery, type RegistrationVerdict, type ScopeContext } from "./doctor_scopes.js";
1
+ import { type HarnessProcess, type IdentityHarness, type OperationSuccess, type OsKeychainBackendOptions, type OsKeychainExec, type SessionRecordEntry, type TrustOptions } from "@consiliency/agent-board-client";
2
+ import { type ClaudePluginSummary, type RegistrationFs, type RegQuery, type RegistrationVerdict, type ScopeContext, type ScopeFile } from "./doctor_scopes.js";
3
3
  import { type ScanBudgets, type StorageRunner } from "./keyslot_status.js";
4
4
  export type DoctorStatus = "pass" | "fail" | "undetermined";
5
5
  export declare const DOCTOR_CHECKS: readonly ["identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime"];
@@ -8,6 +8,19 @@ export interface DoctorCheck {
8
8
  status: DoctorStatus;
9
9
  reason: string;
10
10
  }
11
+ /**
12
+ * ONBOARD IF-0-ONBOARD-3 — `runtime`'s informational hint. It never changes `runtime`'s status or
13
+ * reason, or the exit code (pinned in `doctor_runtime_hint.test.ts`'s exit-invariance cases); where
14
+ * it overlaps `registration`'s existing `plugin_pin_mismatch`, that reason stays and the hint only
15
+ * explains it. Precedence when more than one applies: `session_predates_install`, then `no_session`
16
+ * (zero live records, and no pre-install process found either), then `stale_session_version`, then
17
+ * `no_target_first_session`, then `none`. `session_predates_install` is checked FIRST, ahead of
18
+ * `no_session`, because the motivating case (a harness that wrote no record at all before setup
19
+ * finished — the macmini claude case) has zero live records: a `no_session`-first order would
20
+ * mask it.
21
+ */
22
+ export declare const RUNTIME_HINTS: readonly ["no_session", "stale_session_version", "session_predates_install", "no_target_first_session", "none"];
23
+ export type RuntimeHint = (typeof RUNTIME_HINTS)[number];
11
24
  /**
12
25
  * CCPLUGIN IF-0-CCPLUGIN-3 — the claude plugin report. Outside `DOCTOR_CHECKS`: it never changes
13
26
  * the exit code. A version or ref prints only if it matches `REPORTABLE_TOKEN`, else
@@ -49,6 +62,12 @@ export interface DoctorReport {
49
62
  harness: IdentityHarness;
50
63
  exit: 0 | 1 | 2;
51
64
  checks: Record<DoctorCheckName, DoctorCheck>;
65
+ /**
66
+ * ONBOARD IF-0-ONBOARD-3 — informational only; omitted (not `"none"`) when nothing was found, so
67
+ * `Object.keys(report)` for a report with no `installedAt`/`installedVersion` evidence carried in
68
+ * is byte-unchanged from before this field existed.
69
+ */
70
+ runtime_hint?: RuntimeHint;
52
71
  /** keyslot identities only (IF-552-3). */
53
72
  key_storage_scan?: KeyStorageScanReport;
54
73
  /** claude only. */
@@ -149,6 +168,35 @@ export interface DoctorOptions {
149
168
  statusBudgets?: Partial<ScanBudgets>;
150
169
  /** key_storage: the clock the carry's validity window reads, epoch seconds. Default now. */
151
170
  nowSeconds?: () => number;
171
+ /**
172
+ * ONBOARD IF-0-ONBOARD-3 — when this harness's board package was last installed or upgraded.
173
+ * Overrides the default (`defaultInstallEvidence`, derived from doctor's own registration scan:
174
+ * claude's plugin verdict, or the winning scope file's mtime for every other harness). Where
175
+ * neither an override nor the default can prove one, `runtime_hint` never fires
176
+ * `session_predates_install`/`stale_session_version` from it — never fabricated from a guess.
177
+ */
178
+ installedAt?: Date;
179
+ /**
180
+ * ONBOARD IF-0-ONBOARD-3 — the version setup just installed or upgraded a package to, by that
181
+ * package's `packageName` (a live record's own field, since one harness's live records can carry
182
+ * more than one package — e.g. pi's own `wake_producer` plus mcp's `tools_only`, R-2). Overrides
183
+ * the default the same way `installedAt` does; null (or omitted entirely) makes no claim.
184
+ */
185
+ installedVersion?: (packageName: string) => string | null;
186
+ /**
187
+ * ONBOARD IF-0-ONBOARD-3 — this harness's own running processes, for `session_predates_install`.
188
+ * Default: the client's `listHarnessProcesses`. Test injection only needs to honour
189
+ * `startedBefore` when it cares about the cutoff; production always passes it.
190
+ */
191
+ harnessProcesses?: (harness: IdentityHarness, options: {
192
+ startedBefore?: Date;
193
+ }) => HarnessProcess[];
194
+ /**
195
+ * ONBOARD IF-0-ONBOARD-3 — the default `installedAt` derivation's own file-mtime read, for the
196
+ * config/plugin-source file that named a package's pinned version. Default: `fs.statSync(path).mtime`.
197
+ * Unrelated to `registrationFs.lstat`, which carries no mtime.
198
+ */
199
+ statMtime?: (path: string) => Date | null;
152
200
  }
153
201
  /** The key step's clean fresh scan, as setup carries it to its own doctor (IF-552-3). */
154
202
  export interface SetupScan {
@@ -218,6 +266,38 @@ interface FileIdentity {
218
266
  }
219
267
  /** IF-0-CREDPATH-4 runtime, over already-read records. Exported for its fixtures. */
220
268
  export declare function judgeRuntime(harness: IdentityHarness, entries: readonly SessionRecordEntry[], identity: FileIdentity | null): DoctorCheck;
269
+ /**
270
+ * ONBOARD IF-0-ONBOARD-3 — `runtime_hint`, over the SAME already-read `entries` `judgeRuntime` saw.
271
+ * Exported for its fixtures. Never reads `checks.runtime`'s own verdict, and never influences it:
272
+ * this is purely informational (see `RUNTIME_HINTS`'s doc comment for the precedence).
273
+ */
274
+ export declare function judgeRuntimeHint(harness: IdentityHarness, entries: readonly SessionRecordEntry[], fs: Pick<RegistrationFs, "realpath">, options: Pick<DoctorOptions, "installedAt" | "installedVersion" | "harnessProcesses">): RuntimeHint;
275
+ export interface DefaultInstallEvidence {
276
+ installedAt?: Date;
277
+ installedVersion: (packageName: string) => string | null;
278
+ }
279
+ /**
280
+ * ONBOARD IF-0-ONBOARD-3 — the default `installedAt`/`installedVersion`, derived ONLY from what
281
+ * doctor already reads for its own `registration` check (never a new probe): each scope file's
282
+ * board-identified mcp entry (codex/grok/agy, and claude's own non-plugin mcp registrations — an
283
+ * `npx -y @consiliency/agent-board-mcp@<version> …` stanza) and `packages`/`plugin` spec (pi,
284
+ * opencode — a pinned `<package>@<version>` entry). `files` is in ascending precedence (SAME as
285
+ * `readScopes` returns it), so a later file's match for the same package overrides an earlier
286
+ * one's, exactly as the harness itself would resolve it.
287
+ *
288
+ * For claude, the plugin verdict `pinCheck` already computed and trusts (never an unvalidated
289
+ * directory source) is preferred over a raw config scan, per its own `versionFrom`/`sourceKind`
290
+ * guard — the same one `pinCheck` applies.
291
+ *
292
+ * `installedAt` is the LATEST mtime among every file that named a version this way: several files
293
+ * naming different packages (pi's own `packages` row plus an unrelated mcp row) both count, since
294
+ * either being freshly written is evidence this harness's board setup was just touched. A file (or
295
+ * a claude directory source) whose mtime cannot be read contributes nothing — not an error, and
296
+ * never lowers the claim to "no evidence at all" by itself.
297
+ *
298
+ * Exported for its fixtures.
299
+ */
300
+ export declare function defaultInstallEvidence(harness: IdentityHarness, plugin: ClaudePluginSummary | undefined, files: readonly ScopeFile[], fs: RegistrationFs, statMtime: (path: string) => Date | null): DefaultInstallEvidence;
221
301
  export declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
222
302
  /** Human-readable form; `--json` prints the report object itself. */
223
303
  export declare function formatDoctorReport(report: DoctorReport): string;
package/dist/doctor.js CHANGED
@@ -10,17 +10,32 @@
10
10
  // evidence. Output is names and codes only: no identity value, key or path content is printed,
11
11
  // and an error's `code` is printed only when this repository authored it (`authoredCode`).
12
12
  import { randomBytes } from "node:crypto";
13
- import { readdirSync } from "node:fs";
13
+ import { readdirSync, statSync } from "node:fs";
14
14
  import { homedir, userInfo } from "node:os";
15
15
  import { join, resolve } from "node:path";
16
- import { AUTHORED_CAUSE_CODES, AgentSocketSelectionError, GENERIC_SUPABASE_FIELDS, IDENTITY_FILE_KEYS, IDENTITY_TRIGGER_FIELDS, KeystoreUnavailableError, assertIdentityHarness, codeProvenance, createAgentBoardClient, createOsKeychainBackend, expectedWakeTier, normalizeKeyReference, osKeychainFailureOf, readIdentityFile, readSessionRecords, resolveCredentials, selectAgentSocketIdentity, } from "@consiliency/agent-board-client";
17
- import { judgeRegistration, nodeRegistrationFs, } from "./doctor_scopes.js";
16
+ import { AUTHORED_CAUSE_CODES, AgentSocketSelectionError, GENERIC_SUPABASE_FIELDS, IDENTITY_FILE_KEYS, IDENTITY_TRIGGER_FIELDS, KeystoreUnavailableError, assertIdentityHarness, codeProvenance, createAgentBoardClient, createOsKeychainBackend, expectedWakeTier, listHarnessProcesses, normalizeKeyReference, osKeychainFailureOf, readIdentityFile, readSessionRecords, resolveCredentials, selectAgentSocketIdentity, } from "@consiliency/agent-board-client";
17
+ import { compareVersions } from "./claude_marketplace.js";
18
+ import { CLAUDE_PLUGIN_MCP_PACKAGE, judgeMcpEntry, judgeRegistration, nodeRegistrationFs, packageIdentity, readScopes, } from "./doctor_scopes.js";
18
19
  import { resolveKeyslotDir } from "./assets.js";
19
20
  import { cacheStateOf, measureKeyslot, progressText, scanOf, scanSourceOf, STATUS_SCAN_TIMEOUT_MS, } from "./keyslot_status.js";
20
21
  import { SLOT_RE, externalDeclarationProblems, fileIdentity, keyslotSocketDir, machineIdOf, resolveAgentSocketPath, unsealedOptinProblems, } from "./setup_storage.js";
21
22
  export const DOCTOR_CHECKS = Object.freeze([
22
23
  "identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime",
23
24
  ]);
25
+ /**
26
+ * ONBOARD IF-0-ONBOARD-3 — `runtime`'s informational hint. It never changes `runtime`'s status or
27
+ * reason, or the exit code (pinned in `doctor_runtime_hint.test.ts`'s exit-invariance cases); where
28
+ * it overlaps `registration`'s existing `plugin_pin_mismatch`, that reason stays and the hint only
29
+ * explains it. Precedence when more than one applies: `session_predates_install`, then `no_session`
30
+ * (zero live records, and no pre-install process found either), then `stale_session_version`, then
31
+ * `no_target_first_session`, then `none`. `session_predates_install` is checked FIRST, ahead of
32
+ * `no_session`, because the motivating case (a harness that wrote no record at all before setup
33
+ * finished — the macmini claude case) has zero live records: a `no_session`-first order would
34
+ * mask it.
35
+ */
36
+ export const RUNTIME_HINTS = Object.freeze([
37
+ "no_session", "stale_session_version", "session_predates_install", "no_target_first_session", "none",
38
+ ]);
24
39
  export const REPORTABLE_TOKEN = /^[A-Za-z0-9._/-]{1,64}$/;
25
40
  /**
26
41
  * A path prints only when absolute and made of the token alphabet (no spaces, quotes or control
@@ -591,6 +606,120 @@ export function judgeRuntime(harness, entries, identity) {
591
606
  return check("undetermined", unique(unknowns));
592
607
  return check("pass", "wake_producer_live");
593
608
  }
609
+ /**
610
+ * ONBOARD IF-0-ONBOARD-3 — `runtime_hint`, over the SAME already-read `entries` `judgeRuntime` saw.
611
+ * Exported for its fixtures. Never reads `checks.runtime`'s own verdict, and never influences it:
612
+ * this is purely informational (see `RUNTIME_HINTS`'s doc comment for the precedence).
613
+ */
614
+ export function judgeRuntimeHint(harness, entries, fs, options) {
615
+ const live = entries.filter((entry) => entry.status === "live")
616
+ .map((entry) => entry.record);
617
+ if (options.installedAt) {
618
+ // Checked even with ZERO live records (the macmini claude case: the harness wrote no record at
619
+ // all): an empty `live` makes every candidate process uncovered by construction, exactly as it
620
+ // should. Checking this before the `live.length === 0` fallback below is load-bearing — that
621
+ // fallback would otherwise short-circuit the one case IF-0-ONBOARD-3 names first.
622
+ //
623
+ // The same real-path comparison `pinCheck` uses for a record's cwd against doctor's own: a
624
+ // process is covered by a live record that is EITHER that process's own writer (pid-equal,
625
+ // true for pi/opencode's in-process plugin) OR launched from the same directory (true for the
626
+ // mcp child claude/codex/grok/agy launch, whose own pid never matches the harness's). Two
627
+ // uncovered sessions sharing one cwd is a stated false negative of the cwd half.
628
+ const real = (path) => fs.realpath(path) ?? resolve(path);
629
+ const recordPids = new Set(live.map((record) => record.pid));
630
+ const recordCwds = new Set(live.map((record) => real(record.cwd)));
631
+ const installedAt = options.installedAt;
632
+ const raw = (options.harnessProcesses ?? listHarnessProcesses)(harness, { startedBefore: installedAt });
633
+ // `startedBefore` is passed through as an optimisation hint to the injected/real lister; this
634
+ // cutoff is enforced here too, so an injected function that ignores it cannot fabricate a hint.
635
+ const processes = raw.filter((process) => process.startedAt.getTime() < installedAt.getTime());
636
+ const uncovered = processes.some((process) => !recordPids.has(process.pid) && !(process.cwd !== null && recordCwds.has(real(process.cwd))));
637
+ if (uncovered)
638
+ return "session_predates_install";
639
+ }
640
+ if (live.length === 0)
641
+ return "no_session";
642
+ if (options.installedVersion) {
643
+ const stale = live.some((record) => {
644
+ const installed = options.installedVersion(record.packageName);
645
+ return installed !== null && (compareVersions(record.packageVersion, installed) ?? 0) < 0;
646
+ });
647
+ if (stale)
648
+ return "stale_session_version";
649
+ }
650
+ if (harness === "opencode"
651
+ && live.some((record) => record.role === "wake_producer" && record.wake.tier === "tier0" && record.wake.reason === "no_target")) {
652
+ return "no_target_first_session";
653
+ }
654
+ return "none";
655
+ }
656
+ /** Default `statMtime`: a file that cannot be stat'd proves nothing, never an error. */
657
+ function defaultStatMtime(path) {
658
+ try {
659
+ return statSync(path).mtime;
660
+ }
661
+ catch {
662
+ return null;
663
+ }
664
+ }
665
+ /**
666
+ * ONBOARD IF-0-ONBOARD-3 — the default `installedAt`/`installedVersion`, derived ONLY from what
667
+ * doctor already reads for its own `registration` check (never a new probe): each scope file's
668
+ * board-identified mcp entry (codex/grok/agy, and claude's own non-plugin mcp registrations — an
669
+ * `npx -y @consiliency/agent-board-mcp@<version> …` stanza) and `packages`/`plugin` spec (pi,
670
+ * opencode — a pinned `<package>@<version>` entry). `files` is in ascending precedence (SAME as
671
+ * `readScopes` returns it), so a later file's match for the same package overrides an earlier
672
+ * one's, exactly as the harness itself would resolve it.
673
+ *
674
+ * For claude, the plugin verdict `pinCheck` already computed and trusts (never an unvalidated
675
+ * directory source) is preferred over a raw config scan, per its own `versionFrom`/`sourceKind`
676
+ * guard — the same one `pinCheck` applies.
677
+ *
678
+ * `installedAt` is the LATEST mtime among every file that named a version this way: several files
679
+ * naming different packages (pi's own `packages` row plus an unrelated mcp row) both count, since
680
+ * either being freshly written is evidence this harness's board setup was just touched. A file (or
681
+ * a claude directory source) whose mtime cannot be read contributes nothing — not an error, and
682
+ * never lowers the claim to "no evidence at all" by itself.
683
+ *
684
+ * Exported for its fixtures.
685
+ */
686
+ export function defaultInstallEvidence(harness, plugin, files, fs, statMtime) {
687
+ const found = new Map();
688
+ const VERSIONED = /^\d+[\w.+-]*$/;
689
+ // A live record's own `packageName` (written by the package itself, e.g. pi's `extension.ts`
690
+ // `PACKAGE_NAME`) is always bare — never carries a source scheme. pi's OWN config spec does
691
+ // (`npm:@consiliency/agent-board-pi@<v>`, pi's install-manifest convention); opencode's does not.
692
+ // Strip the scheme here so both key the same way a live record does.
693
+ const bare = (identity) => (identity.startsWith("npm:") ? identity.slice("npm:".length) : identity);
694
+ for (const file of files) {
695
+ for (const entry of file.contribution.mcp) {
696
+ if (!judgeMcpEntry(entry, fs).board)
697
+ continue;
698
+ for (const word of [entry.command ?? "", ...entry.args]) {
699
+ const identity = packageIdentity(word);
700
+ const version = word.slice(identity.length + 1);
701
+ if (identity !== word && VERSIONED.test(version))
702
+ found.set(bare(identity), { version, path: file.path });
703
+ }
704
+ }
705
+ for (const spec of file.contribution.packages) {
706
+ const identity = packageIdentity(spec);
707
+ const version = spec.slice(identity.length + 1);
708
+ if (identity !== spec && VERSIONED.test(version))
709
+ found.set(bare(identity), { version, path: file.path });
710
+ }
711
+ }
712
+ if (harness === "claude" && plugin?.installed && plugin.version
713
+ && !(plugin.sourceKind === "directory" && plugin.versionFrom !== "source")) {
714
+ found.set(CLAUDE_PLUGIN_MCP_PACKAGE, { version: plugin.version, path: plugin.sourceKind === "directory" ? plugin.sourcePath : null });
715
+ }
716
+ const installedVersion = (packageName) => found.get(packageName)?.version ?? null;
717
+ const mtimes = [...found.values()]
718
+ .map((candidate) => (candidate.path ? statMtime(candidate.path) : null))
719
+ .filter((mtime) => mtime !== null);
720
+ const installedAt = mtimes.length > 0 ? new Date(Math.max(...mtimes.map((mtime) => mtime.getTime()))) : undefined;
721
+ return { installedAt, installedVersion };
722
+ }
594
723
  function worst(checks) {
595
724
  return checks.find((item) => item.status === "fail")
596
725
  ?? checks.find((item) => item.status === "undetermined")
@@ -642,17 +771,23 @@ export async function runDoctor(options) {
642
771
  checks.board_membership = check("undetermined", "whoami_not_passed");
643
772
  }
644
773
  }
774
+ const fs = options.registrationFs ?? nodeRegistrationFs;
645
775
  let entries = [];
776
+ // ONBOARD IF-0-ONBOARD-3: `runtime_hint` is computed further down, once records were read
777
+ // without throwing (a caught `records_unreadable` must never be misread as `no_session` from the
778
+ // empty `entries` the catch leaves behind) AND registration has been read (its scope files and
779
+ // claude's plugin verdict are the default install evidence).
780
+ let recordsReadable = false;
646
781
  try {
647
782
  entries = readSessionRecords(harness, { ...trust, env, isAlive: options.isAlive });
648
783
  checks.runtime = judgeRuntime(harness, entries, values && { handle: values[HANDLE_KEY], fingerprint: values[FINGERPRINT_KEY] });
784
+ recordsReadable = true;
649
785
  }
650
786
  catch (error) {
651
787
  checks.runtime = check("undetermined", codeOf(error, "records_unreadable"));
652
788
  }
653
789
  // Registration is read for this directory and for every live record's.
654
790
  const cwds = [...new Set([cwd, ...entries.flatMap((entry) => entry.status === "live" ? [entry.record.cwd] : [])])];
655
- const fs = options.registrationFs ?? nodeRegistrationFs;
656
791
  const home = env.HOME && env.HOME.trim() !== "" ? env.HOME : homedir();
657
792
  const platform = options.platform ?? process.platform;
658
793
  let userName = options.userName;
@@ -678,10 +813,39 @@ export async function runDoctor(options) {
678
813
  verdicts[0] = pinCheck(verdicts[0], entries, cwd, fs);
679
814
  const registration = worst(verdicts);
680
815
  checks.registration = check(registration.status, registration.reason);
816
+ // ONBOARD IF-0-ONBOARD-3: `runtime_hint`, now that registration (this cwd's scope files and
817
+ // claude's plugin verdict) has been read — the default install evidence, unless a caller
818
+ // overrides either half. Computing the default is best-effort: a throw here (an unreadable
819
+ // scope file, say) leaves both unset rather than failing the whole report.
820
+ let runtimeHint = "none";
821
+ if (recordsReadable) {
822
+ let defaultInstalledAt;
823
+ let defaultInstalledVersion;
824
+ if (options.installedAt === undefined || options.installedVersion === undefined) {
825
+ try {
826
+ const { files } = readScopes(harness, { cwd, home, env, fs, claudeManagedDir: options.claudeManagedDir ?? defaultClaudeManagedDir(platform),
827
+ platform, userName, regQuery: options.regQuery, claudeMarketplacePackaged: options.claudeMarketplacePackaged,
828
+ claudeMarketplaceTrust: options.claudeMarketplaceTrust });
829
+ const evidence = defaultInstallEvidence(harness, verdicts[0].plugin, files, fs, options.statMtime ?? defaultStatMtime);
830
+ defaultInstalledAt = evidence.installedAt;
831
+ defaultInstalledVersion = evidence.installedVersion;
832
+ }
833
+ catch {
834
+ // Leave both unset: the hint degrades to what it can prove, never a guess.
835
+ }
836
+ }
837
+ runtimeHint = judgeRuntimeHint(harness, entries, fs, {
838
+ installedAt: options.installedAt ?? defaultInstalledAt,
839
+ installedVersion: options.installedVersion ?? defaultInstalledVersion,
840
+ harnessProcesses: options.harnessProcesses,
841
+ });
842
+ }
681
843
  const ordered = Object.fromEntries(DOCTOR_CHECKS.map((name) => [name, checks[name]]));
682
844
  const statuses = Object.values(ordered).map((item) => item.status);
683
845
  const exit = statuses.every((status) => status === "pass") ? 0 : statuses.includes("fail") ? 1 : 2;
684
846
  const report = { harness, exit, checks: ordered };
847
+ if (runtimeHint !== "none")
848
+ report.runtime_hint = runtimeHint;
685
849
  if (keyStorageScan)
686
850
  report.key_storage_scan = keyStorageScan;
687
851
  const here = verdicts[0];
@@ -721,6 +885,8 @@ function ago(seconds) {
721
885
  /** Human-readable form; `--json` prints the report object itself. */
722
886
  export function formatDoctorReport(report) {
723
887
  const lines = DOCTOR_CHECKS.map((name) => `${report.checks[name].status.padEnd(12)} ${name}: ${report.checks[name].reason}`);
888
+ if (report.runtime_hint)
889
+ lines.push(`${"runtime_hint".padEnd(12)} ${report.runtime_hint}`);
724
890
  if (report.plugin) {
725
891
  const { plugin } = report;
726
892
  const counts = report.registration?.hook_counts;
@@ -1,46 +1 @@
1
- import { type EnrollmentOutcome } from "@consiliency/agent-board-client";
2
- export interface OwnerOnlyWriteOptions {
3
- syncDirectory?: (path: string) => Promise<void>;
4
- }
5
- /** Create `path` exclusively (never following a planted link) at 0600, write, fsync. */
6
- export declare function writeOwnerOnlyFile(path: string, content: string): Promise<void>;
7
- /**
8
- * Write `content` to `path` so a reader sees the old file or the whole new one, never a
9
- * part: an exclusive 0600 temp beside it, fsync'd, renamed over it, then the directory
10
- * fsync'd. On any throw before the rename the temp is removed.
11
- */
12
- export declare function writeOwnerOnlyFileAtomic(path: string, content: string, options?: OwnerOnlyWriteOptions): Promise<void>;
13
- export interface IdentityDeployment {
14
- boardUrl: string;
15
- sessionUrl: string;
16
- publishableKey: string;
17
- }
18
- /**
19
- * Where each deployment value is read, in precedence order: the client's one alias table
20
- * (`DEPLOYMENT_ENV_ALIASES`), which puts the enrollment's own names first (so the file records
21
- * the deployment the enrollment used) and then the identity names. The enrollment resolvers read
22
- * the same table, so the two cannot disagree. The generic Supabase names are in no list: a
23
- * generic key never fills an identity field, here or in the loader.
24
- */
25
- export declare const DEPLOYMENT_ENV_NAMES: Readonly<Record<keyof IdentityDeployment, readonly string[]>>;
26
- /**
27
- * The deployment half of an identity, read and shape-checked before enrollment commits. Each
28
- * value is the shipped default, or env under the board group's all-or-nothing rule (the
29
- * client's `resolveDeploymentField`). Absent from an env-set group → `identity_incomplete`,
30
- * naming the variable that switched the group off; not the non-secret shape its key requires →
31
- * `identity_secret_value`. Both name the variable, never the value.
32
- */
33
- export declare function resolveIdentityDeployment(env: NodeJS.ProcessEnv): IdentityDeployment;
34
- /**
35
- * The identity file's values for an enrolled agent. The URLs and publishable key come only
36
- * from `deployment`; everything else from the outcome. Validated by the client's serializer
37
- * before it is returned.
38
- */
39
- export declare function identityValuesFromEnrollment(outcome: EnrollmentOutcome, deployment: IdentityDeployment): Record<string, string>;
40
- /**
41
- * Write `harness`'s identity file for this host. Every refusal (bad harness, relative root,
42
- * invalid host, bad value) fires before the first filesystem change. The directory is then
43
- * put through the reader's own trusted walk, so the writer never produces a file the loader
44
- * would refuse to open. Returns the path written.
45
- */
46
- export declare function writeIdentityFile(harness: string, values: Readonly<Record<string, string>>, env: NodeJS.ProcessEnv, options?: OwnerOnlyWriteOptions): Promise<string>;
1
+ export { DEPLOYMENT_ENV_NAMES, identityValuesFromEnrollment, resolveIdentityDeployment, writeIdentityFile, writeOwnerOnlyFile, writeOwnerOnlyFileAtomic, type IdentityDeployment, type OwnerOnlyWriteOptions, } from "@consiliency/agent-board-client";