Skip to content

Commit 1f54b0e

Browse files
committed
fix: audit findings + subagent prompt visibility + discord link
Three logical groups of fixes bundled together. ## A. Hide subagent prompts from TUI render (issue #50 part 2) Historian, dreamer, sidekick, compressor, and user-memory subagent calls were sending their full system prompt body as a regular `text` part on the user message. OpenCode TUI rendered this as a giant unreadable "user message" in the subagent pane (compaction prompts can be >90K chars). Fix: add `synthetic: true` to the prompt text part on all 7 subagent call sites. Verified via OpenCode source: - `message-v2.ts:74` (toModelMessagesEffect) only filters `!part.ignored` for user-role text, so synthetic still reaches the LLM. - `transcript.ts:85` filters `!part.synthetic` for TUI render, hiding the prompt body from the visible pane. - OpenCode's own `compaction.ts:547` and `prompt.ts:234-241` use the same pattern — the LLM must read these prompts to function. E2E proof: `historian-success.test.ts` still passes (29/29 e2e tests pass) — if synthetic filtered the prompt out, historian would receive empty input and produce no compartment; the test asserts a compartment is published. ## B. Audit findings — 8 confirmed real bugs + 2 false-positive comments Council audit of the post-v0.16.1 stack identified 10 issues. Manual re-verification against source confirmed 8 real bugs and 2 false positives. ### Real bugs 1. `setup-opencode.ts` cast `existing.plugin as string[]` and called `.startsWith()` on each entry, throwing TypeError on tuple-form `["@pkg/name", { ...options }]` entries. Now imports `isDevPathPluginEntry` and `matchesPluginEntry` from the shared adapter and operates on the raw array. Tuples preserved on write, dev-path entries detected (no double-add) but never replaced. 2. `doctor-opencode.ts` filtered the plugin array with `(p): p is string => typeof p === "string"` BEFORE writing it back, silently destroying every tuple entry on every doctor run. Same fix: operate on the raw array. When upgrading a pinned tuple entry to @latest, the options object is preserved by mutating only index [0] of the tuple. Same fix applied to TUI config plugin handling. Also fixes finding #9: the inline `isDevPathEntry` only matched `opencode-magic-context`, missing bare `magic-context` paths (post-rename) that the shared helper handles correctly. 3. `paths.ts::getOpenCodePluginCacheDir` had a Windows branch resolving to `%LOCALAPPDATA%/opencode/Cache/packages` while OpenCode (and our `data-path.ts` plugin helper) actually use `~/.cache/opencode/packages` on every platform via the `xdg-basedir` fallback. Removed the Windows-specific branch so `doctor --force` clears the right directory. This regressed the exact same fix already applied to `data-path.ts`. 4. `dashboard-release.yml` deploy-updater used `publish_dir: .` + `force_orphan: true` + `keep_files: false` together with an invalid `include_files` input that peaceiris/actions-gh-pages does not support. Result: every dashboard release published the entire repo checkout to gh-pages with a single orphan commit, wiping history. Fixed by staging just `latest.json` in `_updater_publish/`, pointing `publish_dir` at that dir, setting `keep_files: true` and `force_orphan: false`. 5. `dashboard-release.yml` used a hardcoded `sleep 30` before downloading `latest.json` from the draft release. First-time asset uploads can take longer than 30s, causing silent failures. Replaced with a 20×15s retry loop (5 minutes total). 8. `release.yml` `github-release` job depended only on test jobs, not on the three publish jobs, so a release page could be created on GitHub while one of the @cortexkit/* npm packages was missing. Now `needs: [..., publish-npm, publish-npm-pi, publish-npm-cli]`. 10. `doctor-pi.ts` had `add(results, "pass", "No known conflicting Pi extensions detected")` hardcoded with no detection logic. Replaced with real check for multiple magic-context entries in Pi `packages[]` (a real self-conflict that loads the plugin twice). ### False positives — documented inline 6. `setup-opencode.ts` Step 8 (OMO conflict prompt) is gated by `!hadExistingSetup`. Audit flagged this as "skipped for existing users", but existing users hit the same OMO conflict-fix logic via the `hadExistingSetup` branch at lines 231-257 (which calls `detectConflicts` + `fixConflicts` covering all OMO hooks). Added inline comment so future audits don't re-flag. 7. `migrate.ts` reports `messageCount: entries.length - 2`. Audit flagged this as off-by-N. The `- 2` correctly subtracts the structural `session` + `model_change` entries that lead every Pi JSONL file; the result counts boundary marker + all migrated message entries + (when present) compaction marker — exactly what "migrated entries" means in CLI output. Added inline comment. ## C. Discord URL update Switched README Discord badge + nav link from `discord.gg/F2uWxjGnU` to `discord.gg/DSa65w8wuf`. ## Verification - typecheck: 0 errors across plugin + pi-plugin + cli - lint: 0 errors (after biome autofix) - plugin tests: 1050 / 1050 pass - pi-plugin tests: 236 / 236 pass - cli tests: 58 / 58 pass - e2e tests: 29 / 29 pass (proves synthetic:true reaches LLM) - manual smoke tests: - tuple-form other plugin preserved when adding magic-context: ✓ - pinned tuple of magic-context upgraded to @latest, options preserved (`myOption` survives `0.16.0 → @latest`): ✓ - dev-path entry detected (hasDev=true), not double-added: ✓ - bare `magic-context` (post-rename) dev path detected: ✓ ## Reusable helpers `isDevPathPluginEntry` and `matchesPluginEntry` were promoted from private to exported in `packages/cli/src/adapters/opencode.ts` so both `setup-opencode.ts` and `doctor-opencode.ts` can use the same source of truth for plugin-entry classification — eliminates the drift that caused finding #9.
1 parent 66d29ba commit 1f54b0e

16 files changed

Lines changed: 246 additions & 102 deletions

File tree

.github/workflows/dashboard-release.yml

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -121,33 +121,55 @@ jobs:
121121
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
122122
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
123123

124-
# Deploy latest.json to gh-pages for the updater endpoint
124+
# Deploy latest.json to gh-pages for the updater endpoint.
125+
#
126+
# Tauri's updater endpoint lives at https://cortexkit.github.io/magic-context/latest.json.
127+
# That URL must keep serving the latest signed manifest after every dashboard
128+
# release. We deploy ONLY latest.json by staging it in an isolated directory
129+
# and pointing publish_dir at that directory — previous configs used
130+
# `publish_dir: .` together with the (non-existent) `include_files` input,
131+
# which silently published the entire repo checkout to gh-pages on every
132+
# release.
125133
deploy-updater:
126134
name: Deploy updater manifest
127135
needs: build
128136
runs-on: ubuntu-latest
129137
steps:
130138
- uses: actions/checkout@v4
131139

132-
- name: Wait for release assets
133-
run: sleep 30
134-
135-
- name: Download latest.json from release
140+
- name: Download latest.json from release (with retry)
136141
env:
137142
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
138143
run: |
139144
TAG="${{ github.ref_name }}"
140-
# Try draft release first, then published
141-
gh release download "$TAG" --pattern "latest.json" --output latest.json --clobber 2>/dev/null || \
142-
gh release download "$TAG" --pattern "latest.json" --output latest.json --clobber
143-
cat latest.json
145+
mkdir -p _updater_publish
146+
OUT=_updater_publish/latest.json
147+
# Retry up to 20×15s (5 minutes) — first-time uploads of large
148+
# platform binaries can take longer than a single fixed sleep,
149+
# and tauri-action publishes assets in parallel.
150+
for attempt in $(seq 1 20); do
151+
if gh release download "$TAG" --pattern "latest.json" --output "$OUT" --clobber 2>/dev/null; then
152+
echo "✓ downloaded latest.json on attempt $attempt"
153+
cat "$OUT"
154+
exit 0
155+
fi
156+
echo "attempt $attempt: latest.json not yet available, sleeping 15s…"
157+
sleep 15
158+
done
159+
echo "::error::latest.json never became available on release $TAG"
160+
exit 1
144161
145162
- name: Deploy to gh-pages
146163
uses: peaceiris/actions-gh-pages@v4
147164
with:
148165
github_token: ${{ secrets.GITHUB_TOKEN }}
149-
publish_dir: .
166+
# Publish ONLY the staging dir, which contains exactly one file:
167+
# latest.json. Anything else in the workspace stays out of gh-pages.
168+
publish_dir: ./_updater_publish
150169
publish_branch: gh-pages
151-
keep_files: false
152-
force_orphan: true
153-
include_files: latest.json
170+
# keep_files: true preserves any other files that already exist
171+
# on gh-pages so we don't wipe the branch on each release.
172+
keep_files: true
173+
# force_orphan would discard gh-pages history; we keep history so
174+
# the branch acts as a normal append-only artifact log.
175+
force_orphan: false

.github/workflows/release.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,11 @@ jobs:
198198
github-release:
199199
name: Create GitHub Release
200200
runs-on: ubuntu-latest
201-
needs: [test, test-pi, test-cli]
201+
# Wait for ALL three npm publishes to succeed before tagging the GitHub
202+
# release. Previously this depended only on the test jobs, so a publish
203+
# timeout or registry failure could leave a published release page on
204+
# GitHub while one of the @cortexkit/* packages was missing from npm.
205+
needs: [test, test-pi, test-cli, publish-npm, publish-npm-pi, publish-npm-cli]
202206
steps:
203207
- uses: actions/checkout@v4
204208

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<a href="https://www.npmjs.com/package/@cortexkit/magic-context"><img src="https://img.shields.io/npm/v/@cortexkit/magic-context?label=cli&color=orange&style=flat-square" alt="npm @cortexkit/magic-context"></a>
1010
<a href="https://www.npmjs.com/package/@cortexkit/opencode-magic-context"><img src="https://img.shields.io/npm/v/@cortexkit/opencode-magic-context?label=opencode&color=blue&style=flat-square" alt="npm @cortexkit/opencode-magic-context"></a>
1111
<a href="https://www.npmjs.com/package/@cortexkit/pi-magic-context"><img src="https://img.shields.io/npm/v/@cortexkit/pi-magic-context?label=pi&color=purple&style=flat-square" alt="npm @cortexkit/pi-magic-context"></a>
12-
<a href="https://discord.gg/F2uWxjGnU"><img src="https://img.shields.io/discord/1488852091056295957?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2" alt="Discord"></a>
12+
<a href="https://discord.gg/DSa65w8wuf"><img src="https://img.shields.io/discord/1488852091056295957?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2" alt="Discord"></a>
1313
<a href="https://github.com/cortexkit/magic-context/stargazers"><img src="https://img.shields.io/github/stars/cortexkit/magic-context?style=flat-square&color=yellow" alt="stars"></a>
1414
<a href="https://github.com/cortexkit/magic-context/commits"><img src="https://img.shields.io/github/last-commit/cortexkit/magic-context?style=flat-square&color=green" alt="last commit"></a>
1515
<a href="https://github.com/cortexkit/magic-context/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="MIT License"></a>
@@ -31,7 +31,7 @@
3131
<a href="#magic-context-app">🖥️ Desktop App</a> ·
3232
<a href="#commands">Commands</a> ·
3333
<a href="#configuration">Configuration</a> ·
34-
<a href="https://discord.gg/F2uWxjGnU">💬 Discord</a>
34+
<a href="https://discord.gg/DSa65w8wuf">💬 Discord</a>
3535
</p>
3636

3737
---

packages/cli/src/adapters/opencode.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -239,15 +239,6 @@ export class OpenCodeAdapter implements HarnessAdapter {
239239
}
240240
}
241241

242-
/**
243-
* Match a plugin array entry against our plugin name. Plugin entries can be:
244-
* - a string: "@cortexkit/opencode-magic-context@latest" or "@cortexkit/opencode-magic-context"
245-
* - a tuple: ["@cortexkit/opencode-magic-context@latest", { ... options }]
246-
* - a file URL: "file:///path/to/local/dev/checkout"
247-
*
248-
* For matching purposes we strip everything after `@` (after the first `@org/pkg`
249-
* segment) so versioned and unversioned entries are equivalent.
250-
*/
251242
/**
252243
* Match a plugin entry that resolves to a local dev checkout of magic-context:
253244
* - "file:///abs/path/.../opencode-magic-context"
@@ -259,8 +250,12 @@ export class OpenCodeAdapter implements HarnessAdapter {
259250
* Setup and doctor must detect these so they don't double-add @latest, but
260251
* must NEVER replace them — that would silently disable the developer's
261252
* local plugin instance.
253+
*
254+
* Exported because both `setup-opencode.ts` and `doctor-opencode.ts` need this
255+
* exact same logic; previous duplication caused drift (e.g. one path matching
256+
* `opencode-magic-context` only, the other also matching bare `magic-context`).
262257
*/
263-
function isDevPathPluginEntry(entry: unknown): boolean {
258+
export function isDevPathPluginEntry(entry: unknown): boolean {
264259
let candidate: string | null = null;
265260
if (typeof entry === "string") candidate = entry;
266261
else if (Array.isArray(entry) && typeof entry[0] === "string") candidate = entry[0];
@@ -271,7 +266,21 @@ function isDevPathPluginEntry(entry: unknown): boolean {
271266
return candidate.includes("opencode-magic-context") || candidate.includes("magic-context");
272267
}
273268

274-
function matchesPluginEntry(entry: unknown, pkgName: string): boolean {
269+
/**
270+
* Match a plugin array entry against a package name. Plugin entries can be:
271+
* - a string: "@cortexkit/opencode-magic-context@latest" or "@cortexkit/opencode-magic-context"
272+
* - a tuple: ["@cortexkit/opencode-magic-context@latest", { ... options }]
273+
* - a file URL: "file:///path/to/local/dev/checkout"
274+
*
275+
* For matching purposes we strip everything after `@` (after the first `@org/pkg`
276+
* segment) so versioned and unversioned entries are equivalent.
277+
*
278+
* Returns false for `file://` entries so dev paths are not classified as
279+
* "the published plugin". Use `isDevPathPluginEntry` for that detection.
280+
*
281+
* Exported for reuse across setup and doctor flows.
282+
*/
283+
export function matchesPluginEntry(entry: unknown, pkgName: string): boolean {
275284
let candidate: string | null = null;
276285
if (typeof entry === "string") candidate = entry;
277286
else if (Array.isArray(entry) && typeof entry[0] === "string") candidate = entry[0];

packages/cli/src/commands/doctor-opencode.ts

Lines changed: 86 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { Database } from "@magic-context/core/shared/sqlite";
1919
import { ensureTuiPluginEntry } from "@magic-context/core/shared/tui-config";
2020
import { parse, stringify } from "comment-json";
21+
import { isDevPathPluginEntry, matchesPluginEntry } from "../adapters/opencode";
2122
import { collectDiagnostics } from "../lib/diagnostics-opencode";
2223
import { bundleIssueReport } from "../lib/logs-opencode";
2324
import { isOpenCodeInstalled } from "../lib/opencode-helpers";
@@ -689,64 +690,78 @@ export async function runDoctor(
689690
try {
690691
const raw = readFileSync(paths.opencodeConfig, "utf-8");
691692
const config = parse(raw) as Record<string, unknown>;
692-
const plugins = Array.isArray(config?.plugin) ? config.plugin : [];
693-
const pluginList = (plugins as unknown[]).filter(
694-
(p): p is string => typeof p === "string",
695-
);
696-
// Detect any plugin entry that resolves to magic-context, including
697-
// local dev paths (file://..., /abs/path, ./relative). Dev paths
698-
// are recognized so we don't double-add an @latest entry on top,
699-
// but they are NOT replaced — replacing a developer worktree
700-
// path with `@latest` would make the dev plugin instance vanish
701-
// from OpenCode.
702-
const isDevPathEntry = (p: string): boolean =>
703-
p.startsWith("file://") || p.startsWith("/") || p.startsWith("./");
704-
const existingIdx = pluginList.findIndex(
705-
(p) =>
706-
p === PLUGIN_NAME ||
707-
p.startsWith(`${PLUGIN_NAME}@`) ||
708-
(isDevPathEntry(p) && p.includes("opencode-magic-context")),
693+
// Operate on the raw plugin array. Entries can be:
694+
// • a string "@cortexkit/opencode-magic-context@latest"
695+
// • a tuple ["@pkg/name@latest", { ...options }]
696+
// • a dev URL "file:///abs/path/.../packages/plugin"
697+
// We MUST preserve every entry shape on write — filtering out
698+
// tuples (or stripping options) would silently drop user config.
699+
// matchesPluginEntry / isDevPathPluginEntry are imported from
700+
// ../adapters/opencode and accept both strings and tuples.
701+
const rawPlugins: unknown[] = Array.isArray(config?.plugin) ? config.plugin : [];
702+
const existingIdx = rawPlugins.findIndex(
703+
(entry) => matchesPluginEntry(entry, PLUGIN_NAME) || isDevPathPluginEntry(entry),
709704
);
710705
const configName =
711706
paths.opencodeConfigFormat === "jsonc" ? "opencode.jsonc" : "opencode.json";
712-
if (existingIdx >= 0 && pluginList[existingIdx] === PLUGIN_ENTRY_WITH_VERSION) {
707+
708+
// Helper: extract the plain string (or first element of a tuple) so
709+
// we can compare against the desired @latest entry.
710+
const entryAsString = (entry: unknown): string | null => {
711+
if (typeof entry === "string") return entry;
712+
if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
713+
return null;
714+
};
715+
716+
if (existingIdx >= 0 && rawPlugins[existingIdx] === PLUGIN_ENTRY_WITH_VERSION) {
713717
pass(`Plugin registered in ${configName}`);
714718
} else if (existingIdx >= 0) {
715-
const oldEntry = pluginList[existingIdx];
719+
const oldEntry = rawPlugins[existingIdx];
720+
const oldEntryStr = entryAsString(oldEntry) ?? "";
716721

717722
// Dev-path entries (file://, absolute, relative) are detected
718723
// so we don't double-add @latest, but we MUST NOT replace them
719724
// — that would silently disable the developer's local plugin
720725
// checkout. Always log as-is and leave the entry alone, even
721726
// under --force.
722-
if (isDevPathEntry(oldEntry)) {
723-
pass(`Plugin registered in ${configName} (dev path: ${oldEntry})`);
727+
if (isDevPathPluginEntry(oldEntry)) {
728+
pass(`Plugin registered in ${configName} (dev path: ${oldEntryStr})`);
724729
} else {
725730
const isPinned =
726-
oldEntry !== PLUGIN_NAME &&
727-
oldEntry !== PLUGIN_ENTRY_WITH_VERSION &&
728-
/^@cortexkit\/opencode-magic-context@\d/.test(oldEntry);
731+
oldEntryStr !== PLUGIN_NAME &&
732+
oldEntryStr !== PLUGIN_ENTRY_WITH_VERSION &&
733+
/^@cortexkit\/opencode-magic-context@\d/.test(oldEntryStr);
729734

730735
if (isPinned && !options.force) {
731736
// Warn but don't change — user intentionally pinned
732737
warn(
733-
`Plugin pinned to ${oldEntry} in ${configName} — use 'doctor --force' to upgrade`,
738+
`Plugin pinned to ${oldEntryStr} in ${configName} — use 'doctor --force' to upgrade`,
734739
);
735740
} else {
736-
// Upgrade versionless entry to @latest, or --force upgrades pinned
737-
pluginList[existingIdx] = PLUGIN_ENTRY_WITH_VERSION;
738-
config.plugin = pluginList;
741+
// Upgrade versionless entry to @latest, or --force upgrades pinned.
742+
// If the existing entry is a tuple, preserve options by
743+
// updating only the package-name slot; otherwise replace
744+
// with the plain string entry.
745+
if (Array.isArray(oldEntry) && oldEntry.length >= 1) {
746+
const replacement = [...oldEntry];
747+
replacement[0] = PLUGIN_ENTRY_WITH_VERSION;
748+
rawPlugins[existingIdx] = replacement;
749+
} else {
750+
rawPlugins[existingIdx] = PLUGIN_ENTRY_WITH_VERSION;
751+
}
752+
config.plugin = rawPlugins;
739753
writeFileSync(paths.opencodeConfig, `${stringify(config, null, 2)}\n`);
740754
pass(
741-
`Upgraded plugin entry in ${configName}: ${oldEntry}${PLUGIN_ENTRY_WITH_VERSION}`,
755+
`Upgraded plugin entry in ${configName}: ${oldEntryStr}${PLUGIN_ENTRY_WITH_VERSION}`,
742756
);
743757
fixed++;
744758
}
745759
}
746760
} else {
747-
// Auto-add plugin entry — preserves comments
748-
pluginList.push(PLUGIN_ENTRY_WITH_VERSION);
749-
config.plugin = pluginList;
761+
// Auto-add plugin entry — preserves comments AND every existing
762+
// tuple/options entry the user already had.
763+
rawPlugins.push(PLUGIN_ENTRY_WITH_VERSION);
764+
config.plugin = rawPlugins;
750765
writeFileSync(paths.opencodeConfig, `${stringify(config, null, 2)}\n`);
751766
pass(`Added plugin to ${configName}`);
752767
fixed++;
@@ -784,32 +799,52 @@ export async function runDoctor(
784799
warn("Restart OpenCode to see the sidebar");
785800
fixed++;
786801
} else if (existsSync(paths.tuiConfig)) {
787-
// Check for pinned version in tui config
802+
// Check for pinned version in tui config. Same tuple/dev-path rules
803+
// as the main opencode config — preserve every entry shape on write.
788804
try {
789805
const tuiRaw = readFileSync(paths.tuiConfig, "utf-8");
790806
const tuiConfig = parse(tuiRaw) as Record<string, unknown>;
791-
const tuiPlugins = Array.isArray(tuiConfig?.plugin)
792-
? (tuiConfig.plugin as unknown[]).filter((p): p is string => typeof p === "string")
807+
const tuiRawPlugins: unknown[] = Array.isArray(tuiConfig?.plugin)
808+
? tuiConfig.plugin
793809
: [];
794-
const tuiIdx = tuiPlugins.findIndex(
795-
(p) => p === PLUGIN_NAME || p.startsWith(`${PLUGIN_NAME}@`),
810+
const tuiIdx = tuiRawPlugins.findIndex(
811+
(entry) => matchesPluginEntry(entry, PLUGIN_NAME) || isDevPathPluginEntry(entry),
796812
);
813+
const tuiEntryAsString = (entry: unknown): string => {
814+
if (typeof entry === "string") return entry;
815+
if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
816+
return "";
817+
};
797818
if (tuiIdx >= 0) {
798-
const tuiEntry = tuiPlugins[tuiIdx];
799-
const tuiPinned =
800-
tuiEntry !== PLUGIN_NAME &&
801-
tuiEntry !== PLUGIN_ENTRY_WITH_VERSION &&
802-
/^@cortexkit\/opencode-magic-context@\d/.test(tuiEntry);
803-
if (tuiPinned && !options.force) {
804-
warn(`TUI plugin pinned to ${tuiEntry} — use 'doctor --force' to upgrade`);
805-
} else if (tuiPinned && options.force) {
806-
tuiPlugins[tuiIdx] = PLUGIN_ENTRY_WITH_VERSION;
807-
tuiConfig.plugin = tuiPlugins;
808-
writeFileSync(paths.tuiConfig, `${stringify(tuiConfig, null, 2)}\n`);
809-
pass(`Upgraded TUI plugin: ${tuiEntry}${PLUGIN_ENTRY_WITH_VERSION}`);
810-
fixed++;
819+
const tuiEntry = tuiRawPlugins[tuiIdx];
820+
const tuiEntryStr = tuiEntryAsString(tuiEntry);
821+
if (isDevPathPluginEntry(tuiEntry)) {
822+
pass(`TUI sidebar plugin configured (dev path: ${tuiEntryStr})`);
811823
} else {
812-
pass("TUI sidebar plugin configured");
824+
const tuiPinned =
825+
tuiEntryStr !== PLUGIN_NAME &&
826+
tuiEntryStr !== PLUGIN_ENTRY_WITH_VERSION &&
827+
/^@cortexkit\/opencode-magic-context@\d/.test(tuiEntryStr);
828+
if (tuiPinned && !options.force) {
829+
warn(
830+
`TUI plugin pinned to ${tuiEntryStr} — use 'doctor --force' to upgrade`,
831+
);
832+
} else if (tuiPinned && options.force) {
833+
// Preserve tuple options when upgrading.
834+
if (Array.isArray(tuiEntry) && tuiEntry.length >= 1) {
835+
const replacement = [...tuiEntry];
836+
replacement[0] = PLUGIN_ENTRY_WITH_VERSION;
837+
tuiRawPlugins[tuiIdx] = replacement;
838+
} else {
839+
tuiRawPlugins[tuiIdx] = PLUGIN_ENTRY_WITH_VERSION;
840+
}
841+
tuiConfig.plugin = tuiRawPlugins;
842+
writeFileSync(paths.tuiConfig, `${stringify(tuiConfig, null, 2)}\n`);
843+
pass(`Upgraded TUI plugin: ${tuiEntryStr}${PLUGIN_ENTRY_WITH_VERSION}`);
844+
fixed++;
845+
} else {
846+
pass("TUI sidebar plugin configured");
847+
}
813848
}
814849
} else {
815850
pass("TUI sidebar plugin configured");

0 commit comments

Comments
 (0)