Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ import { registerRunInspector, revealInspector } from "./run_inspector";
import { registerCatalogCard } from "./catalog_card_shell";
import { registerTrees } from "./trees";
import { StatusBarManager } from "./status_bar";
import { prepareOpencodeProject, resolveJuliaProject, buildOpencodeConfigContent, resolveModelPin } from "./opencode_config";
import {
prepareOpencodeProject,
resolveJuliaProject,
buildOpencodeConfigContent,
resolveModelPin,
} from "./opencode_config";
import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths";
import { resolveLabTomlPath, checkLabToml } from "./lab_config";
import { OpencodeEventClient } from "./sse_client";
import { RunsManager } from "./runs_manager";
import { stageDemoRun } from "./demo_replay";
import { writeStopFile, savePulseTo, catalogPulsesDir, stopPlan, forceStop, runLogMtime } from "./run_controls";
import { watchSolverMode, applyEntitlementForMode, readSolverModeState } from "./solver_mode";
import { amicodeOpsDir } from "./substrate/vault_store";
import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller";
import * as os from "node:os";
Expand Down Expand Up @@ -148,7 +154,14 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
};
const opencodeProject = prepareOpencodeProject({
agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"),
templateSrc: path.resolve(ctx.extensionPath, "templates", "solve_template.jl"),
// MODE-SELECTED vetted template: HP sessions get the Piccolissimo variant
// (same run-dir contract, spline solver layer). An AGENTS.md instruction
// can't beat the procedural template path — the file itself must swap.
templateSrc: path.resolve(
ctx.extensionPath,
"templates",
readSolverModeState().mode === "hp" ? "solve_template_hp.jl" : "solve_template.jl",
),
juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get<string>("juliaProject", "")),
skillRoots: cfgArr("skillRoots"),
platformSkills: cfgArr("platformSkills"),
Expand Down Expand Up @@ -231,6 +244,66 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
});
ctx.subscriptions.push({ dispose: () => void serverManager?.stop() });

// Solver-mode switcher (rchari/solver-wire): the app's toggle POSTs
// {status:"switching"}; we do the REAL switch — grant/revoke the issimo
// entitlement, re-prep the session project (skills/scores/allowlist follow
// the entitlement), restart the server, and only then report ready (the
// watcher writes it). The app's switch wizard polls through the gap.
ctx.subscriptions.push(
watchSolverMode(async (mode) => {
opencodeChannel.appendLine(`[solver] switching → ${mode}`);
applyEntitlementForMode(mode, path.join(os.homedir(), ".amico", "amicode"));
const project2 = prepareOpencodeProject({
agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"),
// Same mode-selection as boot; `mode` is the requested target of THIS
// switch (the state file still reads status:"switching" here).
templateSrc: path.resolve(
ctx.extensionPath,
"templates",
mode === "hp" ? "solve_template_hp.jl" : "solve_template.jl",
),
juliaProject: resolveJuliaProject(
vscode.workspace.getConfiguration("amicode").get<string>("juliaProject", ""),
),
skillRoots: cfgArr("skillRoots"),
platformSkills: cfgArr("platformSkills"),
skillLibraryRoots: cfgArr("skillLibraryRoots"),
vaultDir: vscode.workspace.getConfiguration("amicode").get<string>("vaultDir", "") || undefined,
});
await serverManager?.stop();
serverManager = new ServerManager({
binary: binary!,
cwd: project2.projectDir,
port: configuredPort > 0 ? configuredPort : undefined,
env: {
PATH: `${amicoRunBinDir ? amicoRunBinDir + ":" : ""}${process.env.PATH ?? ""}`,
OPENCODE_CONFIG_CONTENT: buildOpencodeConfigContent(
project2.agentsPath,
project2.templatePath,
runsRoot,
undefined,
undefined,
project2.skillPaths,
project2.skillsStageDir,
project2.vaultDir,
resolveModelPin(),
),
},
channel: opencodeChannel,
});
serverManager.onReady((url) => {
opencodeReadyUrl = url;
});
await serverManager.start();
opencodeChannel.appendLine(`[solver] switched → ${mode} (server back up)`);
void vscode.window.showInformationMessage(
mode === "hp"
? "Amicode: High-Performance Solver active (Piccolissimo unlocked)."
: "Amicode: back on the Piccolo stack.",
);
}),
);

// Distiller transport (spec-20260705-002847 §4): written once per
// activation so every spawner — the run-finished trigger here, the plugin's
// onboarding trigger, and the batch shell — produces identical headless
Expand Down
20 changes: 18 additions & 2 deletions packages/extension/src/opencode_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
stageOpencodeSkills,
type SkillIndexEntry,
} from "./scores/package_skills";
import { readSolverModeState } from "./solver_mode";
import {
resolvePersonalVault,
defaultVaultsRoot,
Expand Down Expand Up @@ -262,6 +263,21 @@ export function preferredModel(
return "opencode/deepseek-v4-flash-free";
}

/** Solver-mode section for AGENTS.md (rchari/solver-wire): in HP mode the
* agent authors with the Piccolissimo stack by default — the entitlement
* gate (issimo) has already admitted the packages by the time this renders. */
function solverModeSection(): string {
if (readSolverModeState().mode !== "hp") return "";
return (
"\n\n## Solver mode\n" +
"**HIGH-PERFORMANCE (Piccolissimo + Altissimo).** The user selected the HP solver. " +
"Author solves with the **Piccolissimo** stack by default (SplinePulseProblem, free-phase paths, " +
"`using Piccolissimo`) rather than plain Piccolo, falling back to Piccolo only when Piccolissimo " +
"cannot express the problem (say so when you do). The Altissimo GPU executor is provisioned but " +
"dispatches locally until the cloud endpoint ships — never claim cloud execution.\n"
);
}

/** The model pin to inject, or undefined. FALLBACK-only: a model in the user's
* global opencode config wins (our injected config would override it in the
* merge — the 1.17.3 preserve-user-model contract), so we pin nothing then. */
Expand Down Expand Up @@ -481,7 +497,7 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro
console.warn(`amicode: skill index failed (session continues without it): ${e}`);
}

fs.writeFileSync(agentsPath, finalContent, "utf8");
fs.writeFileSync(agentsPath, finalContent + solverModeSection(), "utf8");

// spec C: write the authoring.json seam amico-run reads (allowlist resolved
// from the same entitlements the score filter used + the bundled asset paths).
Expand Down Expand Up @@ -517,7 +533,7 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro
}
}

fs.writeFileSync(agentsPath, finalContent, "utf8");
fs.writeFileSync(agentsPath, finalContent + solverModeSection(), "utf8");

// The agent reads the template from its bundled absolute path (the session
// cwd is the workspace, not this temp dir — so no copy is made here).
Expand Down
96 changes: 96 additions & 0 deletions packages/extension/src/solver_mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { parse as parseToml } from "smol-toml";
import { amicodeOpsDir } from "./substrate/vault_store";

// ============================================================================
// Solver mode — the extension half of the solver-mode.json contract (the fork
// half is packages/opencode/src/server/amicode/solver-mode.ts; change both in
// one change-set). The app's toggle POSTs {mode, status:"switching"}; WE do
// the real switch — grant/revoke the `issimo` entitlement, re-prep the session
// config, restart the opencode server — and only then write status:"ready".
// The entitlement path is deliberate: it exercises the SAME machinery a paid
// subscription will use (packageAllowlist → agent + amico-run import scan).
// ============================================================================

export type SolverMode = "piccolo" | "hp";
export interface SolverModeState {
mode: SolverMode;
status: "ready" | "switching";
}

export function solverModeFile(opsDir: string = amicodeOpsDir()): string {
return path.join(opsDir, "solver-mode.json");
}

export function readSolverModeState(file: string = solverModeFile()): SolverModeState {
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { mode?: unknown; status?: unknown };
return {
mode: parsed.mode === "hp" ? "hp" : "piccolo",
status: parsed.status === "switching" ? "switching" : "ready",
};
} catch {
return { mode: "piccolo", status: "ready" };
}
}

export function writeSolverModeReady(mode: SolverMode, file: string = solverModeFile()): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ mode, status: "ready", switched_at: new Date().toISOString() }));
}

/** Grant (hp) or revoke (piccolo) the `issimo` entitlement, PRESERVING any
* other codes in entitlements.toml (a real license file may carry more). */
export function applyEntitlementForMode(mode: SolverMode, entitlementsDir: string): { codes: string[] } {
const file = path.join(entitlementsDir, "entitlements.toml");
let codes: string[] = [];
let expired: string[] = [];
try {
const parsed = parseToml(fs.readFileSync(file, "utf8")) as { codes?: string[]; expired?: string[] };
codes = Array.isArray(parsed.codes) ? parsed.codes.filter((c) => typeof c === "string") : [];
expired = Array.isArray(parsed.expired) ? parsed.expired.filter((c) => typeof c === "string") : [];
} catch {
/* absent/corrupt → start empty */
}
codes = codes.filter((c) => c !== "issimo");
if (mode === "hp") codes.push("issimo");
fs.mkdirSync(entitlementsDir, { recursive: true });
const lines = [`codes = [${codes.map((c) => JSON.stringify(c)).join(", ")}]`];
if (expired.length > 0) lines.push(`expired = [${expired.map((c) => JSON.stringify(c)).join(", ")}]`);
fs.writeFileSync(file, lines.join("\n") + "\n");
return { codes };
}

/** Poll-based watcher (fs.watch is unreliable for rewrite-in-place): fires
* onSwitch once per switching-request. The busy latch guarantees a slow
* switch is never re-entered by the next tick. */
export function watchSolverMode(
onSwitch: (mode: SolverMode) => Promise<void>,
file: string = solverModeFile(),
intervalMs = 1000,
): { dispose(): void } {
let busy = false;
const timer = setInterval(() => {
if (busy) return;
const state = readSolverModeState(file);
if (state.status !== "switching") return;
busy = true;
void onSwitch(state.mode)
.then(() => writeSolverModeReady(state.mode, file))
.catch(() => {
// Failed switch: report ready-at-PREVIOUS-mode is a lie we can't tell
// (we don't know it), so settle at the requested mode anyway — config
// may be partially applied; the toast in extension.ts says what failed.
writeSolverModeReady(state.mode, file);
})
.finally(() => {
busy = false;
});
}, intervalMs);
return {
dispose() {
clearInterval(timer);
},
};
}
Loading
Loading