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.
package/AGENTS.md CHANGED
@@ -31,6 +31,8 @@ launcher can fall back to Bun source.
31
31
  | `docks-kit` / `docks-kit.ps1` | POSIX and Windows CLI launchers. On supported hosts, each runs the matching binary in `cli/dist/` only when its `--version` matches `package.json`. Otherwise it runs Bun-from-source and auto-installs Bun plus `node_modules`. Hosts outside the support matrix fail before source fallback. The standalone platform release binary provides no-Bun recovery. |
32
32
  | `cli/src/engine-native/` | EngineNative implementation for `sync`, `model`, and `toolchain`; idempotent, flag-gated for destructive reconciliation |
33
33
  | `cli/src/engine-native/ompSync.ts` | omp file deployment, marketplace registration, and plugin synchronization |
34
+ | `cli/src/commands/omp.ts` | `docks-kit omp` session launcher: renders the free-model run overlay and forwards args to omp |
35
+ | `cli/src/engine-native/ompOverlay.ts` | Free-model overlay render plus catalog parse and thinking-ceiling helpers |
34
36
  | `cli/` | Effect 4 RC CLI + bundled docs topics |
35
37
  | `SoT/models.json` | Kit-verified Claude and Codex model catalog |
36
38
  | `SoT/toolchain.json` | Toolchain floors manifest (verified pins consumed by EngineNative) |
@@ -72,6 +74,8 @@ omp SoT notes:
72
74
  - Sync registers the `docks` marketplace. It installs or upgrades `docks@docks` and `plan-lifecycle@docks` at user scope.
73
75
  - Sync installs `pi-intercom` at the verified version from `SoT/toolchain.json`.
74
76
  - The omp CLI is upstream-owned and self-updating through `omp update`. Sync never installs or upgrades the CLI.
77
+ - `docks-kit omp [--model <selector>|--pick] [args...]` starts one interactive omp session on a single free model. It renders a run overlay to `~/.cache/docks-kit/omp-free-<model>-<digest>.yml` (mode 0600) and passes it through omp's repeatable `--config` flag. `ompOverlay.ts overlayFileName` gives each model its own file, because omp can re-read the overlay during a live session and a second launcher on another model must not rewrite it. Deployed `~/.omp/agent/` files stay untouched, so the next plain `omp` run uses the paid configuration again. Remaining arguments forward verbatim to omp.
78
+ - The session model persists per machine in `~/.docks-kit/state.json` under `ompSession`, next to `harnesses`. The default is `opencode-zen/muse-spark-1.3-contributor-free` at `xhigh`. `ompOverlay.ts ladderCeiling, advisorLevelFor` derive both the session level and the advisor level from the ladder of the chosen model, never from a fixed list: free ladders are not uniform, three free models publish no `medium`, and two publish no ladder, which the overlay renders as bare selectors. The picker lists only zero-cost catalog models.
75
79
 
76
80
  For per-tool SoT layouts (`SoT/.claude/`, `SoT/.codex/`, `SoT/.omp/`), see the matching SoT directory.
77
81
 
@@ -83,7 +87,7 @@ For per-tool SoT layouts (`SoT/.claude/`, `SoT/.codex/`, `SoT/.omp/`), see the m
83
87
  - **Effect 4 CLI stack.** The CLI pins `effect@4.0.0-rc.115` (including `effect/unstable/cli`), `@effect/platform-bun@4.0.0-rc.115` (`BunServices.layer`, `BunRuntime.runMain`), `@effect/vitest@4.0.0-rc.115`, and `vitest@5.0.0` (inside the `@effect/vitest` peer range `>=5.0.0 <6.0.0`; 5.0.0 is past the 5.x patch point for GHSA-82fw-gwwq-j7x9, fixed in 5.0.0-rc.2). `@effect/cli` and `@effect/platform` are removed and must not be reintroduced.
84
88
  - **Effect skill routing.** Effect work in this checkout must verify migration and API call shapes against the installed declarations under `node_modules/effect/dist/unstable/cli/`, never from memory or a mutable dist-tag. The `effect-ts-setup`, `effect-ts-port`, and `effect-ts-specialist` skills target Effect 3.x and do not apply.
85
89
  - **Targeted syncs.** `./docks-kit sync` accepts positional targets: `claude`, `codex`, `agents`, and `omp`. Use the narrowest target that matches the SoT change (for example, `./docks-kit sync omp` for omp-only config edits); targets can be combined with `--dry-run`, `--skip-bubblewrap` (skip optional bubblewrap bootstrap for the Codex Linux sandbox), `--skip-plugin-refresh` (install missing plugins without refreshing existing caches), `--reconcile`, `--prune`, and the deploy-time modifiers `--claude-compact-window=<tokens>` / `--claude-permissive` / `--claude-model=<m>` / `--claude-effort=<level>` / `--claude-advisor=<on|off|default>` / `--codex-model=<m>` / `--codex-effort=<level>` (see `CLAUDE.md` § Deploy-time modifiers).
86
- - **Per-machine harness selection.** `~/.docks-kit/state.json` drives a flag-less sync. A missing file selects `claude`, `codex`, and `agents`; it never selects `omp` implicitly. `sync` never prompts and never writes the selection file. `docks-kit harnesses` is the only command that writes the selection.
90
+ - **Per-machine harness selection.** `~/.docks-kit/state.json` drives a flag-less sync. A missing file selects `claude`, `codex`, and `agents`; it never selects `omp` implicitly. `sync` never prompts and never writes the selection file. `docks-kit harnesses` is the only command that writes the `harnesses` selection. The same state file also carries the independent `ompSession` key, which only `docks-kit omp` writes; every writer merges over the stored record, so neither key can drop the other.
87
91
  - **Additive by default.** Keys present in deployed config but absent from SoT are preserved on default sync. This protects user-only additions, but means drift accumulates — neither flag-less reset can clean it up. The one exception is the Claude `removed` manifest (`claudeSync.ts REMOVED_MANIFEST, baseline removal inventory`), a curated list of unambiguous kit-owned artifacts that `claudeSync.ts syncRemovals, baseline artifact prune` force-prunes on every sync, including the home-relative `~/.local/bin/session-relay` artifact installed outside `~/.claude`; see `CLAUDE.md` § Pruning stale artifacts.
88
92
  - **`--reconcile` / `--prune` are the kit-owned reconcile flags.** Orthogonal — `--reconcile` reconciles the settings layer (SoT-declared keys/tables/arrays win; user-only keys and nested objects are preserved; permissions arrays are replaced wholesale by SoT). `--prune` uninstalls kit-managed installations not in the SoT (plugins, marketplaces, and `~/.agents/skills/*` entries tracked in `~/.agents/.kit-managed-skills`). Combine for a full reset to SoT's kit-managed scope. User-only additions outside the kit's scope (custom env vars, mcpServers, manually-installed skills, third-party plugins not declared in SoT) are always preserved. Each tool's per-tool file documents the specific paths and diff recipes.
89
93
  - **SOLID-aligned modules.** `cli/src/engine-native/parseArgs.ts` owns flag parsing and validation. `toolchain.ts` owns verified-version floor reporting over `SoT/toolchain.json`; `bun.ts` owns the shared, memoized Bun bootstrap; `claudeRuntime.ts` owns Claude settings materialization. `claudeSync.ts`, `codexSync.ts`, and `skillsSync.ts` own their tool-specific sync logic. `ompSync.ts` owns omp file and plugin sync. `ompYaml.ts` owns the omp YAML merge. `harnesses.ts` owns per-machine harness selection. `index.ts` is the thin orchestrator. The public CLI seam is `cli/src/engine.ts`.
package/cli/docs/flags.md CHANGED
@@ -52,6 +52,27 @@ Bare model, effort, or advisor modifiers print the relevant valid-value catalog
52
52
  and exit 2. A modifier for a target not selected by the positional arguments is
53
53
  ignored with a warning; Claude modifiers never touch Codex config and vice versa.
54
54
 
55
+ ## `omp` session flags
56
+
57
+ | Flag | Effect |
58
+ |------|--------|
59
+ | `--model <selector>` | Session model for this run and later runs; rejected unless the live catalog reports zero input and output cost; recorded in `~/.docks-kit/state.json` under `ompSession` |
60
+ | `--pick` | Interactive picker over free catalog models; records the choice the same way |
61
+
62
+ Remaining arguments forward verbatim to omp after the launcher flags.
63
+ `docks-kit omp -p "..."` runs one prompt. `docks-kit omp --continue` resumes
64
+ the previous session. A bare `docks-kit omp` opens an interactive session.
65
+ The launcher changes no deployed file and never invokes `sync`.
66
+ The boundary opens at the first token that is neither a declared launcher
67
+ flag nor a declared global flag. Declared flags stay with docks-kit and
68
+ never reach omp:
69
+
70
+ - `--model`, `--pick`
71
+ - `--help`, `--version`, `--log-level`, `--wizard`, `--completions`
72
+
73
+ The same names on omp stay reachable behind an explicit delimiter. Use
74
+ `docks-kit omp -- --help` to ask omp for help.
75
+
55
76
  ## Renamed legacy flags (pre-CLI sync.sh)
56
77
 
57
78
  Old flags exit with a rename hint — there is no compat behavior.
@@ -153,6 +153,7 @@ sync/config reads.
153
153
 
154
154
  - Bun for source/global installs; release binaries embed the runtime
155
155
  - Node/npm for npm-global LSP servers
156
+ - rustup is optional and needed only for the Rust language server
156
157
  - jq is optional doctor/test tooling; sync has no jq runtime dependency
157
158
  - curl is required only when a source launcher must download Bun. The POSIX
158
159
  launchers run `install.sh`; Windows runs `install.ps1` through PowerShell.
@@ -211,6 +211,65 @@ omp's 272k context window is the `/extended-context off` window for Astra.
211
211
  `/extended-context on` uses the 922k input window, 1.05M total, which matches
212
212
  AA's 1M.
213
213
 
214
+ ## Free session launcher (`docks-kit omp`)
215
+
216
+ `docks-kit omp [--model <selector>|--pick] [args...]` starts one interactive
217
+ omp session on a single free model. All 12 model roles resolve to that model.
218
+ All 9 retry fallback chains are empty, so a retry cannot reach a paid model.
219
+ The overlay also sets `defaultThinkingLevel` and `task.maxEffort`, except for
220
+ a model that publishes no thinking ladder, where both keys are omitted and the
221
+ deployed values apply.
222
+
223
+ The launcher renders a run overlay to
224
+ `~/.cache/docks-kit/omp-free-<model>-<digest>.yml` at mode 0600 and passes it
225
+ through omp's repeatable `--config` flag. Each model gets its own file,
226
+ because omp can re-read the overlay during a live session, and a second
227
+ launcher on another model must not rewrite that file. The name sanitizes the
228
+ selector for the file system and appends a digest of the exact selector, so
229
+ two selectors that differ only in separator characters stay apart. The
230
+ launcher never reads or writes `~/.omp/agent/config.yml` or `models.yml`. It
231
+ never touches the SoT. The next plain `omp` run uses the paid configuration
232
+ again.
233
+
234
+ Every argument after the launcher flags forwards verbatim to omp. `docks-kit
235
+ omp -p "..."` runs one prompt. `docks-kit omp --continue` resumes the
236
+ previous session. A bare `docks-kit omp` opens an interactive session.
237
+
238
+ The default model is `opencode-zen/muse-spark-1.3-contributor-free` ("Muse
239
+ Spark 1.3 Free", 1,048,576-token context) at `xhigh`. `xhigh` is the ceiling
240
+ of that model ladder: `minimal, low, medium, high, xhigh`. The paid
241
+ `muse-spark-1.3` sibling also offers `max`. The free variant does not.
242
+
243
+ Both levels come from the ladder of the chosen model, never from a fixed
244
+ list. The session level is the ceiling of that ladder. The advisor level is
245
+ the highest level at or below `medium`, because the advisor performs quick
246
+ review passes. When every level of a ladder is above `medium`, the advisor
247
+ takes the lowest level, so it never becomes the most expensive role.
248
+
249
+ Free ladders are not uniform. On 2026-09-11 the 26 free models published six
250
+ distinct ladders: 21 include `medium`, 3 stop at `low, high, max` or
251
+ `high, max`, and 2 publish no ladder at all. A model without a ladder gets
252
+ bare selectors with no `:level` suffix, because an invented level makes omp
253
+ fail when the session starts.
254
+
255
+ The choice persists per machine in `~/.docks-kit/state.json` under
256
+ `ompSession`, next to `harnesses`. It survives across sessions. It is never
257
+ committed. `--model <selector>` records one selector. `--pick` opens an
258
+ interactive picker.
259
+
260
+ The picker lists only models the live `omp models --json` catalog reports at
261
+ zero input and output cost (26 entries on 2026-09-11). This path cannot start
262
+ a paid session. The catalog can advertise a free model that the account
263
+ cannot call; omp reports that provider error unchanged.
264
+
265
+ An omp login is required. The kit owns no login flow. It surfaces omp's own
266
+ authentication error unchanged.
267
+
268
+ When the catalog renames the free variant, change only the default selector
269
+ constant. A ladder change needs no code change, because both levels come from
270
+ the catalog row of the chosen model. Refresh the recorded default and the
271
+ ladder counts in these docs in the same commit.
272
+
214
273
  ## Maintenance
215
274
 
216
275
  - Refresh the snapshot from the AA release page of each family
@@ -16,7 +16,7 @@ AI-assisted dev environment on every machine.
16
16
  | `SoT/models.json` | Kit-verified model catalog (see `docks-kit docs models`) |
17
17
  | `SoT/toolchain.json` | Verified-version floors for external tools (see `docks-kit docs toolchain`) |
18
18
  | `cli/src/generated/sotPayload.ts` | Deterministic generated payload embedded in standalone/npm execution |
19
- | `cli/src/engine-native/` | EngineNative mutation logic for sync/model/toolchain |
19
+ | `cli/src/engine-native/` | EngineNative mutation logic for sync/model/toolchain and the `docks-kit omp` session overlay |
20
20
  | `cli/` | This CLI (Effect 4 RC on Bun) plus bundled docs |
21
21
  | `docks-kit` / `docks-kit.ps1` | POSIX and Windows launchers: version-matching compiled binary → Bun-from-source, with Bun auto-install |
22
22
 
@@ -30,7 +30,7 @@ Order matters — runtime readiness and settings form one transaction:
30
30
  7. **Plugins** — seven idempotent passes via the `claude plugin` CLI
31
31
  (marketplaces → install → update → [--prune: uninstall/remove] → re-assert
32
32
  SoT enabled-state). Optional opt-ins via `--claude-plugin=<name>`.
33
- 8. LSP server binaries (npm globals).
33
+ 8. LSP server binaries (npm globals plus the rust-analyzer rustup component).
34
34
 
35
35
  The statusline reads Claude's native `rate_limits`. There is no OAuth request,
36
36
  usage cache, jq/curl runtime dependency, or Stop fetch hook.
@@ -39,12 +39,25 @@ floor: it skips the `typescript-language-server` install on such a host, warns
39
39
  with the host Node version, and installs the other language servers. Nothing
40
40
  else consults the floor, so a host below it still runs every kit operation.
41
41
 
42
+ ## rust-analyzer: no floor, no pin
43
+
44
+ `rust-analyzer` is a `check` row with no floor and no `verified` pin. The kit
45
+ installs the component with `rustup component add rust-analyzer` for the
46
+ `rust-analyzer-lsp` plugin, so the version follows the host Rust toolchain. A
47
+ pin would claim control the kit does not have, the stance `bubblewrap` already
48
+ takes for a tool the kit does not publish. `docks-kit toolchain check` reports
49
+ the installed version and judges nothing.
50
+
42
51
  ## Language-server upgrades
43
52
 
44
53
  `claudeSync syncLspServers` installs `intelephense`, `typescript-language-server`,
45
- and `typescript` only when the binary is missing. It never upgrades a server
46
- that is already present. A `verified` bump therefore reaches a fresh host
47
- immediately and leaves an existing install alone.
54
+ `typescript`, and `rust-analyzer` only when the binary is missing. It never
55
+ upgrades a server that is already present. A `verified` bump therefore reaches
56
+ a fresh host immediately and leaves an existing install alone.
57
+
58
+ The rustup channel follows the same rule. Sync adds the component only when the
59
+ `rust-analyzer` binary is missing, and `rustup update` is the user's remedy for
60
+ an old component.
48
61
 
49
62
  A lagging server shows as `below-floor` in `docks-kit toolchain check`. To move
50
63
  it, upgrade Node to 22.22.2 or newer, then install the pinned version by hand:
package/cli/src/argv.ts CHANGED
@@ -8,6 +8,7 @@ import { statusCommand } from "./commands/status"
8
8
  import { syncCommand } from "./commands/sync"
9
9
  import { toolchainCommand } from "./commands/toolchain"
10
10
  import { updateCommand } from "./commands/update"
11
+ import { ompCommand } from "./commands/omp"
11
12
  import {
12
13
  advisorCatalog,
13
14
  advisorFlagGrammar,
@@ -161,7 +162,8 @@ const COMMANDS: ReadonlyArray<CommandValue> = [
161
162
  statusCommand,
162
163
  pluginsCommand,
163
164
  skillsCommand,
164
- docsCommand
165
+ docsCommand,
166
+ ompCommand
165
167
  ]
166
168
 
167
169
  // A Map, not a plain object: an object literal answers `toString` and friends from
@@ -373,11 +375,68 @@ const missingModifierValue = (flag: string): string | undefined => {
373
375
  }
374
376
  }
375
377
 
378
+ // Without the injected `--`, Effect 4 rejects a forwarded flag such as `-p` or
379
+ // `--mode` as unrecognized. The omp launcher therefore owns a passthrough
380
+ // boundary: the first token after the `omp` word that is neither a flag
381
+ // declared on the omp or global surface nor a value consumed by such a flag
382
+ // starts the verbatim tail forwarded to omp. An undeclared long flag joins the
383
+ // tail too, because most omp flags are long (`--mode`, `--continue`,
384
+ // `--models`), and omp itself reports an unrecognized one accurately. Use
385
+ // `docks-kit omp -- --model x` to reach omp's own same-named flag.
386
+ const spliceOmpBoundary = (args: ReadonlyArray<string>): ReadonlyArray<string> => {
387
+ const surface = COMMAND_SURFACES.get("omp")
388
+ let ompIndex = -1
389
+ for (let index = 0; index < args.length; index++) {
390
+ const token = args[index] as string
391
+ if (token === "--") return args
392
+ const name = flagNameOf(token)
393
+ if (token === name && takesValueInAnySurface(name)) {
394
+ const next = args[index + 1]
395
+ if (next === "--") return args
396
+ if (next !== undefined && !(next.startsWith("-") && declaredInAnySurface(flagNameOf(next)))) {
397
+ index++
398
+ continue
399
+ }
400
+ }
401
+ if (!token.startsWith("-")) {
402
+ ompIndex = index
403
+ break
404
+ }
405
+ }
406
+ if (ompIndex === -1 || args[ompIndex] !== "omp") return args
407
+ for (let index = ompIndex + 1; index < args.length; index++) {
408
+ const token = args[index] as string
409
+ // An explicit delimiter already marks the tail; a second one would be
410
+ // forwarded to omp as a literal argument.
411
+ if (token === "--") return args
412
+ if (!token.startsWith("-")) {
413
+ return [...args.slice(0, index), "--", ...args.slice(index)]
414
+ }
415
+ const name = flagNameOf(token)
416
+ const canonical = declaredFlagName(name, surface)
417
+ if (canonical === undefined) return [...args.slice(0, index), "--", ...args.slice(index)]
418
+ const takesValue =
419
+ surface?.valueFlags.includes(canonical) === true ||
420
+ GLOBAL_SURFACE.valueFlags.includes(canonical)
421
+ if (takesValue && !token.includes("=")) {
422
+ const next = args[index + 1]
423
+ if (
424
+ next !== undefined &&
425
+ !(next.startsWith("-") && declaredFlagName(flagNameOf(next), surface) !== undefined)
426
+ ) {
427
+ index++
428
+ }
429
+ }
430
+ }
431
+ return args
432
+ }
433
+
376
434
  /** Validate the argument list, then hand back the arguments Effect 4 should parse. */
377
435
  export const prepareArgv = (args: ReadonlyArray<string>): ArgvOutcome => {
378
- const subcommand = subcommandName(args)
436
+ const boundaryArgs = subcommandName(args) === "omp" ? spliceOmpBoundary(args) : args
437
+ const subcommand = subcommandName(boundaryArgs)
379
438
  const commandSurface = subcommand === undefined ? undefined : COMMAND_SURFACES.get(subcommand)
380
- const { flags, normalizations } = scanArgv(args, commandSurface)
439
+ const { flags, normalizations } = scanArgv(boundaryArgs, commandSurface)
381
440
  const unknownCommandWouldMisdiagnoseFlag =
382
441
  subcommand !== undefined &&
383
442
  commandSurface === undefined &&
@@ -430,5 +489,5 @@ export const prepareArgv = (args: ReadonlyArray<string>): ArgvOutcome => {
430
489
  }
431
490
  }
432
491
 
433
- return { kind: "accept", args: normalizeArgv(args, normalizations) }
492
+ return { kind: "accept", args: normalizeArgv(boundaryArgs, normalizations) }
434
493
  }
@@ -0,0 +1,189 @@
1
+ import { Argument, Command, Flag, Prompt } from "effect/unstable/cli"
2
+ import { Console, Effect, Option } from "effect"
3
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs"
4
+ import { bail } from "../engine"
5
+ import { capture, p, spawnHost, which } from "../engine-native/exec"
6
+ import {
7
+ DEFAULT_OMP_SESSION_MODEL,
8
+ engineHome,
9
+ readOmpSessionModel,
10
+ writeOmpSessionModel,
11
+ type OmpSessionModel
12
+ } from "../engine-native/harnesses"
13
+ import {
14
+ advisorLevelFor,
15
+ buildOmpArgs,
16
+ ladderCeiling,
17
+ overlayFileName,
18
+ parseFreeModels,
19
+ renderFreeOverlay,
20
+ type CatalogModel
21
+ } from "../engine-native/ompOverlay"
22
+
23
+ const model = Flag.String("model").pipe(
24
+ Flag.withDescription("Free model selector to use and remember for omp sessions"),
25
+ Flag.optional
26
+ )
27
+ const pick = Flag.Boolean("pick").pipe(
28
+ Flag.withDescription("Choose the free model interactively"),
29
+ Flag.withDefault(false)
30
+ )
31
+ const args: Argument.Argument<ReadonlyArray<string>> = Argument.String("args").pipe(
32
+ Argument.withDescription("Arguments forwarded verbatim to omp"),
33
+ Argument.variadic()
34
+ )
35
+
36
+ export type FreeSelectorResolution =
37
+ | { readonly ok: true; readonly model: CatalogModel }
38
+ | { readonly ok: false; readonly message: string }
39
+
40
+ // Pure selector matching so the picker rule stays testable without spawning omp.
41
+ // An exact selector always wins; otherwise one case-insensitive substring hit
42
+ // across the free selectors resolves, because a longer exact selector is easy
43
+ // to mistype and the catalog is small enough to disambiguate safely.
44
+ export function resolveFreeSelector(
45
+ input: string,
46
+ free: ReadonlyArray<CatalogModel>
47
+ ): FreeSelectorResolution {
48
+ const trimmed = input.trim()
49
+ if (trimmed === "") {
50
+ return { ok: false, message: "Model selector must not be empty or blank" }
51
+ }
52
+ const exact = free.find((candidate) => candidate.selector === trimmed)
53
+ if (exact !== undefined) return { ok: true, model: exact }
54
+ const lowered = trimmed.toLowerCase()
55
+ const matches = free.filter((candidate) => candidate.selector.toLowerCase().includes(lowered))
56
+ const list = free.map((candidate) => candidate.selector).join(", ")
57
+ if (matches.length === 0) {
58
+ return {
59
+ ok: false,
60
+ message: `Model '${trimmed}' is not in the free catalog. Free models: ${list}`
61
+ }
62
+ }
63
+ const only = matches[0]
64
+ if (matches.length === 1 && only !== undefined) return { ok: true, model: only }
65
+ return {
66
+ ok: false,
67
+ message: `Model '${trimmed}' is ambiguous; matches: ${matches.map((candidate) => candidate.selector).join(", ")}. Pass the exact selector.`
68
+ }
69
+ }
70
+
71
+ const loadFreeCatalog = (omp: string): Effect.Effect<ReadonlyArray<CatalogModel>> =>
72
+ Effect.gen(function* () {
73
+ const raw = yield* Effect.promise(() => capture(omp, ["models", "--json"]))
74
+ if (raw === "") {
75
+ return yield* bail(
76
+ "'omp models --json' returned no output; verify omp runs on this host, then retry"
77
+ )
78
+ }
79
+ let parsed: unknown
80
+ try {
81
+ parsed = JSON.parse(raw) as unknown
82
+ } catch {
83
+ return yield* bail(
84
+ "'omp models --json' returned output that is not JSON; verify the omp version, then retry"
85
+ )
86
+ }
87
+ const free = parseFreeModels(parsed)
88
+ if (free.length === 0) {
89
+ return yield* bail("'omp models --json' listed no free models; verify the omp login, then retry")
90
+ }
91
+ return free
92
+ })
93
+
94
+ export const ompCommand = Command.make("omp", { model, pick, args }, (config) =>
95
+ Effect.gen(function* () {
96
+ if (Option.isSome(config.model) && config.pick) {
97
+ return yield* bail("Pass either --model <selector> or --pick, not both")
98
+ }
99
+
100
+ const omp = yield* Effect.sync(() => which("omp"))
101
+ if (omp === "") {
102
+ return yield* bail("omp not found on PATH; install omp, then retry", 1)
103
+ }
104
+
105
+ const home = engineHome(process.env)
106
+ if (Option.isSome(config.model)) {
107
+ const free = yield* loadFreeCatalog(omp)
108
+ const resolved = resolveFreeSelector(config.model.value, free)
109
+ if (!resolved.ok) return yield* bail(resolved.message)
110
+ // Compute both levels once from the catalog row so a plain launch
111
+ // never needs the catalog again.
112
+ const thinking = ladderCeiling(resolved.model.thinking)
113
+ const advisorThinking = advisorLevelFor(resolved.model.thinking)
114
+ yield* Effect.sync(() =>
115
+ writeOmpSessionModel(home, { selector: resolved.model.selector, thinking, advisorThinking })
116
+ )
117
+ }
118
+
119
+ if (config.pick) {
120
+ // Check the terminal before the catalog spawn, so a non-interactive run
121
+ // fails with the actionable message instead of an omp subprocess first.
122
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
123
+ return yield* bail("Picking needs a terminal; pass --model <selector> instead")
124
+ }
125
+ const free = yield* loadFreeCatalog(omp)
126
+ const chosen = yield* Prompt.Select({
127
+ message: "Choose the free model for this omp session",
128
+ choices: free.map((candidate) => {
129
+ const ceiling = ladderCeiling(candidate.thinking)
130
+ return {
131
+ title: `${candidate.name} — ${candidate.selector}`,
132
+ value: candidate.selector,
133
+ description:
134
+ ceiling === undefined
135
+ ? `model default thinking; ${candidate.contextWindow} context`
136
+ : `thinking to ${ceiling}; ${candidate.contextWindow} context`
137
+ }
138
+ })
139
+ })
140
+ const match = free.find((candidate) => candidate.selector === chosen)
141
+ if (match === undefined) return yield* bail(`Model '${chosen}' is not in the free catalog`)
142
+ const thinking = ladderCeiling(match.thinking)
143
+ const advisorThinking = advisorLevelFor(match.thinking)
144
+ yield* Effect.sync(() => writeOmpSessionModel(home, { selector: match.selector, thinking, advisorThinking }))
145
+ }
146
+
147
+ const session: OmpSessionModel = (yield* Effect.sync(() => readOmpSessionModel(home))) ??
148
+ DEFAULT_OMP_SESSION_MODEL
149
+
150
+ // A stable cache path, not a temp file deleted at exit, because omp may
151
+ // re-read the overlay during a live session. The name carries the model,
152
+ // so a second launcher on another model cannot rewrite the file a running
153
+ // session still reads.
154
+ const directory = p(home, ".cache", "docks-kit")
155
+ const overlayPath = p(directory, overlayFileName(session.selector))
156
+ yield* Effect.sync(() => {
157
+ // `mode` applies only when mkdir creates the path, so an existing
158
+ // permissive cache directory keeps its mode until the chmod below.
159
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
160
+ chmodSync(directory, 0o700)
161
+ writeFileSync(overlayPath, renderFreeOverlay(session), { mode: 0o600 })
162
+ chmodSync(overlayPath, 0o600)
163
+ })
164
+
165
+ // A level-free model states the model default so the line never shows
166
+ // an undefined level.
167
+ const hasLevel = typeof session.thinking === "string" && session.thinking.trim() !== ""
168
+ yield* Console.error(
169
+ hasLevel
170
+ ? `Starting omp with ${session.selector} at ${session.thinking} thinking (session only; deployed config unchanged)`
171
+ : `Starting omp with ${session.selector} at the model default thinking level (session only; deployed config unchanged)`
172
+ )
173
+
174
+ const child = yield* Effect.sync(() =>
175
+ spawnHost("omp", buildOmpArgs(overlayPath, config.args), { stdio: "inherit" })
176
+ )
177
+ if (child.error !== undefined) {
178
+ return yield* bail(
179
+ `Failed to start omp: ${child.error instanceof Error ? child.error.message : String(child.error)}`
180
+ )
181
+ }
182
+ // A signalled child reports a null status, which must not look successful.
183
+ yield* Effect.sync(() => process.exit(child.status ?? 1))
184
+ })
185
+ ).pipe(
186
+ Command.withDescription(
187
+ "Start omp with every model role overridden to the remembered free model for this run only (no deployed configuration is changed; --model or --pick remembers a new free default)."
188
+ )
189
+ )
@@ -31,7 +31,7 @@ const updateNudge = (logger: Logger): void => {
31
31
 
32
32
  const VALID_TARGETS = ["claude", "codex", "agents", "omp"]
33
33
 
34
- const targets = Argument.String("target").pipe(
34
+ const targets: Argument.Argument<ReadonlyArray<string>> = Argument.String("target").pipe(
35
35
  Argument.withDescription("Sync targets: claude, codex, agents, omp (default: selected harnesses)"),
36
36
  Argument.variadic()
37
37
  )
@@ -1,63 +1,19 @@
1
1
  import { Command, Flag } from "effect/unstable/cli"
2
2
  import { Console, Effect } from "effect"
3
- import { spawnSync, type SpawnSyncOptions, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from "node:child_process"
4
3
  import { existsSync, readFileSync } from "node:fs"
5
4
  import { bail, compiled } from "../engine"
6
5
  import { kitHome } from "../kitHome"
7
- import { p, which } from "../engine-native/exec"
8
- import { hostOs, type HostOs, type Invocation } from "../engine-native/os"
6
+ import { p, spawnHost, which } from "../engine-native/exec"
7
+ import { hostOs, type HostOs } from "../engine-native/os"
9
8
 
10
9
  const noSync = Flag.Boolean("no-sync").pipe(
11
10
  Flag.withDescription("Update the kit only; skip the chained flag-less sync"),
12
11
  Flag.withDefault(false)
13
12
  )
14
13
 
15
- /** A tool this host cannot resolve, shaped like the failed spawn it replaces. */
16
- const notFound = (command: string): SpawnSyncReturns<string> => ({
17
- pid: 0,
18
- output: [],
19
- stdout: "",
20
- stderr: "",
21
- status: null,
22
- signal: null,
23
- error: new Error(`command not found on PATH: ${command}`)
24
- })
25
-
26
- /**
27
- * Every child in this command starts here, because two host facts must never be
28
- * separated from the argv they describe: a Windows shim invocation is only
29
- * correct with the verbatim-arguments flag, and a pathless name would let
30
- * CreateProcess search the parent's current directory before the system one.
31
- */
32
- export const spawnUpdate = (
33
- command: string,
34
- args: ReadonlyArray<string>,
35
- overrides: SpawnSyncOptions = {},
36
- host: HostOs = hostOs()
37
- ): SpawnSyncReturns<string> => {
38
- const resolvesSuffixes = host.executableSuffixes.some((suffix) => suffix !== "")
39
- const executablePath = resolvesSuffixes ? which(command, host.executableSuffixes) : command
40
- if (executablePath === "") return notFound(command)
41
- let invocation: Invocation
42
- try {
43
- invocation = host.invoke(executablePath, args)
44
- } catch (cause) {
45
- // A value this host cannot put on a command line at all. Print the encoder's
46
- // reason and exit, matching how this command reports a failed child.
47
- process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
48
- return process.exit(2)
49
- }
50
- const options: SpawnSyncOptionsWithStringEncoding = {
51
- stdio: ["ignore", "pipe", "pipe"],
52
- ...overrides,
53
- encoding: "utf8",
54
- windowsVerbatimArguments: invocation.windowsVerbatimArguments
55
- }
56
- return spawnSync(invocation.command, [...invocation.args], options)
57
- }
58
14
 
59
15
  const git = (home: string, args: Array<string>): { ok: boolean; out: string } => {
60
- const res = spawnUpdate("git", ["-C", home, ...args])
16
+ const res = spawnHost("git", ["-C", home, ...args])
61
17
  return { ok: res.error === undefined && res.status === 0, out: `${res.stdout ?? ""}${res.stderr ?? ""}`.trim() }
62
18
  }
63
19
 
@@ -65,7 +21,7 @@ const git = (home: string, args: Array<string>): { ok: boolean; out: string } =>
65
21
  * version loaded, so the chained sync must be a new process. */
66
22
  const chainSync = (argv0: string, args: Array<string>): Effect.Effect<void> =>
67
23
  Effect.sync(() => {
68
- const res = spawnUpdate(argv0, args, { stdio: "inherit" })
24
+ const res = spawnHost(argv0, args, { stdio: "inherit" })
69
25
  if (res.error !== undefined || res.status !== 0) process.exit(res.status ?? 1)
70
26
  })
71
27
 
@@ -95,7 +51,7 @@ type CapturePackageRoot = (
95
51
  ) => PackageRootCapture
96
52
 
97
53
  const capturePackageRoot: CapturePackageRoot = (command, args) => {
98
- const res = spawnUpdate(command, args)
54
+ const res = spawnHost(command, args)
99
55
  return {
100
56
  status: res.status,
101
57
  stdout: res.stdout ?? "",
@@ -191,7 +147,7 @@ export const packageUpdateResult = (
191
147
 
192
148
  const updateCheckout = (home: string, skipSync: boolean) =>
193
149
  Effect.gen(function* () {
194
- if (spawnUpdate("git", ["--version"], { stdio: "ignore" }).status !== 0) {
150
+ if (spawnHost("git", ["--version"], { stdio: "ignore" }).status !== 0) {
195
151
  return yield* bail("git not found - cannot update the kit checkout")
196
152
  }
197
153
  const dirty = git(home, ["status", "--porcelain"])
@@ -217,7 +173,7 @@ const updateCheckout = (home: string, skipSync: boolean) =>
217
173
 
218
174
  const touched = git(home, ["diff", "--name-only", before, after]).out.split("\n")
219
175
  if (touched.includes("bun.lock") || touched.includes("package.json")) {
220
- const res = spawnUpdate("bun", ["install", "--frozen-lockfile"], { cwd: home, stdio: "inherit" })
176
+ const res = spawnHost("bun", ["install", "--frozen-lockfile"], { cwd: home, stdio: "inherit" })
221
177
  if (res.error !== undefined || res.status !== 0) {
222
178
  return yield* bail("dependencies changed but 'bun install --frozen-lockfile' failed - fix that, then run docks-kit sync", 1)
223
179
  }
@@ -245,7 +201,7 @@ const updatePackage = (home: string, skipSync: boolean) =>
245
201
  const updateArgs = manager === "bun"
246
202
  ? ["add", "-g", "docks-kit@latest"]
247
203
  : ["install", "-g", "docks-kit@latest"]
248
- const res = spawnUpdate(manager, updateArgs, { stdio: "inherit" })
204
+ const res = spawnHost(manager, updateArgs, { stdio: "inherit" })
249
205
  if (res.error !== undefined || res.status !== 0) {
250
206
  return yield* bail(
251
207
  `global package update failed (${manager === "bun" ? "bun add -g" : "npm install -g"} docks-kit@latest)`,