opencode-resolve 0.1.19 → 0.2.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.
@@ -20,48 +20,17 @@ const ADDITIVE_DEFAULTS = {
20
20
  autoApprove: true,
21
21
  }
22
22
 
23
- // Heuristic strength scoring for an arbitrary provider/model id.
24
- // Higher = "stronger" / more expensive in expected behavior. Heuristic-only.
25
- const STRENGTH_BOOSTS = [
26
- { re: /\b(mini|flash|nano|lite|haiku|air|small)\b/i, score: -3 },
27
- { re: /\b(claude-3|gpt-3|gpt-4o-mini|o1-mini|o3-mini|4\.5)\b/i, score: -1 },
28
- { re: /\b(gpt-4o|opus|sonnet|gemini-1\.5)\b/i, score: 1 },
29
- { re: /\b(o1|o3|o4|reason|reasoning|pro|max|ultra|gold|sota)\b/i, score: 2 },
30
- { re: /\b(5\.5|5\.4|5\.3|5\.2|5\.1|sonnet-4|opus-4|opus-5|max-5|next|2026)\b/i, score: 2 },
31
- { re: /\b(codex|coder|code|coding)\b/i, score: 1 },
32
- ]
23
+ // Tier classification by name keywords. Returns 0=bronze (cheap scout),
24
+ // 1=silver (workhorse coder), 2=gold (flagship reasoner). Within the same
25
+ // tier we tiebreak by numeric version so 5.1 ranks above 4.5.
26
+ const TIER_BRONZE_RE = /\b(mini|flash|nano|lite|haiku|air|small|gpt-3\.5)\b/i
27
+ const TIER_GOLD_RE = /\b(5\.5|5\.4|opus|o1-pro|o1|o3|o4|max|ultra|sota|reasoning|gpt-4-turbo)\b/i
33
28
 
34
29
  // Provider-neutral agents — safe to enable for everyone (no profile-specific model needed).
35
30
  const DEFAULT_ENABLED_AGENTS = [
36
31
  "coder", "resolver", "explorer", "reviewer", "deep-reviewer", "planner",
37
32
  "architect", "debugger", "researcher",
38
33
  ]
39
- const GPT_ENABLED_AGENTS = [...DEFAULT_ENABLED_AGENTS, "gpt", "gpt-coder", "codex"]
40
- const GLM_ENABLED_AGENTS = [...DEFAULT_ENABLED_AGENTS, "glm"]
41
-
42
- // ZAI local MCP server bootstrap for GLM users.
43
- // Do not copy provider secrets from OpenCode's auth store into opencode.json.
44
- // The MCP process should receive credentials from the user's runtime environment.
45
- const ZAI_MCP_SERVERS = {
46
- "zai-mcp-server": {
47
- type: "local",
48
- command: ["npx", "-y", "@z_ai/mcp-server"],
49
- environment: {
50
- Z_AI_MODE: "ZAI",
51
- },
52
- },
53
- }
54
-
55
- const COMPANION_PLUGINS = [
56
- {
57
- pkg: "@tarquinen/opencode-dcp",
58
- desc: "Dynamic Context Pruning — strips obsolete tool outputs so long resolver loops cost fewer tokens",
59
- },
60
- {
61
- pkg: "@slkiser/opencode-quota",
62
- desc: "Live token/quota usage indicator — supports GLM coding-plan, OpenAI Plus/Pro, Qwen, and more",
63
- },
64
- ]
65
34
 
66
35
  const OPENAI_MODEL_HINTS = [
67
36
  "openai/gpt-5.5",
@@ -74,11 +43,13 @@ const OPENAI_MODEL_HINTS = [
74
43
  ]
75
44
 
76
45
  const GLM_MODEL_HINTS = [
46
+ "zai-coding-plan/glm-5.2",
77
47
  "zai-coding-plan/glm-5.1",
78
48
  "zai-coding-plan/glm-4.5",
79
49
  "zai-coding-plan/glm-4.5-air",
80
50
  "zai-coding-plan/glm-5",
81
51
  "zai-coding-plan/glm-4.7",
52
+ "zai/glm-5.2",
82
53
  "zai/glm-5.1",
83
54
  "zai/glm-4.5",
84
55
  "zai/glm-4.5-air",
@@ -105,7 +76,6 @@ async function printSummaryBanner(version) {
105
76
  const raw = await readFile(resolveConfigPath, "utf8")
106
77
  const cfg = JSON.parse(raw)
107
78
  const parts = []
108
- if (cfg.profile) parts.push(`profile=${cfg.profile}`)
109
79
  if (cfg.tier) parts.push(`tier=${cfg.tier}`)
110
80
  const enabled = Array.isArray(cfg.enabled) ? cfg.enabled.length : Object.keys(cfg.agents ?? {}).length
111
81
  if (enabled) parts.push(`${enabled} agents`)
@@ -131,7 +101,6 @@ async function printSummaryBanner(version) {
131
101
  try {
132
102
  await registerPlugin()
133
103
  await refreshSelfPluginCache(pluginVersion)
134
- await offerCompanionPlugins()
135
104
  console.log(`[${packageName}] v${pluginVersion} install complete — restart OpenCode to load the plugin`)
136
105
  await printSummaryBanner(pluginVersion)
137
106
  } catch (error) {
@@ -154,17 +123,11 @@ async function registerPlugin() {
154
123
  const scriptedAnswers = await readScriptedAnswersIfNeeded()
155
124
 
156
125
  const probe = await readOpenCodeConfig()
157
- const allModels = detectAllModels(probe)
158
- const hasGLM = allModels.some((m) => isGLMModel(m))
159
126
  const pluginNeeded = !isPluginRegisteredIn(probe)
160
- const missingMCPNames = hasGLM
161
- ? Object.keys(ZAI_MCP_SERVERS).filter((name) => probe.mcp?.[name] === undefined)
162
- : []
163
127
 
164
- if (pluginNeeded || missingMCPNames.length > 0) {
128
+ if (pluginNeeded) {
165
129
  const fresh = await readOpenCodeConfig()
166
- if (pluginNeeded) applyPluginPatch(fresh)
167
- if (missingMCPNames.length > 0) applyMCPPatches(fresh, missingMCPNames)
130
+ applyPluginPatch(fresh)
168
131
  await writeFile(opencodeConfigPath, `${JSON.stringify(fresh, null, 2)}\n`)
169
132
  console.log(`[${packageName}] updated ${opencodeConfigPath}`)
170
133
  } else {
@@ -233,7 +196,6 @@ async function runOpenCodePluginInstall() {
233
196
  ...process.env,
234
197
  OPENCODE_RESOLVE_REFRESHING_CACHE: "1",
235
198
  OPENCODE_RESOLVE_SKIP_POSTINSTALL: "1",
236
- OPENCODE_RESOLVE_SKIP_COMPANIONS: "1",
237
199
  },
238
200
  })
239
201
  child.on("exit", (code) => resolveSpawn(code === 0))
@@ -255,25 +217,13 @@ function applyPluginPatch(config) {
255
217
  }
256
218
  }
257
219
 
258
- function applyMCPPatches(config, names) {
259
- config.mcp ??= {}
260
- for (const name of names) {
261
- if (config.mcp[name] === undefined) {
262
- config.mcp[name] = ZAI_MCP_SERVERS[name]
263
- }
264
- }
265
- console.log(`[${packageName}] injected ZAI MCP server config: ${names.join(", ")}`)
266
- console.log(`[${packageName}] note: API keys are not copied into opencode.json; export Z_AI_API_KEY if the MCP server requires it.`)
267
- }
268
-
269
220
  async function handleExistingResolveConfig(opencodeConfig, scriptedAnswers) {
270
221
  const action = await chooseExistingResolveConfigAction(scriptedAnswers)
271
222
  if (action === "fresh") {
272
- const existing = await readExistingResolveConfig()
273
223
  await backupResolveConfig()
274
- const preserveModels = readInstallerOption("reset_models") !== "1"
224
+ console.log(`[${packageName}] resetting resolve.json — running setup from scratch (model pins wiped)`)
275
225
  await createAdaptiveResolveConfig(opencodeConfig, scriptedAnswers, {
276
- preservedModels: preserveModels ? existing?.models : undefined,
226
+ preservedModels: undefined,
277
227
  })
278
228
  return
279
229
  }
@@ -290,7 +240,7 @@ async function handleExistingResolveConfig(opencodeConfig, scriptedAnswers) {
290
240
  async function chooseExistingResolveConfigAction(scriptedAnswers) {
291
241
  if (readInstallerOption("configure_models") === "1") return "models"
292
242
  const requested = readInstallerOption("reinstall").trim().toLowerCase()
293
- if (["fresh", "reset", "recreate", "new"].includes(requested)) return "fresh"
243
+ if (["fresh", "reset", "recreate", "new", "nuke", "wipe", "full-reset", "scratch"].includes(requested)) return "fresh"
294
244
  if (["update", "keep", "migrate", "preserve"].includes(requested)) return "update"
295
245
  if (requested) {
296
246
  console.warn(`[${packageName}] ignoring unknown reinstall mode ${JSON.stringify(requested)}; use "fresh" or "update".`)
@@ -301,6 +251,7 @@ async function chooseExistingResolveConfigAction(scriptedAnswers) {
301
251
  if (!canPrompt) {
302
252
  console.log(`[${packageName}] existing ${resolveConfigPath} found; preserving it and applying additive updates.`)
303
253
  console.log(`[${packageName}] for model setup, run: ${packageName} setup --models`)
254
+ console.log(`[${packageName}] for a full reset (wipes the whole file incl. model pins), run: ${packageName} setup --reset`)
304
255
  console.log(`[${packageName}] to force plugin cache reinstall without touching settings, run: ${packageName} setup --force-cache`)
305
256
  return "update"
306
257
  }
@@ -316,10 +267,11 @@ async function chooseExistingResolveConfigAction(scriptedAnswers) {
316
267
  console.log("How do you want to handle it?")
317
268
  console.log(" 1. keep — preserve existing settings, only add missing defaults (recommended)")
318
269
  console.log(" 2. models — re-pick models only, keep the rest")
319
- console.log(" 3. reset — back up the current file and run setup from scratch (model pins preserved)")
270
+ console.log(" 3. reset — back up the file and wipe EVERYTHING (model pins included)")
320
271
  const answer = await askChoice(rl, "Choice [1=keep, 2=models, 3=reset, default 1]: ", ["1", "2", "3"], "1")
321
272
  if (answer === "2") return "models"
322
- return answer === "3" ? "fresh" : "update"
273
+ if (answer === "3") return "fresh"
274
+ return "update"
323
275
  } finally {
324
276
  rl.close()
325
277
  }
@@ -361,7 +313,6 @@ async function createAdaptiveResolveConfig(opencodeConfig, scriptedAnswers, opti
361
313
  : undefined
362
314
 
363
315
  if (interactivePreset) {
364
- resolveConfig.profile = interactivePreset.profile
365
316
  if (interactivePreset.tier) resolveConfig.tier = interactivePreset.tier
366
317
  else delete resolveConfig.tier
367
318
  if (interactivePreset.enabled) resolveConfig.enabled = interactivePreset.enabled
@@ -375,42 +326,17 @@ async function createAdaptiveResolveConfig(opencodeConfig, scriptedAnswers, opti
375
326
  return
376
327
  }
377
328
 
378
- const hasGLM = allModels.some((m) => isGLMModel(m))
379
- const hasGPT = allModels.some((m) => isGPTModel(m))
329
+ // Non-interactive: agents inherit the top-level OpenCode model. No model
330
+ // pinning unless the user runs setup in a TTY and picks one.
380
331
  const preset = buildModelPreset(currentModel, allModels)
381
332
 
382
- if (hasGLM && !hasGPT) {
383
- resolveConfig.profile = "glm"
384
- resolveConfig.tier = "silver"
385
- resolveConfig.agents = {
386
- ...resolveConfig.agents,
387
- glm: { ...(resolveConfig.agents?.glm ?? {}), enabled: true },
388
- }
389
- } else if (hasGPT && !hasGLM) {
390
- resolveConfig.profile = "gpt"
391
- resolveConfig.tier = "gold"
392
- resolveConfig.agents = {
393
- ...resolveConfig.agents,
394
- gpt: { ...(resolveConfig.agents?.gpt ?? {}), enabled: true },
395
- }
396
- } else {
397
- resolveConfig.profile = "mix"
398
- if (hasGLM && hasGPT) {
399
- resolveConfig.agents = {
400
- ...resolveConfig.agents,
401
- gpt: { ...(resolveConfig.agents?.gpt ?? {}), enabled: true },
402
- glm: { ...(resolveConfig.agents?.glm ?? {}), enabled: true },
403
- }
404
- }
405
- }
406
-
407
333
  if (preset && Object.keys(preset).length > 0) {
408
334
  resolveConfig.models = mergePreservedModels(preset, preservedModels)
409
335
  } else if (preservedModels) {
410
336
  resolveConfig.models = { ...preservedModels }
411
337
  } else {
412
338
  const providerHint = currentModel ? ` (top-level model: ${currentModel})` : ""
413
- console.log(`[${packageName}] no GPT/GLM models detected in opencode.json — agents inherit the top-level model${providerHint}`)
339
+ console.log(`[${packageName}] no models detected in opencode.json — agents inherit the top-level model${providerHint}`)
414
340
  console.log(`[${packageName}] to pin role-specific models, edit ${resolveConfigPath} ("models" section)`)
415
341
  console.log(`[${packageName}] or rerun setup in a TTY: ${packageName} setup --models`)
416
342
  }
@@ -437,17 +363,15 @@ async function reconfigureExistingModels(opencodeConfig, scriptedAnswers) {
437
363
  : undefined
438
364
  const preset = interactivePreset ?? {
439
365
  label: getPresetLabel(currentModel),
440
- profile: inferProfileFromModels(currentModel, allModels),
441
366
  models: buildModelPreset(currentModel, allModels),
442
367
  }
443
368
 
444
369
  if (!preset.models || Object.keys(preset.models).length === 0) {
445
- console.log(`[${packageName}] no GPT/GLM models detected; existing model pins preserved`)
370
+ console.log(`[${packageName}] no models detected; existing model pins preserved`)
446
371
  return
447
372
  }
448
373
 
449
374
  const updated = { ...existing }
450
- updated.profile = preset.profile
451
375
  if (preset.tier) updated.tier = preset.tier
452
376
  else delete updated.tier
453
377
  if (preset.enabled) updated.enabled = preset.enabled
@@ -466,12 +390,28 @@ function mergePreservedModels(generated, preserved) {
466
390
  return { ...generated, ...preserved }
467
391
  }
468
392
 
469
- function inferProfileFromModels(currentModel, allModels) {
470
- const hasGLM = allModels.some((m) => isGLMModel(m)) || isGLMModel(currentModel)
471
- const hasGPT = allModels.some((m) => isGPTModel(m)) || isGPTModel(currentModel)
472
- if (hasGLM && !hasGPT) return "glm"
473
- if (hasGPT && !hasGLM) return "gpt"
474
- return "mix"
393
+ function inferModelStrengthHint(currentModel, allModels = []) {
394
+ // Non-interactive fallback: pin a single detected model to every role so
395
+ // agents don't fall through to an unrelated default. Returns {} when nothing
396
+ // is detectable (agents then inherit the top-level OpenCode model).
397
+ if (!currentModel) return {}
398
+ return {
399
+ bronze: currentModel,
400
+ silver: currentModel,
401
+ gold: currentModel,
402
+ fast: currentModel,
403
+ strong: currentModel,
404
+ mini: currentModel,
405
+ explorer: "bronze",
406
+ coder: "gold",
407
+ resolver: "gold",
408
+ reviewer: "gold",
409
+ "deep-reviewer": "gold",
410
+ planner: "gold",
411
+ architect: "gold",
412
+ debugger: "gold",
413
+ researcher: "bronze",
414
+ }
475
415
  }
476
416
 
477
417
  function detectOpenCodeModel(config) {
@@ -502,198 +442,16 @@ function detectOpenCodeModel(config) {
502
442
  }
503
443
 
504
444
  function buildModelPreset(currentModel, allModels = []) {
505
- const detectedGLMModels = allModels.filter(isGLMModel)
506
- const detectedGPTModels = allModels.filter(isGPTModel)
507
- const glmModels = detectedGLMModels.length > 0 ? collectModelChoices(detectedGLMModels, isGLMModel, GLM_MODEL_HINTS, false) : []
508
- const gptModels = detectedGPTModels.length > 0 ? collectModelChoices(detectedGPTModels, isGPTModel, OPENAI_MODEL_HINTS, false) : []
509
- const glmModel = glmModels[0]
510
- const gptModel = gptModels[0]
511
-
512
- if (glmModel && gptModel) {
513
- const glmTiers = chooseThreeTier(glmModels, "glm", false)
514
- const gptTiers = chooseThreeTier(gptModels, "gpt", false)
515
- return {
516
- mix: "gpt",
517
- gpt: gptTiers.gold,
518
- bronze: glmTiers.bronze,
519
- silver: glmTiers.silver,
520
- gold: gptTiers.gold,
521
- "glm-bronze": glmTiers.bronze,
522
- "glm-silver": glmTiers.silver,
523
- "glm-gold": glmTiers.gold,
524
- "gpt-bronze": gptTiers.bronze,
525
- "gpt-silver": gptTiers.silver,
526
- "gpt-gold": gptTiers.gold,
527
- fast: "bronze",
528
- strong: "gold",
529
- mini: "bronze",
530
- codex: "gpt-gold",
531
- glm: glmTiers.gold,
532
- explorer: "bronze",
533
- coder: "silver",
534
- resolver: "gold",
535
- reviewer: "gold",
536
- "deep-reviewer": "gold",
537
- planner: "gold",
538
- }
539
- }
540
-
541
- if (!currentModel) {
542
- return {}
543
- }
544
-
545
- const lower = currentModel.toLowerCase()
546
-
547
- // GLM / ZAI — GLM-only preset (no GPT dependency, avoids token-exhaustion errors)
548
- if (lower.includes("glm") || lower.includes("zai")) {
549
- return buildGLMOnlyPreset(currentModel, glmModels)
550
- }
551
-
552
- if (lower.includes("openai/") || lower.includes("gpt")) {
553
- return buildGPTOnlyPreset(currentModel, gptModels)
554
- }
555
-
556
- // OpenAI / GPT single-provider preset
557
- if (lower.includes("openai/") || lower.includes("gpt")) {
558
- return buildGPTOnlyPreset(currentModel)
559
- }
560
-
561
- // Unknown provider — keep model-neutral
562
- return {}
563
- }
564
-
565
- function buildGLMOnlyPreset(model, glmModels) {
566
- const models = glmModels && glmModels.length > 0 ? glmModels : [model]
567
- const tiers = chooseThreeTier(models, "glm", false)
568
- return {
569
- glm: tiers.gold,
570
- bronze: tiers.bronze,
571
- silver: tiers.silver,
572
- gold: tiers.gold,
573
- fast: "bronze",
574
- strong: "gold",
575
- mini: "bronze",
576
- coder: "gold",
577
- resolver: "gold",
578
- reviewer: "gold",
579
- "deep-reviewer": "gold",
580
- explorer: "bronze",
581
- planner: "gold",
582
- }
583
- }
584
-
585
- function buildGPTOnlyPreset(model, gptModels) {
586
- const models = gptModels && gptModels.length > 0 ? gptModels : [model]
587
- const tiers = chooseThreeTier(models, "gpt", false)
588
- return {
589
- gpt: tiers.gold,
590
- bronze: tiers.bronze,
591
- silver: tiers.silver,
592
- gold: tiers.gold,
593
- fast: "bronze",
594
- strong: "gold",
595
- mini: "bronze",
596
- codex: "gold",
597
- coder: "silver",
598
- resolver: "gold",
599
- explorer: "bronze",
600
- reviewer: "gold",
601
- "deep-reviewer": "gold",
602
- }
445
+ // Non-interactive path: pin a single detected model across all roles.
446
+ // The interactive TTY flow (buildGenericInteractivePreset) handles tier
447
+ // splitting; this is just the headless fallback.
448
+ return inferModelStrengthHint(currentModel, allModels)
603
449
  }
604
450
 
605
451
  async function buildInteractivePreset(currentModel, allModels, scriptedAnswers, opencodeConfig) {
606
- const choices = {
607
- gpt: collectModelChoices(allModels, isGPTModel, OPENAI_MODEL_HINTS),
608
- glm: collectModelChoices(allModels, isGLMModel, GLM_MODEL_HINTS),
609
- }
610
- if (currentModel) {
611
- if (isGPTModel(currentModel)) choices.gpt = unique([currentModel, ...choices.gpt])
612
- if (isGLMModel(currentModel)) choices.glm = unique([currentModel, ...choices.glm])
613
- }
614
-
615
- const hasGPTOrGLM = allModels.some((m) => isGPTModel(m) || isGLMModel(m))
616
- const providerCount = opencodeConfig ? detectProvidersFromConfig(opencodeConfig).length : 0
617
- const offerAuto = !hasGPTOrGLM && providerCount > 0
618
- const defaultChoice = offerAuto ? "4" : "1"
619
-
620
- const rl = createPromptInterface(scriptedAnswers)
621
- try {
622
- console.log("")
623
- console.log("──────────────────────────────────────────────────────────────")
624
- console.log(` opencode-resolve setup`)
625
- console.log(` Press enter at any prompt to accept the default in [brackets].`)
626
- console.log("──────────────────────────────────────────────────────────────")
627
- console.log("")
628
- console.log(`[${packageName}] Step 1/2 — Choose resolve profile:`)
629
- console.log(` 1. mix — neutral resolver plus optional Codex and GLM primary agents${offerAuto ? "" : " (recommended)"}`)
630
- console.log(" 2. gpt — GPT/Codex-only, three-tier")
631
- console.log(" 3. glm — GLM-only, three-tier")
632
- if (offerAuto) {
633
- console.log(` 4. auto — provider-agnostic tier setup from detected providers (recommended)`)
634
- }
635
- const validChoices = offerAuto ? ["1", "2", "3", "4"] : ["1", "2", "3"]
636
- const profileAnswer = await askChoice(rl, `Profile [${validChoices.join(",")}, default ${defaultChoice}]: `, validChoices, defaultChoice)
637
-
638
- if (profileAnswer === "4" && offerAuto) {
639
- rl.close()
640
- const generic = await buildGenericInteractivePreset(currentModel, opencodeConfig, scriptedAnswers)
641
- if (generic) return generic
642
- // fall through to mix if generic returned nothing
643
- }
644
-
645
- const profile = profileAnswer === "2" ? "gpt" : profileAnswer === "3" ? "glm" : "mix"
646
-
647
- if (profile === "gpt") {
648
- const tiers = await askThreeTier(rl, "GPT/Codex", choices.gpt)
649
- return {
650
- label: "gpt-three-tier",
651
- profile: "gpt",
652
- tier: "gold",
653
- enabled: GPT_ENABLED_AGENTS,
654
- models: buildGPTThreeTierModels(tiers),
655
- agents: { gpt: { enabled: true } },
656
- }
657
- }
658
-
659
- if (profile === "glm") {
660
- const useCodingPlan = await askYesNo(rl, "Use coding-plan (zai-coding-plan) instead of standard (zai)? [y/N]: ", false)
661
- const glmChoices = useCodingPlan
662
- ? choices.glm.map((m) => m.replace(/^zai\//, "zai-coding-plan/"))
663
- : choices.glm.map((m) => m.replace(/^zai-coding-plan\//, "zai/"))
664
- const deduped = unique([...glmChoices.filter((m) => isGLMModel(m)), ...GLM_MODEL_HINTS])
665
- const tiers = await askThreeTier(rl, "GLM", deduped)
666
- return {
667
- label: "glm-three-tier",
668
- profile: "glm",
669
- tier: "gold",
670
- enabled: GLM_ENABLED_AGENTS,
671
- models: buildGLMThreeTierModels(tiers),
672
- agents: { glm: { enabled: true } },
673
- }
674
- }
675
-
676
- const useGPT = await askYesNo(rl, "Enable dedicated GPT primary agent too? [Y/n]: ", true)
677
- const useGLM = await askYesNo(rl, "Enable dedicated GLM primary agent too? [Y/n]: ", true)
678
- const gptTiers = await askThreeTier(rl, "GPT", choices.gpt)
679
- const glmTiers = await askThreeTier(rl, "GLM", choices.glm)
680
- return {
681
- label: "mix-three-tier",
682
- profile: "mix",
683
- enabled: unique([
684
- ...DEFAULT_ENABLED_AGENTS,
685
- ...(useGPT ? ["gpt"] : []),
686
- ...(useGLM ? ["glm"] : []),
687
- ]),
688
- models: buildMixedThreeTierModels(gptTiers, glmTiers),
689
- agents: {
690
- gpt: { enabled: useGPT },
691
- glm: { enabled: useGLM },
692
- },
693
- }
694
- } finally {
695
- rl.close()
696
- }
452
+ // Single flow: detect providers from opencode.json, pick which API/model to
453
+ // pin, done. No profile selection — model-agnostic by design.
454
+ return buildGenericInteractivePreset(currentModel, opencodeConfig, scriptedAnswers)
697
455
  }
698
456
 
699
457
  function createPromptInterface(scriptedAnswers) {
@@ -715,8 +473,15 @@ async function askThreeTier(rl, label, models) {
715
473
  const defaults = chooseThreeTier(choices, label.toLowerCase().includes("glm") ? "glm" : "gpt")
716
474
  while (true) {
717
475
  console.log("")
718
- console.log(`[${packageName}] ${label} model choices:`)
719
- choices.forEach((model, index) => console.log(` ${index + 1}. ${model}`))
476
+ console.log(`[${packageName}] ${label} model choices (★ = recommended default for that tier):`)
477
+ choices.forEach((model, index) => {
478
+ const tags = []
479
+ if (model === defaults.bronze) tags.push("★ bronze")
480
+ if (model === defaults.silver) tags.push("★ silver")
481
+ if (model === defaults.gold) tags.push("★ gold")
482
+ const suffix = tags.length > 0 ? ` ${tags.join(" / ")}` : ""
483
+ console.log(` ${index + 1}. ${model}${suffix}`)
484
+ })
720
485
  const bronze = await askModel(rl, choices, `Pick ${label} bronze/scout [default ${defaults.bronze}]: `, defaults.bronze)
721
486
  const silver = await askModel(rl, choices, `Pick ${label} silver/coder [default ${defaults.silver}]: `, defaults.silver)
722
487
  const gold = await askModel(rl, choices, `Pick ${label} gold/reasoner [default ${defaults.gold}]: `, defaults.gold)
@@ -750,87 +515,48 @@ async function askYesNo(rl, question, defaultValue) {
750
515
  return answer === "y" || answer === "yes"
751
516
  }
752
517
 
753
- function buildMixedThreeTierModels(gptTiers, glmTiers) {
754
- return {
755
- mix: "gpt-gold",
756
- gpt: "gpt-gold",
757
- glm: "glm-gold",
758
- bronze: "glm-bronze",
759
- silver: "glm-silver",
760
- gold: "gpt-gold",
761
- "gpt-bronze": gptTiers.bronze,
762
- "gpt-silver": gptTiers.silver,
763
- "gpt-gold": gptTiers.gold,
764
- "glm-bronze": glmTiers.bronze,
765
- "glm-silver": glmTiers.silver,
766
- "glm-gold": glmTiers.gold,
767
- fast: "bronze",
768
- strong: "gold",
769
- mini: "bronze",
770
- codex: "gpt-gold",
771
- explorer: "bronze",
772
- coder: "silver",
773
- resolver: "gold",
774
- reviewer: "gold",
775
- "deep-reviewer": "gold",
776
- planner: "gold",
777
- }
778
- }
779
-
780
- function buildGPTThreeTierModels(tiers) {
781
- return {
782
- gpt: "gold",
783
- bronze: tiers.bronze,
784
- silver: tiers.silver,
785
- gold: tiers.gold,
786
- fast: "bronze",
787
- strong: "gold",
788
- mini: "bronze",
789
- codex: "gold",
790
- explorer: "bronze",
791
- coder: "silver",
792
- resolver: "gold",
793
- reviewer: "gold",
794
- "deep-reviewer": "gold",
795
- planner: "gold",
796
- }
797
- }
798
-
799
- function buildGLMThreeTierModels(tiers) {
800
- return {
801
- glm: "gold",
802
- bronze: tiers.bronze,
803
- silver: tiers.silver,
804
- gold: tiers.gold,
805
- fast: "bronze",
806
- strong: "gold",
807
- mini: "bronze",
808
- explorer: "bronze",
809
- coder: "gold",
810
- resolver: "gold",
811
- reviewer: "gold",
812
- "deep-reviewer": "gold",
813
- planner: "gold",
814
- }
815
- }
816
-
817
518
  function collectModelChoices(allModels, predicate, hints, includeFallbackHints = true) {
818
519
  const detected = allModels.filter(predicate)
819
- const providerIds = new Set(detected.map((model) => model.split("/")[0]).filter(Boolean))
820
- const matchingHints = includeFallbackHints
821
- ? hints.filter((model) => providerIds.size === 0 || providerIds.has(model.split("/")[0]) || detected.length < 3)
822
- : []
823
- const choices = unique([...detected, ...matchingHints])
520
+ if (detected.length > 0) {
521
+ // Only show the user's own models when they have any — never pollute the picker
522
+ // with hint IDs they don't actually have configured in opencode.json.
523
+ return predicate === isGLMModel ? sortGLMModelChoices(unique(detected)) : unique(detected)
524
+ }
525
+ // Fallback: user has zero models of this family — show the hint list so the
526
+ // picker still has something to display.
527
+ if (!includeFallbackHints) return []
528
+ const choices = unique(hints)
824
529
  return predicate === isGLMModel ? sortGLMModelChoices(choices) : choices
825
530
  }
826
531
 
827
- function chooseThreeTier(models, family, includeFallbackHints = true) {
828
- const fallback = family === "glm" ? GLM_MODEL_HINTS : OPENAI_MODEL_HINTS
829
- const choices = unique(includeFallbackHints ? [...models, ...fallback] : models)
532
+ function chooseThreeTier(models, family) {
533
+ const choices = unique(models)
534
+ if (choices.length === 0) {
535
+ const fallback = family === "glm" ? GLM_MODEL_HINTS : OPENAI_MODEL_HINTS
536
+ return chooseThreeTier(fallback, family)
537
+ }
538
+ // Bucket by tier, then pick the STRONGEST within each bucket so defaults are
539
+ // the newest model of that class (e.g. gpt-5-mini beats gpt-4o-mini for bronze).
540
+ const sorted = sortModelsByStrength(choices)
541
+ return pickFromBuckets(sorted)
542
+ }
543
+
544
+ function pickFromBuckets(sorted) {
545
+ // bronze = cheapest scout (WEAKEST of bronze bucket — e.g. gpt-4o-mini over gpt-5-mini)
546
+ // silver = strongest workhorse coder (STRONGEST of silver bucket — e.g. gpt-5.3-codex)
547
+ // gold = flagship reasoner (STRONGEST of gold bucket — e.g. gpt-5.5)
548
+ const buckets = [[], [], []]
549
+ for (const m of sorted) buckets[inferModelTier(m)].push(m)
550
+ const weakestOf = (bucket) => bucket.length > 0 ? bucket[0] : null
551
+ const strongestOf = (bucket) => bucket.length > 0 ? bucket[bucket.length - 1] : null
552
+ const n = sorted.length
553
+ const fallbackBronze = sorted[0]
554
+ const fallbackGold = sorted[n - 1]
555
+ const fallbackSilver = n >= 2 ? sorted[Math.floor(n / 2)] : sorted[0]
830
556
  return {
831
- bronze: preferModel(choices, family === "glm" ? ["5.1", "4.5", "5"] : ["spark", "mini", "4o-mini"], choices[0]),
832
- silver: preferModel(choices, family === "glm" ? ["5.1", "4.5", "5"] : ["codex", "5.3", "5.2"], choices[1] ?? choices[0]),
833
- gold: preferModel(choices, family === "glm" ? ["5.1", "5", "4.5"] : ["5.5", "5.4", "gpt-5.3-codex"], choices[2] ?? choices[1] ?? choices[0]),
557
+ bronze: weakestOf(buckets[0]) ?? fallbackBronze,
558
+ silver: strongestOf(buckets[1]) ?? fallbackSilver,
559
+ gold: strongestOf(buckets[2]) ?? fallbackGold,
834
560
  }
835
561
  }
836
562
 
@@ -840,6 +566,7 @@ function sortGLMModelChoices(models) {
840
566
 
841
567
  function rankGLMModel(model) {
842
568
  const lower = model.toLowerCase()
569
+ if (lower.includes("5.2")) return -1
843
570
  if (lower.includes("5.1")) return 0
844
571
  if (lower.includes("4.5") && !lower.includes("air") && !lower.includes("flash")) return 1
845
572
  if (lower.includes("4.5-airx")) return 2
@@ -891,24 +618,35 @@ function readInstallerOption(name) {
891
618
  }
892
619
 
893
620
  function getPresetLabel(currentModel) {
894
- if (!currentModel) return "inherited"
895
- const lower = currentModel.toLowerCase()
896
- if (lower.includes("glm") || lower.includes("zai")) return "glm-only"
897
- if (lower.includes("openai/") || lower.includes("gpt")) return "gpt-only"
898
- return "inherited"
621
+ return currentModel ? "configured" : "inherited"
899
622
  }
900
623
 
901
- function inferModelStrength(modelId) {
624
+ function inferModelTier(modelId) {
625
+ if (typeof modelId !== "string") return 1
626
+ if (TIER_BRONZE_RE.test(modelId)) return 0
627
+ if (TIER_GOLD_RE.test(modelId)) return 2
628
+ return 1
629
+ }
630
+
631
+ function extractModelVersion(modelId) {
902
632
  if (typeof modelId !== "string") return 0
903
- let score = 0
904
- for (const { re, score: s } of STRENGTH_BOOSTS) {
905
- if (re.test(modelId)) score += s
906
- }
907
- return score
633
+ const match = modelId.match(/\b(\d+(?:\.\d+)?)\b/)
634
+ return match ? Number.parseFloat(match[1]) : 0
635
+ }
636
+
637
+ function inferModelStrength(modelId) {
638
+ // Composite ordering: tier first, then version. Used for sortModelsByStrength.
639
+ return inferModelTier(modelId) * 100 + extractModelVersion(modelId)
908
640
  }
909
641
 
910
642
  function sortModelsByStrength(models) {
911
- return [...models].sort((a, b) => inferModelStrength(a) - inferModelStrength(b))
643
+ return [...models].sort((a, b) => {
644
+ const diff = inferModelStrength(a) - inferModelStrength(b)
645
+ if (diff !== 0) return diff
646
+ // Tie on strength: keep the canonical/shorter variant last so bucket "strongest"
647
+ // picks e.g. gpt-5.3-codex over gpt-5.3-codex-spark.
648
+ return b.length - a.length
649
+ })
912
650
  }
913
651
 
914
652
  function detectProvidersFromConfig(config) {
@@ -1051,23 +789,41 @@ async function buildGenericInteractivePreset(currentModel, opencodeConfig, scrip
1051
789
  const shapeAns = await askChoice(rl, `Tier shape [1,2,3, default ${shapeDefault}]: `, ["1", "2", "3"], shapeDefault)
1052
790
  const shape = shapeAns === "3" ? "three" : shapeAns === "2" ? "two" : "single"
1053
791
 
1054
- // 3. Pick models
1055
- const weakest = sorted[0]
1056
- const strongest = sorted[sorted.length - 1]
1057
- const middle = sorted[Math.floor((sorted.length - 1) / 2)]
792
+ // 3. Pick models — use the same bucket-aware picks as askThreeTier so the
793
+ // generic flow defaults line up with the GPT/GLM tier shortcuts.
794
+ const tierPicks = pickFromBuckets(sorted)
795
+ const n = sorted.length
796
+ const strongest = sorted[n - 1]
797
+ const bronzeDefault = tierPicks.bronze
798
+ const silverDefault = tierPicks.silver
799
+ const goldDefault = tierPicks.gold
1058
800
  console.log("")
1059
- console.log(`[${packageName}] Step 3/3 — Pick models (defaults inferred from name strength):`)
1060
- sorted.forEach((m, i) => console.log(` ${i + 1}. ${m}`))
801
+ console.log(`[${packageName}] Step 3/3 — Pick models ( = recommended default for that tier):`)
802
+ sorted.forEach((m, i) => {
803
+ const tags = []
804
+ if (shape === "three") {
805
+ if (m === bronzeDefault) tags.push("★ bronze")
806
+ if (m === silverDefault) tags.push("★ silver")
807
+ if (m === goldDefault) tags.push("★ gold")
808
+ } else if (shape === "two") {
809
+ if (m === bronzeDefault) tags.push("★ silver")
810
+ if (m === goldDefault) tags.push("★ gold")
811
+ } else if (m === strongest) {
812
+ tags.push("★ all roles")
813
+ }
814
+ const suffix = tags.length > 0 ? ` ${tags.join(" / ")}` : ""
815
+ console.log(` ${i + 1}. ${m}${suffix}`)
816
+ })
1061
817
 
1062
818
  while (true) {
1063
819
  const tiers = { shape }
1064
820
  if (shape === "three") {
1065
- tiers.bronze = await askModel(rl, sorted, `Bronze (scout for explorer/researcher) [default ${weakest}]: `, weakest)
1066
- tiers.silver = await askModel(rl, sorted, `Silver (coder/debugger) [default ${middle}]: `, middle)
1067
- tiers.gold = await askModel(rl, sorted, `Gold (resolver/reviewer/deep-reviewer/planner/architect) [default ${strongest}]: `, strongest)
821
+ tiers.bronze = await askModel(rl, sorted, `Bronze (scout for explorer/researcher) [default ${bronzeDefault}]: `, bronzeDefault)
822
+ tiers.silver = await askModel(rl, sorted, `Silver (coder/debugger) [default ${silverDefault}]: `, silverDefault)
823
+ tiers.gold = await askModel(rl, sorted, `Gold (resolver/reviewer/deep-reviewer/planner/architect) [default ${goldDefault}]: `, goldDefault)
1068
824
  } else if (shape === "two") {
1069
- tiers.silver = await askModel(rl, sorted, `Silver (fast: coder/explorer/debugger/researcher) [default ${weakest}]: `, weakest)
1070
- tiers.gold = await askModel(rl, sorted, `Gold (strong: resolver/reviewer/…/architect) [default ${strongest}]: `, strongest)
825
+ tiers.silver = await askModel(rl, sorted, `Silver (fast: coder/explorer/debugger/researcher) [default ${bronzeDefault}]: `, bronzeDefault)
826
+ tiers.gold = await askModel(rl, sorted, `Gold (strong: resolver/reviewer/…/architect) [default ${goldDefault}]: `, goldDefault)
1071
827
  } else {
1072
828
  tiers.gold = await askModel(rl, sorted, `Model for all roles [default ${strongest}]: `, strongest)
1073
829
  }
@@ -1079,8 +835,7 @@ async function buildGenericInteractivePreset(currentModel, opencodeConfig, scrip
1079
835
  const ok = await askYesNo(rl, `Confirm picks? [Y/n] (n re-asks all): `, true)
1080
836
  if (ok) {
1081
837
  return {
1082
- label: `generic-${shape}-tier`,
1083
- profile: "mix",
838
+ label: `${shape}-tier`,
1084
839
  enabled: DEFAULT_ENABLED_AGENTS,
1085
840
  models: buildGenericResolveModels(tiers),
1086
841
  agents: {},
@@ -1256,115 +1011,3 @@ function formatError(error) {
1256
1011
  return error instanceof Error ? error.message : String(error)
1257
1012
  }
1258
1013
 
1259
- async function offerCompanionPlugins() {
1260
- if (process.env.OPENCODE_RESOLVE_SKIP_COMPANIONS === "1") return
1261
-
1262
- const config = await readOpenCodeConfig()
1263
- const existing = collectPluginBaseNames(config.plugin ?? [])
1264
- const missing = COMPANION_PLUGINS.filter((c) => !existing.has(c.pkg))
1265
-
1266
- if (missing.length === 0) {
1267
- console.log(`[${packageName}] recommended companion plugins already present — skipping`)
1268
- return
1269
- }
1270
-
1271
- const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
1272
-
1273
- if (!isInteractive) {
1274
- console.log(`[${packageName}] recommended companion plugins not detected:`)
1275
- for (const c of missing) {
1276
- console.log(` - ${c.pkg} — ${c.desc}`)
1277
- console.log(` install: opencode plugin ${c.pkg}@latest --global --force`)
1278
- }
1279
- return
1280
- }
1281
-
1282
- const rl = createInterface({ input: process.stdin, output: process.stdout })
1283
- try {
1284
- for (const c of missing) {
1285
- console.log("")
1286
- console.log(`[${packageName}] recommended companion: ${c.pkg}`)
1287
- console.log(` ${c.desc}`)
1288
- const raw = await rl.question(" install now? [Y/n] ")
1289
- const answer = raw.trim().toLowerCase()
1290
- const accepted = answer === "" || answer === "y" || answer === "yes"
1291
- if (!accepted) {
1292
- console.log(` skipped — install later via: opencode plugin ${c.pkg}@latest --global --force`)
1293
- continue
1294
- }
1295
- const installed = await installCompanion(c.pkg)
1296
- if (!installed) {
1297
- console.warn(` ${c.pkg} install command failed — leave plugin list untouched, retry manually`)
1298
- continue
1299
- }
1300
- await addCompanionToOpenCodeConfig(`${c.pkg}@latest`)
1301
- console.log(` ${c.pkg} cached and registered — restart OpenCode to activate`)
1302
- }
1303
- } finally {
1304
- rl.close()
1305
- }
1306
- }
1307
-
1308
- function collectPluginBaseNames(plugins) {
1309
- const names = new Set()
1310
- for (const entry of plugins) {
1311
- const raw = typeof entry === "string"
1312
- ? entry
1313
- : Array.isArray(entry) && typeof entry[0] === "string"
1314
- ? entry[0]
1315
- : null
1316
- if (!raw) continue
1317
- names.add(stripVersionSuffix(raw))
1318
- }
1319
- return names
1320
- }
1321
-
1322
- function stripVersionSuffix(name) {
1323
- if (name.startsWith("@")) {
1324
- const slashIndex = name.indexOf("/")
1325
- if (slashIndex === -1) return name
1326
- const scope = name.slice(0, slashIndex)
1327
- const rest = name.slice(slashIndex + 1)
1328
- return `${scope}/${rest.split("@")[0]}`
1329
- }
1330
- return name.split("@")[0]
1331
- }
1332
-
1333
- async function installCompanion(pkg) {
1334
- return new Promise((resolveSpawn) => {
1335
- const child = spawn("opencode", ["plugin", `${pkg}@latest`, "--global", "--force"], {
1336
- stdio: "inherit",
1337
- })
1338
- child.on("exit", (code) => resolveSpawn(code === 0))
1339
- child.on("error", () => resolveSpawn(false))
1340
- })
1341
- }
1342
-
1343
- async function addCompanionToOpenCodeConfig(pluginEntry) {
1344
- const baseName = stripVersionSuffix(pluginEntry)
1345
- const probe = await readOpenCodeConfig()
1346
- const alreadyPresent = isCompanionPresent(probe, baseName)
1347
- if (alreadyPresent) return
1348
-
1349
- const fresh = await readOpenCodeConfig()
1350
- if (!isCompanionPresent(fresh, baseName)) {
1351
- fresh.plugin ??= []
1352
- if (!Array.isArray(fresh.plugin)) {
1353
- throw new Error(`${opencodeConfigPath}.plugin must be an array`)
1354
- }
1355
- fresh.plugin.push(pluginEntry)
1356
- await writeFile(opencodeConfigPath, `${JSON.stringify(fresh, null, 2)}\n`)
1357
- }
1358
- }
1359
-
1360
- function isCompanionPresent(config, baseName) {
1361
- if (!Array.isArray(config.plugin)) return false
1362
- return config.plugin.some((entry) => {
1363
- const raw = typeof entry === "string"
1364
- ? entry
1365
- : Array.isArray(entry) && typeof entry[0] === "string"
1366
- ? entry[0]
1367
- : null
1368
- return raw !== null && stripVersionSuffix(raw) === baseName
1369
- })
1370
- }