From 7927f859d5e72ec0dab305769f3b602627c82018 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 21 Aug 2026 14:08:42 +0200 Subject: [PATCH 1/5] =?UTF-8?q?fix(devtools):=20toggle=20stuck=20ON=20?= =?UTF-8?q?=E2=80=94=20persist=20enabled=20state=20bidirectionally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The developer tools toggle was stuck in the ON position because: 1. The default for developer.enabled was true (always showed ON) 2. Toggling OFF never persisted false to the store 3. Kobalte's controlled Switch always reflected the stale true value 4. Every click triggered onChange(false) but the visual never changed Fix: default to false, and persist both true/false so the controlled component reflects reality. The toggle now works bidirectionally: ON triggers rebuild, OFF restores marketplace and reloads. --- .../settings-v2/developer-tools-controller.ts | 10 ++-------- .../overlay/packages/app/src/context/settings.tsx | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools-controller.ts b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools-controller.ts index 9813b8c4..12a6e867 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools-controller.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/settings-v2/developer-tools-controller.ts @@ -161,20 +161,14 @@ export function createDeveloperToolsController() { settings.developer.setAmicodePath(DEFAULT_AMICODE_PATH) } } - // Don't persist enabled=false — the marketplace build doesn't render - // Developer Tools at all, and persisting false prevents the dev build - // from showing it after a bash-script bootstrap without toggle interaction. + settings.developer.setEnabled(value) if (value) { - settings.developer.setEnabled(value) // Toggle ON: trigger a full rebuild (shows "Rebuilding..." status) rebuild("local") } else { - // Toggle OFF: show switching status, then the extension restores + reloads. - // Don't persist false — the reload brings up the marketplace build which - // doesn't have this section anyway. + // Toggle OFF: restore marketplace build and reload. setRebuildState("rebuilding") setRebuildError(undefined) - // Send enabled=false explicitly (can't rely on the signal since we didn't persist it) if (!inAmicode()) return setPending(true) setStatus(undefined) diff --git a/packages/app-bundle/overlay/packages/app/src/context/settings.tsx b/packages/app-bundle/overlay/packages/app/src/context/settings.tsx index 25fb8be8..299cbc27 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/settings.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/settings.tsx @@ -216,7 +216,7 @@ const defaultSettings: Settings = { configDir: "", }, developer: { - enabled: true, + enabled: false, opencodePath: "", amicodePath: "", }, From 9dc12237998b17a0dfc531ebed26dc05885f8449 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 21 Aug 2026 15:19:30 +0200 Subject: [PATCH 2/5] fix(devtools): replace stale backup restore with marketplace reinstall on toggle OFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old backup mechanism captured dist/ once and never refreshed it. When the extension was republished at the same version (e.g. 0.2.4 rebuilt with new features), the backup stayed stale and toggling OFF restored an ancient build missing webviews and the Developer Tools section entirely. Replace with uninstall+install from the marketplace on toggle OFF. This always gives a clean dist matching whatever the user is entitled to on the marketplace — never older, never stale. Onboarding state survives (stored at ~/.amico/, not in VS Code extension state). --- packages/extension/src/chat_bridge.ts | 35 ++++++++++++--------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index d1469af7..3d939176 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -258,34 +258,31 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean }; if (!enabled) { - // Toggle OFF: clear overrides, restore marketplace extension, and reload. + // Toggle OFF: clear overrides, reinstall the marketplace extension, and reload. void vscode.workspace.getConfiguration("amicode").update("opencodeBinary", "", vscode.ConfigurationTarget.Global); void vscode.workspace.getConfiguration("amicode").update("devAssetRoot", "", vscode.ConfigurationTarget.Global); - // Restore the marketplace extension dist if a backup exists + // Reinstall from the marketplace to restore the user's current release. + // The old backup approach was fragile (went stale on extension updates). + // Uninstall+install is the only reliable way to restore a clean dist — + // `--force` alone says "already installed" for the same version. const installedExt = vscode.extensions.getExtension("harmoniqs.amicode"); if (installedExt) { - const backupDist = path.join(installedExt.extensionPath, "dist.marketplace-backup"); - const installedDist = path.join(installedExt.extensionPath, "dist"); - if (fs.existsSync(backupDist)) { - try { - const backupFiles = fs.readdirSync(backupDist).filter(f => f.endsWith(".js") || f.endsWith(".js.map")); - for (const f of backupFiles) { - fs.copyFileSync(path.join(backupDist, f), path.join(installedDist, f)); - } - console.log("[amicode/bridge] restored marketplace dist from backup"); - } catch (restoreErr) { - console.warn("[amicode/bridge] marketplace dist restore failed:", restoreErr); + const { exec } = require("child_process") as typeof import("child_process"); + const extId = "harmoniqs.amicode"; + exec(`code --uninstall-extension ${extId} && code --install-extension ${extId}`, { timeout: 60_000 }, (err) => { + if (err) { + console.warn("[amicode/bridge] marketplace reinstall failed:", err.message); + } else { + console.log("[amicode/bridge] reinstalled marketplace extension"); } - } + void vscode.commands.executeCommand("workbench.action.reloadWindow"); + }); + } else { + void vscode.commands.executeCommand("workbench.action.reloadWindow"); } - // Don't restart server separately — reloading the window does it. - // Don't send reloadNeeded — the auto-reload handles it silently. io.postToWebview(reply); - setTimeout(() => { - void vscode.commands.executeCommand("workbench.action.reloadWindow"); - }, 300); return true; } From 9b7522d3518b6554c888695ce36505bf03408ecf Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 21 Aug 2026 15:31:55 +0200 Subject: [PATCH 3/5] chore: gitignore dist.marketplace-backup in extension package --- packages/extension/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/extension/.gitignore b/packages/extension/.gitignore index b3fe3184..ac059ade 100644 --- a/packages/extension/.gitignore +++ b/packages/extension/.gitignore @@ -2,3 +2,4 @@ dev/brand_harness/main.js dev/pulseplot_harness/main.js scripts/benchmark/out/ scripts/benchmark/traces/ +dist.marketplace-backup/ From e4c79b71deeb0663b63b4905e8917ab652eccb4c Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 21 Aug 2026 17:19:46 +0200 Subject: [PATCH 4/5] fix(devtools): guard onboarding from re-triggering on toggle-OFF reinstall The uninstall+install cycle clears VS Code globalState, but the onboarding routing predicate checks a filesystem event at ~/.amico/amicode/onboarding/events.jsonl. Before uninstalling, ensureOnboardingCompleted() idempotently writes the completion marker so onboarding never re-triggers on a devtools mode switch. A manual uninstall+install by the user (outside the toggle) does NOT write this marker, so onboarding correctly re-triggers for fresh installs. Includes tests for the new ensureOnboardingCompleted function. --- packages/extension/src/chat_bridge.ts | 8 ++++ .../extension/src/substrate/vault_store.ts | 11 ++++++ .../extension/test/onboarding_routing.test.ts | 37 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 3d939176..4d1dfb4d 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -262,6 +262,14 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean void vscode.workspace.getConfiguration("amicode").update("opencodeBinary", "", vscode.ConfigurationTarget.Global); void vscode.workspace.getConfiguration("amicode").update("devAssetRoot", "", vscode.ConfigurationTarget.Global); + // Guard: ensure onboarding won't re-trigger after the reinstall. + // The uninstall clears VS Code globalState; the filesystem event is what + // the routing predicate checks, so writing it here is sufficient. + try { + const { ensureOnboardingCompleted, onboardingDir } = require("./substrate/vault_store") as typeof import("./substrate/vault_store"); + ensureOnboardingCompleted(onboardingDir()); + } catch { /* non-critical — worst case onboarding re-shows */ } + // Reinstall from the marketplace to restore the user's current release. // The old backup approach was fragile (went stale on extension updates). // Uninstall+install is the only reliable way to restore a clean dist — diff --git a/packages/extension/src/substrate/vault_store.ts b/packages/extension/src/substrate/vault_store.ts index a9a02ef2..ef3b45bb 100644 --- a/packages/extension/src/substrate/vault_store.ts +++ b/packages/extension/src/substrate/vault_store.ts @@ -107,3 +107,14 @@ export function hasOnboardingCompleted(onboardingStreamDir: string): boolean { } return false; } + +/** Idempotently ensure the onboarding_completed event exists in the stream. + * Used by the devtools toggle-OFF to prevent onboarding from re-triggering + * after an extension reinstall. */ +export function ensureOnboardingCompleted(onboardingStreamDir: string): void { + if (hasOnboardingCompleted(onboardingStreamDir)) return; + fs.mkdirSync(onboardingStreamDir, { recursive: true }); + const eventsPath = path.join(onboardingStreamDir, "events.jsonl"); + const event = JSON.stringify({ entity: "onboarding_completed", ts: Date.now(), source: "devtools-restore" }); + fs.appendFileSync(eventsPath, event + "\n"); +} diff --git a/packages/extension/test/onboarding_routing.test.ts b/packages/extension/test/onboarding_routing.test.ts index 46b070e0..b49e9036 100644 --- a/packages/extension/test/onboarding_routing.test.ts +++ b/packages/extension/test/onboarding_routing.test.ts @@ -19,6 +19,11 @@ import { writeWelcomeShown, } from "../src/onboarding_routing"; +import { + hasOnboardingCompleted, + ensureOnboardingCompleted, +} from "../src/substrate/vault_store"; + // ─── AC10: Routing predicate (pure function, table-driven) ─────────────────── describe("resolveOnboardingAction — routing predicate (AC10)", () => { @@ -237,3 +242,35 @@ describe("welcome_shown flag semantics (AC6)", () => { expect(readWelcomeShown(file)).toBe(true); }); }); + +// ─── ensureOnboardingCompleted — devtools restore guard ────────────────────── + +describe("ensureOnboardingCompleted — devtools restore guard", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "onboard-guard-")); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("writes the onboarding_completed event when events.jsonl does not exist", () => { + ensureOnboardingCompleted(tmpDir); + expect(hasOnboardingCompleted(tmpDir)).toBe(true); + }); + + it("writes the event when events.jsonl exists but has no completion marker", () => { + fs.writeFileSync(path.join(tmpDir, "events.jsonl"), '{"entity":"profile","ts":1}\n'); + ensureOnboardingCompleted(tmpDir); + expect(hasOnboardingCompleted(tmpDir)).toBe(true); + }); + + it("is idempotent — does not duplicate the event if already present", () => { + ensureOnboardingCompleted(tmpDir); + ensureOnboardingCompleted(tmpDir); + const content = fs.readFileSync(path.join(tmpDir, "events.jsonl"), "utf8"); + const completionLines = content.split("\n").filter(l => l.includes("onboarding_completed")); + expect(completionLines).toHaveLength(1); + }); +}); From 9cd188a691706947293928e9faaae8bb45383c36 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 21 Aug 2026 18:17:38 +0200 Subject: [PATCH 5/5] fix(devtools): use temp marker instead of writing onboarding_completed The previous approach wrote an onboarding_completed event to the permanent event stream. This polluted the onboarding state: a manual uninstall+reinstall would never re-trigger onboarding because the filesystem marker survived. Replace with a temporary .devtools-restore marker file: - Toggle OFF writes it before uninstalling - On next activation, consumeDevtoolsRestoreMarker() reads + deletes it - If present: skip onboarding (this was a devtools mode switch) - If absent: onboarding triggers normally (genuine fresh install) Manual uninstall by the user does not write this marker, so onboarding correctly re-triggers. --- packages/extension/src/chat_bridge.ts | 11 +++--- packages/extension/src/opencode_config.ts | 2 ++ .../extension/src/substrate/vault_store.ts | 32 ++++++++++++----- .../extension/test/onboarding_routing.test.ts | 36 ++++++++++--------- 4 files changed, 50 insertions(+), 31 deletions(-) diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts index 4d1dfb4d..fe3b1756 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -262,12 +262,13 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean void vscode.workspace.getConfiguration("amicode").update("opencodeBinary", "", vscode.ConfigurationTarget.Global); void vscode.workspace.getConfiguration("amicode").update("devAssetRoot", "", vscode.ConfigurationTarget.Global); - // Guard: ensure onboarding won't re-trigger after the reinstall. - // The uninstall clears VS Code globalState; the filesystem event is what - // the routing predicate checks, so writing it here is sufficient. + // Guard: write a temporary marker so onboarding won't re-trigger after + // the reinstall. The marker is consumed (deleted) on next activation. + // A manual uninstall by the user does NOT write this marker, so + // onboarding correctly re-triggers for genuine fresh installs. try { - const { ensureOnboardingCompleted, onboardingDir } = require("./substrate/vault_store") as typeof import("./substrate/vault_store"); - ensureOnboardingCompleted(onboardingDir()); + const { writeDevtoolsRestoreMarker } = require("./substrate/vault_store") as typeof import("./substrate/vault_store"); + writeDevtoolsRestoreMarker(); } catch { /* non-critical — worst case onboarding re-shows */ } // Reinstall from the marketplace to restore the user's current release. diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 8a111445..eb6b973f 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -26,6 +26,7 @@ import { readMemoryIndexLines, hasOnboardingCompleted, onboardingDir, + consumeDevtoolsRestoreMarker, } from "./substrate/vault_store"; import { resolveMountStack, personalMount, type Mount, type MountStack } from "./substrate/mount_store"; import { @@ -614,6 +615,7 @@ export function prepareOpencodeProject(opts: OpencodeConfigOptions): OpencodePro vaultDir !== "" && readProfileMd(vaultDir) === "" && !hasOnboardingCompleted(onboardingDir()) && + !consumeDevtoolsRestoreMarker() && !profileHasIdentity(); // the welcome WIZARD already collected identity — don't re-interview if (shouldOnboard && overture && score0) { // Chained: ONE compiled section, ONE manifest (id `overture`, stages = diff --git a/packages/extension/src/substrate/vault_store.ts b/packages/extension/src/substrate/vault_store.ts index ef3b45bb..8e64aaab 100644 --- a/packages/extension/src/substrate/vault_store.ts +++ b/packages/extension/src/substrate/vault_store.ts @@ -108,13 +108,27 @@ export function hasOnboardingCompleted(onboardingStreamDir: string): boolean { return false; } -/** Idempotently ensure the onboarding_completed event exists in the stream. - * Used by the devtools toggle-OFF to prevent onboarding from re-triggering - * after an extension reinstall. */ -export function ensureOnboardingCompleted(onboardingStreamDir: string): void { - if (hasOnboardingCompleted(onboardingStreamDir)) return; - fs.mkdirSync(onboardingStreamDir, { recursive: true }); - const eventsPath = path.join(onboardingStreamDir, "events.jsonl"); - const event = JSON.stringify({ entity: "onboarding_completed", ts: Date.now(), source: "devtools-restore" }); - fs.appendFileSync(eventsPath, event + "\n"); +// ─── Devtools restore marker ───────────────────────────────────────────────── +// A temporary file that tells the next activation "this reinstall was triggered +// by the devtools toggle, not a manual user action — skip onboarding." The +// marker is consumed (deleted) on read so it only suppresses once. + +const DEVTOOLS_RESTORE_MARKER = ".devtools-restore"; + +/** Write the devtools restore marker. Called by toggle-OFF before uninstall. */ +export function writeDevtoolsRestoreMarker(opsDir: string = amicodeOpsDir()): void { + fs.mkdirSync(opsDir, { recursive: true }); + fs.writeFileSync(path.join(opsDir, DEVTOOLS_RESTORE_MARKER), String(Date.now())); +} + +/** Check and consume the devtools restore marker. Returns true if it existed + * (meaning this activation follows a toggle-OFF reinstall, not a fresh install). */ +export function consumeDevtoolsRestoreMarker(opsDir: string = amicodeOpsDir()): boolean { + const markerPath = path.join(opsDir, DEVTOOLS_RESTORE_MARKER); + try { + fs.unlinkSync(markerPath); + return true; + } catch { + return false; + } } diff --git a/packages/extension/test/onboarding_routing.test.ts b/packages/extension/test/onboarding_routing.test.ts index b49e9036..ed400d2f 100644 --- a/packages/extension/test/onboarding_routing.test.ts +++ b/packages/extension/test/onboarding_routing.test.ts @@ -21,7 +21,8 @@ import { import { hasOnboardingCompleted, - ensureOnboardingCompleted, + writeDevtoolsRestoreMarker, + consumeDevtoolsRestoreMarker, } from "../src/substrate/vault_store"; // ─── AC10: Routing predicate (pure function, table-driven) ─────────────────── @@ -243,34 +244,35 @@ describe("welcome_shown flag semantics (AC6)", () => { }); }); -// ─── ensureOnboardingCompleted — devtools restore guard ────────────────────── +// ─── devtools restore marker — toggle-OFF guard ───────────────────────────── -describe("ensureOnboardingCompleted — devtools restore guard", () => { +describe("devtools restore marker — toggle-OFF guard", () => { let tmpDir: string; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "onboard-guard-")); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devtools-marker-")); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it("writes the onboarding_completed event when events.jsonl does not exist", () => { - ensureOnboardingCompleted(tmpDir); - expect(hasOnboardingCompleted(tmpDir)).toBe(true); + it("consumeDevtoolsRestoreMarker returns false when no marker exists", () => { + expect(consumeDevtoolsRestoreMarker(tmpDir)).toBe(false); }); - it("writes the event when events.jsonl exists but has no completion marker", () => { - fs.writeFileSync(path.join(tmpDir, "events.jsonl"), '{"entity":"profile","ts":1}\n'); - ensureOnboardingCompleted(tmpDir); - expect(hasOnboardingCompleted(tmpDir)).toBe(true); + it("writeDevtoolsRestoreMarker + consumeDevtoolsRestoreMarker returns true and deletes the marker", () => { + writeDevtoolsRestoreMarker(tmpDir); + expect(consumeDevtoolsRestoreMarker(tmpDir)).toBe(true); + // Second consume returns false (marker was deleted) + expect(consumeDevtoolsRestoreMarker(tmpDir)).toBe(false); }); - it("is idempotent — does not duplicate the event if already present", () => { - ensureOnboardingCompleted(tmpDir); - ensureOnboardingCompleted(tmpDir); - const content = fs.readFileSync(path.join(tmpDir, "events.jsonl"), "utf8"); - const completionLines = content.split("\n").filter(l => l.includes("onboarding_completed")); - expect(completionLines).toHaveLength(1); + it("does not interfere with onboarding completion state", () => { + // Marker exists but onboarding events.jsonl does not + writeDevtoolsRestoreMarker(tmpDir); + expect(hasOnboardingCompleted(tmpDir)).toBe(false); + // Consuming the marker doesn't create onboarding_completed + consumeDevtoolsRestoreMarker(tmpDir); + expect(hasOnboardingCompleted(tmpDir)).toBe(false); }); });