omo-ai 5.1.1 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/bin/lib/claude-code-floor.js +8 -4
  2. package/bin/lib/doctor-migration.js +22 -7
  3. package/bin/lib/doctor-pi-config.js +70 -0
  4. package/bin/lib/doctor.js +2 -0
  5. package/bin/lib/engine-dependency.js +23 -0
  6. package/bin/lib/engine-models.js +5 -2
  7. package/bin/lib/provider-map.json +1 -1
  8. package/bin/lib/setup-opencode-models.d.ts +53 -0
  9. package/bin/lib/setup-opencode-models.js +121 -34
  10. package/package.json +2 -3
  11. package/plugin/CHANGELOG.md +60 -0
  12. package/plugin/extensions/memory-run-supervisor.mjs +1 -1
  13. package/plugin/extensions/omo-computer-use.js +1 -1
  14. package/plugin/extensions/omo-init-deep-advisor.js +1 -1
  15. package/plugin/extensions/omo-member.js +1 -1
  16. package/plugin/extensions/omo-task.js +2 -2
  17. package/plugin/extensions/omo.js +2 -2
  18. package/plugin/package.json +1 -1
  19. package/plugin/runtime/agent-toolkit-sdk/sdk.js +2 -2
  20. package/plugin/runtime/ast-grep-mcp/manifest.json +1 -1
  21. package/plugin/runtime/category-coverage/assets.generated.json +7 -0
  22. package/plugin/runtime/rollback-migrate.js +1 -1
  23. package/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  24. package/plugin/skills/hyperplan/SKILL.md +1 -0
  25. package/plugin/skills/init-deep/SKILL.md +1 -0
  26. package/plugin/skills/mass-ulw/SKILL.md +1 -0
  27. package/plugin/skills/refactor/SKILL.md +1 -0
  28. package/plugin/skills/remove-ai-slops/SKILL.md +1 -0
  29. package/plugin/skills/ulw-execute/SKILL.md +1 -0
  30. package/plugin/skills/ulw-loop/SKILL.md +1 -0
  31. package/plugin/skills/ulw-plan/SKILL.md +1 -0
  32. package/plugin/skills/ulw-research/SKILL.md +1 -0
@@ -1,7 +1,10 @@
1
1
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
+ import { engineDependencyDir } from "./engine-dependency.js"
3
4
 
4
- const claudeCodeVersionRelative = "node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js"
5
+ const piAiPackage = "@earendil-works/pi-ai"
6
+ const claudeCodeVersionRelative = "dist/api/anthropic-messages.js"
7
+ const claudeCodeVersionTarget = `${piAiPackage}/${claudeCodeVersionRelative}`
5
8
  const claudeCodeVersionPattern = /const claudeCodeVersion = "(\d+)\.(\d+)\.(\d+)";/
6
9
  // Claude Opus 5.5 rejects OAuth requests advertising Claude Code below 2.1.280 (claude_code_version_too_old).
7
10
  export const claudeCodeVersionFloor = "2.1.280"
@@ -13,11 +16,12 @@ function isBelowFloor([major, minor, patch]) {
13
16
  }
14
17
 
15
18
  function floorPiAi(senpiRoot) {
16
- const path = join(senpiRoot, claudeCodeVersionRelative)
17
- if (!existsSync(path)) throw new Error(`omo-ai: installed Senpi target is missing: ${claudeCodeVersionRelative}`)
19
+ const piAiRoot = engineDependencyDir(senpiRoot, piAiPackage)
20
+ const path = piAiRoot === undefined ? undefined : join(piAiRoot, claudeCodeVersionRelative)
21
+ if (path === undefined || !existsSync(path)) throw new Error(`omo-ai: installed Senpi target is missing: ${claudeCodeVersionTarget}`)
18
22
  const source = readFileSync(path, "utf8")
19
23
  const match = claudeCodeVersionPattern.exec(source)
20
- if (match === null) throw new Error(`omo-ai: unsupported Senpi ${claudeCodeVersionRelative}`)
24
+ if (match === null) throw new Error(`omo-ai: unsupported Senpi ${claudeCodeVersionTarget}`)
21
25
  if (!isBelowFloor(match.slice(1).map(Number))) return
22
26
  writeFileSync(path, source.replace(claudeCodeVersionPattern, `const claudeCodeVersion = "${claudeCodeVersionFloor}";`))
23
27
  }
@@ -4,6 +4,7 @@ import { delimiter, dirname, isAbsolute, join, win32 } from "node:path"
4
4
  import { parseJsonc } from "./jsonc.js"
5
5
  import { releaseChannel } from "./package-paths.js"
6
6
  import { opencodeConfigSources } from "./setup-opencode-assets.js"
7
+ import { openCodeRoutingGap, openCodeRoutingNotice, readEditionRouting } from "./setup-opencode-models.js"
7
8
  import { standaloneBinaryVersion } from "./standalone-binary.js"
8
9
 
9
10
  // Migration leftovers from the OpenCode edition, reported and never touched: another `omo` ahead of
@@ -321,16 +322,30 @@ export function formatMigrationLines({ shadowing, nativeDirectory, legacyPackage
321
322
  return lines
322
323
  }
323
324
 
325
+ // The OpenCode edition's model settings Native does not use: the same line Native's first start
326
+ // prints (omo-senpi config-startup), for as long as the gap exists.
327
+ export function openCodeRoutingReport({ env, homeDir }) {
328
+ const path = ["omo.jsonc", "omo.json"].map((name) => join(homeDir, ".omo", name)).find((candidate) => readWholeFile(candidate) !== undefined)
329
+ let document = {}
330
+ if (path !== undefined) {
331
+ try {
332
+ document = parseJsonc(readWholeFile(path))
333
+ } catch {
334
+ return [] // The config loader reports an unreadable omo.jsonc itself.
335
+ }
336
+ }
337
+ const gap = openCodeRoutingGap(document, readEditionRouting({ home: homeDir, env }))
338
+ return gap.length === 0 ? [] : [`INFO ${openCodeRoutingNotice(gap)}`]
339
+ }
340
+
324
341
  /** The doctor's migration section. `options.env` / `homeDir` / `platform` keep tests off the real machine. */
325
342
  export function migrationReport(options, restoreCommand) {
326
- const environment = resolveMigrationEnvironment({
327
- env: options.env ?? process.env,
328
- platform: options.platform ?? process.platform,
329
- homeDir: options.homeDir ?? homedir(),
330
- })
343
+ const env = options.env ?? process.env
344
+ const homeDir = options.homeDir ?? homedir()
345
+ const environment = resolveMigrationEnvironment({ env, platform: options.platform ?? process.platform, homeDir })
331
346
  const bins = scanOmoBins(environment)
332
347
  const omoInstalls = omoInstallsOnPath(bins)
333
- return formatMigrationLines({
348
+ return [...formatMigrationLines({
334
349
  shadowing: shadowingOmoBins(bins),
335
350
  nativeDirectory: omoInstalls[0]?.directory ?? null,
336
351
  legacyPackages: findLegacyPackages(environment),
@@ -339,5 +354,5 @@ export function migrationReport(options, restoreCommand) {
339
354
  omoInstalls,
340
355
  bunRoot: environment.bunRoot,
341
356
  standalone: options.standalone === true,
342
- })
357
+ }), ...openCodeRoutingReport({ env, homeDir })]
343
358
  }
@@ -0,0 +1,70 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { canonicalAgentDir, runtimeHome } from "./agent-dir.js"
4
+
5
+ // Mirrors the engine's legacy-pi-edits check (senpi `src/legacy-pi-edits.ts`), which this package
6
+ // cannot import synchronously: the same four files, the same `legacyPiAgentDir.copiedAt` record in
7
+ // migrations-state.json, and the same rule: newer than the recorded copy (or, before the time was
8
+ // recorded, than the agent copy's preserved mtime) and different in content. `~/.pi/agent` is only
9
+ // read (#9173).
10
+ export const PI_CONFIG_FILES = ["auth.json", "keybindings.json", "models.json", "settings.json"]
11
+
12
+ function fileStat(path) {
13
+ try {
14
+ const stat = statSync(path)
15
+ return stat.isFile() ? stat : undefined
16
+ } catch (error) {
17
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return undefined
18
+ throw error
19
+ }
20
+ }
21
+
22
+ function recordedCopyTime(agentDir) {
23
+ let state
24
+ try {
25
+ state = JSON.parse(readFileSync(join(agentDir, "migrations-state.json"), "utf8"))
26
+ } catch {
27
+ // A missing or hand-broken state file means no recorded copy time; the rule falls back.
28
+ return undefined
29
+ }
30
+ const copiedAt = state?.schemaVersion === 1 ? state.legacyPiAgentDir?.copiedAt : undefined
31
+ return typeof copiedAt === "number" && Number.isFinite(copiedAt) ? copiedAt : undefined
32
+ }
33
+
34
+ export function findPiConfigEdits({ agentDir, homeDir }) {
35
+ const piAgentDir = join(homeDir, ".pi", "agent")
36
+ if (!existsSync(piAgentDir)) return []
37
+ if (existsSync(agentDir) && realpathSync(agentDir) === realpathSync(piAgentDir)) return []
38
+ const copiedAt = recordedCopyTime(agentDir)
39
+ const edits = []
40
+ for (const file of PI_CONFIG_FILES) {
41
+ const piPath = join(piAgentDir, file)
42
+ const agentPath = join(agentDir, file)
43
+ const pi = fileStat(piPath)
44
+ if (pi === undefined) continue
45
+ const agent = fileStat(agentPath)
46
+ if (pi.mtimeMs <= (copiedAt ?? agent?.mtimeMs ?? 0)) continue
47
+ if (agent !== undefined && readFileSync(piPath).equals(readFileSync(agentPath))) continue
48
+ edits.push({ file, piPath, agentPath })
49
+ }
50
+ return edits
51
+ }
52
+
53
+ export function formatPiConfigLines(agentDir, edits) {
54
+ return [
55
+ `INFO config dir: ${agentDir}`,
56
+ ...edits.map((edit) =>
57
+ `WARN You edited ${edit.piPath} after omo moved to ${agentDir}; omo reads ${edit.agentPath}. Copy your change there (or run: omo config import-pi ${edit.file}).`),
58
+ ]
59
+ }
60
+
61
+ export function piConfigReport(options = {}) {
62
+ const env = options.env ?? process.env
63
+ const agentDir = options.agentDir ?? canonicalAgentDir(env)
64
+ const homeDir = options.homeDir ?? runtimeHome(env)
65
+ try {
66
+ return formatPiConfigLines(agentDir, findPiConfigEdits({ agentDir, homeDir }))
67
+ } catch (error) {
68
+ return [`INFO config dir: ${agentDir}`, `WARN could not check ${join(homeDir, ".pi", "agent")} for config edits: ${error.message}`]
69
+ }
70
+ }
package/bin/lib/doctor.js CHANGED
@@ -6,6 +6,7 @@ import { canonicalAgentDir } from "./agent-dir.js"
6
6
  import { packageManifest, packageRoot, readJson, releaseChannel, resolveSenpi, updateTarget } from "./package-paths.js"
7
7
  import { daemonReportLines } from "./daemon.js"
8
8
  import { migrationReport } from "./doctor-migration.js"
9
+ import { piConfigReport } from "./doctor-pi-config.js"
9
10
  import { needsSetupSuggestion } from "./setup-detect.js"
10
11
 
11
12
  const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/omo-ai/dist-tags"
@@ -430,6 +431,7 @@ export function runDoctor(inventory, args = [], options = {}) {
430
431
  lines.push(`INFO Update: ${updateTarget().command}`)
431
432
  lines.push(...migrationReport(options, updateTarget().command))
432
433
  lines.push(...warningsForSettings())
434
+ lines.push(...piConfigReport({ env: options.env, homeDir: options.homeDir }))
433
435
  lines.push(...staleEngineReport(options))
434
436
  lines.push(...retiredPayloadReport(options))
435
437
  lines.push(...transientMemoryReport(options))
@@ -0,0 +1,23 @@
1
+ import { realpathSync, statSync } from "node:fs"
2
+ import { basename, dirname, join } from "node:path"
3
+
4
+ /**
5
+ * The directory of `name` as the engine installed at `senpiRoot` resolves it: Node's
6
+ * node_modules walk from senpi's real path. That one walk covers every install shape - a
7
+ * bundled senpi keeps the dependency in its own `node_modules`, npm hoists it beside senpi,
8
+ * and bun's isolated store links it beside senpi's real path in `.bun/<key>/node_modules` -
9
+ * so no caller may assume where the dependency lives. Returns undefined when nothing on the
10
+ * walk provides `name`.
11
+ */
12
+ export function engineDependencyDir(senpiRoot, name) {
13
+ let current = realpathSync(senpiRoot)
14
+ while (true) {
15
+ if (basename(current) !== "node_modules") {
16
+ const candidate = join(current, "node_modules", name)
17
+ if (statSync(candidate, { throwIfNoEntry: false })?.isDirectory()) return candidate
18
+ }
19
+ const parent = dirname(current)
20
+ if (parent === current) return undefined
21
+ current = parent
22
+ }
23
+ }
@@ -11,13 +11,16 @@ import { existsSync, readFileSync } from "node:fs"
11
11
  import { join } from "node:path"
12
12
  import { pathToFileURL } from "node:url"
13
13
  import { parseJsonc } from "./jsonc.js"
14
+ import { engineDependencyDir } from "./engine-dependency.js"
14
15
  import { resolveSenpi } from "./package-paths.js"
15
16
 
16
17
  // The same module the engine's model-runtime.js composes its builtins from
17
- // (`builtinProviderCatalog.builtinProviders()`), at the path claude-code-floor.js already relies on.
18
+ // (`builtinProviderCatalog.builtinProviders()`), in the pi-ai the engine itself resolves.
18
19
  async function builtinCatalog() {
19
20
  const { packageRoot } = resolveSenpi()
20
- const path = join(packageRoot, "node_modules", "@earendil-works", "pi-ai", "dist", "providers", "all.js")
21
+ const piAiRoot = engineDependencyDir(packageRoot, "@earendil-works/pi-ai")
22
+ if (piAiRoot === undefined) throw new Error(`the engine model catalog is missing: @earendil-works/pi-ai is not resolvable from ${packageRoot}`)
23
+ const path = join(piAiRoot, "dist", "providers", "all.js")
21
24
  if (!existsSync(path)) throw new Error(`the engine model catalog is missing at ${path}`)
22
25
  return (await import(pathToFileURL(path).href)).builtinProviders()
23
26
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Derived from @code-yeongyu/senpi 2026.9.29 builtinProviders(). builtinProviderIds and oauthProviderIds are the engine registry verbatim; providers maps a source harness id onto the omo provider that serves the same endpoint; oauthLogins maps a source OAuth id onto the omo provider to run /login for. Re-derive all four whenever the senpi pin changes.",
2
+ "_comment": "Derived from @code-yeongyu/senpi 2026.9.29-3 builtinProviders(). builtinProviderIds and oauthProviderIds are the engine registry verbatim; providers maps a source harness id onto the omo provider that serves the same endpoint; oauthLogins maps a source OAuth id onto the omo provider to run /login for. Re-derive all four whenever the senpi pin changes.",
3
3
  "builtinProviderIds": [
4
4
  "alibaba-token-plan",
5
5
  "amazon-bedrock",
@@ -0,0 +1,53 @@
1
+ // Types for setup-opencode-models.js, which omo-senpi's config-startup imports across the package
2
+ // boundary: omo-ai ships this module as plain JS, so it is the single source of the mapping.
3
+
4
+ export type ModelRegistry = {
5
+ readonly models: ReadonlyMap<string, ReadonlySet<string>>
6
+ readonly dynamic: ReadonlySet<string>
7
+ }
8
+
9
+ export type EditionRouting = {
10
+ readonly agents: Readonly<Record<string, unknown>>
11
+ readonly categories: Readonly<Record<string, unknown>>
12
+ readonly notices: readonly string[]
13
+ }
14
+
15
+ export type ConvertedChoice = {
16
+ readonly name: string
17
+ readonly entry: Readonly<Record<string, unknown>>
18
+ readonly from: string
19
+ }
20
+
21
+ export const NATIVE_AGENT_NAMES: readonly string[]
22
+
23
+ export function readEditionRouting(options: { home: string; env: Readonly<Record<string, string | undefined>> }): EditionRouting
24
+
25
+ export function readModelChoices(options: {
26
+ home: string
27
+ env: Readonly<Record<string, string | undefined>>
28
+ opencodeBlock: unknown
29
+ }): Record<string, unknown>
30
+
31
+ export function hasModelChoices(raw: unknown): boolean
32
+
33
+ export function translateModelRef(
34
+ raw: unknown,
35
+ context: { registry?: ModelRegistry; oauthProviders: ReadonlySet<string> },
36
+ ): { ref?: string; provider?: string; modelId?: string; reason?: string }
37
+
38
+ export function convertModelChoices(
39
+ raw: unknown,
40
+ registry: ModelRegistry,
41
+ oauthProviders?: ReadonlySet<string>,
42
+ ): {
43
+ defaultModel?: { source: string; provider: string; modelId: string; ref: string }
44
+ categories: readonly ConvertedChoice[]
45
+ agents: readonly ConvertedChoice[]
46
+ dropped: readonly string[]
47
+ }
48
+
49
+ export function nativeRoutingLine(section: string, name: string, value: Readonly<Record<string, unknown>>): string
50
+
51
+ export function openCodeRoutingGap(document: unknown, edition: EditionRouting): readonly string[]
52
+
53
+ export function openCodeRoutingNotice(lines: readonly string[]): string
@@ -4,10 +4,14 @@
4
4
  * converts each into what the native harness reads. Provider ids go through provider-map.json, the
5
5
  * table the credential stage uses, and every provider/model is checked against the engine's model
6
6
  * list; what does not resolve is returned with its reason, never converted. Read-only.
7
+ *
8
+ * Native's startup migration (omo-senpi config-startup) runs the same conversion without the
9
+ * engine's model list through `openCodeRoutingGap`, so both paths map names and providers alike.
7
10
  */
8
11
 
9
- import { existsSync, readdirSync, readFileSync } from "node:fs"
12
+ import { existsSync, readdirSync } from "node:fs"
10
13
  import { isAbsolute, join, relative, resolve } from "node:path"
14
+ import providerMap from "./provider-map.json" with { type: "json" }
11
15
  import { opencodeConfigSources, readOpencodeConfig } from "./setup-opencode-assets.js"
12
16
 
13
17
  // The OpenCode edition's legacy plugin files, highest precedence first, as its config migration
@@ -29,6 +33,9 @@ export const NATIVE_AGENT_NAMES = [
29
33
  // OpenCode's own primary agents: the native main session has no per-mode agent, it runs the default model.
30
34
  const OPENCODE_PRIMARY_AGENTS = new Set(["build", "plan"])
31
35
 
36
+ // The OpenCode edition's plan agents, keyed to the native agent that does the same job.
37
+ const AGENT_ALIASES = { metis: "plan-consultant", momus: "plan-reviewer" }
38
+
32
39
  // omo-config-core schema/legacy-category-names.ts: the loader renames these and reports it.
33
40
  const CATEGORY_ALIASES = { deep: "deep-low" }
34
41
 
@@ -44,10 +51,6 @@ function record(value) {
44
51
  return isPlainObject(value) ? value : {}
45
52
  }
46
53
 
47
- function readProviderMap() {
48
- return JSON.parse(readFileSync(new URL("./provider-map.json", import.meta.url), "utf8"))
49
- }
50
-
51
54
  // The first start of any current harness runs the config unification migration, which keeps none
52
55
  // of a legacy file's categories or agents and moves the file to
53
56
  // `~/.omo/migration-backup-<timestamp>-opencode-config/<path relative to home>`
@@ -69,12 +72,11 @@ function editionFile(path, home, notices) {
69
72
  }
70
73
 
71
74
  /**
72
- * The raw choices, before any translation. `opencodeBlock` is the `[opencode]` block of the user's
73
- * omo.json[c]: the unification migration's target, so an entry there wins over the legacy files.
75
+ * The OpenCode edition's own routing (`agents`, `categories`) from its legacy plugin files, or the
76
+ * migration backups the unification migration moved them into.
74
77
  */
75
- export function readModelChoices({ home, env, opencodeBlock }) {
78
+ export function readEditionRouting({ home, env }) {
76
79
  const notices = []
77
- const opencode = readOpencodeConfig(opencodeConfigSources(home, env).files, "model choices", notices)
78
80
  const globalDir = join(env.XDG_CONFIG_HOME || join(home, ".config"), "opencode")
79
81
  const explicitDir = env.OPENCODE_CONFIG_DIR?.trim()
80
82
  const directories = explicitDir ? [resolve(explicitDir), globalDir] : [globalDir]
@@ -83,14 +85,31 @@ export function readModelChoices({ home, env, opencodeBlock }) {
83
85
  .flatMap((directory) => EDITION_FILES.map((name) => editionFile(join(directory, name), home, notices)))
84
86
  .reverse()
85
87
  const edition = readOpencodeConfig(legacyFiles, "model choices", notices)
86
- const block = record(opencodeBlock)
88
+ return { agents: record(edition.agents), categories: record(edition.categories), notices }
89
+ }
90
+
91
+ /**
92
+ * The raw choices, before any translation. `opencodeBlock` is the `[opencode]` block of the user's
93
+ * omo.json[c]: the unification migration's target, so an entry there wins over the legacy files.
94
+ */
95
+ export function readModelChoices({ home, env, opencodeBlock }) {
96
+ const notices = []
97
+ const opencode = readOpencodeConfig(opencodeConfigSources(home, env).files, "model choices", notices)
98
+ const edition = readEditionRouting({ home, env })
87
99
  return {
88
100
  model: typeof opencode.model === "string" ? opencode.model : undefined,
89
101
  smallModel: typeof opencode.small_model === "string" ? opencode.small_model : undefined,
90
102
  opencodeAgents: record(opencode.agent),
91
- categories: { ...record(edition.categories), ...record(block.categories) },
92
- agents: { ...record(edition.agents), ...record(block.agents) },
93
- notices,
103
+ ...editionChoices(edition, opencodeBlock),
104
+ notices: [...notices, ...edition.notices],
105
+ }
106
+ }
107
+
108
+ function editionChoices(edition, opencodeBlock) {
109
+ const block = record(opencodeBlock)
110
+ return {
111
+ categories: { ...edition.categories, ...record(block.categories) },
112
+ agents: { ...edition.agents, ...record(block.agents) },
94
113
  }
95
114
  }
96
115
 
@@ -104,12 +123,12 @@ export function hasModelChoices(raw) {
104
123
  }
105
124
 
106
125
  function engineProvider(provider, modelId, context) {
107
- const { providerMap, oauthProviders, registry } = context
126
+ const { oauthProviders, registry } = context
108
127
  // A provider opencode signed in to with OAuth (`openai` through a ChatGPT plan) is served here by
109
128
  // the provider the credential stage says to `/login` to (provider-map.json `oauthLogins`), not by
110
129
  // the same id, which would need an API key the user never had.
111
130
  const login = oauthProviders.has(provider) && Object.hasOwn(providerMap.oauthLogins, provider) ? providerMap.oauthLogins[provider] : undefined
112
- if (login !== undefined && registry.models.get(login)?.has(modelId)) return login
131
+ if (login !== undefined && registry?.models.get(login)?.has(modelId)) return login
113
132
  if (providerMap.builtinProviderIds.includes(provider)) return provider
114
133
  return providerMap.providers[provider] ?? provider
115
134
  }
@@ -125,20 +144,30 @@ function splitSuffix(text) {
125
144
  return { selector: text.slice(0, colon), suffix: `:${token}` }
126
145
  }
127
146
 
128
- /** `{ ref, provider, modelId }` for a model string the engine serves, else `{ reason }`. */
147
+ /**
148
+ * `{ ref, provider, modelId }` for a model string the engine serves, else `{ reason }`. Without a
149
+ * `context.registry` (Native startup, which has no engine model list) only the provider is checked,
150
+ * against the engine's builtin providers.
151
+ */
129
152
  export function translateModelRef(raw, context) {
130
153
  if (typeof raw !== "string" || raw.trim() === "") return { reason: "not a model string" }
131
154
  const { selector, suffix } = splitSuffix(raw.trim())
132
155
  const slash = selector.indexOf("/")
156
+ const { registry } = context
133
157
  if (slash <= 0) {
134
158
  // A bare id is matched against every provider by the native resolver, so any provider serving it will do.
135
- const served = [...context.registry.models.values()].some((ids) => ids.has(selector))
159
+ const served = registry === undefined || [...registry.models.values()].some((ids) => ids.has(selector))
136
160
  return served ? { ref: `${selector}${suffix}`, modelId: selector } : { reason: `no omo provider serves a model named ${selector}` }
137
161
  }
138
162
  const source = selector.slice(0, slash)
139
163
  const modelId = selector.slice(slash + 1)
140
164
  const provider = engineProvider(source, modelId, context)
141
- const ids = context.registry.models.get(provider)
165
+ if (registry === undefined) {
166
+ return providerMap.builtinProviderIds.includes(provider)
167
+ ? { ref: `${provider}/${modelId}${suffix}`, provider, modelId }
168
+ : { reason: `provider ${source} is not one omo serves or was carried over` }
169
+ }
170
+ const ids = registry.models.get(provider)
142
171
  if (ids === undefined) return { reason: `provider ${source} is not one omo serves or was carried over` }
143
172
  if (!ids.has(modelId)) {
144
173
  return {
@@ -180,10 +209,10 @@ function chainItem(item, take) {
180
209
  * OpenCode edition tried them in. Undefined when the entry named models and none of them resolve:
181
210
  * its tuning alone would retune the builtin chain the user had replaced.
182
211
  */
183
- function convertEntry(label, entry, context, dropped) {
212
+ function convertEntry(label, entry, context, dropped, target) {
184
213
  const take = (raw) => {
185
214
  const result = translateModelRef(raw, context)
186
- if (result.reason !== undefined) dropped.push(`${label} model ${String(raw)}: ${result.reason}`)
215
+ if (result.reason !== undefined) dropped.push({ text: `${label} model ${String(raw)}: ${result.reason}`, target })
187
216
  return result.ref
188
217
  }
189
218
  const folded = !Array.isArray(entry.models) && entry.fallback_models !== undefined
@@ -208,17 +237,31 @@ function convertAgents(raw, context, dropped) {
208
237
  for (const [origin, entries] of [["opencode agent", raw.opencodeAgents], ["agent", raw.agents]]) {
209
238
  for (const [name, entry] of Object.entries(entries)) {
210
239
  if (!hasModelChoice(entry)) continue
211
- if (!native.has(name)) {
240
+ const target = Object.hasOwn(AGENT_ALIASES, name) ? AGENT_ALIASES[name] : name
241
+ // A native name beside its alias in the same source wins, as categories resolve the pair.
242
+ if (target !== name && Object.hasOwn(entries, target) && hasModelChoice(entries[target])) continue
243
+ if (!native.has(target)) {
212
244
  dropped.push(OPENCODE_PRIMARY_AGENTS.has(name) && origin === "opencode agent"
213
- ? `${origin} ${name}: the OpenCode primary agent; omo's main session runs the default model instead`
214
- : `${origin} ${name}: omo has no agent of that name (its agents: ${NATIVE_AGENT_NAMES.join(", ")}); route that work through a category instead`)
245
+ ? { text: `${origin} ${name}: the OpenCode primary agent; omo's main session runs the default model instead` }
246
+ : {
247
+ text: `${origin} ${name}: omo has no agent of that name (its agents: ${NATIVE_AGENT_NAMES.join(", ")}); route that work through a category instead`,
248
+ target: { section: "categories", name: "<category>", model: firstModel(entry, context) },
249
+ })
215
250
  continue
216
251
  }
217
- const converted = convertEntry(`${origin} ${name}`, entry, context, dropped)
218
- if (converted !== undefined) agents.set(name, converted)
252
+ const converted = convertEntry(`${origin} ${name}`, entry, context, dropped, { section: "agents", name: target })
253
+ if (converted !== undefined) agents.set(target, { entry: converted, from: name })
219
254
  }
220
255
  }
221
- return [...agents].map(([name, entry]) => ({ name, entry })).sort((left, right) => left.name.localeCompare(right.name))
256
+ return [...agents].map(([name, { entry, from }]) => ({ name, entry, from })).sort((left, right) => left.name.localeCompare(right.name))
257
+ }
258
+
259
+ // The model an unmappable agent named, as the native harness would spell it when it can.
260
+ function firstModel(entry, context) {
261
+ const raw = entry.model ?? [entry.models, entry.fallback_models].flat().find((item) => item !== undefined)
262
+ const text = isPlainObject(raw) ? raw.model : raw
263
+ if (typeof text !== "string") return undefined
264
+ return translateModelRef(text, context).ref ?? text
222
265
  }
223
266
 
224
267
  function convertCategories(raw, context, dropped) {
@@ -228,21 +271,21 @@ function convertCategories(raw, context, dropped) {
228
271
  const target = Object.hasOwn(CATEGORY_ALIASES, name) ? CATEGORY_ALIASES[name] : name
229
272
  // A canonical key beside its retired alias wins, as the loader resolves the pair.
230
273
  if (target !== name && Object.hasOwn(raw.categories, target)) continue
231
- const converted = convertEntry(`category ${name}`, entry, context, dropped)
232
- if (converted !== undefined) categories.set(target, converted)
274
+ const converted = convertEntry(`category ${name}`, entry, context, dropped, { section: "categories", name: target })
275
+ if (converted !== undefined) categories.set(target, { entry: converted, from: name })
233
276
  }
234
- return [...categories].map(([name, entry]) => ({ name, entry })).sort((left, right) => left.name.localeCompare(right.name))
277
+ return [...categories].map(([name, { entry, from }]) => ({ name, entry, from })).sort((left, right) => left.name.localeCompare(right.name))
235
278
  }
236
279
 
237
280
  function convertDefault(model, context, dropped) {
238
281
  if (model === undefined) return undefined
239
282
  const result = translateModelRef(model, context)
240
283
  if (result.reason !== undefined) {
241
- dropped.push(`default model ${model}: ${result.reason}`)
284
+ dropped.push({ text: `default model ${model}: ${result.reason}` })
242
285
  return undefined
243
286
  }
244
287
  if (result.provider === undefined) {
245
- dropped.push(`default model ${model}: names no provider, and omo's default model needs one`)
288
+ dropped.push({ text: `default model ${model}: names no provider, and omo's default model needs one` })
246
289
  return undefined
247
290
  }
248
291
  return { source: model, provider: result.provider, modelId: result.modelId, ref: `${result.provider}/${result.modelId}` }
@@ -253,11 +296,55 @@ function convertDefault(model, context, dropped) {
253
296
  * `oauthProviders` are the opencode provider ids whose opencode credential is an OAuth login.
254
297
  */
255
298
  export function convertModelChoices(raw, registry, oauthProviders = new Set()) {
256
- const context = { registry, providerMap: readProviderMap(), oauthProviders }
299
+ const context = { registry, oauthProviders }
257
300
  const dropped = []
258
301
  const defaultModel = convertDefault(raw.model, context, dropped)
259
- if (raw.smallModel !== undefined) dropped.push(`small_model ${raw.smallModel}: omo has no small-model setting`)
302
+ if (raw.smallModel !== undefined) dropped.push({ text: `small_model ${raw.smallModel}: omo has no small-model setting` })
260
303
  const categories = convertCategories(raw, context, dropped)
261
304
  const agents = convertAgents(raw, context, dropped)
262
- return { defaultModel, categories, agents, dropped }
305
+ return { defaultModel, categories, agents, dropped: dropped.map((item) => item.text) }
306
+ }
307
+
308
+ // omo-config-core loader/resolution.ts folds a legacy `[senpi]` block under `[native]`.
309
+ const NATIVE_BLOCKS = ["[senpi]", "[native]"]
310
+
311
+ /** The omo.jsonc member that sets `value` for `section.name` in the native harness. */
312
+ export function nativeRoutingLine(section, name, value) {
313
+ const members = Object.entries(value).map(([key, member]) => `"${key}": ${JSON.stringify(member)}`).join(", ")
314
+ return `"[native]": { "${section}": { "${name}": { ${members} } } }`
315
+ }
316
+
317
+ /**
318
+ * The OpenCode edition's model settings (`edition`: readEditionRouting, overlaid by `document`'s
319
+ * `[opencode]` block) that the native harness of `document` (a user omo.json[c]) does not use,
320
+ * one line each with the omo.jsonc member that would set it natively. The legacy migration never
321
+ * copies agents or categories (#7270), and `omo setup` carries them only with the user's consent, so
322
+ * this only reports: Native's startup notice and `omo doctor` print these lines. Names and providers
323
+ * convert as setup converts them, without the engine's model list; a key the native view (shared
324
+ * base, `[senpi]`, `[native]`) already sets is the user's and is not reported.
325
+ */
326
+ export function openCodeRoutingGap(document, edition) {
327
+ const raw = { opencodeAgents: {}, ...editionChoices(edition, record(document)["[opencode]"]) }
328
+ const context = { registry: undefined, oauthProviders: new Set() }
329
+ const dropped = []
330
+ const converted = { categories: convertCategories(raw, context, dropped), agents: convertAgents(raw, context, dropped) }
331
+ const carriable = ["agents", "categories"].flatMap((section) => converted[section]
332
+ .filter(({ name }) => !isConfigured(document, section, name))
333
+ .map(({ name, entry, from }) => `${section === "agents" ? "agent" : "category"} ${from}: add ${nativeRoutingLine(section, name, entry)}`))
334
+ const unmapped = dropped
335
+ .filter(({ target }) => target === undefined || !isConfigured(document, target.section, target.name))
336
+ .map(({ text, target }) => target === undefined
337
+ ? text
338
+ : `${text}: add ${nativeRoutingLine(target.section, target.name, { model: target.model ?? "<provider>/<model>" })}`)
339
+ return [...carriable, ...unmapped]
340
+ }
341
+
342
+ /** The one-line lead of the report, for `lines` from openCodeRoutingGap. */
343
+ export function openCodeRoutingNotice(lines) {
344
+ return `OpenCode edition model settings Native does not use; run omo setup to carry them over, or set them in omo.jsonc: ${lines.join("; ")}`
345
+ }
346
+
347
+ function isConfigured(document, section, name) {
348
+ return [record(document)[section], ...NATIVE_BLOCKS.map((key) => record(record(document)[key])[section])]
349
+ .some((entries) => Object.hasOwn(record(entries), name))
263
350
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omo-ai",
3
- "version": "5.1.1",
3
+ "version": "5.1.2",
4
4
  "type": "module",
5
5
  "description": "OmO Native - the standalone omo command with the OMO extension built in: bun add -g omo-ai",
6
6
  "bin": {
@@ -17,8 +17,7 @@
17
17
  "postinstall": "node bin/senpi-patch.mjs"
18
18
  },
19
19
  "dependencies": {
20
- "@babel/parser": "8.0.4",
21
- "@code-yeongyu/senpi": "2026.9.29"
20
+ "@code-yeongyu/senpi": "2026.9.29-3"
22
21
  },
23
22
  "repository": {
24
23
  "type": "git",
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.1.2] - 2026-09-29
11
+
12
+ ### Added
13
+
14
+ **A one-line installer at get.omo.dev.** ([#9166](https://github.com/code-yeongyu/oh-my-openagent/issues/9166)) `curl -fsSL https://get.omo.dev/install.sh | bash` on macOS and Linux, or `irm https://get.omo.dev/install.ps1 | iex` in Windows PowerShell, installs the native `omo` binary for your OS and CPU into `~/.local/bin`, after checking it against the release `SHA256SUMS`. When get.omo.dev is unreachable it falls back to the npm registry and GitHub Releases. `bun add -g omo-ai` keeps working. omo.dev now shows the command for your OS and has an install page at `/docs/install` ([#9177](https://github.com/code-yeongyu/oh-my-openagent/issues/9177)).
15
+
16
+ **`web_search` can use your own SearXNG.** ([senpi#2339](https://github.com/code-yeongyu/senpi/issues/2339)) Add `{ "provider": "searxng", "baseUrl": "http://localhost:8888" }` to `websearch.json`.
17
+
18
+ ### Changed
19
+
20
+ **An unknown slash command no longer reaches the model by accident.** ([senpi#2348](https://github.com/code-yeongyu/senpi/issues/2348)) A typo like `/ulw-exec` goes back into the editor with a `Did you mean /skill:ulw-execute?` hint instead of being sent as a prompt. A second Enter on the unchanged text sends it as a normal message, and Esc keeps editing.
21
+
22
+ **Moving from the OpenCode edition, OmO Native tells you which model choices it ignores.** ([#9147](https://github.com/code-yeongyu/oh-my-openagent/issues/9147), [#6794](https://github.com/code-yeongyu/oh-my-openagent/issues/6794)) On its first start, Native lists each agent and category model from your OpenCode edition settings that it ignores, with the `omo setup` step and the `omo.jsonc` key that carries it over. `omo setup` itself now carries the edition's `metis` and `momus` models over to `plan-consultant` and `plan-reviewer`.
23
+
24
+ **Hosted web search runs on the provider's cheaper search model.** ([senpi#2340](https://github.com/code-yeongyu/senpi/issues/2340)) On the same login, for example `claude-haiku-4-5` on Claude routes, when your model list shows it at a lower price than the session model. If it fails or finds nothing, the search retries on the session model. `"nativeModel": "session"` in `websearch.json` restores the old behavior.
25
+
26
+ **Without a `websearch.json`, `web_search` tries more than DuckDuckGo.** ([senpi#2339](https://github.com/code-yeongyu/senpi/issues/2339)) It now tries DuckDuckGo, Exa, Startpage, Mojeek, Ecosia and Google in turn and pauses an engine that blocks it, so your queries may reach these services. List only the providers you want in `websearch.json` to keep them away from the rest.
27
+
28
+ **Remote MCP servers declared by a skill no longer receive your `bearerTokenEnv` token.** ([senpi#2345](https://github.com/code-yeongyu/senpi/issues/2345)) The skill picks the server's URL, so declare that server in your own `mcp.json` to keep its auth. Stdio servers from skills you installed now expand `${VAR}` the way your own `mcp.json` does.
29
+
30
+ ### Fixed
31
+
32
+ **A running session survives `omo update`.** ([senpi#2358](https://github.com/code-yeongyu/senpi/issues/2358)) `omo update` and `bun install -g` replace the installed package, and a session started earlier used to fail every later request with `Cannot find module './<chunk>-<hash>.js'` until you restarted it. Each launch now runs from its own copy of the engine.
33
+
34
+ **Config you edit in `~/.pi/agent` is no longer silently ignored.** ([#9173](https://github.com/code-yeongyu/oh-my-openagent/issues/9173)) After OmO copied `~/.pi/agent` to `~/.omo/agent`, edits to the old copy had no effect. The next start now warns once per edited file and names the file OmO reads, `omo doctor` lists the same files, and `omo config import-pi <file>` copies the edit over.
35
+
36
+ **`omo doctor` no longer fails its computer-use check on every OS.** ([#9193](https://github.com/code-yeongyu/oh-my-openagent/issues/9193)) 5.1.1 shipped without an asset the check reads, so it always reported `ENOENT` for `assets.generated.json`.
37
+
38
+ **A finished task child no longer stays "running" after its parent's host dies.** ([#9183](https://github.com/code-yeongyu/oh-my-openagent/issues/9183)) The child's record kept its dead owner, so Desktop showed a working agent that no longer existed and task status and DAG waits kept waiting on it.
39
+
40
+ **Scrolling on X11 delivers every wheel click.** ([#9136](https://github.com/code-yeongyu/oh-my-openagent/issues/9136)) A foreground scroll from computer use sometimes dropped clicks and still reported success.
41
+
42
+ **Bedrock accepts tool schemas with a root `anyOf`, `oneOf` or `allOf`.** ([senpi#1947](https://github.com/code-yeongyu/senpi/issues/1947)) It also accepts schemas without an object type, which it used to reject.
43
+
44
+ **Memory reflection works when your only provider comes from an extension.** ([#9175](https://github.com/code-yeongyu/oh-my-openagent/issues/9175)) If that provider disappears you get one notice instead of the same failure on every run.
45
+
46
+ **Cursor runs each tool call once.** ([senpi#2334](https://github.com/code-yeongyu/senpi/issues/2334)) It used to run every call a second time, and the replayed write could undo a file fix.
47
+
48
+ **A resumed Claude conversation keeps its resume point.** ([senpi#1972](https://github.com/code-yeongyu/senpi/issues/1972)) When a monitor or goal starts the first turn on restore, the conversation no longer resends its whole history.
49
+
50
+ **In headless runs, a late Kibitzer verdict no longer replaces the final answer.** ([#9158](https://github.com/code-yeongyu/oh-my-openagent/pull/9158)) It used to add a turn after the answer that ended in `NO_REPLY`.
51
+
52
+ **An MCP server that changes its tool list mid-session gets the new list registered.** ([senpi#2188](https://github.com/code-yeongyu/senpi/issues/2188)) Added tools now show up and removed ones stay gone.
53
+
54
+ **Native OpenAI requests with hosted web search no longer fail every turn with `Tool choice 'web_search' not found in 'tools' parameter`.** ([senpi#2234](https://github.com/code-yeongyu/senpi/issues/2234))
55
+
56
+ **Web search through OpenAI and xAI lists only pages the search returned.** ([senpi#2337](https://github.com/code-yeongyu/senpi/issues/2337)) Links the model wrote on its own are no longer counted as sources.
57
+
58
+ **A rejected OpenAI Responses WebSocket request shows the provider's message.** ([senpi#2235](https://github.com/code-yeongyu/senpi/issues/2235)) It used to show `Error Code undefined: undefined`.
59
+
60
+ **Claude subscription sessions keep the field descriptions in custom tool schemas.** ([senpi#2145](https://github.com/code-yeongyu/senpi/issues/2145)) Claude no longer wastes a call on a missing required field.
61
+
62
+ **The OpenAI-compatible adapter merges back-to-back user messages for non-OpenAI hosts.** ([senpi#2120](https://github.com/code-yeongyu/senpi/issues/2120)) Direct OpenAI requests are unchanged.
63
+
64
+ **Extensions that depend on sloppy-mode CommonJS packages load again under Bun.** ([senpi#1841](https://github.com/code-yeongyu/senpi/issues/1841))
65
+
66
+ **Picking a skill that needs input from the slash menu now waits for you to type it.** ([#9168](https://github.com/code-yeongyu/oh-my-openagent/issues/9168)) On OmO Native, choosing `/ulw-execute`, `/ulw-plan`, `/ulw-loop`, `/ulw-research`, `/mass-ulw`, `/hyperplan`, `/init-deep`, `/refactor` or `/remove-ai-slops` from the slash menu with Enter sent it right away with nothing after it, so the skill started without the plan name, request or goal it works on. These skills now declare what they take, and Enter leaves `/ulw-execute ` (or `/skill:ulw-execute `) in the input with the hint shown in the menu; type the arguments and press Enter again to send. Skills that take no input still run on one Enter. The waiting half needs the engine release that reads the hint (code-yeongyu/senpi#2258); until OmO adopts it, the menu behaves as before.
67
+
68
+ **Your own `computer-use` skill no longer opens every session with a "Skill conflicts" warning.** ([#9160](https://github.com/code-yeongyu/oh-my-openagent/issues/9160)) With computer use on, OmO Native added its built-in `computer-use` skill on every start, even when you already had a skill of that name (Orca CLI ships one), so each session opened with a collision box. The built-in skill now steps aside for a same-name user, project or package skill, which is the one that loads, and `/computer status` says so in one line. The same applies to the `x-search` skill. `disabled_skills` now hides both the way it hides the bundled skills; the `computer` and `x_search` tools stay available either way.
69
+
10
70
  ## [5.1.1] - 2026-09-29
11
71
 
12
72
  ### Changed