docks-kit 0.16.17 → 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 +5 -1
- package/cli/docs/flags.md +21 -0
- package/cli/docs/omp-models.md +59 -0
- package/cli/docs/overview.md +1 -1
- package/cli/src/argv.ts +63 -4
- package/cli/src/commands/omp.ts +189 -0
- package/cli/src/commands/update.ts +8 -52
- package/cli/src/engine-native/exec.ts +54 -2
- package/cli/src/engine-native/harnesses.ts +93 -12
- package/cli/src/engine-native/ompOverlay.ts +174 -0
- package/cli/src/generated/sotPayload.ts +1 -1
- package/cli/src/main.ts +4 -1
- package/package.json +1 -1
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.
|
package/cli/docs/omp-models.md
CHANGED
|
@@ -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
|
package/cli/docs/overview.md
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
)
|
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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)`,
|
|
@@ -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 {
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by cli/scripts/generate-sot-payload.ts. DO NOT EDIT.
|
|
2
2
|
// Edit SoT/, notification.mp3, or package.json, then run: bun cli/scripts/generate-sot-payload.ts
|
|
3
3
|
|
|
4
|
-
export const GENERATED_PACKAGE_VERSION = "0.
|
|
4
|
+
export const GENERATED_PACKAGE_VERSION = "0.17.0"
|
|
5
5
|
|
|
6
6
|
export const GENERATED_PAYLOAD_TEXT = {
|
|
7
7
|
"SoT/.agents/skills.txt": "# Universal AI-agent skill manifest intentionally empty.\n# Global skill discovery is opt-in: add one <owner>/<repo> slug per line.\n# EngineNative ignores comments and blank lines.\n",
|
package/cli/src/main.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { docsCommand } from "./commands/docs"
|
|
|
7
7
|
import { harnessesCommand } from "./commands/harnesses"
|
|
8
8
|
import { modelCommand } from "./commands/model"
|
|
9
9
|
import { modelsCommand } from "./commands/models"
|
|
10
|
+
import { ompCommand } from "./commands/omp"
|
|
10
11
|
import { pluginsCommand } from "./commands/plugins"
|
|
11
12
|
import { skillsCommand } from "./commands/skills"
|
|
12
13
|
import { statusCommand } from "./commands/status"
|
|
@@ -32,6 +33,7 @@ const root = Command.make("docks-kit", {}, () =>
|
|
|
32
33
|
yield* Console.log(" docks-kit plugins list plugin tri-state")
|
|
33
34
|
yield* Console.log(" docks-kit skills list universal skills")
|
|
34
35
|
yield* Console.log(" docks-kit docs [topic] self-documentation")
|
|
36
|
+
yield* Console.log(" docks-kit omp [--model|--pick] [...] free-model omp session")
|
|
35
37
|
yield* Console.log("")
|
|
36
38
|
yield* Console.log("Run 'docks-kit --help' for full option listings (also: --wizard, --completions).")
|
|
37
39
|
yield* Console.log("No-Bun recovery path: use a platform release binary.")
|
|
@@ -50,7 +52,8 @@ const root = Command.make("docks-kit", {}, () =>
|
|
|
50
52
|
statusCommand,
|
|
51
53
|
pluginsCommand,
|
|
52
54
|
skillsCommand,
|
|
53
|
-
docsCommand
|
|
55
|
+
docsCommand,
|
|
56
|
+
ompCommand
|
|
54
57
|
])
|
|
55
58
|
)
|
|
56
59
|
|
package/package.json
CHANGED