@cortexkit/opencode-magic-context 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/features/magic-context/dreamer/runner.d.ts +15 -0
  2. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  3. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  4. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  5. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  6. package/dist/features/magic-context/storage-meta-persisted.d.ts +14 -0
  7. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  8. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
  9. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  10. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  11. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  12. package/dist/features/magic-context/storage.d.ts +1 -1
  13. package/dist/features/magic-context/storage.d.ts.map +1 -1
  14. package/dist/features/magic-context/types.d.ts +1 -0
  15. package/dist/features/magic-context/types.d.ts.map +1 -1
  16. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
  17. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  18. package/dist/hooks/magic-context/command-handler.d.ts +2 -0
  19. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  20. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +1 -0
  21. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +1 -1
  22. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +7 -0
  23. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  24. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  25. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  26. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  27. package/dist/hooks/magic-context/compartment-runner-types.d.ts +2 -0
  28. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  29. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  30. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  31. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  32. package/dist/hooks/magic-context/todo-view.d.ts +102 -0
  33. package/dist/hooks/magic-context/todo-view.d.ts.map +1 -0
  34. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +1 -0
  35. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  36. package/dist/hooks/magic-context/transform-message-helpers.d.ts +22 -0
  37. package/dist/hooks/magic-context/transform-message-helpers.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/transform.d.ts +2 -0
  40. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  41. package/dist/index.js +626 -178
  42. package/dist/plugin/dream-timer.d.ts.map +1 -1
  43. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  44. package/dist/plugin/rpc-handlers.d.ts +2 -1
  45. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  46. package/dist/shared/index.d.ts +1 -0
  47. package/dist/shared/index.d.ts.map +1 -1
  48. package/dist/shared/model-suggestion-retry.d.ts +37 -0
  49. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  50. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  51. package/dist/shared/resolve-fallbacks.d.ts +32 -0
  52. package/dist/shared/resolve-fallbacks.d.ts.map +1 -0
  53. package/dist/shared/tag-transcript.d.ts.map +1 -1
  54. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/shared/index.ts +1 -0
  57. package/src/shared/model-suggestion-retry.test.ts +251 -0
  58. package/src/shared/model-suggestion-retry.ts +194 -6
  59. package/src/shared/models-dev-cache.ts +7 -7
  60. package/src/shared/resolve-fallbacks.test.ts +136 -0
  61. package/src/shared/resolve-fallbacks.ts +76 -0
  62. package/src/shared/tag-transcript.ts +3 -2
  63. package/src/tui/index.tsx +114 -18
@@ -1,6 +1,8 @@
1
1
  import type { createOpencodeClient } from "@opencode-ai/sdk";
2
2
 
3
+ import { detectOverflow } from "../features/magic-context/overflow-detection";
3
4
  import { log } from "./logger";
5
+ import { parseProviderModel } from "./resolve-fallbacks";
4
6
 
5
7
  type Client = ReturnType<typeof createOpencodeClient>;
6
8
 
@@ -20,6 +22,29 @@ export interface PromptRetryOptions {
20
22
  timeoutMs?: number;
21
23
  /** External abort signal — cancels the in-flight LLM prompt immediately when aborted */
22
24
  signal?: AbortSignal;
25
+ /**
26
+ * Ordered list of "provider/modelID" alternates to try if the primary call
27
+ * (and its single-suggestion retry) fails. Empty / undefined = no fallback
28
+ * iteration (legacy behavior).
29
+ *
30
+ * Fallback policy:
31
+ * - Each fallback gets the FULL `timeoutMs` budget (per-attempt, not total).
32
+ * - Suggestion-retry runs inside each attempt (so "did you mean X?" errors
33
+ * still self-heal at the primary AND at each fallback).
34
+ * - Iteration stops immediately on abort/timeout/context-overflow errors —
35
+ * fallbacks won't help and the caller's emergency-recovery path needs
36
+ * to handle these.
37
+ * - On all-failed, the LAST error is thrown (matches legacy behavior when
38
+ * `fallbackModels` is empty).
39
+ */
40
+ fallbackModels?: readonly string[];
41
+ /**
42
+ * Identifier for structured logging (e.g. "dreamer:consolidate",
43
+ * "historian", "compressor", "sidekick"). Helps correlate fallback
44
+ * attempts to a specific call site in `magic-context.log`. Defaults to
45
+ * "subagent" if not provided.
46
+ */
47
+ callContext?: string;
23
48
  }
24
49
 
25
50
  export interface ModelSuggestionInfo {
@@ -95,6 +120,15 @@ async function promptWithTimeout(
95
120
  timeoutMs: number,
96
121
  signal?: AbortSignal,
97
122
  ): Promise<void> {
123
+ // Bail immediately if the caller's signal is already aborted (e.g.
124
+ // lease loss before this attempt was scheduled). Per spec
125
+ // `addEventListener('abort', ...)` on an already-aborted signal fires
126
+ // synchronously in modern Node/Bun, but an explicit guard is clearer
127
+ // and avoids one wasted upstream `client.session.prompt` round-trip
128
+ // before `isNonRetryable` catches the cancellation at the chain loop.
129
+ if (signal?.aborted) {
130
+ throw new Error("prompt aborted by external signal");
131
+ }
98
132
  const controller = new AbortController();
99
133
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
100
134
 
@@ -121,22 +155,76 @@ async function promptWithTimeout(
121
155
  }
122
156
  }
123
157
 
124
- export async function promptSyncWithModelSuggestionRetry(
158
+ /**
159
+ * Returns true if the error indicates a NON-RETRYABLE condition where iterating
160
+ * to a fallback model would be pointless or harmful:
161
+ *
162
+ * - External abort (user cancellation, lease loss, etc.) — caller wants to
163
+ * stop, not retry.
164
+ * - Context overflow — same prompt will overflow on any reasonably-sized
165
+ * model. Caller has its own emergency-recovery path for this.
166
+ * - Timeout — same wall-clock budget on the same prompt is unlikely to
167
+ * succeed on another model. Caller decides whether to retry at a higher
168
+ * level (e.g. historian's MAX_HISTORIAN_RETRIES loop).
169
+ *
170
+ * Everything else (auth errors, ProviderModelNotFoundError without suggestion,
171
+ * rate limits, transient network failures, etc.) is considered retryable on a
172
+ * different model.
173
+ */
174
+ function isNonRetryable(error: unknown, externalSignal?: AbortSignal): boolean {
175
+ if (externalSignal?.aborted) return true;
176
+
177
+ if (error instanceof Error) {
178
+ if (error.name === "AbortError") return true;
179
+ // promptWithTimeout wraps both abort cases in plain `Error` with a
180
+ // recognizable message.
181
+ if (error.message === "prompt aborted by external signal") return true;
182
+ if (/^prompt timed out after \d+ms$/.test(error.message)) return true;
183
+ }
184
+
185
+ if (detectOverflow(error).isOverflow) return true;
186
+
187
+ return false;
188
+ }
189
+
190
+ function shortErr(error: unknown): string {
191
+ if (error instanceof Error) {
192
+ return error.name && error.name !== "Error"
193
+ ? `${error.name}: ${error.message}`
194
+ : error.message;
195
+ }
196
+ return extractMessage(error);
197
+ }
198
+
199
+ /**
200
+ * Try a single prompt attempt against the supplied body, with the existing
201
+ * single-suggestion retry layered inside (so "did you mean X?" still self-heals
202
+ * per attempt). Throws on failure; returns on success.
203
+ */
204
+ async function attemptOnce(
125
205
  client: Client,
126
206
  args: PromptArgs,
127
- options: PromptRetryOptions = {},
207
+ timeoutMs: number,
208
+ signal: AbortSignal | undefined,
209
+ callContext: string,
210
+ label: string,
128
211
  ): Promise<void> {
129
- const timeoutMs = options.timeoutMs ?? 300_000;
130
-
131
212
  try {
132
- await promptWithTimeout(client, args, timeoutMs, options.signal);
213
+ await promptWithTimeout(client, args, timeoutMs, signal);
214
+ return;
133
215
  } catch (error) {
216
+ // If non-retryable (abort, overflow, timeout), bubble up immediately.
217
+ // Don't even try suggestion retry — caller needs the original error.
218
+ if (isNonRetryable(error, signal)) throw error;
219
+
134
220
  const suggestion = parseModelSuggestion(error);
135
221
  if (!suggestion || !args.body.model) {
222
+ // No suggestion available — caller's fallback loop will decide
223
+ // whether to try the next chain entry.
136
224
  throw error;
137
225
  }
138
226
 
139
- log("[model-suggestion-retry] Model not found, retrying with suggestion", {
227
+ log(`[${callContext}] ${label}: model not found, retrying with suggestion`, {
140
228
  original: `${suggestion.providerID}/${suggestion.modelID}`,
141
229
  suggested: suggestion.suggestion,
142
230
  });
@@ -154,7 +242,107 @@ export async function promptSyncWithModelSuggestionRetry(
154
242
  },
155
243
  },
156
244
  timeoutMs,
245
+ signal,
246
+ );
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Run an OpenCode subagent prompt with model fallback support.
252
+ *
253
+ * Attempts the configured primary model first (whatever `args.body.model` or
254
+ * the registered agent default resolves to), then iterates through
255
+ * `options.fallbackModels` if provided. Each attempt internally retries once on
256
+ * the SDK's "model not found, did you mean X?" suggestion. Aborts, timeouts,
257
+ * and context-overflow errors short-circuit the fallback loop because retrying
258
+ * the same prompt against another model won't help.
259
+ *
260
+ * Behavior with `fallbackModels` empty/undefined is identical to the pre-v0.18
261
+ * single-suggestion retry — fully backward-compatible for callers that haven't
262
+ * been updated to thread a chain.
263
+ */
264
+ export async function promptSyncWithModelSuggestionRetry(
265
+ client: Client,
266
+ args: PromptArgs,
267
+ options: PromptRetryOptions = {},
268
+ ): Promise<void> {
269
+ const timeoutMs = options.timeoutMs ?? 300_000;
270
+ const callContext = options.callContext ?? "subagent";
271
+ const fallbacks = options.fallbackModels ?? [];
272
+
273
+ // Attempt 0 = whatever the agent or explicit body.model resolves to.
274
+ // Subsequent attempts override body.model with each fallback in order.
275
+ const explicitPrimaryLabel =
276
+ args.body.model?.providerID && args.body.model.modelID
277
+ ? `${args.body.model.providerID}/${args.body.model.modelID}`
278
+ : "primary";
279
+
280
+ let lastError: unknown = null;
281
+
282
+ try {
283
+ await attemptOnce(
284
+ client,
285
+ args,
286
+ timeoutMs,
157
287
  options.signal,
288
+ callContext,
289
+ explicitPrimaryLabel,
290
+ );
291
+ return;
292
+ } catch (error) {
293
+ lastError = error;
294
+ if (isNonRetryable(error, options.signal)) throw error;
295
+
296
+ if (fallbacks.length === 0) {
297
+ // No fallbacks configured — behave exactly like legacy: propagate
298
+ // the original error (which may already have had its suggestion
299
+ // retry attempted inside `attemptOnce`).
300
+ throw error;
301
+ }
302
+
303
+ log(
304
+ `[${callContext}] primary (${explicitPrimaryLabel}) failed: ${shortErr(error)}; trying ${fallbacks.length} fallback(s)`,
158
305
  );
159
306
  }
307
+
308
+ // Iterate fallbacks.
309
+ for (let i = 0; i < fallbacks.length; i += 1) {
310
+ const parsed = parseProviderModel(fallbacks[i]);
311
+ if (!parsed) {
312
+ log(`[${callContext}] skipping invalid fallback spec: ${fallbacks[i]}`);
313
+ continue;
314
+ }
315
+
316
+ const label = `${parsed.providerID}/${parsed.modelID}`;
317
+ const attemptArgs: PromptArgs = {
318
+ ...args,
319
+ body: { ...args.body, model: parsed },
320
+ };
321
+
322
+ try {
323
+ await attemptOnce(client, attemptArgs, timeoutMs, options.signal, callContext, label);
324
+ log(
325
+ `[${callContext}] fallback succeeded with ${label} (attempt ${i + 2}/${fallbacks.length + 1})`,
326
+ );
327
+ return;
328
+ } catch (error) {
329
+ lastError = error;
330
+ if (isNonRetryable(error, options.signal)) throw error;
331
+
332
+ const remaining = fallbacks.length - i - 1;
333
+ if (remaining > 0) {
334
+ log(
335
+ `[${callContext}] ${label} failed: ${shortErr(error)}; ${remaining} fallback(s) left`,
336
+ );
337
+ }
338
+ }
339
+ }
340
+
341
+ // All exhausted. Log the full chain and throw the last error so the
342
+ // caller's report (e.g. /ctx-dream tasks_json) still surfaces a real
343
+ // diagnostic.
344
+ log(
345
+ `[${callContext}] all models exhausted; tried: ${[explicitPrimaryLabel, ...fallbacks].join(", ")}; last error: ${shortErr(lastError)}`,
346
+ );
347
+ throw lastError ?? new Error("All fallback models failed");
160
348
  }
@@ -24,6 +24,7 @@ import { existsSync, readFileSync } from "node:fs";
24
24
  import { homedir, platform } from "node:os";
25
25
  import { join } from "node:path";
26
26
  import { getCacheDir } from "./data-path";
27
+ import { parseJsonc } from "./jsonc-parser";
27
28
  import { sessionLog } from "./logger";
28
29
 
29
30
  interface OpencodeClientLike {
@@ -195,19 +196,18 @@ function loadModelsDevMetadataFromFile(): Map<string, CachedModelMetadata> {
195
196
  try {
196
197
  const configPath = getOpencodeConfigPath();
197
198
  if (configPath && existsSync(configPath)) {
198
- let raw = readFileSync(configPath, "utf-8");
199
- // Strip JSONC single-line comments while preserving // inside strings.
200
- raw = raw.replace(/"(?:[^"\\]|\\.)*"|\/\/.*$/gm, (match) =>
201
- match.startsWith('"') ? match : "",
202
- );
203
- const config = JSON.parse(raw) as {
199
+ // Use the shared JSONC parser — handles `//` comments AND trailing commas.
200
+ // The previous custom regex stripped comments only; OpenCode's `opencode.jsonc`
201
+ // frequently contains trailing commas (valid JSONC, invalid JSON), which broke
202
+ // custom provider model-limit resolution silently. See issue #14 follow-up.
203
+ const config = parseJsonc<{
204
204
  provider?: Record<
205
205
  string,
206
206
  {
207
207
  models?: Record<string, { limit?: { context?: number; input?: number } }>;
208
208
  }
209
209
  >;
210
- };
210
+ }>(readFileSync(configPath, "utf-8"));
211
211
 
212
212
  if (config.provider && typeof config.provider === "object") {
213
213
  for (const [providerId, provider] of Object.entries(config.provider)) {
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { DREAMER_AGENT } from "../agents/dreamer";
4
+ import { HISTORIAN_AGENT } from "../agents/historian";
5
+ import { SIDEKICK_AGENT } from "../agents/sidekick";
6
+ import { parseProviderModel, resolveFallbackChain } from "./resolve-fallbacks";
7
+
8
+ describe("resolveFallbackChain", () => {
9
+ test("returns builtin chain when user provides nothing", () => {
10
+ const chain = resolveFallbackChain(DREAMER_AGENT, undefined);
11
+ // Builtin DREAMER_FALLBACK_CHAIN expands to multiple provider/model entries.
12
+ expect(chain.length).toBeGreaterThan(2);
13
+ // Every entry must be in "provider/model" form.
14
+ for (const entry of chain) {
15
+ expect(entry.indexOf("/")).toBeGreaterThan(0);
16
+ }
17
+ });
18
+
19
+ test("returns builtin chain for empty string", () => {
20
+ const chain = resolveFallbackChain(DREAMER_AGENT, "");
21
+ expect(chain.length).toBeGreaterThan(0);
22
+ });
23
+
24
+ test("returns builtin chain for empty array", () => {
25
+ const chain = resolveFallbackChain(DREAMER_AGENT, []);
26
+ expect(chain.length).toBeGreaterThan(0);
27
+ });
28
+
29
+ test("user-only when user provides valid fallback_models string", () => {
30
+ const chain = resolveFallbackChain(DREAMER_AGENT, "anthropic/claude-sonnet-4-6");
31
+ expect(chain).toEqual(["anthropic/claude-sonnet-4-6"]);
32
+ });
33
+
34
+ test("user-only when user provides valid fallback_models array", () => {
35
+ const chain = resolveFallbackChain(DREAMER_AGENT, [
36
+ "anthropic/claude-sonnet-4-6",
37
+ "google/gemini-3-flash",
38
+ ]);
39
+ expect(chain).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
40
+ });
41
+
42
+ test("dedupes user-provided list", () => {
43
+ const chain = resolveFallbackChain(DREAMER_AGENT, [
44
+ "anthropic/claude-sonnet-4-6",
45
+ "anthropic/claude-sonnet-4-6",
46
+ "google/gemini-3-flash",
47
+ ]);
48
+ expect(chain).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
49
+ });
50
+
51
+ test("strips invalid 'provider/model' entries", () => {
52
+ const chain = resolveFallbackChain(DREAMER_AGENT, [
53
+ "anthropic/claude-sonnet-4-6",
54
+ "no-slash-here",
55
+ "/leading-slash",
56
+ "trailing-slash/",
57
+ "",
58
+ " ",
59
+ ]);
60
+ expect(chain).toEqual(["anthropic/claude-sonnet-4-6"]);
61
+ });
62
+
63
+ test("trims whitespace in user entries", () => {
64
+ const chain = resolveFallbackChain(DREAMER_AGENT, [
65
+ " anthropic/claude-sonnet-4-6 ",
66
+ "\tgoogle/gemini-3-flash\n",
67
+ ]);
68
+ expect(chain).toEqual(["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"]);
69
+ });
70
+
71
+ test("returns empty array for unknown agent with no user fallbacks", () => {
72
+ const chain = resolveFallbackChain("unknown-agent", undefined);
73
+ expect(chain).toEqual([]);
74
+ });
75
+
76
+ test("returns user fallbacks for unknown agent when provided", () => {
77
+ const chain = resolveFallbackChain("unknown-agent", ["foo/bar"]);
78
+ expect(chain).toEqual(["foo/bar"]);
79
+ });
80
+
81
+ test("HISTORIAN_AGENT has builtin chain", () => {
82
+ const chain = resolveFallbackChain(HISTORIAN_AGENT, undefined);
83
+ expect(chain.length).toBeGreaterThan(0);
84
+ });
85
+
86
+ test("SIDEKICK_AGENT has builtin chain", () => {
87
+ const chain = resolveFallbackChain(SIDEKICK_AGENT, undefined);
88
+ expect(chain.length).toBeGreaterThan(0);
89
+ });
90
+
91
+ test("user-only policy: builtin not appended even if user set short list", () => {
92
+ const chain = resolveFallbackChain(DREAMER_AGENT, ["anthropic/claude-sonnet-4-6"]);
93
+ expect(chain).toEqual(["anthropic/claude-sonnet-4-6"]);
94
+ // Confirm length is exactly 1, not user+builtin
95
+ expect(chain.length).toBe(1);
96
+ });
97
+ });
98
+
99
+ describe("parseProviderModel", () => {
100
+ test("parses standard provider/model", () => {
101
+ expect(parseProviderModel("anthropic/claude-sonnet-4-6")).toEqual({
102
+ providerID: "anthropic",
103
+ modelID: "claude-sonnet-4-6",
104
+ });
105
+ });
106
+
107
+ test("handles model id with slashes (only splits on first slash)", () => {
108
+ expect(parseProviderModel("lemonade/GLM-4.7-Flash-GGUF/main")).toEqual({
109
+ providerID: "lemonade",
110
+ modelID: "GLM-4.7-Flash-GGUF/main",
111
+ });
112
+ });
113
+
114
+ test("trims whitespace", () => {
115
+ expect(parseProviderModel(" anthropic/claude-sonnet-4-6 ")).toEqual({
116
+ providerID: "anthropic",
117
+ modelID: "claude-sonnet-4-6",
118
+ });
119
+ });
120
+
121
+ test("returns null for no slash", () => {
122
+ expect(parseProviderModel("anthropic")).toBeNull();
123
+ });
124
+
125
+ test("returns null for leading slash", () => {
126
+ expect(parseProviderModel("/claude-sonnet-4-6")).toBeNull();
127
+ });
128
+
129
+ test("returns null for trailing slash", () => {
130
+ expect(parseProviderModel("anthropic/")).toBeNull();
131
+ });
132
+
133
+ test("returns null for empty string", () => {
134
+ expect(parseProviderModel("")).toBeNull();
135
+ });
136
+ });
@@ -0,0 +1,76 @@
1
+ import { getAgentFallbackModels } from "./model-requirements";
2
+
3
+ /**
4
+ * Resolve the final fallback model list to attempt for an OpenCode subagent
5
+ * call.
6
+ *
7
+ * Policy (decided 2026-05-10):
8
+ * - If user configured explicit `fallback_models` in their magic-context.jsonc
9
+ * for this agent: use ONLY those. Respects user intent, no surprise
10
+ * providers.
11
+ * - If user did NOT configure any: fall back to the plugin's builtin
12
+ * provider-agnostic chain (`AGENT_MODEL_REQUIREMENTS`).
13
+ *
14
+ * The returned list does NOT include the primary model — it's the ordered
15
+ * list of *alternates* to try after the primary fails. Each entry is
16
+ * "provider/modelID" form.
17
+ *
18
+ * Duplicates and empty strings are filtered. Entries that don't match the
19
+ * "provider/modelID" shape (must contain a "/" with non-empty parts) are
20
+ * also dropped — defensive guard against malformed user config.
21
+ */
22
+ export function resolveFallbackChain(
23
+ agentName: string,
24
+ userFallbacks: readonly string[] | string | undefined,
25
+ ): string[] {
26
+ const userList = normalizeUserFallbacks(userFallbacks);
27
+
28
+ if (userList.length > 0) {
29
+ return dedupe(userList.filter(isValidModelSpec));
30
+ }
31
+
32
+ const builtin = getAgentFallbackModels(agentName);
33
+ if (!builtin || builtin.length === 0) return [];
34
+ return dedupe(builtin.filter(isValidModelSpec));
35
+ }
36
+
37
+ function normalizeUserFallbacks(userFallbacks: readonly string[] | string | undefined): string[] {
38
+ if (!userFallbacks) return [];
39
+ if (typeof userFallbacks === "string") {
40
+ const trimmed = userFallbacks.trim();
41
+ return trimmed ? [trimmed] : [];
42
+ }
43
+ return userFallbacks.map((s) => s.trim()).filter((s) => s.length > 0);
44
+ }
45
+
46
+ function isValidModelSpec(spec: string): boolean {
47
+ const slash = spec.indexOf("/");
48
+ return slash > 0 && slash < spec.length - 1;
49
+ }
50
+
51
+ function dedupe(list: string[]): string[] {
52
+ const seen = new Set<string>();
53
+ const out: string[] = [];
54
+ for (const item of list) {
55
+ if (seen.has(item)) continue;
56
+ seen.add(item);
57
+ out.push(item);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Parse a "provider/modelID" string into the OpenCode `model` object shape.
64
+ * Returns null on invalid input.
65
+ *
66
+ * Note: only splits on the FIRST "/" — modelID can legitimately contain slashes
67
+ * (e.g. `lemonade/GLM-4.7-Flash-GGUF/main`).
68
+ */
69
+ export function parseProviderModel(spec: string): { providerID: string; modelID: string } | null {
70
+ const slash = spec.indexOf("/");
71
+ if (slash < 1 || slash >= spec.length - 1) return null;
72
+ return {
73
+ providerID: spec.slice(0, slash).trim(),
74
+ modelID: spec.slice(slash + 1).trim(),
75
+ };
76
+ }
@@ -159,9 +159,10 @@ export function tagTranscript(
159
159
  const messageId = message.info.id;
160
160
 
161
161
  let textOrdinal = 0;
162
+ const parts = message.parts;
162
163
 
163
- for (let partIndex = 0; partIndex < message.parts.length; partIndex += 1) {
164
- const part = message.parts[partIndex];
164
+ for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
165
+ const part = parts[partIndex];
165
166
  if (part === undefined) continue;
166
167
 
167
168
  if (part.kind === "text") {
package/src/tui/index.tsx CHANGED
@@ -453,6 +453,111 @@ function showStatusDialog(api: TuiPluginApi) {
453
453
  })
454
454
  }
455
455
 
456
+ /**
457
+ * Register Magic Context command palette entries, preferring the v1.14.42+
458
+ * `keymap.registerLayer` API and falling back to the legacy
459
+ * `api.command.register` for older hosts.
460
+ *
461
+ * The `keymap.registerLayer` shape uses `name`/`title`/`run`/`namespace`
462
+ * (see `@opencode-ai/plugin/tui` types) and is what the host's own legacy
463
+ * command-shim translates into. Calling it directly skips the deprecation
464
+ * warning and works without depending on the (now-deprecated) `api.command`
465
+ * namespace existing at all.
466
+ *
467
+ * Version coverage:
468
+ * 1.14.0–1.14.41 — `api.command.register` only
469
+ * 1.14.42–1.14.43 — both surfaces broken (api.command removed, keymap landed
470
+ * but with bugs); plugins crash on init either way
471
+ * 1.14.44+ — `api.keymap.registerLayer` canonical, `api.command` shim
472
+ */
473
+ function registerCommandPaletteEntries(api: TuiPluginApi): void {
474
+ type ApiAny = {
475
+ keymap?: {
476
+ registerLayer?: (layer: {
477
+ commands: Array<Record<string, unknown>>
478
+ bindings: Array<Record<string, unknown>>
479
+ }) => unknown
480
+ }
481
+ command?: {
482
+ register?: (cb: () => Array<Record<string, unknown>>) => unknown
483
+ }
484
+ }
485
+ const apiAny = api as unknown as ApiAny
486
+
487
+ if (typeof apiAny.keymap?.registerLayer === "function") {
488
+ // Audit Finding #2 hardening: even when registerLayer exists as a
489
+ // function, the underlying keymap implementation in OpenCode TUI
490
+ // 1.14.42-1.14.43 can throw at call time. Without the try-catch the
491
+ // `return` below would propagate the throw and the legacy
492
+ // `command.register` fallback path (~20 lines down) would be
493
+ // unreachable. The cost is one debug log on the rare broken-TUI
494
+ // build; the benefit is that older command.register-only TUIs
495
+ // running alongside a partially-broken keymap surface still get
496
+ // their command palette entries.
497
+ try {
498
+ apiAny.keymap.registerLayer({
499
+ commands: [
500
+ {
501
+ namespace: "palette",
502
+ name: "magic-context.status",
503
+ title: "Magic Context: Status",
504
+ category: "Magic Context",
505
+ run() {
506
+ showStatusDialog(api)
507
+ },
508
+ },
509
+ {
510
+ namespace: "palette",
511
+ name: "magic-context.recomp",
512
+ title: "Magic Context: Recomp",
513
+ category: "Magic Context",
514
+ run() {
515
+ showRecompDialog(api)
516
+ },
517
+ },
518
+ ],
519
+ bindings: [],
520
+ })
521
+ return
522
+ } catch (err) {
523
+ console.debug(
524
+ "[magic-context-tui] keymap.registerLayer threw; falling back to command.register",
525
+ err,
526
+ )
527
+ // Fall through to legacy registration.
528
+ }
529
+ }
530
+
531
+ if (typeof apiAny.command?.register === "function") {
532
+ apiAny.command.register(() => [
533
+ {
534
+ title: "Magic Context: Status",
535
+ value: "magic-context.status",
536
+ category: "Magic Context",
537
+ onSelect() {
538
+ showStatusDialog(api)
539
+ },
540
+ },
541
+ {
542
+ title: "Magic Context: Recomp",
543
+ value: "magic-context.recomp",
544
+ category: "Magic Context",
545
+ onSelect() {
546
+ showRecompDialog(api)
547
+ },
548
+ },
549
+ ])
550
+ return
551
+ }
552
+
553
+ // Neither API surface is present. The TUI host can still load — we only
554
+ // lose the command palette entry points. The sidebar (registered above
555
+ // via api.slots.register) remains visible. Status/Recomp are still
556
+ // reachable through the server-side `/ctx-status` and `/ctx-recomp`
557
+ // slash commands, which the server handler bridges to the TUI dialogs
558
+ // via RPC.
559
+ }
560
+
456
561
  const tui: TuiPlugin = async (api, _options, meta) => {
457
562
  // Initialize RPC client for server communication
458
563
  const directory = api.state.path.directory ?? ""
@@ -465,24 +570,15 @@ const tui: TuiPlugin = async (api, _options, meta) => {
465
570
  // are registered server-side so there's only one /ctx-* registration).
466
571
  // The server detects TUI mode and sends dialog requests via RPC instead
467
572
  // of sendIgnoredMessage.
468
- api.command.register(() => [
469
- {
470
- title: "Magic Context: Status",
471
- value: "magic-context.status",
472
- category: "Magic Context",
473
- onSelect() {
474
- showStatusDialog(api)
475
- },
476
- },
477
- {
478
- title: "Magic Context: Recomp",
479
- value: "magic-context.recomp",
480
- category: "Magic Context",
481
- onSelect() {
482
- showRecompDialog(api)
483
- },
484
- },
485
- ])
573
+ //
574
+ // OpenCode 1.14.42 removed `api.command.register` entirely
575
+ // (anomalyco/opencode#26053). A later patch (1.14.44+) reinstated it as
576
+ // a deprecated shim that translates to `api.keymap.registerLayer`. To
577
+ // work across all hosts (1.14.0–1.14.41 with command-only, the broken
578
+ // 1.14.42–1.14.43, and 1.14.44+ where both exist), we prefer
579
+ // `api.keymap.registerLayer` and fall back to `api.command.register`
580
+ // only when keymap is missing.
581
+ registerCommandPaletteEntries(api)
486
582
 
487
583
  // Poll for server→TUI messages: toasts and dialog requests.
488
584
  // Single poller because consumeTuiMessages() is destructive (deletes consumed rows).