omo-ai 5.1.0 → 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 (41) hide show
  1. package/bin/lib/category-coverage.js +5 -1
  2. package/bin/lib/claude-code-floor.js +8 -4
  3. package/bin/lib/daemon-args.js +54 -0
  4. package/bin/lib/daemon-doctor-report.js +30 -0
  5. package/bin/lib/daemon-operations.js +190 -0
  6. package/bin/lib/daemon-rollback.js +158 -0
  7. package/bin/lib/daemon-status.js +128 -0
  8. package/bin/lib/daemon.js +109 -66
  9. package/bin/lib/doctor-migration.js +22 -7
  10. package/bin/lib/doctor-pi-config.js +70 -0
  11. package/bin/lib/doctor.js +2 -0
  12. package/bin/lib/engine-dependency.js +23 -0
  13. package/bin/lib/engine-models.js +5 -2
  14. package/bin/lib/launcher.js +15 -0
  15. package/bin/lib/provider-map.json +1 -1
  16. package/bin/lib/setup-opencode-models.d.ts +53 -0
  17. package/bin/lib/setup-opencode-models.js +121 -34
  18. package/package.json +2 -3
  19. package/plugin/CHANGELOG.md +80 -0
  20. package/plugin/extensions/memory-run-supervisor.mjs +1 -1
  21. package/plugin/extensions/omo-computer-use.js +1 -1
  22. package/plugin/extensions/omo-init-deep-advisor.js +2 -2
  23. package/plugin/extensions/omo-member.js +2 -2
  24. package/plugin/extensions/omo-task.js +2 -2
  25. package/plugin/extensions/omo.js +2 -2
  26. package/plugin/package.json +1 -1
  27. package/plugin/runtime/agent-toolkit-sdk/sdk.js +2 -2
  28. package/plugin/runtime/ast-grep-mcp/manifest.json +1 -1
  29. package/plugin/runtime/category-coverage/assets.generated.json +7 -0
  30. package/plugin/runtime/category-coverage/index.js +4 -4
  31. package/plugin/runtime/rollback-migrate.js +2 -0
  32. package/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  33. package/plugin/skills/hyperplan/SKILL.md +1 -0
  34. package/plugin/skills/init-deep/SKILL.md +1 -0
  35. package/plugin/skills/mass-ulw/SKILL.md +1 -0
  36. package/plugin/skills/refactor/SKILL.md +1 -0
  37. package/plugin/skills/remove-ai-slops/SKILL.md +1 -0
  38. package/plugin/skills/ulw-execute/SKILL.md +1 -0
  39. package/plugin/skills/ulw-loop/SKILL.md +1 -0
  40. package/plugin/skills/ulw-plan/SKILL.md +1 -0
  41. package/plugin/skills/ulw-research/SKILL.md +1 -0
package/bin/lib/daemon.js CHANGED
@@ -1,45 +1,53 @@
1
1
  import { existsSync, readFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
+ import {
4
+ parseEngineLine,
5
+ parseStoreArgs,
6
+ hostCommandEnvironment,
7
+ runGc,
8
+ runHandoff,
9
+ runStatus,
10
+ runStopAll,
11
+ } from "./daemon-operations.js"
12
+ import { runRollbackPrepare } from "./daemon-rollback.js"
13
+ import { attachLaunchArgs, blockingPause, DAEMON_EXIT, readTimeoutSeconds } from "./daemon-args.js"
14
+
15
+ export { DAEMON_EXIT } from "./daemon-args.js"
16
+ export { daemonReportLines } from "./daemon-doctor-report.js"
3
17
 
4
18
  /**
5
- * `omo daemon` - the operator's view of the one machine-wide engine host.
19
+ * `omo daemon` - the operator's view of every engine host in one agent dir: the operator daemon on
20
+ * `rpc.sock` (the only endpoint `run` and `attach` ensure), each session's task host (`p-*`), each
21
+ * Desktop thread host (`i-*`), and any other endpoint the engine enumerates. `status`, `gc`,
22
+ * `handoff`, `stop --all` and `rollback-prepare` cover all of them.
6
23
  *
7
- * Everything that decides WHO serves the socket lives in the engine (`senpi host`): probing an
24
+ * Everything that decides WHO serves a socket lives in the engine (`senpi host`): probing an
8
25
  * existing host, comparing build ordinals, handing a generation over, refusing when the two sides
9
26
  * cannot agree. This wrapper owns three much smaller things, and deliberately nothing else:
10
27
  * omo's launch spec is the argv source, omo.json is where the policy comes from, and the caller
11
28
  * gets an exit code it can branch on without reading prose.
12
29
  */
13
30
 
14
- const SUBCOMMANDS = new Set(["run", "attach", "status", "stop", "handoff"])
15
- /** Flags this wrapper consumes itself; anything else after `attach` belongs to the launch. */
16
- const DAEMON_FLAGS = new Set(["--json", "--no-upgrade", "--persistent", "--foreground", "--include-workers", "--drain"])
31
+ const SUBCOMMANDS = new Set(["run", "attach", "status", "stop", "handoff", "gc", "rollback-prepare"])
17
32
  /** The subcommands that can bring a host into existence, and therefore need omo's argv source. */
18
33
  const NEEDS_SPEC = new Set(["run", "attach", "handoff"])
19
34
 
20
- /** A named code per outcome, so a script never has to parse the message to know what happened. */
21
- export const DAEMON_EXIT = {
22
- ok: 0,
23
- usage: 2,
24
- notRunning: 3,
25
- unsupported: 4,
26
- engineRefused: 5,
27
- }
28
-
29
35
  const USAGE = [
30
- "usage: omo daemon <run|attach|status|stop|handoff> [options]",
36
+ "usage: omo daemon <run|attach|status|stop|handoff|gc|rollback-prepare> [options]",
31
37
  "",
32
38
  " run ensure a daemon is serving this agent dir (start, reuse, or hand off)",
33
39
  " attach print the environment a child needs to reach the daemon",
34
40
  " status report who is serving and which sessions exist",
35
41
  " stop end the daemon; --drain lets in-flight work finish first",
36
42
  " handoff hand the socket to this build, keeping live sessions",
43
+ " gc remove dead endpoint state; --prune-store-index drops missing store paths",
44
+ " rollback-prepare migrate retained task records back to rpc.sock before downgrading",
37
45
  "",
38
46
  " --json print the engine's JSON line instead of a summary",
39
47
  " --no-upgrade never hand off, even from an older build",
40
- " --persistent outlive the process that started it",
41
- " --foreground run the host in this process instead of detaching",
42
48
  " --include-workers include subagent sessions in status",
49
+ " --all apply stop to every discovered endpoint",
50
+ " --wait with stop --drain --all, wait for generations and claims to end",
43
51
  ].join("\n")
44
52
 
45
53
  /**
@@ -71,32 +79,16 @@ function readConfig(agentDir) {
71
79
  */
72
80
  const ENGINE_SUBCOMMAND = { run: "ensure", attach: "ensure", status: "status", stop: "stop", handoff: "handoff" }
73
81
 
74
- function buildArgs(subcommand, args, { specPath, policy, config }) {
82
+ function buildArgs(subcommand, args, { specPath, policy }) {
75
83
  const engineArgs = ["host", ENGINE_SUBCOMMAND[subcommand], "--json"]
76
84
  if (subcommand === "run" || subcommand === "attach" || subcommand === "handoff") {
77
85
  engineArgs.push("--launch-spec", specPath, "--policy", policy)
78
- const idleExitMs = config?.task?.host_idle_exit_ms
79
- if (typeof idleExitMs === "number" && Number.isFinite(idleExitMs)) {
80
- engineArgs.push("--idle-exit-ms", String(Math.trunc(idleExitMs)))
81
- }
82
- if (args.includes("--persistent")) engineArgs.push("--persistent")
83
- if (args.includes("--foreground")) engineArgs.push("--foreground")
84
86
  }
85
87
  if (subcommand === "stop" && args.includes("--drain")) engineArgs.push("--drain")
86
88
  if (subcommand === "status" && args.includes("--include-workers")) engineArgs.push("--include-workers")
87
89
  return engineArgs
88
90
  }
89
91
 
90
- function parseLine(stdout) {
91
- const line = stdout.trim().split("\n").filter(Boolean).pop()
92
- if (line === undefined) return undefined
93
- try {
94
- return JSON.parse(line)
95
- } catch {
96
- return undefined
97
- }
98
- }
99
-
100
92
  /** What a child process needs in its environment to reach this daemon rather than start its own. */
101
93
  function attachEnv(parsed, agentDir) {
102
94
  return {
@@ -123,7 +115,7 @@ function summarize(subcommand, parsed, exitCode) {
123
115
  * @returns the process exit code the launcher should use
124
116
  */
125
117
  export function runDaemonCommand(args, options) {
126
- const { engine, pluginRoot, agentDir, env, stdout, stderr, platform } = options
118
+ const { engine, migration, pluginRoot, agentDir, env, stdout, stderr, platform } = options
127
119
  const subcommand = args[0]
128
120
 
129
121
  if (subcommand === "--help" || subcommand === "-h") {
@@ -141,9 +133,13 @@ export function runDaemonCommand(args, options) {
141
133
  // A named pipe is per-process on win32: there is no socket for a second client to attach to,
142
134
  // so refusing here is honest, where pretending would strand the caller on a host it cannot reach.
143
135
  if (platform === "win32") {
144
- stderr.write("omo daemon: a shared host needs a unix socket, which win32 does not provide\n")
136
+ stderr.write("omo daemon: a task host needs a unix socket, which win32 does not provide\n")
145
137
  return DAEMON_EXIT.unsupported
146
138
  }
139
+ if (args.includes("--foreground")) {
140
+ stderr.write("omo daemon: --foreground is unsupported; the engine host always detaches\n")
141
+ return DAEMON_EXIT.usage
142
+ }
147
143
 
148
144
  // Only the subcommands that may START something need the spec; asking who is serving, or
149
145
  // asking it to stop, must still work on an install whose plugin payload was never built.
@@ -154,9 +150,82 @@ export function runDaemonCommand(args, options) {
154
150
  }
155
151
 
156
152
  const config = readConfig(agentDir)
157
- const engineArgs = buildArgs(subcommand, args, { specPath, policy: resolvePolicy(args, config), config })
158
- const result = engine.run(engineArgs, { env: { ...env, OMO_AGENT_DIR: agentDir } })
159
- const parsed = parseLine(result.stdout ?? "")
153
+ if (subcommand === "status") {
154
+ const status = runStatus({
155
+ engine,
156
+ agentDir,
157
+ env,
158
+ json: args.includes("--json"),
159
+ stdout,
160
+ stderr,
161
+ })
162
+ if (!status.legacy) return status.exitCode
163
+ }
164
+ if (subcommand === "gc") {
165
+ if (args.includes("--prune-store-index") && migration === undefined) {
166
+ stderr.write("omo daemon: rollback migration runtime is unavailable\n")
167
+ return DAEMON_EXIT.engineRefused
168
+ }
169
+ return runGc({
170
+ engine,
171
+ migration,
172
+ agentDir,
173
+ env,
174
+ json: args.includes("--json"),
175
+ pruneStoreIndex: args.includes("--prune-store-index"),
176
+ stdout,
177
+ stderr,
178
+ })
179
+ }
180
+ if (subcommand === "rollback-prepare") {
181
+ if (migration === undefined) {
182
+ stderr.write("omo daemon: rollback migration runtime is unavailable\n")
183
+ return DAEMON_EXIT.engineRefused
184
+ }
185
+ return runRollbackPrepare({
186
+ engine,
187
+ migration,
188
+ agentDir,
189
+ env,
190
+ explicitStores: parseStoreArgs(args),
191
+ allowMissingIndex: args.includes("--allow-missing-index"),
192
+ dryRun: args.includes("--dry-run"),
193
+ json: args.includes("--json"),
194
+ stdout,
195
+ stderr,
196
+ })
197
+ }
198
+ if (subcommand === "handoff") {
199
+ return runHandoff({
200
+ engine,
201
+ pluginRoot,
202
+ agentDir,
203
+ env,
204
+ policy: resolvePolicy(args, config),
205
+ config,
206
+ stdout,
207
+ stderr,
208
+ })
209
+ }
210
+ if (subcommand === "stop" && args.includes("--all")) {
211
+ const timeoutSeconds = readTimeoutSeconds(args)
212
+ const outcome = runStopAll({
213
+ engine,
214
+ agentDir,
215
+ env,
216
+ drain: args.includes("--drain"),
217
+ wait: args.includes("--wait"),
218
+ timeoutSeconds,
219
+ stdout,
220
+ stderr,
221
+ now: options.now ?? Date.now,
222
+ pause: options.pause ?? blockingPause,
223
+ })
224
+ if (outcome !== undefined) return outcome
225
+ }
226
+ const engineArgs = buildArgs(subcommand, args, { specPath, policy: resolvePolicy(args, config) })
227
+ const result = engine.run(engineArgs, { env: hostCommandEnvironment(env, agentDir, config) })
228
+ const parsed = parseEngineLine(result.stdout ?? "")
160
229
 
161
230
  if (result.stderr) stderr.write(result.stderr)
162
231
 
@@ -165,7 +234,7 @@ export function runDaemonCommand(args, options) {
165
234
  const daemonEnv = attachEnv(parsed, agentDir)
166
235
  // `omo daemon attach --model x`: the trailing args are a normal omo launch that should run
167
236
  // against the daemon, so the caller gets the merged environment back and continues with them.
168
- const launchArgs = args.slice(1).filter((arg) => !DAEMON_FLAGS.has(arg))
237
+ const launchArgs = attachLaunchArgs(args)
169
238
  if (launchArgs.length > 0) return { passthrough: true, args: launchArgs, env: { ...env, ...daemonEnv } }
170
239
  const payload = { ...(parsed ?? {}), env: daemonEnv }
171
240
  if (args.includes("--json")) stdout.write(`${JSON.stringify(payload)}\n`)
@@ -177,29 +246,3 @@ export function runDaemonCommand(args, options) {
177
246
  else stdout.write(`${summarize(subcommand, parsed, result.exitCode)}\n`)
178
247
  return result.exitCode
179
248
  }
180
-
181
- /**
182
- * The `omo doctor` view: one INFO line, never a FAIL - a machine without a daemon is healthy,
183
- * it just has nothing shared to report. Returned as lines so doctor can place it with the rest.
184
- */
185
- export function daemonReportLines({ engine, pluginRoot, agentDir, env, platform }) {
186
- if (platform === "win32") return ["INFO Daemon: unavailable on win32 (no unix socket to share)"]
187
- const stdout = { write() {} }
188
- const captured = []
189
- const exitCode = runDaemonCommand(["status", "--json"], {
190
- engine, pluginRoot, agentDir, env, platform,
191
- stdout: { write: (text) => void captured.push(text) },
192
- stderr: stdout,
193
- })
194
- const parsed = parseLine(captured.join(""))
195
- if (exitCode !== DAEMON_EXIT.ok || parsed === undefined) return ["INFO Daemon: not running"]
196
- const sessions = parsed.sessions?.total ?? parsed.sessions?.length ?? 0
197
- const parts = [
198
- `pid ${parsed.pid}`,
199
- parsed.instanceId === undefined ? undefined : `instance ${parsed.instanceId}`,
200
- parsed.engineVersion === undefined ? undefined : `engine ${parsed.engineVersion}`,
201
- `${sessions} session(s)`,
202
- `zombies ${parsed.zombies ?? 0}`,
203
- ].filter(Boolean)
204
- return [`INFO Daemon: running ${parts.join(" · ")}`]
205
- }
@@ -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
  }
@@ -210,6 +210,20 @@ export function engineHostCall(engineArgs, options) {
210
210
  return { exitCode: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }
211
211
  }
212
212
 
213
+ export function rollbackMigrateCall(request) {
214
+ const runtime = join(packageRoot, "plugin", "runtime", "rollback-migrate.js")
215
+ const result = spawnSync(process.execPath, [runtime], {
216
+ encoding: "utf8",
217
+ input: JSON.stringify(request),
218
+ env: senpiEnvironment(preparedSenpi().packageRoot),
219
+ windowsHide: true,
220
+ })
221
+ if (result.status !== 0) {
222
+ throw new Error(result.stderr?.trim() || `rollback migration runtime exited ${result.status ?? 1}`)
223
+ }
224
+ return JSON.parse(result.stdout)
225
+ }
226
+
213
227
  export async function runLauncher(args = process.argv.slice(2)) {
214
228
  migrateLegacyBunGlobalManifest()
215
229
  reportLegacyFlatAdoption()
@@ -226,6 +240,7 @@ export async function runLauncher(args = process.argv.slice(2)) {
226
240
  if (command === "daemon") {
227
241
  const outcome = runDaemonCommand(args.slice(1), {
228
242
  engine: { run: engineHostCall },
243
+ migration: { run: rollbackMigrateCall },
229
244
  pluginRoot: join(packageRoot, "plugin"),
230
245
  agentDir: canonicalAgentDir(),
231
246
  env: process.env,
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Derived from @code-yeongyu/senpi 2026.9.28-7 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