docks-kit 0.16.16 → 0.17.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.
@@ -402,43 +402,10 @@ async function nodeBelowServerFloor(ctx: Ctx): Promise<string> {
402
402
  return belowFloor(installed, floor) ? installed : ""
403
403
  }
404
404
 
405
- export async function syncLspServers(ctx: Ctx): Promise<void> {
405
+ /** npm-global channel: intelephense, typescript-language-server, typescript. */
406
+ async function installNpmServers(ctx: Ctx, missing: ReadonlyArray<string>): Promise<void> {
406
407
  const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
407
- const sot = parseJson(payloadText("SoT/.claude/settings.json"))
408
- const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
409
- if (enabled === undefined) return
410
- const hasPhp = Object.prototype.hasOwnProperty.call(enabled, "php-lsp@claude-plugins-official")
411
- const hasTs = Object.prototype.hasOwnProperty.call(enabled, "typescript-lsp@claude-plugins-official")
412
- if (!hasPhp && !hasTs) return
413
-
414
- const phpMissing = hasPhp && ctx.services.deps.probe("intelephense").state === "missing"
415
- const tsServerMissing = hasTs && ctx.services.deps.probe("typescript-language-server").state === "missing"
416
- const tscMissing = hasTs && ctx.services.deps.probe("tsc").state === "missing"
417
- const missingToolCount = Number(phpMissing) + Number(tsServerMissing) + Number(tscMissing)
418
- const blockingNode = tsServerMissing ? await nodeBelowServerFloor(ctx) : ""
419
- if (blockingNode !== "") {
420
- warn(
421
- `Skipping typescript-language-server install: Node ${blockingNode} is older than the ${field("node", "floor")} that version requires. Upgrade Node, then re-run sync.`
422
- )
423
- }
424
- const missing = [
425
- phpMissing ? lspPkg(ctx, "intelephense", "intelephense") : undefined,
426
- tsServerMissing && blockingNode === ""
427
- ? lspPkg(ctx, "typescript-language-server", "typescript-language-server")
428
- : undefined,
429
- tscMissing ? lspPkg(ctx, "tsc", "typescript") : undefined
430
- ].filter((spec): spec is string => spec !== undefined)
431
-
432
- if (missingToolCount === 0) {
433
- if (ctx.dryRun) {
434
- echo("[dry-run] LSP server binaries present")
435
- } else {
436
- verbose("LSP server binaries present")
437
- }
438
- return
439
- }
440
408
  if (missing.length === 0) return
441
-
442
409
  const specs = missing.join(" ")
443
410
  if (ctx.dryRun) {
444
411
  echo(`[dry-run] would install: npm install -g ${specs}`)
@@ -465,3 +432,79 @@ export async function syncLspServers(ctx: Ctx): Promise<void> {
465
432
  warn(`npm install -g ${specs} failed. Try manually: npm install -g ${specs}`)
466
433
  }
467
434
  }
435
+
436
+ /**
437
+ * rustup channel: rust-analyzer ships no npm package, and upstream's first
438
+ * recommendation is the rustup component, which tracks the toolchain the host
439
+ * already trusts. The component version therefore follows that toolchain, so
440
+ * the manifest row carries no verified pin - the stance bubblewrap already
441
+ * takes for a distro package.
442
+ */
443
+ async function installRustAnalyzer(ctx: Ctx): Promise<void> {
444
+ const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
445
+ if (ctx.dryRun) {
446
+ echo("[dry-run] would install: rustup component add rust-analyzer")
447
+ return
448
+ }
449
+ verbose("Installing rust-analyzer via rustup...")
450
+ progress("Installing rust-analyzer via rustup...")
451
+ const result = await spawnProcess("rustup", ["component", "add", "rust-analyzer"], { stdio: "ignore" })
452
+ clearProgress()
453
+ if (result.exitCode === 0) {
454
+ change("LSP server installed (rust-analyzer via rustup)")
455
+ ctx.nextStepTriggers.claudeRestart = true
456
+ } else {
457
+ warn("rustup component add rust-analyzer failed. Try manually: rustup component add rust-analyzer")
458
+ }
459
+ }
460
+
461
+ export async function syncLspServers(ctx: Ctx): Promise<void> {
462
+ const { echo, verbose, warn } = ctx.services.logger
463
+ const sot = parseJson(payloadText("SoT/.claude/settings.json"))
464
+ const enabled = sot !== undefined && isObject(sot) && isObject(sot["enabledPlugins"]) ? sot["enabledPlugins"] : undefined
465
+ if (enabled === undefined) return
466
+ const enables = (plugin: string): boolean => Object.prototype.hasOwnProperty.call(enabled, plugin)
467
+ const hasPhp = enables("php-lsp@claude-plugins-official")
468
+ const hasTs = enables("typescript-lsp@claude-plugins-official")
469
+ const hasRust = enables("rust-analyzer-lsp@claude-plugins-official")
470
+ if (!hasPhp && !hasTs && !hasRust) return
471
+
472
+ const phpMissing = hasPhp && ctx.services.deps.probe("intelephense").state === "missing"
473
+ const tsServerMissing = hasTs && ctx.services.deps.probe("typescript-language-server").state === "missing"
474
+ const tscMissing = hasTs && ctx.services.deps.probe("tsc").state === "missing"
475
+ const rustMissing = hasRust && ctx.services.deps.probe("rust-analyzer").state === "missing"
476
+ // A host without rustup does no Rust work, so the plugin has nothing to
477
+ // serve there. Installing is impossible and warning every sync would name no
478
+ // action the user wants, so that host is not counted as missing a tool.
479
+ const rustInstallable = rustMissing && ctx.services.deps.probe("rustup").state === "present"
480
+ if (rustMissing && !rustInstallable) {
481
+ verbose("Skipping rust-analyzer: rustup is not installed, so the rust-analyzer-lsp plugin stays a no-op")
482
+ }
483
+
484
+ const missingToolCount = Number(phpMissing) + Number(tsServerMissing) + Number(tscMissing) + Number(rustInstallable)
485
+ if (missingToolCount === 0) {
486
+ if (ctx.dryRun) {
487
+ echo("[dry-run] LSP server binaries present")
488
+ } else {
489
+ verbose("LSP server binaries present")
490
+ }
491
+ return
492
+ }
493
+
494
+ const blockingNode = tsServerMissing ? await nodeBelowServerFloor(ctx) : ""
495
+ if (blockingNode !== "") {
496
+ warn(
497
+ `Skipping typescript-language-server install: Node ${blockingNode} is older than the ${field("node", "floor")} that version requires. Upgrade Node, then re-run sync.`
498
+ )
499
+ }
500
+ const missing = [
501
+ phpMissing ? lspPkg(ctx, "intelephense", "intelephense") : undefined,
502
+ tsServerMissing && blockingNode === ""
503
+ ? lspPkg(ctx, "typescript-language-server", "typescript-language-server")
504
+ : undefined,
505
+ tscMissing ? lspPkg(ctx, "tsc", "typescript") : undefined
506
+ ].filter((spec): spec is string => spec !== undefined)
507
+
508
+ await installNpmServers(ctx, missing)
509
+ if (rustInstallable) await installRustAnalyzer(ctx)
510
+ }
@@ -30,6 +30,8 @@ export type ToolId =
30
30
  | "intelephense"
31
31
  | "typescript-language-server"
32
32
  | "tsc"
33
+ | "rust-analyzer"
34
+ | "rustup"
33
35
  | "apt-get"
34
36
  | "dnf"
35
37
  | "pacman"
@@ -224,6 +226,13 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
224
226
  resolve: pathProbe("tsc"),
225
227
  version: versionProbe("tsc")
226
228
  }),
229
+ "rust-analyzer": spec("rust-analyzer", "optional", () => "rustup component add rust-analyzer", {
230
+ resolve: pathProbe("rust-analyzer"),
231
+ version: versionProbe("rust-analyzer")
232
+ }),
233
+ rustup: spec("rustup", "optional", () => "install the Rust toolchain from https://rustup.rs", {
234
+ version: versionProbe("rustup")
235
+ }),
227
236
  "apt-get": spec("apt-get", "optional", () => "install apt via your Linux distribution"),
228
237
  dnf: spec("dnf", "optional", () => "install dnf via your Linux distribution"),
229
238
  pacman: spec("pacman", "optional", () => "install pacman via your Linux distribution"),
@@ -3,10 +3,62 @@
3
3
  * the intended binary with deterministic argv, and capture() mirrors command
4
4
  * substitution: stdout with trailing newlines stripped, empty on failure.
5
5
  */
6
- import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
6
+ import {
7
+ spawn,
8
+ spawnSync,
9
+ type ChildProcess,
10
+ type SpawnOptions,
11
+ type SpawnSyncOptions,
12
+ type SpawnSyncOptionsWithStringEncoding,
13
+ type SpawnSyncReturns
14
+ } from "node:child_process"
7
15
  import { accessSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
8
16
  import { delimiter, extname, isAbsolute, join } from "node:path"
9
- import { hostOs, type HostOs } from "./os"
17
+ import { hostOs, type HostOs, type Invocation } from "./os"
18
+
19
+ /** A tool this host cannot resolve, shaped like the failed spawn it replaces. */
20
+ const notFound = (command: string): SpawnSyncReturns<string> => ({
21
+ pid: 0,
22
+ output: [],
23
+ stdout: "",
24
+ stderr: "",
25
+ status: null,
26
+ signal: null,
27
+ error: new Error(`command not found on PATH: ${command}`)
28
+ })
29
+
30
+ /**
31
+ * Every synchronous child starts here, because two host facts must never be
32
+ * separated from the argv they describe: a Windows shim invocation is only
33
+ * correct with the verbatim-arguments flag, and a pathless name would let
34
+ * CreateProcess search the parent's current directory before the system one.
35
+ */
36
+ export const spawnHost = (
37
+ command: string,
38
+ args: ReadonlyArray<string>,
39
+ overrides: SpawnSyncOptions = {},
40
+ host: HostOs = hostOs()
41
+ ): SpawnSyncReturns<string> => {
42
+ const resolvesSuffixes = host.executableSuffixes.some((suffix) => suffix !== "")
43
+ const executablePath = resolvesSuffixes ? which(command, host.executableSuffixes) : command
44
+ if (executablePath === "") return notFound(command)
45
+ let invocation: Invocation
46
+ try {
47
+ invocation = host.invoke(executablePath, args)
48
+ } catch (cause) {
49
+ // A value this host cannot put on a command line at all. Print the encoder's
50
+ // reason and exit, matching how a caller reports a failed child.
51
+ process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
52
+ return process.exit(2)
53
+ }
54
+ const options: SpawnSyncOptionsWithStringEncoding = {
55
+ stdio: ["ignore", "pipe", "pipe"],
56
+ ...overrides,
57
+ encoding: "utf8",
58
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments
59
+ }
60
+ return spawnSync(invocation.command, [...invocation.args], options)
61
+ }
10
62
 
11
63
  /** Keep engine paths slash-separated so rendered output is host-stable. */
12
64
  export function p(...parts: Array<string>): string {
@@ -14,6 +14,21 @@ export type Harness = "claude" | "codex" | "agents" | "omp"
14
14
  export const HARNESSES: ReadonlyArray<Harness> = ["claude", "codex", "agents", "omp"]
15
15
  export const LEGACY_SELECTION: ReadonlyArray<Harness> = ["claude", "codex", "agents"]
16
16
 
17
+ export interface OmpSessionModel {
18
+ readonly selector: string
19
+ // Session ceiling; absent when the model publishes no ladder, so no
20
+ // invented level ever reaches a selector that omp must resolve.
21
+ readonly thinking?: string
22
+ // Advisor level; absent when the model publishes no ladder.
23
+ readonly advisorThinking?: string
24
+ }
25
+
26
+ export const DEFAULT_OMP_SESSION_MODEL: OmpSessionModel = {
27
+ selector: "opencode-zen/muse-spark-1.3-contributor-free",
28
+ thinking: "xhigh",
29
+ advisorThinking: "medium",
30
+ }
31
+
17
32
  function isHarness(value: unknown): value is Harness {
18
33
  return value === "claude" || value === "codex" || value === "agents" || value === "omp"
19
34
  }
@@ -36,8 +51,9 @@ export function harnessStateFile(home: string): string {
36
51
  return p(home, ".docks-kit", "state.json")
37
52
  }
38
53
 
39
- /** Read valid local state without allowing corruption to make sync unusable. */
40
- export function readHarnessSelection(home: string): ReadonlyArray<Harness> | undefined {
54
+ // Read the whole state record so one key writer keeps sibling keys intact.
55
+ // A corrupt file degrades to undefined so callers fall back to defaults.
56
+ function readWholeState(home: string): Record<string, unknown> | undefined {
41
57
  let parsed: unknown
42
58
  try {
43
59
  parsed = JSON.parse(readFileSync(harnessStateFile(home), "utf8")) as unknown
@@ -47,7 +63,30 @@ export function readHarnessSelection(home: string): ReadonlyArray<Harness> | und
47
63
 
48
64
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
49
65
  const state = parsed as Record<string, unknown>
50
- if (state["version"] !== 1 || !Array.isArray(state["harnesses"])) return undefined
66
+ if (state["version"] !== 1) return undefined
67
+ return state
68
+ }
69
+
70
+ // Merge the patch over the stored record so independent keys never erase
71
+ // each other when only one writer runs.
72
+ function writeWholeState(home: string, patch: Record<string, unknown>): void {
73
+ const existing = readWholeState(home) ?? {}
74
+ const next = { ...existing, ...patch, version: 1 }
75
+ const directory = p(home, ".docks-kit")
76
+ const file = harnessStateFile(home)
77
+ const text = `${JSON.stringify(next, null, 2)}\n`
78
+ // `mode` applies only when mkdir creates the path, so an existing permissive
79
+ // ~/.docks-kit would keep its mode.
80
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
81
+ chmodSync(directory, 0o700)
82
+ writeFileSync(file, text, { mode: 0o600 })
83
+ chmodSync(file, 0o600)
84
+ }
85
+
86
+ /** Read valid local state without allowing corruption to make sync unusable. */
87
+ export function readHarnessSelection(home: string): ReadonlyArray<Harness> | undefined {
88
+ const state = readWholeState(home)
89
+ if (state === undefined || !Array.isArray(state["harnesses"])) return undefined
51
90
 
52
91
  const selection = normalizeHarnesses(state["harnesses"])
53
92
  return selection.length > 0 ? selection : undefined
@@ -63,13 +102,55 @@ export function writeHarnessSelection(home: string, selection: ReadonlyArray<Har
63
102
  throw new Error("Harness selection must contain at least one known harness name")
64
103
  }
65
104
 
66
- const directory = p(home, ".docks-kit")
67
- const file = harnessStateFile(home)
68
- const text = `${JSON.stringify({ version: 1, harnesses }, null, 2)}\n`
69
- // `mode` applies only when mkdir creates the path, so an existing permissive
70
- // ~/.docks-kit would keep its mode.
71
- mkdirSync(directory, { recursive: true, mode: 0o700 })
72
- chmodSync(directory, 0o700)
73
- writeFileSync(file, text, { mode: 0o600 })
74
- chmodSync(file, 0o600)
105
+ writeWholeState(home, { harnesses })
106
+ }
107
+
108
+ function isNonBlankString(value: unknown): value is string {
109
+ return typeof value === "string" && value.trim() !== ""
110
+ }
111
+
112
+ // Read the stored session model without throwing so a corrupt entry falls
113
+ // back to the default instead of breaking sync.
114
+ export function readOmpSessionModel(home: string): OmpSessionModel | undefined {
115
+ const state = readWholeState(home)
116
+ if (state === undefined) return undefined
117
+ const entry = state["ompSession"]
118
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return undefined
119
+ const record = entry as Record<string, unknown>
120
+ if (!isNonBlankString(record["selector"])) {
121
+ return undefined
122
+ }
123
+ // Each level stands alone, so a level-free model reads back with no
124
+ // levels while a half-corrupt entry keeps the valid level.
125
+ const model: { selector: string; thinking?: string; advisorThinking?: string } = {
126
+ selector: record["selector"],
127
+ }
128
+ if (isNonBlankString(record["thinking"])) {
129
+ model.thinking = record["thinking"]
130
+ }
131
+ if (isNonBlankString(record["advisorThinking"])) {
132
+ model.advisorThinking = record["advisorThinking"]
133
+ }
134
+ return model
135
+ }
136
+
137
+ export function writeOmpSessionModel(home: string, model: OmpSessionModel): void {
138
+ // A blank selector would make omp resolve an arbitrary model, so reject it
139
+ // before anything reaches disk.
140
+ if (!isNonBlankString(model.selector)) {
141
+ throw new Error("Omp session model selector must be a non-empty string")
142
+ }
143
+ // Persist only non-blank levels so a switch to a level-free model leaves
144
+ // no stale level behind in the stored record.
145
+ const entry: { selector: string; thinking?: string; advisorThinking?: string } = {
146
+ selector: model.selector,
147
+ }
148
+ if (isNonBlankString(model.thinking)) {
149
+ entry.thinking = model.thinking
150
+ }
151
+ if (isNonBlankString(model.advisorThinking)) {
152
+ entry.advisorThinking = model.advisorThinking
153
+ }
154
+
155
+ writeWholeState(home, { ompSession: entry })
75
156
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Run overlay for `docks-kit omp`. The overlay merges over the deployed omp
3
+ * config for one run only, so every key that can cause paid spend is set here
4
+ * and every other key falls through to the deployed paid config.
5
+ */
6
+ import { createHash } from "node:crypto"
7
+ import type { OmpSessionModel } from "./harnesses"
8
+
9
+ export interface CatalogModel {
10
+ readonly selector: string
11
+ readonly name: string
12
+ readonly thinking: ReadonlyArray<string>
13
+ readonly contextWindow: number
14
+ }
15
+
16
+ /** Thinking levels from lowest to highest effort. */
17
+ export const THINKING_LADDER: ReadonlyArray<string> = [
18
+ "minimal",
19
+ "low",
20
+ "medium",
21
+ "high",
22
+ "xhigh",
23
+ "max",
24
+ ]
25
+
26
+ /**
27
+ * One overlay file per model, so a second launcher on another model cannot
28
+ * rewrite the file a running session still reads. A selector carries a slash
29
+ * and may carry other characters a host rejects in a file name, so unsafe
30
+ * characters become hyphens. That mapping alone is not injective, because
31
+ * `a/b` and `a-b` both fold to `a-b`, so a digest of the exact selector keeps
32
+ * two distinct models on two distinct files.
33
+ */
34
+ export function overlayFileName(selector: string): string {
35
+ const safe = selector.replace(/[^A-Za-z0-9._-]/g, "-")
36
+ const digest = createHash("sha256").update(selector).digest("hex").slice(0, 8)
37
+ return `omp-free-${safe}-${digest}.yml`
38
+ }
39
+
40
+ /** Role keys in the order used by SoT/.omp/config.yml for readable diffs. */
41
+ const ROLE_ORDER: ReadonlyArray<string> = [
42
+ "smol",
43
+ "advisor",
44
+ "designer",
45
+ "plan",
46
+ "commit",
47
+ "task",
48
+ "vision",
49
+ "tiny",
50
+ "default",
51
+ "slow",
52
+ "fable",
53
+ "switch_fable",
54
+ ]
55
+
56
+ /** Retry chain keys in the order used by SoT/.omp/config.yml. */
57
+ const FALLBACK_ORDER: ReadonlyArray<string> = [
58
+ "default",
59
+ "advisor",
60
+ "task",
61
+ "vision",
62
+ "smol",
63
+ "tiny",
64
+ "commit",
65
+ "switch_fable",
66
+ "fable",
67
+ ]
68
+
69
+ /**
70
+ * Collect free models from parsed `omp models --json` output. The function is
71
+ * total because catalog output varies across omp versions, and one malformed
72
+ * row must not discard the remaining picker list.
73
+ */
74
+ export function parseFreeModels(catalogJson: unknown): ReadonlyArray<CatalogModel> {
75
+ if (typeof catalogJson !== "object" || catalogJson === null || Array.isArray(catalogJson)) {
76
+ return []
77
+ }
78
+ const models = (catalogJson as Record<string, unknown>)["models"]
79
+ if (!Array.isArray(models)) return []
80
+ const found: Array<CatalogModel> = []
81
+ for (const row of models) {
82
+ if (typeof row !== "object" || row === null || Array.isArray(row)) continue
83
+ const record = row as Record<string, unknown>
84
+ const selector = record["selector"]
85
+ if (typeof selector !== "string" || selector.length === 0) continue
86
+ const cost = record["cost"]
87
+ if (typeof cost !== "object" || cost === null || Array.isArray(cost)) continue
88
+ const charges = cost as Record<string, unknown>
89
+ if (charges["input"] !== 0 || charges["output"] !== 0) continue
90
+ const rawName = record["name"]
91
+ const name = typeof rawName === "string" && rawName.length > 0 ? rawName : selector
92
+ const rawLevels = record["thinking"]
93
+ // Keep only known ladder levels in ladder order so an unknown future
94
+ // level never reaches a role value that omp would reject.
95
+ const thinking = THINKING_LADDER.filter(
96
+ (level) => Array.isArray(rawLevels) && (rawLevels as Array<unknown>).includes(level),
97
+ )
98
+ const rawWindow = record["contextWindow"]
99
+ const contextWindow =
100
+ typeof rawWindow === "number" && Number.isFinite(rawWindow) ? rawWindow : 0
101
+ found.push({ selector, name, thinking, contextWindow })
102
+ }
103
+ // Sort by selector so the picker list is stable across catalog orderings.
104
+ found.sort((a, b) => (a.selector < b.selector ? -1 : a.selector > b.selector ? 1 : 0))
105
+ return found
106
+ }
107
+
108
+ /**
109
+ * Resolve the highest ladder level a model supports. A model without a
110
+ * recognized ladder yields undefined so callers never invent a level the
111
+ * model would reject at session start.
112
+ */
113
+ export function ladderCeiling(levels: ReadonlyArray<string>): string | undefined {
114
+ for (let index = THINKING_LADDER.length - 1; index >= 0; index -= 1) {
115
+ if (levels.includes(THINKING_LADDER[index] as string)) return THINKING_LADDER[index] as string
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ /**
121
+ * Resolve the advisor level from the model own ladder. The advisor runs
122
+ * quick review passes, so it takes the highest level at or below medium,
123
+ * and the lowest level when the ladder starts above medium. The result
124
+ * never exceeds the ladder ceiling by construction.
125
+ */
126
+ export function advisorLevelFor(levels: ReadonlyArray<string>): string | undefined {
127
+ const known = THINKING_LADDER.filter((level) => levels.includes(level))
128
+ if (known.length === 0) return undefined
129
+ const mediumIndex = THINKING_LADDER.indexOf("medium")
130
+ let advisor: string | undefined
131
+ for (const level of known) {
132
+ if ((THINKING_LADDER.indexOf(level) as number) <= (mediumIndex as number)) advisor = level
133
+ }
134
+ return advisor ?? (known[0] as string)
135
+ }
136
+
137
+ /**
138
+ * Render the run overlay as YAML text. Empty fallback chains keep a retry
139
+ * from falling back onto a paid model. A model without a level renders bare
140
+ * selectors with no thinking keys so the deployed values apply.
141
+ */
142
+ export function renderFreeOverlay(model: OmpSessionModel): string {
143
+ const thinking = typeof model.thinking === "string" && model.thinking.trim() !== "" ? model.thinking : undefined
144
+ const lines: Array<string> = ["# Generated per run by docks-kit omp. Edits are overwritten."]
145
+ if (thinking !== undefined) {
146
+ lines.push(`defaultThinkingLevel: ${thinking}`, "modelRoles:")
147
+ const advisor =
148
+ typeof model.advisorThinking === "string" && model.advisorThinking.trim() !== ""
149
+ ? model.advisorThinking
150
+ : thinking
151
+ for (const role of ROLE_ORDER) {
152
+ lines.push(` ${role}: ${model.selector}:${role === "advisor" ? advisor : thinking}`)
153
+ }
154
+ lines.push("task:", ` maxEffort: ${thinking}`)
155
+ } else {
156
+ lines.push("modelRoles:")
157
+ for (const role of ROLE_ORDER) {
158
+ lines.push(` ${role}: ${model.selector}`)
159
+ }
160
+ }
161
+ lines.push("retry:", " fallbackChains:")
162
+ for (const chain of FALLBACK_ORDER) {
163
+ lines.push(` ${chain}: []`)
164
+ }
165
+ return `${lines.join("\n")}\n`
166
+ }
167
+
168
+ /** Build the omp argv with the overlay flag ahead of the forwarded args. */
169
+ export function buildOmpArgs(
170
+ overlayPath: string,
171
+ passthrough: ReadonlyArray<string>,
172
+ ): ReadonlyArray<string> {
173
+ return ["--config", overlayPath, ...passthrough]
174
+ }
@@ -67,6 +67,9 @@ export async function installedVersion(ctx: Ctx, tool: ToolId): Promise<string>
67
67
  return (await version()).replace(/^jq-/, "")
68
68
  case "curl":
69
69
  case "tsc":
70
+ // `rust-analyzer --version` prints "rust-analyzer <version>", where the
71
+ // version tail also carries a commit and a date in parentheses.
72
+ case "rust-analyzer":
70
73
  return firstLineField(await version(), 1)
71
74
  case "bun":
72
75
  case "omp":