diff --git a/.gitignore b/.gitignore index 26ff0b0..a649270 100644 --- a/.gitignore +++ b/.gitignore @@ -49,5 +49,8 @@ docs/backlog/ .cursor/* !.cursor/rules/ !.cursor/rules/** +# Live MBA-staged cards (named after the connected model) +.cursor/rules/*.mdc +!.cursor/rules/npm-publish.mdc .cursorignore .cursorindexingignore diff --git a/README.md b/README.md index b313ea1..b0ca3f3 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ mba status `mba models pull` downloads the GGUF (resume + sha256). HuggingFace repos take the digest from LFS metadata; other sources need `--sha256`. After verify it parses the header locally, writes a TODO-marked adapter (and a family tier if that family is new), empty BCB/TCB/`server_setup` bindings, and empty `instructions.md` (the model reads this later) plus `notes.md` (you read this; never injected). A failed verify deletes the partial and leaves no scaffold. -`mba s boot` resolves that adapter tree into llama.cpp flags — the same chain as the preview — then boots. `mba models search` is the interactive HuggingFace path into the same pull. `mba models stage` copies a non-empty winning `instructions.md` into a file the harness already injects (`CLAUDE.local.md`, Cursor rules, …). `mba connect` does that and mints a Bearer token; once any session exists, chat through the MBA proxy requires it. `notes.md` stays in the store. +`mba s boot` resolves that adapter tree into llama.cpp flags — the same chain as the preview — then boots. `mba models search` is the interactive HuggingFace path into the same pull. `mba models stage` copies a non-empty winning `instructions.md` into the file the harness already injects (`CLAUDE.local.md`, `.cursor/rules/mba.mdc`, …). The model id is in that card; the filename stays the harness slot. `mba connect` does that and mints a Bearer token; once any session exists, chat through the MBA proxy requires it. `notes.md` stays in the store. ## CLI diff --git a/packages/core/src/cli/clients.ts b/packages/core/src/cli/clients.ts index 639f29a..5cce4a1 100644 --- a/packages/core/src/cli/clients.ts +++ b/packages/core/src/cli/clients.ts @@ -2,6 +2,7 @@ * Client plane: list paired sessions, add operator clients, connect, revoke. */ +import { formatClientLabel } from "../service/env-context.js"; import { fail, serviceGet, servicePost } from "./client.js"; import { listHarnessChoices } from "./harness-choices.js"; import { askValueInteractive, pickLabeledInteractive } from "./interactive.js"; @@ -20,6 +21,7 @@ interface PublicSession { readonly ide?: string; readonly projectRoot: string; readonly createdAt: string; + readonly card?: boolean; } async function listSessions(baseUrl: string): Promise { @@ -30,9 +32,10 @@ async function listSessions(baseUrl: string): Promise } function printSession(s: PublicSession): void { - const who = s.ide ? `${s.harness}+${s.ide}` : s.harness; + const who = formatClientLabel(s.harness, s.ide); + const tag = (s.card ? "card" : "pair").padEnd(4); process.stdout.write( - ` ${paint(who.padEnd(18), BOLD)} ${s.modelId.padEnd(22)} ${dim(shortenHome(s.projectRoot))}\n`, + ` ${paint(who.padEnd(18), BOLD)} ${s.modelId.padEnd(22)} ${s.card ? paint(tag, BOLD) : dim(tag)} ${dim(shortenHome(s.projectRoot))}\n`, ); } diff --git a/packages/core/src/cli/harness-choices.ts b/packages/core/src/cli/harness-choices.ts index 3f243b4..cc1633c 100644 --- a/packages/core/src/cli/harness-choices.ts +++ b/packages/core/src/cli/harness-choices.ts @@ -3,7 +3,7 @@ * operator-defined clients in mba/clients.json. */ -import { builtInEnvelopeBindings, envelopeRelativePath } from "../mba/envelope.js"; +import { builtInEnvelopeBindings } from "../mba/envelope.js"; import { defaultStorePaths } from "../service/config-store.js"; import { readOperatorClients } from "../service/operator-clients.js"; @@ -37,7 +37,7 @@ export function harnessPickerRows(): Array<{ label: h.source === "added" ? `${h.name} (added)` : h.name, value: h.name, preview: [ - ["envelope", envelopeRelativePath(h.name, undefined, [{ name: h.name, envelope: h.envelope }]) ?? h.envelope], + ["envelope", h.envelope], ["source", h.source], ] as const, })); diff --git a/packages/core/src/cli/models.ts b/packages/core/src/cli/models.ts index 9224f82..e47c2b9 100644 --- a/packages/core/src/cli/models.ts +++ b/packages/core/src/cli/models.ts @@ -466,7 +466,14 @@ interface ConnectResult { readonly harness: string; readonly projectRoot: string; readonly stage: - | { readonly action: string; readonly reason?: string; readonly envelope?: string; readonly dest?: string } + | { + readonly action: string; + readonly reason?: string; + readonly envelope?: string; + readonly dest?: string; + readonly owner?: string; + readonly replaced?: string; + } | { readonly action: "conflict"; readonly error: string }; } @@ -554,6 +561,11 @@ export async function cmdModelsConnect( process.stdout.write(`[mba] point the client at ${baseUrl}/v1 (Authorization: Bearer )\n`); if ("envelope" in result.stage && result.stage.action === "wrote") { process.stdout.write(`[mba] staged → ${result.stage.envelope}\n`); + if (result.stage.replaced) { + process.stdout.write( + `[mba] envelope now ${result.modelId} (replaced ${result.stage.replaced})\n`, + ); + } } else if ("error" in result.stage) { process.stdout.write(`[mba] card not overwritten — ${result.stage.error}\n`); } diff --git a/packages/core/src/cli/status.ts b/packages/core/src/cli/status.ts index 3cebf85..2141004 100644 --- a/packages/core/src/cli/status.ts +++ b/packages/core/src/cli/status.ts @@ -1,3 +1,4 @@ +import { formatClientLabel } from "../service/env-context.js"; import { resolveServiceUrl, serviceGet } from "./client.js"; import { brand, dim, heading, kv, paint, shortenHome, BOLD, GRN, RED } from "./style.js"; import type { ModelEntry } from "./interactive.js"; @@ -10,6 +11,7 @@ interface PublicSession { readonly ide?: string; readonly projectRoot: string; readonly createdAt: string; + readonly card?: boolean; } interface StatusBody { @@ -29,9 +31,10 @@ function printServerRow(s: ServerEntry): void { } function printSessionRow(s: PublicSession): void { - const who = s.ide ? `${s.harness}+${s.ide}` : s.harness; + const who = formatClientLabel(s.harness, s.ide); + const tag = (s.card ? "card" : "pair").padEnd(4); process.stdout.write( - ` ${paint(who.padEnd(18), BOLD)} ${s.modelId.padEnd(22)} ${dim(shortenHome(s.projectRoot))}\n`, + ` ${paint(who.padEnd(18), BOLD)} ${s.modelId.padEnd(22)} ${s.card ? paint(tag, GRN) : dim(tag)} ${dim(shortenHome(s.projectRoot))}\n`, ); } diff --git a/packages/core/src/mba/envelope.test.ts b/packages/core/src/mba/envelope.test.ts index 7d2b6f4..8ebb4cb 100644 --- a/packages/core/src/mba/envelope.test.ts +++ b/packages/core/src/mba/envelope.test.ts @@ -2,19 +2,36 @@ import { describe, expect, it } from "vitest"; import { KNOWN_HARNESSES, MBA_STAGE_MARKER, + envelopeFileStem, envelopeRelativePath, isMbaStaged, normalizeHarness, + stagedModelId, wrapStagedCard, } from "./envelope.js"; describe("envelopeRelativePath", () => { it("maps each known harness to a slot the client already injects", () => { expect(envelopeRelativePath("claude-code")).toBe("CLAUDE.local.md"); + expect(envelopeRelativePath("claude-code", undefined, [], "deepseek_test")).toBe( + "CLAUDE.local.md", + ); expect(envelopeRelativePath("cursor")).toBe(".cursor/rules/mba.mdc"); + expect(envelopeRelativePath("cursor", undefined, [], "deepseek_test")).toBe( + ".cursor/rules/mba.mdc", + ); expect(envelopeRelativePath("cline")).toBe(".clinerules/mba.md"); + expect(envelopeRelativePath("cline", undefined, [], "deepseek_test")).toBe( + ".clinerules/mba.md", + ); expect(envelopeRelativePath("copilot")).toBe(".github/instructions/mba.instructions.md"); + expect(envelopeRelativePath("copilot", undefined, [], "deepseek_test")).toBe( + ".github/instructions/mba.instructions.md", + ); expect(envelopeRelativePath("continue")).toBe(".continue/rules/mba.md"); + expect(envelopeRelativePath("continue", undefined, [], "deepseek_test")).toBe( + ".continue/rules/mba.md", + ); }); it("folds aliases onto the closed set", () => { @@ -40,6 +57,11 @@ describe("envelopeRelativePath", () => { { name: "windsurf", envelope: ".windsurf/mba.md" }, ]), ).toBe(".cursor/rules/mba.mdc"); + expect( + envelopeRelativePath("windsurf", undefined, [ + { name: "windsurf", envelope: ".windsurf/{model}.md" }, + ], "deepseek_test"), + ).toBe(".windsurf/deepseek_test.md"); }); it("keeps the closed set small", () => { @@ -56,11 +78,19 @@ describe("wrapStagedCard", () => { }); it("adds Cursor alwaysApply frontmatter", () => { - const wrapped = wrapStagedCard("cursor", "# card"); + const wrapped = wrapStagedCard("cursor", "# card", "deepseek_test"); expect(wrapped).toContain("alwaysApply: true"); + expect(wrapped).toContain('"deepseek_test model card (live copy; do not edit)"'); + expect(wrapped).toContain(""); expect(isMbaStaged(wrapped)).toBe(true); }); + it("names the model in the staged marker for every harness", () => { + const wrapped = wrapStagedCard("claude-code", "# card", "deepseek_test"); + expect(wrapped).toContain(""); + expect(wrapped.startsWith(MBA_STAGE_MARKER)).toBe(true); + }); + it("adds Copilot applyTo frontmatter", () => { const wrapped = wrapStagedCard("copilot", "# card"); expect(wrapped).toContain('applyTo: "**"'); @@ -71,3 +101,29 @@ describe("wrapStagedCard", () => { expect(wrapped).toBe(`${MBA_STAGE_MARKER}\n\n# card\n`); }); }); + +describe("envelopeFileStem", () => { + it("keeps a safe model id and falls back to mba", () => { + expect(envelopeFileStem("deepseek_test")).toBe("deepseek_test"); + expect(envelopeFileStem("qwen3-coder-30b")).toBe("qwen3-coder-30b"); + expect(envelopeFileStem("../odd name")).toBe("odd-name"); + expect(envelopeFileStem("...")).toBe("mba"); + expect(envelopeFileStem("")).toBe("mba"); + expect(envelopeFileStem(undefined)).toBe("mba"); + }); +}); + +describe("stagedModelId", () => { + it("reads the model marker from a wrapped card", () => { + const wrapped = wrapStagedCard("cursor", "# card", "deepseek_test"); + expect(stagedModelId(wrapped)).toBe("deepseek_test"); + expect(stagedModelId("# no marker")).toBeUndefined(); + }); + + it("does not let a model id close the HTML comment", () => { + const wrapped = wrapStagedCard("cline", "# card", "foo-->bar"); + expect(wrapped).toContain(""); + expect(wrapped).not.toContain("-->bar"); + expect(stagedModelId(wrapped)).toBe("foo-bar"); + }); +}); diff --git a/packages/core/src/mba/envelope.ts b/packages/core/src/mba/envelope.ts index 9402005..7d1d8c7 100644 --- a/packages/core/src/mba/envelope.ts +++ b/packages/core/src/mba/envelope.ts @@ -62,45 +62,131 @@ export function builtInEnvelopeBindings(): readonly EnvelopeBinding[] { return KNOWN_HARNESSES.map((name) => ({ name, envelope: ENVELOPE_BY_HARNESS[name] })); } +/** + * Filesystem-safe stem when an operator envelope uses `{model}`. + * Built-in slots do not use this — they are the filenames the harness + * already injects. Empty / junk → `mba`. + */ +function isStemChar(ch: string): boolean { + return ( + (ch >= "A" && ch <= "Z") || + (ch >= "a" && ch <= "z") || + (ch >= "0" && ch <= "9") || + ch === "." || + ch === "_" || + ch === "-" + ); +} + +export function envelopeFileStem(modelId?: string): string { + // Walk the id; no regex (CodeQL js/polynomial-redos on `[-.]+`). + let stem = ""; + for (const ch of (modelId ?? "").trim()) { + if (isStemChar(ch)) stem += ch; + else if (stem.length > 0 && !stem.endsWith("-")) stem += "-"; + } + while (stem.startsWith("-") || stem.startsWith(".")) stem = stem.slice(1); + while (stem.endsWith("-") || stem.endsWith(".")) stem = stem.slice(0, -1); + return stem.length > 0 ? stem.slice(0, 64) : "mba"; +} + +function fillEnvelopeTemplate(template: string, modelId?: string): string { + if (!template.includes("{model}")) return template; + return template.replaceAll("{model}", envelopeFileStem(modelId)); +} + +function modelMarkerPayload(modelId?: string): string { + // Strip comment terminators without a HTML-filter regex (CodeQL js/bad-tag-filter). + let raw = (modelId ?? "").trim(); + while (raw.includes("--")) raw = raw.split("--").join("-"); + let out = ""; + for (const ch of raw) { + if (ch !== ">") out += ch; + } + return out.trim(); +} + +function modelMarkerLine(modelId?: string): string { + const raw = modelMarkerPayload(modelId); + return raw.length > 0 ? `\n` : ""; +} + /** * Project-relative path the harness already injects. * `ide` is accepted so the door matches env (`harness` + `ide`); this cut - * keys the filename on harness. Unknown harness with no extra → undefined. + * keys the filename on harness. Built-in paths are stable so the client + * keeps injecting the same slot; `modelId` is written into the card body. + * An added client may put `{model}` in its envelope. Unknown harness with + * no extra → undefined. */ export function envelopeRelativePath( harness: string, _ide?: string, extras: readonly EnvelopeBinding[] = [], + modelId?: string, ): string | undefined { const known = normalizeHarness(harness); - if (known) return ENVELOPE_BY_HARNESS[known]; + if (known) return fillEnvelopeTemplate(ENVELOPE_BY_HARNESS[known], modelId); const key = compactHarnessKey(harness); const extra = extras.find((e) => compactHarnessKey(e.name) === key); - return extra?.envelope; + return extra ? fillEnvelopeTemplate(extra.envelope, modelId) : undefined; } export function isMbaStaged(text: string): boolean { return text.includes(MBA_STAGE_MARKER); } +function isSpace(ch: string | undefined): boolean { + return ch === " " || ch === "\t" || ch === "\n" || ch === "\r"; +} + +/** Model id planted in a staged envelope, if present. */ +export function stagedModelId(text: string): string | undefined { + const needle = "mba-model:"; + let from = 0; + while (from < text.length) { + const tag = text.indexOf(needle, from); + if (tag < 0) return undefined; + const open = text.lastIndexOf("", p) >= 0) return id; + } + from = tag + needle.length; + } + return undefined; +} + /** Wrap store card text so the harness file is identifiable and (when needed) always-on. */ -export function wrapStagedCard(harness: string, body: string): string { - const trimmed = body.replace(/^\uFEFF/, "").trimEnd() + "\n"; +export function wrapStagedCard(harness: string, body: string, modelId?: string): string { + const withoutBom = body.startsWith("\uFEFF") ? body.slice(1) : body; + const trimmed = withoutBom.trimEnd() + "\n"; const known = normalizeHarness(harness); + const marker = `${MBA_STAGE_MARKER}${modelMarkerLine(modelId)}`; if (known === "cursor") { + const label = modelId && modelId.trim().length > 0 ? modelId.trim() : "MBA"; + const description = JSON.stringify(`${label} model card (live copy; do not edit)`); return [ "---", - "description: MBA model card (live copy; do not edit)", + `description: ${description}`, "alwaysApply: true", "---", "", - MBA_STAGE_MARKER, + marker, "", trimmed, ].join("\n"); } if (known === "copilot") { - return ["---", 'applyTo: "**"', "---", "", MBA_STAGE_MARKER, "", trimmed].join("\n"); + return ["---", 'applyTo: "**"', "---", "", marker, "", trimmed].join("\n"); } - return `${MBA_STAGE_MARKER}\n\n${trimmed}`; + return `${marker}\n\n${trimmed}`; } diff --git a/packages/core/src/mba/stage-instructions.test.ts b/packages/core/src/mba/stage-instructions.test.ts index 19793f9..ba40800 100644 --- a/packages/core/src/mba/stage-instructions.test.ts +++ b/packages/core/src/mba/stage-instructions.test.ts @@ -62,8 +62,11 @@ describe("stageInstructions", () => { sourcePath: join(store, "instructions.md"), harness: "cursor", sourceRoot: store, + modelId: "deepseek_test", }); expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.envelope).toBe(".cursor/rules/mba.mdc"); const staged = readFileSync(join(project, ".cursor/rules/mba.mdc"), "utf8"); expect(staged).toContain("card"); expect(staged).not.toContain("operator secret"); @@ -215,4 +218,92 @@ describe("stageInstructions", () => { expect(result).toMatchObject({ ok: false, code: "conflict" }); expect(readFileSync(join(project, ".clinerules"), "utf8")).toBe("user cline rules\n"); }); + + it("overwrites the Cursor slot and sweeps leftover MBA cards", () => { + const store = join(dir, "store"); + const project = join(dir, "project"); + mkdirSync(store); + mkdirSync(join(project, ".cursor/rules"), { recursive: true }); + writeFileSync(join(store, "instructions.md"), "card\n"); + writeFileSync( + join(project, ".cursor/rules/deepseek_test.mdc"), + `${MBA_STAGE_MARKER}\n\nold named card\n`, + ); + writeFileSync(join(project, ".cursor/rules/mine.mdc"), "# operator rule\n"); + + const result = stageInstructions({ + projectRoot: project, + sourcePath: join(store, "instructions.md"), + harness: "cursor", + sourceRoot: store, + modelId: "deepseek_test", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.envelope).toBe(".cursor/rules/mba.mdc"); + const staged = readFileSync(join(project, ".cursor/rules/mba.mdc"), "utf8"); + expect(staged).toContain("card"); + expect(staged).toContain(""); + expect(() => readFileSync(join(project, ".cursor/rules/deepseek_test.mdc"), "utf8")).toThrow(); + expect(readFileSync(join(project, ".cursor/rules/mine.mdc"), "utf8")).toBe("# operator rule\n"); + }); + + it("writes the model id into Cline and Copilot slots without renaming them", () => { + const store = join(dir, "store"); + const project = join(dir, "project"); + mkdirSync(store); + mkdirSync(project); + writeFileSync(join(store, "instructions.md"), "card\n"); + + const cline = stageInstructions({ + projectRoot: project, + sourcePath: join(store, "instructions.md"), + harness: "cline", + sourceRoot: store, + modelId: "deepseek_test", + }); + expect(cline.ok).toBe(true); + if (!cline.ok) return; + expect(cline.envelope).toBe(".clinerules/mba.md"); + expect(readFileSync(join(project, ".clinerules/mba.md"), "utf8")).toContain( + "", + ); + + const copilot = stageInstructions({ + projectRoot: project, + sourcePath: join(store, "instructions.md"), + harness: "copilot", + sourceRoot: store, + modelId: "deepseek_test", + }); + expect(copilot.ok).toBe(true); + if (!copilot.ok) return; + expect(copilot.envelope).toBe(".github/instructions/mba.instructions.md"); + expect( + readFileSync(join(project, ".github/instructions/mba.instructions.md"), "utf8"), + ).toContain(""); + }); + + it("leaves a sibling's playbook when this model has no card", () => { + const store = join(dir, "store"); + const project = join(dir, "project"); + mkdirSync(store); + mkdirSync(join(project, ".cursor/rules"), { recursive: true }); + writeFileSync(join(store, "instructions.md"), ""); + writeFileSync( + join(project, ".cursor/rules/mba.mdc"), + `${MBA_STAGE_MARKER}\n\n\nkeep me\n`, + ); + + const result = stageInstructions({ + projectRoot: project, + sourcePath: join(store, "instructions.md"), + harness: "cursor", + sourceRoot: store, + modelId: "nomic-embed-text-v1.5", + slotPeers: ["deepseek_test"], + }); + expect(result).toMatchObject({ ok: true, action: "skipped", reason: "empty" }); + expect(readFileSync(join(project, ".cursor/rules/mba.mdc"), "utf8")).toContain("keep me"); + }); }); diff --git a/packages/core/src/mba/stage-instructions.ts b/packages/core/src/mba/stage-instructions.ts index 4e7f559..21a0daa 100644 --- a/packages/core/src/mba/stage-instructions.ts +++ b/packages/core/src/mba/stage-instructions.ts @@ -5,11 +5,12 @@ * `messages[]`. Empty cards are not staged. `notes.md` is never a source. */ -import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { envelopeRelativePath, isMbaStaged, + stagedModelId, wrapStagedCard, type EnvelopeBinding, } from "./envelope.js"; @@ -29,6 +30,14 @@ export interface StageInstructionsInput { readonly ide?: string; /** Operator-defined envelopes (name + path), looked up after the shipped set. */ readonly envelopes?: readonly EnvelopeBinding[]; + /** Model id written into the staged card so the file shows which model is loaded. */ + readonly modelId?: string; + /** + * Other models already paired on this harness + project. An empty card + * must not pull down a sibling's playbook — pairing is many keys, the + * envelope is one slot. + */ + readonly slotPeers?: readonly string[]; /** * When set, the source must sit under this directory (the adapter tree). * Stops a binding of `../../outside.md` from being copied into the project. @@ -56,6 +65,27 @@ export function isPathInside(root: string, target: string): boolean { return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel); } +/** Who currently owns the harness envelope for this project slot, if anyone. */ +export function readEnvelopeOwner( + projectRoot: string, + harness: string, + extras: readonly EnvelopeBinding[] = [], + ide?: string, +): string | undefined { + const envelope = envelopeRelativePath(harness, ide, extras); + if (!envelope) return undefined; + const dest = resolve(projectRoot, envelope); + const st = statSync(dest, { throwIfNoEntry: false }); + if (!st?.isFile()) return undefined; + try { + const text = readFileSync(dest, "utf8"); + if (!isMbaStaged(text)) return undefined; + return stagedModelId(text); + } catch { + return undefined; + } +} + function atomicWriteText(path: string, text: string): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp-${process.pid}-${Date.now()}`; @@ -63,6 +93,31 @@ function atomicWriteText(path: string, text: string): void { renameSync(tmp, path); } +/** + * Directory slots stack if two MBA cards sit side by side. When switching + * models, drop other MBA-staged siblings and leave the operator's files alone. + * Project-root slots (CLAUDE.local.md) are a single filename; do not sweep. + */ +function sweepStaleMbaSiblings(projectRoot: string, keepDest: string): void { + const dir = dirname(keepDest); + if (resolve(dir) === resolve(projectRoot)) return; + if (!isPathInside(projectRoot, dir)) return; + const st = statSync(dir, { throwIfNoEntry: false }); + if (!st?.isDirectory()) return; + const keep = resolve(keepDest); + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (resolve(path) === keep) continue; + const file = statSync(path, { throwIfNoEntry: false }); + if (!file?.isFile()) continue; + try { + if (isMbaStaged(readFileSync(path, "utf8"))) rmSync(path); + } catch { + // Unreadable leftover — leave it. + } + } +} + function parentIsFile(dest: string, projectRoot: string): string | undefined { let dir = dirname(dest); while (dir !== projectRoot && isPathInside(projectRoot, dir)) { @@ -80,7 +135,7 @@ function parentIsFile(dest: string, projectRoot: string): string | undefined { * Never reads or writes `notes.md`. */ export function stageInstructions(input: StageInstructionsInput): StageInstructionsResult { - const envelope = envelopeRelativePath(input.harness, input.ide, input.envelopes); + const envelope = envelopeRelativePath(input.harness, input.ide, input.envelopes, input.modelId); if (!envelope) { return { ok: false, code: "unknown-harness", error: `unknown harness '${input.harness}'` }; } @@ -119,8 +174,9 @@ export function stageInstructions(input: StageInstructionsInput): StageInstructi } const sourcePath = input.sourcePath; + const preservePeerCard = shouldPreservePeerCard(input); if (!sourcePath) { - return unstageIfOurs(dest, envelope, "no-card"); + return unstageIfOurs(projectRoot, dest, envelope, "no-card", undefined, preservePeerCard); } const sourceAbs = resolve(sourcePath); @@ -133,28 +189,43 @@ export function stageInstructions(input: StageInstructionsInput): StageInstructi const sourceStat = statSync(sourceAbs, { throwIfNoEntry: false }); if (!sourceStat || !sourceStat.isFile()) { - return unstageIfOurs(dest, envelope, "no-card"); + return unstageIfOurs(projectRoot, dest, envelope, "no-card", undefined, preservePeerCard); } const body = readFileSync(sourceAbs, "utf8"); if (body.trim().length === 0) { - return unstageIfOurs(dest, envelope, "empty", sourceAbs); + return unstageIfOurs(projectRoot, dest, envelope, "empty", sourceAbs, preservePeerCard); } - atomicWriteText(dest, wrapStagedCard(input.harness, body)); + atomicWriteText(dest, wrapStagedCard(input.harness, body, input.modelId)); + sweepStaleMbaSiblings(projectRoot, dest); return { ok: true, action: "wrote", envelope, dest, source: sourceAbs }; } +function shouldPreservePeerCard(input: StageInstructionsInput): boolean { + const self = input.modelId; + if (!self) return false; + return (input.slotPeers ?? []).some((id) => id !== self); +} + function unstageIfOurs( + projectRoot: string, dest: string, envelope: string, reason: StageSkipReason, source?: string, + preservePeerCard = false, ): StageInstructionsResult { const st = statSync(dest, { throwIfNoEntry: false }); - if (st?.isFile() && isMbaStaged(readFileSync(dest, "utf8"))) { + const ours = Boolean(st?.isFile() && isMbaStaged(readFileSync(dest, "utf8"))); + if (ours && preservePeerCard) { + return { ok: true, action: "skipped", reason, envelope, dest, source }; + } + let action: StageAction = "skipped"; + if (ours) { rmSync(dest); - return { ok: true, action: "removed", reason, envelope, dest, source }; + action = "removed"; } - return { ok: true, action: "skipped", reason, envelope, dest, source }; + sweepStaleMbaSiblings(projectRoot, dest); + return { ok: true, action, reason, envelope, dest, source }; } diff --git a/packages/core/src/service/env-context.test.ts b/packages/core/src/service/env-context.test.ts index ee4b4bd..11075d2 100644 --- a/packages/core/src/service/env-context.test.ts +++ b/packages/core/src/service/env-context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_RESOLVE_ENV, defaultIdeForHarness, resolveEnvContext } from "./env-context.js"; +import { DEFAULT_RESOLVE_ENV, defaultIdeForHarness, formatClientLabel, resolveEnvContext } from "./env-context.js"; import { hashToken, type ClientSession } from "./sessions.js"; function session(partial: Partial & Pick): ClientSession { @@ -63,3 +63,17 @@ describe("defaultIdeForHarness", () => { expect(defaultIdeForHarness("windsurf")).toBe("vscode"); }); }); + +describe("formatClientLabel", () => { + it("drops a redundant or default ide", () => { + expect(formatClientLabel("cursor", "cursor")).toBe("cursor"); + expect(formatClientLabel("copilot", "vscode")).toBe("copilot"); + expect(formatClientLabel("claude-code", "cli")).toBe("claude-code"); + expect(formatClientLabel("cursor")).toBe("cursor"); + expect(formatClientLabel("windsurf", "vscode")).toBe("windsurf"); + }); + + it("keeps a non-default ide", () => { + expect(formatClientLabel("copilot", "cursor")).toBe("copilot+cursor"); + }); +}); diff --git a/packages/core/src/service/env-context.ts b/packages/core/src/service/env-context.ts index e9e34d1..5e0a8be 100644 --- a/packages/core/src/service/env-context.ts +++ b/packages/core/src/service/env-context.ts @@ -6,7 +6,7 @@ * historical default (copilot + vscode + llamacpp). */ -import { normalizeHarness } from "../mba/envelope.js"; +import { compactHarnessKey, normalizeHarness } from "../mba/envelope.js"; import type { RecipeResolutionContext } from "./recipe-resolution.js"; import type { ClientSession } from "./sessions.js"; import type { OperatorClient } from "./operator-clients.js"; @@ -25,6 +25,18 @@ export function defaultIdeForHarness(harness: string): string { return "vscode"; } +/** + * Status/list label. Hide a redundant ide (`cursor+cursor`) and the shipped + * default (`copilot+vscode` → `copilot`). Keep `harness+ide` when the ide + * is a real extra. + */ +export function formatClientLabel(harness: string, ide?: string): string { + if (!ide) return harness; + if (compactHarnessKey(ide) === compactHarnessKey(harness)) return harness; + if (ide === defaultIdeForHarness(harness)) return harness; + return `${harness}+${ide}`; +} + function sessionForModel( sessions: readonly ClientSession[], modelId: string, diff --git a/packages/core/src/service/server-connect.test.ts b/packages/core/src/service/server-connect.test.ts index 3e19ecc..79fe716 100644 --- a/packages/core/src/service/server-connect.test.ts +++ b/packages/core/src/service/server-connect.test.ts @@ -31,6 +31,48 @@ bindings: writeFileSync(join(modelDir, "m.gguf"), "gguf"); } +function writeEmbedFixture(dir: string): void { + const modelDir = join(dir, "nomic", "nomic-embed-text-v1.5"); + mkdirSync(modelDir, { recursive: true }); + writeFileSync( + join(modelDir, "nomic-embed-text-v1.5.yaml"), + `apiVersion: mba.ai/v1alpha1 +kind: ModelBehavioralAdapter +metadata: + id: nomic-embed-text-v1.5 + name: nomic-embed-text-v1.5 +identity: + model: + name: nomic-embed-text-v1.5 + file: "./m.gguf" +bindings: {} +`, + ); + writeFileSync(join(modelDir, "m.gguf"), "gguf"); +} + +function writeOtherCardFixture(dir: string): void { + const modelDir = join(dir, "other", "other-coder"); + mkdirSync(modelDir, { recursive: true }); + writeFileSync( + join(modelDir, "other-coder.yaml"), + `apiVersion: mba.ai/v1alpha1 +kind: ModelBehavioralAdapter +metadata: + id: other-coder + name: other-coder +identity: + model: + name: other-coder + file: "./m.gguf" +bindings: + instructions: "./instructions.md" +`, + ); + writeFileSync(join(modelDir, "instructions.md"), "# other card\n"); + writeFileSync(join(modelDir, "m.gguf"), "gguf"); +} + describe("POST /connect", () => { let adapterDir: string; let project: string; @@ -83,6 +125,7 @@ describe("POST /connect", () => { expect(st.pairing.sessions[0]).toMatchObject({ modelId: "qwen3-coder-30b", harness: "cursor", + card: true, }); expect(st.pairing.sessions[0]).not.toHaveProperty("token"); expect(st.pairing.sessions[0]).not.toHaveProperty("tokenHash"); @@ -132,4 +175,90 @@ describe("POST /connect", () => { }); expect(open.status).toBe(503); }); + + it("keeps a playbook when a second model on the same slot has no card", async () => { + const first = await app.request("/connect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "qwen3-coder-30b", + projectRoot: project, + harness: "cursor", + }), + }); + expect(first.status).toBe(200); + expect(readFileSync(join(project, ".cursor/rules/mba.mdc"), "utf8")).toContain("# card"); + + writeEmbedFixture(adapterDir); + + const second = await app.request("/connect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "nomic-embed-text-v1.5", + projectRoot: project, + harness: "cursor", + }), + }); + expect(second.status).toBe(200); + const body = (await second.json()) as { stage: { action: string } }; + expect(body.stage.action).toBe("skipped"); + expect(readFileSync(join(project, ".cursor/rules/mba.mdc"), "utf8")).toContain("# card"); + expect(readSessions(paths.sessionsPath)).toHaveLength(2); + + const rev = await app.request("/connect/revoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "nomic-embed-text-v1.5" }), + }); + expect(rev.status).toBe(200); + expect(readFileSync(join(project, ".cursor/rules/mba.mdc"), "utf8")).toContain("# card"); + expect(readSessions(paths.sessionsPath)).toHaveLength(1); + }); + + it("lets the last model with a card own the harness envelope", async () => { + await app.request("/connect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "qwen3-coder-30b", + projectRoot: project, + harness: "cursor", + }), + }); + writeOtherCardFixture(adapterDir); + const second = await app.request("/connect", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "other-coder", + projectRoot: project, + harness: "cursor", + }), + }); + expect(second.status).toBe(200); + const body = (await second.json()) as { + stage: { action: string; owner?: string; replaced?: string }; + }; + expect(body.stage).toMatchObject({ + action: "wrote", + owner: "other-coder", + replaced: "qwen3-coder-30b", + }); + const disk = readFileSync(join(project, ".cursor/rules/mba.mdc"), "utf8"); + expect(disk).toContain("# other card"); + expect(disk).not.toContain("# card\n"); + expect(disk).toContain(""); + + const status = await app.request("/status"); + const st = (await status.json()) as { + pairing: { sessions: Array<{ modelId: string; card: boolean }> }; + }; + expect(st.pairing.sessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ modelId: "qwen3-coder-30b", card: false }), + expect.objectContaining({ modelId: "other-coder", card: true }), + ]), + ); + }); }); diff --git a/packages/core/src/service/server-models.test.ts b/packages/core/src/service/server-models.test.ts index a30f24f..588dead 100644 --- a/packages/core/src/service/server-models.test.ts +++ b/packages/core/src/service/server-models.test.ts @@ -1,10 +1,11 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it, expect, beforeEach, vi } from "vitest"; import { createMbaServiceApp } from "./server.js"; import { defaultStorePaths } from "./config-store.js"; import { writeRegistry } from "./upstream-registry.js"; +import { hashToken, writeSessions } from "./sessions.js"; function writeAdapter( dir: string, @@ -183,6 +184,70 @@ describe("mba service model plane (ADR-0093 Phase 1)", () => { expect(res.status).toBe(400); }); + it("POST /models/ensure restages the paired envelope on the same trigger", async () => { + const mbaDir = mkdtempSync(join(tmpdir(), "mba-ensure-card-")); + const nestedAdapters = join(mbaDir, "adapters"); + const modelDir = join(nestedAdapters, "qwen", "qwen3-coder", "qwen3-coder-30b"); + mkdirSync(modelDir, { recursive: true }); + writeFileSync( + join(modelDir, "qwen3-coder-30b.yaml"), + `apiVersion: mba.ai/v1alpha1 +kind: ModelBehavioralAdapter +metadata: + id: qwen3-coder-30b + name: qwen3-coder-30b +identity: + model: + name: qwen3-coder-30b + file: "./m.gguf" +bindings: + instructions: "./instructions.md" +`, + ); + writeFileSync(join(modelDir, "instructions.md"), "# qwen card\n"); + writeFileSync(join(modelDir, "m.gguf"), "gguf"); + const project = join(mbaDir, "project"); + mkdirSync(project); + writeSessions(paths.sessionsPath, [ + { + id: "s1", + modelId: "qwen3-coder-30b", + harness: "cursor", + projectRoot: project, + tokenHash: hashToken("mba.test"), + createdAt: "2026-09-09T00:00:00.000Z", + }, + ]); + const app = createMbaServiceApp({ + paths, + adapterDir: nestedAdapters, + upstreamUrl: "http://127.0.0.1:8080", + switchEnabled: true, + switchExecutor: vi.fn(), + fetch: modelsFetch([join(modelDir, "m.gguf")]), + }); + const res = await app.request("/models/ensure", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "qwen3-coder-30b" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + status: string; + id: string; + stage: Array<{ action: string; owner?: string; envelope?: string }>; + }; + expect(body.status).toBe("loaded"); + expect(body.stage).toEqual([ + expect.objectContaining({ + action: "wrote", + owner: "qwen3-coder-30b", + envelope: ".cursor/rules/mba.mdc", + }), + ]); + expect(readFileSync(join(project, ".cursor/rules/mba.mdc"), "utf8")).toContain("# qwen card"); + }); + describe("probe target resolution (ADR-0097 Phase 1)", () => { const qwenRel = "qwen/qwen3-coder/qwen3-coder-30b/qwen3-coder-30b.yaml"; const qwenFile = (dir: string) => diff --git a/packages/core/src/service/server.ts b/packages/core/src/service/server.ts index 9b24621..9dfe0f4 100644 --- a/packages/core/src/service/server.ts +++ b/packages/core/src/service/server.ts @@ -64,7 +64,7 @@ import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import { serve } from "@hono/node-server"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { defaultStorePaths, @@ -91,7 +91,8 @@ import { } from "./model-switch.js"; import { readModelDials, setModelDial, type ModelDialFile } from "./model-config.js"; import { compactHarnessKey } from "../mba/envelope.js"; -import { stageModelCard } from "./stage-model-card.js"; +import { readEnvelopeOwner } from "../mba/stage-instructions.js"; +import { stageModelCard, restageSlotsAfterRevoke, restagePairedSlotsForModel } from "./stage-model-card.js"; import { defaultIdeForHarness } from "./env-context.js"; import { operatorEnvelopeBindings, readOperatorClients } from "./operator-clients.js"; import { @@ -102,6 +103,7 @@ import { publicSessions, readSessions, revokeSessions, + sessionsSharingSlot, upsertSession, writeSessions, } from "./sessions.js"; @@ -296,13 +298,22 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { app.get("/status", (c) => { const cfg = readGlobalConfig(paths); const sessions = readSessions(paths.sessionsPath); + const extras = operatorEnvelopeBindings(paths.clientsPath); + const ownerBySlot = new Map(); + const sessionsOut = publicSessions(sessions).map((s) => { + const key = `${s.harness}\0${resolve(s.projectRoot)}`; + if (!ownerBySlot.has(key)) { + ownerBySlot.set(key, readEnvelopeOwner(s.projectRoot, s.harness, extras, s.ide)); + } + return { ...s, card: ownerBySlot.get(key) === s.modelId }; + }); return c.json({ version: cfg.version, uptimeMs: Date.now() - startedAt, pairing: { active: pairingActive(sessions), count: sessions.length, - sessions: publicSessions(sessions), + sessions: sessionsOut, }, paths: { baseDir: paths.baseDir, @@ -366,7 +377,13 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { if (result.status === "failed") { return c.json(result, 500); } - return c.json(result); + const stage = restagePairedSlotsForModel({ + adapterDir: opts.adapterDir ?? "", + modelId: result.id, + sessions: readSessions(paths.sessionsPath), + envelopes: operatorEnvelopeBindings(paths.clientsPath), + }); + return c.json(stage.length > 0 ? { ...result, stage } : result); }); app.post("/models/pull", async (c) => { @@ -525,6 +542,13 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { ); } const envelopes = operatorEnvelopeBindings(paths.clientsPath); + const slotPeers = sessionsSharingSlot( + readSessions(paths.sessionsPath), + input.harness, + input.projectRoot, + ) + .map((s) => s.modelId) + .filter((id) => id !== input.id); const result = stageModelCard({ adapterDir: opts.adapterDir ?? "", modelId: input.id, @@ -532,6 +556,7 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { harness: input.harness, ide: typeof input.ide === "string" && input.ide.length > 0 ? input.ide : undefined, envelopes, + slotPeers, }); if (!result.ok) { const status = @@ -593,6 +618,14 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { )?.ide ?? defaultIdeForHarness(harness); const envelopes = operatorEnvelopeBindings(paths.clientsPath); + const previousOwner = readEnvelopeOwner(input.projectRoot, harness, envelopes, ide); + const slotPeers = sessionsSharingSlot( + readSessions(paths.sessionsPath), + harness, + input.projectRoot, + ) + .map((s) => s.modelId) + .filter((id) => id !== input.id); const staged = stageModelCard({ adapterDir: opts.adapterDir ?? "", modelId: input.id, @@ -600,6 +633,7 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { harness, ide, envelopes, + slotPeers, }); if (!staged.ok && staged.code !== "conflict") { const status = staged.code === "unknown-model" ? 404 : 400; @@ -628,6 +662,13 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { reason: staged.reason, envelope: staged.envelope, dest: staged.dest, + owner: readEnvelopeOwner(input.projectRoot, harness, envelopes, ide) ?? + (staged.action === "wrote" ? input.id : previousOwner), + ...(previousOwner && + previousOwner !== input.id && + staged.action === "wrote" + ? { replaced: previousOwner } + : {}), } : { action: "conflict", error: staged.error, code: staged.code }, }); @@ -655,12 +696,20 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { if (input.projectRoot !== undefined && typeof input.projectRoot !== "string") { return c.json({ error: "body.projectRoot must be a string when set" }, 400); } - const next = revokeSessions(readSessions(paths.sessionsPath), { + const before = readSessions(paths.sessionsPath); + const next = revokeSessions(before, { modelId: typeof input.id === "string" ? input.id : undefined, harness: typeof input.harness === "string" ? input.harness : undefined, projectRoot: typeof input.projectRoot === "string" ? input.projectRoot : undefined, }); + const revoked = before.filter((s) => !next.some((n) => n.id === s.id)); writeSessions(paths.sessionsPath, next); + restageSlotsAfterRevoke({ + adapterDir: opts.adapterDir ?? "", + envelopes: operatorEnvelopeBindings(paths.clientsPath), + remaining: next, + revoked, + }); return c.json({ pairing: { active: pairingActive(next), count: next.length } }); }); diff --git a/packages/core/src/service/sessions.test.ts b/packages/core/src/service/sessions.test.ts index 241d4cf..f161891 100644 --- a/packages/core/src/service/sessions.test.ts +++ b/packages/core/src/service/sessions.test.ts @@ -7,10 +7,12 @@ import { bearerToken, hashToken, mintToken, + newestSession, pairingActive, publicSessions, readSessions, revokeSessions, + sessionsSharingSlot, upsertSession, writeSessions, type ClientSession, @@ -117,6 +119,25 @@ describe("sessions", () => { expect(revokeSessions(rows, {})).toEqual([]); }); + it("groups sessions that share a harness and project", () => { + const a = session({ modelId: "deepseek_test", token: "t1", id: "1" }); + const b = session({ + modelId: "nomic-embed-text-v1.5", + token: "t2", + id: "2", + createdAt: "2026-09-09T02:00:00.000Z", + }); + const other = session({ + modelId: "nomic-embed-text-v1.5", + token: "t3", + id: "3", + projectRoot: "/tmp/other", + }); + const onSlot = sessionsSharingSlot([a, b, other], "cursor", "/tmp/proj"); + expect(onSlot.map((s) => s.modelId)).toEqual(["deepseek_test", "nomic-embed-text-v1.5"]); + expect(newestSession(onSlot)?.modelId).toBe("nomic-embed-text-v1.5"); + }); + it("parses a Bearer token", () => { expect(bearerToken("Bearer mba.abc")).toBe("mba.abc"); expect(bearerToken("bearer mba.abc")).toBe("mba.abc"); diff --git a/packages/core/src/service/sessions.ts b/packages/core/src/service/sessions.ts index 5c9c0ce..bc7edf8 100644 --- a/packages/core/src/service/sessions.ts +++ b/packages/core/src/service/sessions.ts @@ -4,8 +4,10 @@ * Empty file = door open (today's behavior). One or more sessions = the * proxy requires a Bearer token. MBA is the bouncer; it is not the client. * - * The plaintext token is minted once, returned on POST /connect, and never - * written to disk. sessions.json keeps a SHA-256 hex digest (`tokenHash`). + * Pairing is many keys: two models may share a harness + project (chat + + * embed). The staged envelope is one playbook for that slot. Last connect + * that has a card owns the file. A connect with no card must not clear a + * sibling's playbook. */ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; @@ -202,6 +204,25 @@ export function upsertSession( return out; } +/** Sessions that share one envelope slot (same harness + project). */ +export function sessionsSharingSlot( + sessions: readonly ClientSession[], + harness: string, + projectRoot: string, +): ClientSession[] { + const root = resolve(projectRoot); + return sessions.filter( + (s) => s.harness === harness && resolve(s.projectRoot) === root, + ); +} + +export function newestSession( + sessions: readonly ClientSession[], +): ClientSession | undefined { + if (sessions.length === 0) return undefined; + return [...sessions].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))[0]; +} + export function revokeSessions( sessions: readonly ClientSession[], opts: { readonly modelId?: string; readonly harness?: string; readonly projectRoot?: string } = {}, diff --git a/packages/core/src/service/stage-model-card.ts b/packages/core/src/service/stage-model-card.ts index d44ed19..bf6e8cd 100644 --- a/packages/core/src/service/stage-model-card.ts +++ b/packages/core/src/service/stage-model-card.ts @@ -7,13 +7,18 @@ */ import { readFileSync } from "node:fs"; -import { dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import YAML from "yaml"; import { resolveMbaConfig } from "../mba/resolver.js"; import type { EnvelopeBinding } from "../mba/envelope.js"; -import { stageInstructions, type StageInstructionsResult } from "../mba/stage-instructions.js"; +import { stageInstructions, readEnvelopeOwner, type StageInstructionsResult } from "../mba/stage-instructions.js"; import { defaultIdeForHarness, DEFAULT_RESOLVE_ENV } from "./env-context.js"; import { readModelCatalog } from "./model-catalog.js"; +import { + newestSession, + sessionsSharingSlot, + type ClientSession, +} from "./sessions.js"; export interface StageModelCardInput { readonly adapterDir: string; @@ -22,6 +27,8 @@ export interface StageModelCardInput { readonly harness: string; readonly ide?: string; readonly envelopes?: readonly EnvelopeBinding[]; + /** Other model ids already paired on this harness + project. */ + readonly slotPeers?: readonly string[]; } export type StageModelCardResult = @@ -63,7 +70,103 @@ export function stageModelCard(input: StageModelCardInput): StageModelCardResult ide, envelopes: input.envelopes, sourceRoot: input.adapterDir, + modelId: input.modelId, + slotPeers: input.slotPeers, }); if (!staged.ok) return staged; return { ...staged, modelId: input.modelId }; } + +/** + * After a revoke, each vacated (harness, project) slot either restages the + * newest remaining peer's card or unstages if the slot is empty. + */ +export function restageSlotsAfterRevoke(input: { + readonly adapterDir: string; + readonly envelopes?: readonly EnvelopeBinding[]; + readonly remaining: readonly ClientSession[]; + readonly revoked: readonly ClientSession[]; +}): void { + const seen = new Set(); + for (const vacated of input.revoked) { + const key = `${vacated.harness}\0${resolve(vacated.projectRoot)}`; + if (seen.has(key)) continue; + seen.add(key); + const onSlot = sessionsSharingSlot(input.remaining, vacated.harness, vacated.projectRoot); + const newest = newestSession(onSlot); + if (!newest) { + stageInstructions({ + projectRoot: vacated.projectRoot, + harness: vacated.harness, + ide: vacated.ide, + envelopes: input.envelopes, + }); + continue; + } + stageModelCard({ + adapterDir: input.adapterDir, + modelId: newest.modelId, + projectRoot: newest.projectRoot, + harness: newest.harness, + ide: newest.ide, + envelopes: input.envelopes, + slotPeers: onSlot.filter((s) => s.modelId !== newest.modelId).map((s) => s.modelId), + }); + } +} + +export interface RestageSlotResult { + readonly harness: string; + readonly projectRoot: string; + readonly action: string; + readonly envelope?: string; + readonly owner?: string; + readonly replaced?: string; +} + +/** + * After a model switch, restage this model's card in every harness slot + * it is already paired to. Same trigger as ensure — not a second command. + */ +export function restagePairedSlotsForModel(input: { + readonly adapterDir: string; + readonly modelId: string; + readonly sessions: readonly ClientSession[]; + readonly envelopes?: readonly EnvelopeBinding[]; +}): RestageSlotResult[] { + const seen = new Set(); + const out: RestageSlotResult[] = []; + for (const row of input.sessions) { + if (row.modelId !== input.modelId) continue; + const key = `${row.harness}\0${resolve(row.projectRoot)}`; + if (seen.has(key)) continue; + seen.add(key); + const previous = readEnvelopeOwner(row.projectRoot, row.harness, input.envelopes, row.ide); + const peers = sessionsSharingSlot(input.sessions, row.harness, row.projectRoot) + .map((s) => s.modelId) + .filter((id) => id !== input.modelId); + const staged = stageModelCard({ + adapterDir: input.adapterDir, + modelId: input.modelId, + projectRoot: row.projectRoot, + harness: row.harness, + ide: row.ide, + envelopes: input.envelopes, + slotPeers: peers, + }); + const owner = + readEnvelopeOwner(row.projectRoot, row.harness, input.envelopes, row.ide) ?? + (staged.ok && staged.action === "wrote" ? input.modelId : previous); + out.push({ + harness: row.harness, + projectRoot: resolve(row.projectRoot), + action: staged.ok ? staged.action : staged.code, + envelope: staged.ok ? staged.envelope : undefined, + owner, + ...(previous && previous !== input.modelId && staged.ok && staged.action === "wrote" + ? { replaced: previous } + : {}), + }); + } + return out; +} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 98268f5..6cbf454 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -22,7 +22,7 @@ Service-backed (fail soft if the daemon is down): - `mba_set_rules` — enable/disable BCB rules and rule classes - `mba_list_models` — adapter tree plus live loaded state - `mba_set_model_config` — one dial on `server_setup` or `client` (never restarts) -- `mba_ensure_model` — ask the service to load a model. Off until the service is started with `MBA_MODEL_SWITCH=on` +- `mba_ensure_model` — load a model and restage its paired instructions card. Off until the service is started with `MBA_MODEL_SWITCH=on` ## Install diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 79c6c80..b7cd2ca 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -163,6 +163,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { description: "Ask the MBA service to make sure a specific model is the loaded one " + "(ADR-0093). Idempotent: if the model is already loaded this is a no-op. " + + "Also restages that model's instructions card in projects it is paired to. " + "Model switching is OFF by default — until the service is armed with " + "MBA_MODEL_SWITCH=on, this returns a 409 'disabled' error. Unknown model " + "ids return 404. This is the user-triggered switch; the proxy never " + diff --git a/packages/mcp-server/src/service-client.ts b/packages/mcp-server/src/service-client.ts index f4fa8b3..620aed9 100644 --- a/packages/mcp-server/src/service-client.ts +++ b/packages/mcp-server/src/service-client.ts @@ -65,9 +65,18 @@ export interface MbaModelsResult { readonly models: MbaModelEntry[]; } +export type MbaEnsureStage = { + readonly action: string; + readonly harness?: string; + readonly projectRoot?: string; + readonly envelope?: string; + readonly owner?: string; + readonly replaced?: string; +}; + export type MbaEnsureModelResult = - | { readonly status: "loaded"; readonly id: string } - | { readonly status: "switched"; readonly id: string }; + | { readonly status: "loaded"; readonly id: string; readonly stage?: readonly MbaEnsureStage[] } + | { readonly status: "switched"; readonly id: string; readonly stage?: readonly MbaEnsureStage[] }; export type MbaModelDialFile = "server_setup" | "client"; diff --git a/packages/mcp-server/src/tools/ensure-model.ts b/packages/mcp-server/src/tools/ensure-model.ts index 8d5811e..0891e16 100644 --- a/packages/mcp-server/src/tools/ensure-model.ts +++ b/packages/mcp-server/src/tools/ensure-model.ts @@ -7,7 +7,9 @@ * gates on DNA (Phase 3). * * The service is OFF by default: until `MBA_MODEL_SWITCH=on` is set on the - * service, this tool returns the 409 "disabled" message. + * service, this tool returns the 409 "disabled" message. A successful + * ensure also restages this model's instructions card in every project + * the model is already paired to. */ import { fetchEnsureModel,