import { isAbsolute, normalize } from "node:path"; import type { AmqCommandResult, AmqRunOptions } from "../transport/amq-cli-adapter.js"; import { BindingResolver, type BindingProjectionV1, type BindingResolution, } from "../transport/binding-resolver.js"; interface JsonRunner { runJson( argv: readonly string[], options?: AmqRunOptions, ): Promise>; } export interface ConfirmedBindRequest { readonly projectDirectory: string; readonly root: string; readonly handle: string; readonly agents: readonly string[]; readonly environment?: Readonly; } export interface ConfirmedBindPreview { readonly kind: "existing" | "bootstrap"; readonly projectDirectory: string; readonly root: string; readonly session: string; readonly project: string; readonly handle: string; readonly agents: readonly string[]; readonly command: readonly string[]; readonly commandText: string; readonly effects: readonly string[]; } export type ConfirmedBindResult = | { readonly kind: "cancelled"; readonly diagnostic: string } | { readonly kind: "refused"; readonly diagnostic: string } | { readonly kind: "bound"; readonly binding: BindingProjectionV1 }; interface WhoAgent { readonly handle: string; } interface WhoSession { readonly name: string; readonly agents: readonly WhoAgent[]; } interface WhoProjection { readonly initialized: boolean; readonly sessions: readonly WhoSession[]; } interface EnvPreview { readonly root: string; readonly session: string; readonly project: string; } /** Owns the confirmation-gated first-run boundary; it never guesses authority. */ export class ConfirmedBindOnboarding { readonly #runner: JsonRunner; readonly #resolver: BindingResolver; constructor(runner: JsonRunner, resolver = new BindingResolver(runner)) { this.#runner = runner; this.#resolver = resolver; } async preview(request: ConfirmedBindRequest): Promise { const projectDirectory = absolute(request.projectDirectory, "project directory"); const root = absolute(request.root, "AMQ root"); const handle = agentHandle(request.handle, "chosen handle"); const requestedAgents = roster(request.agents, handle); const environment = request.environment ?? process.env; const who = await this.#runner.runJson( ["who", "--root", root], { env: commandEnvironment(environment), parse: parseWho }, ); if (who.ok && who.json?.initialized) { const existingAgents = unique(who.json.sessions.flatMap((session) => session.agents.map((agent) => agent.handle), )); if (!existingAgents.includes(handle)) { throw new Error( `Chosen handle ${handle} is not provisioned in the existing AMQ roster: ${existingAgents.join(",")}`, ); } const live = await this.#runner.runJson( ["env", "--root", root, "--me", handle], { env: commandEnvironment(environment), parse: parseEnvPreview }, ); if (!live.ok || !live.json) { throw new Error(literalFailure(live, "Existing AMQ root could not be inspected")); } return { kind: "existing", projectDirectory, root: live.json.root, session: live.json.session, project: live.json.project, handle, agents: existingAgents, command: [], commandText: "No initialization command; verify and bind the selected existing mailbox", effects: ["Persist pi-amq-binding/v1 only after live mailbox verification"], }; } if (!(who.ok && who.json && !who.json.initialized) && !bootstrapEligible(who)) { throw new Error(literalFailure(who, "AMQ roster discovery failed")); } const command = ["amq", "coop", "init", "--root", root, "--agents", requestedAgents.join(",")]; return { kind: "bootstrap", projectDirectory, root, session: "", project: projectDirectory, handle, agents: requestedAgents, command, commandText: command.map(shellWord).join(" "), effects: [ `Create the physical AMQ root at ${root}`, `Provision the complete roster: ${requestedAgents.join(",")}`, `Write ${projectDirectory}/.amqrc`, `Update ${projectDirectory}/.gitignore when needed`, ], }; } async confirm( preview: ConfirmedBindPreview, confirmed: boolean, environment: Readonly = process.env, ): Promise { if (!confirmed) return { kind: "cancelled", diagnostic: "AMQ bind cancelled; no state changed" }; if (preview.kind === "bootstrap") { const initialized = await this.#runner.runJson( preview.command.slice(1), { cwd: preview.projectDirectory, env: commandEnvironment(environment) }, ); if (!initialized.ok) { return { kind: "refused", diagnostic: literalFailure(initialized, "AMQ roster bootstrap failed; pi-amq remains dormant"), }; } } const resolution = await this.#resolver.resolve({ flags: { handle: preview.handle, root: preview.root }, environment: clearInheritedBinding(environment), }); if (resolution.kind !== "bound") return refusedResolution(resolution); if (resolution.binding.root !== preview.root || resolution.binding.handle !== preview.handle) { return { kind: "refused", diagnostic: "Post-provision binding differs from the confirmed root or handle; pi-amq remains dormant", }; } const who = await this.#runner.runJson( ["who", "--root", preview.root], { env: commandEnvironment(environment), parse: parseWho }, ); if (!who.ok || !who.json?.initialized) { return { kind: "refused", diagnostic: literalFailure(who, "Post-provision roster verification failed; pi-amq remains dormant"), }; } const verifiedAgents = unique(who.json.sessions.flatMap((session) => session.agents.map((agent) => agent.handle), )); if (!sameRoster(verifiedAgents, preview.agents)) { return { kind: "refused", diagnostic: `Post-provision roster mismatch: confirmed ${preview.agents.join(",")}; live ${verifiedAgents.join(",")}`, }; } return { kind: "bound", binding: resolution.binding }; } } export function parseRosterInput(value: string, handle: string): readonly string[] { return roster(value.split(","), agentHandle(handle, "chosen handle")); } function parseWho(value: unknown): WhoProjection { if (value === null) return { initialized: false, sessions: [] }; if (!Array.isArray(value)) throw new Error("amq who --json must return an array"); const sessions = value.map((rawSession, sessionIndex) => { const session = record(rawSession, `amq who session ${sessionIndex}`); if (!Array.isArray(session.agents)) throw new Error("amq who session agents must be an array"); return { name: stringField(session, "name"), agents: session.agents.map((rawAgent, agentIndex) => { const agent = record(rawAgent, `amq who agent ${agentIndex}`); return { handle: agentHandle(stringField(agent, "handle"), "AMQ roster handle") }; }), }; }); return { initialized: true, sessions }; } function parseEnvPreview(value: unknown): EnvPreview { const object = record(value, "amq env --json"); return { root: absolute(stringField(object, "root"), "live AMQ root"), session: stringField(object, "session_name"), project: stringField(object, "project"), }; } function bootstrapEligible(result: AmqCommandResult): boolean { return result.category === "not-found" || (result.category === "error" && /(?:not initialized|missing|not found|no such file)/i.test(result.stderr)); } function refusedResolution(resolution: Exclude): ConfirmedBindResult { return { kind: "refused", diagnostic: `${resolution.diagnostic}; pi-amq remains dormant`, }; } function roster(values: readonly string[], handle: string): readonly string[] { const normalized = values .flatMap((value) => value.split(",")) .map((value) => value.trim()) .filter((value) => value.length > 0) .map((value) => agentHandle(value, "roster handle")); return unique([...normalized, handle, "user"]); } function unique(values: readonly string[]): readonly string[] { return [...new Set(values)].sort((left, right) => left.localeCompare(right)); } function sameRoster(left: readonly string[], right: readonly string[]): boolean { const normalizedLeft = unique(left); const normalizedRight = unique(right); return normalizedLeft.length === normalizedRight.length && normalizedLeft.every((value, index) => value === normalizedRight[index]); } function absolute(value: string, name: string): string { if (!isAbsolute(value)) throw new TypeError(`${name} must be absolute`); return normalize(value); } function agentHandle(value: string, name: string): string { const normalized = value.trim(); if (!/^[a-z0-9][a-z0-9._-]*$/.test(normalized)) { throw new TypeError(`${name} must use lowercase AMQ handle syntax`); } return normalized; } function clearInheritedBinding(environment: Readonly): NodeJS.ProcessEnv { const cleared: NodeJS.ProcessEnv = { ...environment }; for (const key of ["AM_ROOT", "AM_ROOT_ID", "AM_BASE_ROOT", "AM_BASE_ROOT_ID", "AM_SESSION", "AM_ME"]) { delete cleared[key]; } return cleared; } function commandEnvironment(environment: Readonly): NodeJS.ProcessEnv { return { ...environment }; } function shellWord(value: string): string { return /^[A-Za-z0-9_./,:=-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\"'\"'`)}'`; } function literalFailure(result: AmqCommandResult, fallback: string): string { return result.stderr.trim() || result.jsonError?.message || result.processError?.message || fallback; } function record(value: unknown, name: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`); return value as Record; } function stringField(object: Record, key: string): string { const value = object[key]; if (typeof value !== "string") throw new Error(`${key} must be a string`); return value; }