From d89e8b45a2073a73131229ac062527c37e0174ac Mon Sep 17 00:00:00 2001 From: Michael Gasperini Date: Mon, 26 Oct 2026 15:26:13 +0100 Subject: [PATCH] feat(settings): sign out when the Muse Code route is deleted The Models page Delete only unsets the user-layer route. For a plugin-managed provider that left a green credential dot behind with no row, so the plugin now watches its settings section and treats that removal as a sign-out: it deletes the `llm-pi-ai/meta` OAuth record and clears the mirrored `MUSE_CODE_SUB_TOKEN` reference. The row is re-declared with a red dot on the next refresh or restart. Also records the measured cache window in the docs: same-key entries still hit after 12 minutes, a fresh key reports zero, and `prompt_cache_retention: 24h` made no difference in that window. --- CHANGELOG.md | 16 +++++++++ CITATION.cff | 2 +- README.md | 17 +++++---- lib/index.d.ts | 10 ++++++ lib/index.js | 83 ++++++++++++++++++++++++++++++++++++++++++-- package-lock.json | 4 +-- package.json | 2 +- src/index.ts | 72 ++++++++++++++++++++++++++++++++++++-- test/plugin.test.mjs | 56 +++++++++++++++++++++++++++--- 9 files changed, 243 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afb56dd..0c5f4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.3] - 2026-10-26 + +### Added + +- Measured the Meta prompt-cache window: same-key entries still hit after 12 minutes (2673 of + 2800 tokens), a fresh key reports 0, and `prompt_cache_retention: 24h` made no difference in + that window — so there is no retention knob worth exposing. + +### Changed + +- **Delete on the Muse Code row now signs out.** The Models page's Delete only unsets the + user-layer route; the plugin treats that removal as a sign-out and deletes the + `llm-pi-ai/meta` OAuth record together with the mirrored `MUSE_CODE_SUB_TOKEN` reference, + instead of leaving a green dot behind for a row that no longer exists. The row is re-declared + (red dot) on the next refresh or restart. + ## [0.2.2] - 2026-10-26 ### Added diff --git a/CITATION.cff b/CITATION.cff index 14a1d6a..1687d7c 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,7 +1,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below." title: "dsh-muse-code" -version: 0.2.2 +version: 0.2.3 date-released: "2026-10-26" url: "https://github.com/TheStreamCode/dsh-muse-code" repository-code: "https://github.com/TheStreamCode/dsh-muse-code" diff --git a/README.md b/README.md index fa28157..48f7937 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,9 @@ The Meta Model API caches the request prefix per `prompt_cache_key`, and a reque key gets no reuse at all. DSH's pi-ai adapter sends the session id as the key for every call, so turns in the same session reuse the prefix Meta already saw. Measured against the live API with an identical 1.6k-token prefix: same key → 1521 of 1600 tokens cached on the second call; -different key or no key → 0. +different key or no key → 0. After a 12-minute pause the same-key entries still reported 2673 of +2800 cached tokens, with or without `prompt_cache_retention`, and a fresh key still reported 0 — +so the default retention is enough for normal turn pacing and there is no retention knob to tune. The ratio you see in the usage panel therefore depends on how the session is used: @@ -234,12 +236,13 @@ Both are DSH's generic provider-row actions and a plugin cannot hide them. - **Edit** writes the route profile into your profile patch: display name, models, and the `apiKeyEnv` reference. The plugin restores the reference on the next boot and refills an emptied model list from the account catalog. -- **Delete** removes only that user-layer route: the provider leaves Settings → Models and the - model picker, and a hand-edited model list is lost; the credential record and its mirrored - reference stay. The plugin re-declares the route on the next boot and resyncs the catalog. To - actually remove Muse Code, uninstall the bundle and delete the credential record. - -The supported off switch is the plugin itself: +- **Delete** removes the user-layer route and the plugin treats that removal as a sign-out: it + deletes the `llm-pi-ai/meta` OAuth record and clears the mirrored `MUSE_CODE_SUB_TOKEN` + reference. The provider leaves Settings → Models and the picker, and a hand-edited model list + is lost; the plugin re-declares the row on its next refresh (and on restart) with a red dot, so + sign in again to keep using Muse Code. +- To remove Muse Code for good, uninstall the bundle and delete the credential record; the + supported off switch is the plugin itself: ```bash dsh plugin --profile desktop remove @thestreamcode/dsh-muse-code diff --git a/lib/index.d.ts b/lib/index.d.ts index 06b4b09..bb44fa0 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -99,6 +99,7 @@ type CredentialsFace = { } | undefined>; set?(reference: string, value: string): Promise; unset?(reference: string): Promise; + deleteRecord?(key: string): Promise; modifyRecord?(key: string, update: (current: CredentialRecord | undefined) => Promise): Promise; }; type WebRoute = { @@ -275,6 +276,15 @@ export declare function prettifyModelName(id: string): string; * @returns the chat model ids, or null on any failure. */ export declare function fetchLiveModelIds(accessKey: string, timeoutMs?: number): Promise; +/** + * Sign out of Muse Code: drop the OAuth record and the route's mirrored + * credential reference. The Models page's **Delete** action only unsets the + * user-layer route, so the plugin treats that removal as a sign-out rather + * than leaving credentials behind for a row that no longer exists. + * @param credentials - the credential seam. + * @param logger - optional plugin logger. + */ +export declare function logoutMuseCode(credentials: CredentialsFace, logger?: Logger): Promise; /** Injected side effects for the credential bridge, overridable in tests. */ export type CredentialSyncOptions = { logger?: Logger; diff --git a/lib/index.js b/lib/index.js index 510349f..db12c10 100644 --- a/lib/index.js +++ b/lib/index.js @@ -50,6 +50,8 @@ const KEY_LIFETIME_MS = 24 * 60 * 60 * 1000; const REFRESH_WINDOW_MS = 6 * 60 * 60 * 1000; /** How often the plugin re-checks the record while it runs. */ const REFRESH_CHECK_MS = 15 * 60 * 1000; +/** Settle time before treating a removed route as a user sign-out. */ +const ROUTE_DELETE_CHECK_MS = 400; /** Web route the Settings control calls to start the sign-in. */ export const LOGIN_ROUTE = "/muse-code/login"; /** Web route that reports whether the sign-in completed. */ @@ -146,20 +148,70 @@ export function apply(ctx) { const settings = child.settings; if (settings === undefined) return () => { }; + let sawRoute = false; + let checkTimer; + const routeIsDeclared = () => { + const row = settings.describe().find((candidate) => candidate.ns === SETTINGS_NS); + if (row === undefined) + return false; + const providers = row.value["providers"]; + return isRecord(providers) && isRecord(providers[PROVIDER]); + }; const refresh = () => { void (async () => { await ensureMuseRoute(settings, ctx.logger); await syncMuseModels(settings, ctx.logger, options); + if (routeIsDeclared()) + sawRoute = true; })().catch((error) => { warn(ctx.logger, "dsh-muse-code: could not refresh the Muse Code settings", error); }); }; + // The Models page Delete only unsets the user-layer route; for a + // plugin-managed provider that reads as a sign-out, so drop the OAuth + // record and its mirrored reference instead of leaving a green dot for a + // row that no longer exists. + const scheduleDeleteCheck = () => { + if (checkTimer !== undefined) + clearTimeout(checkTimer); + checkTimer = setTimeout(() => { + checkTimer = undefined; + if (routeIsDeclared()) { + sawRoute = true; + return; + } + if (!sawRoute) + return; + sawRoute = false; + const credentials = credentialsFace; + if (credentials === undefined) + return; + void logoutMuseCode(credentials, ctx.logger).catch((error) => { + warn(ctx.logger, "dsh-muse-code: could not sign out after the route was deleted", error); + }); + }, ROUTE_DELETE_CHECK_MS); + checkTimer.unref?.(); + }; refresh(); - const dispose = ctx.on?.("credentials/record-updated", (key) => { + const disposers = []; + const onRecord = ctx.on?.("credentials/record-updated", (key) => { if (key === AUTH_KEY) refresh(); }); - return dispose ?? (() => { }); + if (onRecord !== undefined) + disposers.push(onRecord); + const onDocument = ctx.on?.("settings/document-updated", (ns) => { + if (ns === SETTINGS_NS) + scheduleDeleteCheck(); + }); + if (onDocument !== undefined) + disposers.push(onDocument); + return () => { + if (checkTimer !== undefined) + clearTimeout(checkTimer); + for (const dispose of disposers) + dispose(); + }; }, "dsh-muse-code: settings"); }); } @@ -649,6 +701,33 @@ async function waitForCredentials(getter, attempts = 8, delayMs = 250) { } return undefined; } +/** + * Sign out of Muse Code: drop the OAuth record and the route's mirrored + * credential reference. The Models page's **Delete** action only unsets the + * user-layer route, so the plugin treats that removal as a sign-out rather + * than leaving credentials behind for a row that no longer exists. + * @param credentials - the credential seam. + * @param logger - optional plugin logger. + */ +export async function logoutMuseCode(credentials, logger) { + let failed = false; + try { + await credentials.deleteRecord?.(AUTH_KEY); + } + catch (error) { + failed = true; + warn(logger, "dsh-muse-code: could not delete the Muse Code record", error); + } + try { + await credentials.unset?.(CREDENTIAL_REFERENCE); + } + catch (error) { + failed = true; + warn(logger, "dsh-muse-code: could not clear the Muse Code credential reference", error); + } + if (!failed) + logger?.info?.("dsh-muse-code: deleted the route in Settings > Models; signed out"); +} /** * Keep the route's credential reference equal to the OAuth record's minted key * and re-mint that key before it expires. diff --git a/package-lock.json b/package-lock.json index ea0b5ed..506b4be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thestreamcode/dsh-muse-code", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thestreamcode/dsh-muse-code", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "devDependencies": { "@types/node": "^26.6.5", diff --git a/package.json b/package.json index c35aadf..7827d1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thestreamcode/dsh-muse-code", - "version": "0.2.2", + "version": "0.2.3", "description": "Unofficial DeepSeek Harness integration for Meta's Muse Code subscription: declares the shipped Meta provider route and adds its sign-in command. Use at your own risk.", "license": "MIT", "author": { diff --git a/src/index.ts b/src/index.ts index fec778c..2ce48a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,8 @@ const KEY_LIFETIME_MS = 24 * 60 * 60 * 1000 const REFRESH_WINDOW_MS = 6 * 60 * 60 * 1000 /** How often the plugin re-checks the record while it runs. */ const REFRESH_CHECK_MS = 15 * 60 * 1000 +/** Settle time before treating a removed route as a user sign-out. */ +const ROUTE_DELETE_CHECK_MS = 400 /** Web route the Settings control calls to start the sign-in. */ export const LOGIN_ROUTE = "/muse-code/login" /** Web route that reports whether the sign-in completed. */ @@ -160,6 +162,7 @@ type CredentialsFace = { resolve?(reference: string): Promise<{ value?: string; source?: string } | undefined> set?(reference: string, value: string): Promise unset?(reference: string): Promise + deleteRecord?(key: string): Promise modifyRecord?( key: string, update: (current: CredentialRecord | undefined) => Promise, @@ -305,19 +308,59 @@ export function apply(ctx: HostContext): void { child.effect(() => { const settings = child.settings if (settings === undefined) return () => {} + let sawRoute = false + let checkTimer: ReturnType | undefined + const routeIsDeclared = (): boolean => { + const row = settings.describe().find((candidate) => candidate.ns === SETTINGS_NS) + if (row === undefined) return false + const providers = row.value["providers"] + return isRecord(providers) && isRecord(providers[PROVIDER]) + } const refresh = () => { void (async () => { await ensureMuseRoute(settings, ctx.logger) await syncMuseModels(settings, ctx.logger, options) + if (routeIsDeclared()) sawRoute = true })().catch((error: unknown) => { warn(ctx.logger, "dsh-muse-code: could not refresh the Muse Code settings", error) }) } + // The Models page Delete only unsets the user-layer route; for a + // plugin-managed provider that reads as a sign-out, so drop the OAuth + // record and its mirrored reference instead of leaving a green dot for a + // row that no longer exists. + const scheduleDeleteCheck = () => { + if (checkTimer !== undefined) clearTimeout(checkTimer) + checkTimer = setTimeout(() => { + checkTimer = undefined + if (routeIsDeclared()) { + sawRoute = true + return + } + if (!sawRoute) return + sawRoute = false + const credentials = credentialsFace + if (credentials === undefined) return + void logoutMuseCode(credentials, ctx.logger).catch((error: unknown) => { + warn(ctx.logger, "dsh-muse-code: could not sign out after the route was deleted", error) + }) + }, ROUTE_DELETE_CHECK_MS) + checkTimer.unref?.() + } refresh() - const dispose = ctx.on?.("credentials/record-updated", (key: string) => { + const disposers: Array<() => void> = [] + const onRecord = ctx.on?.("credentials/record-updated", (key: string) => { if (key === AUTH_KEY) refresh() }) - return dispose ?? (() => {}) + if (onRecord !== undefined) disposers.push(onRecord) + const onDocument = ctx.on?.("settings/document-updated", (ns: string) => { + if (ns === SETTINGS_NS) scheduleDeleteCheck() + }) + if (onDocument !== undefined) disposers.push(onDocument) + return () => { + if (checkTimer !== undefined) clearTimeout(checkTimer) + for (const dispose of disposers) dispose() + } }, "dsh-muse-code: settings") }) } @@ -855,6 +898,31 @@ async function waitForCredentials( return undefined } +/** + * Sign out of Muse Code: drop the OAuth record and the route's mirrored + * credential reference. The Models page's **Delete** action only unsets the + * user-layer route, so the plugin treats that removal as a sign-out rather + * than leaving credentials behind for a row that no longer exists. + * @param credentials - the credential seam. + * @param logger - optional plugin logger. + */ +export async function logoutMuseCode(credentials: CredentialsFace, logger?: Logger): Promise { + let failed = false + try { + await credentials.deleteRecord?.(AUTH_KEY) + } catch (error) { + failed = true + warn(logger, "dsh-muse-code: could not delete the Muse Code record", error) + } + try { + await credentials.unset?.(CREDENTIAL_REFERENCE) + } catch (error) { + failed = true + warn(logger, "dsh-muse-code: could not clear the Muse Code credential reference", error) + } + if (!failed) logger?.info?.("dsh-muse-code: deleted the route in Settings > Models; signed out") +} + /** Injected side effects for the credential bridge, overridable in tests. */ export type CredentialSyncOptions = { logger?: Logger diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index d8001f9..1421f38 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -13,6 +13,7 @@ import { isChatModelId, inject, LOGIN_ROUTE, + logoutMuseCode, mintInferenceKey, mirrorCredentialReference, museLoginCommand, @@ -56,27 +57,39 @@ function makeSettings(rows) { } function makeCredentials(configured = false, payload = undefined) { + const calls = { deleted: [], unset: [], set: [] } return { + calls, async describeRecord() { return { configured } }, async readRecord() { return configured ? { kind: "grant", payload } : undefined }, + async deleteRecord(key) { + calls.deleted.push(key) + }, + async unset(reference) { + calls.unset.push(reference) + }, + async set(reference, value) { + calls.set.push({ reference, value }) + }, } } function makeContext(options = {}) { const flow = "flow" in options ? options.flow : FLOW const credentials = options.credentials ?? makeCredentials(false) - const settingsRows = options.settingsRows ?? [ + const rows = options.settingsRows ?? [ { ns: "llm-pi-ai", revision: 3, value: { providers: { meta: { displayName: "Muse Code (Meta subscription)" } } } }, ] const commands = [] const children = [] const routes = [] const cancels = [] - const { settings, updates } = makeSettings(settingsRows) + const listeners = {} + const { settings, updates } = makeSettings(rows) const ctx = { credentials, authorization: { @@ -107,11 +120,12 @@ function makeContext(options = {}) { callback({ ...ctx, settings, credentials, webServer: { register(route) { routes.push(route); return () => {} } } }) return () => {} }, - on() { + on(event, listener) { + ;(listeners[event] ??= []).push(listener) return () => {} }, } - return { ctx, commands, children, routes, cancels, updates, settings, credentials } + return { ctx, commands, children, routes, cancels, updates, settings, credentials, rows, listeners } } function makeResponse() { @@ -553,3 +567,37 @@ test("mintInferenceKey rejects a failed mint", async () => { assert.equal(await mintInferenceKey("identity", fetchImpl), undefined) }) +test("logoutMuseCode drops the record and its mirrored reference", async () => { + const credentials = makeCredentials(true, { access: "key", refresh: "identity", expires: Date.now() + 1e6 }) + await logoutMuseCode(credentials) + assert.deepEqual(credentials.calls.deleted, ["llm-pi-ai/meta"]) + assert.deepEqual(credentials.calls.unset, [CREDENTIAL_REFERENCE]) +}) + +test("deleting the route in Settings signs out the subscription", async () => { + // No record: the boot refresh must not reach the network for this test. + const credentials = makeCredentials(false) + const context = makeContext({ credentials }) + apply(context.ctx) + await new Promise((resolve) => setTimeout(resolve, 150)) + context.rows.splice(0, context.rows.length, { ns: "llm-pi-ai", revision: 9, value: { providers: {} } }) + for (const listener of context.listeners["settings/document-updated"] ?? []) listener("llm-pi-ai") + await new Promise((resolve) => setTimeout(resolve, 600)) + assert.deepEqual(credentials.calls.deleted, ["llm-pi-ai/meta"]) + assert.ok(credentials.calls.unset.includes(CREDENTIAL_REFERENCE)) +}) + +test("a settings update without a previously declared route does not sign out", async () => { + const credentials = makeCredentials(false) + const context = makeContext({ + credentials, + settingsRows: [{ ns: "llm-pi-ai", revision: 1, value: { providers: {} } }], + }) + apply(context.ctx) + await new Promise((resolve) => setTimeout(resolve, 150)) + for (const listener of context.listeners["settings/document-updated"] ?? []) listener("llm-pi-ai") + await new Promise((resolve) => setTimeout(resolve, 600)) + assert.deepEqual(credentials.calls.deleted, []) +}) + +