omo-ai 5.0.0 → 5.1.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.
Files changed (31) hide show
  1. package/bin/lib/computer-use-doctor.js +90 -0
  2. package/bin/lib/doctor-migration.js +69 -15
  3. package/bin/lib/doctor.js +2 -2
  4. package/bin/lib/launcher.js +47 -6
  5. package/bin/lib/package-paths.js +22 -5
  6. package/bin/lib/provider-map.json +1 -1
  7. package/bin/lib/standalone-binary.js +67 -0
  8. package/package.json +2 -2
  9. package/plugin/CHANGELOG.md +139 -0
  10. package/plugin/extensions/assets.generated.json +7 -0
  11. package/plugin/extensions/memory-run-supervisor.mjs +2 -2
  12. package/plugin/extensions/omo-computer-use.js +2 -0
  13. package/plugin/extensions/omo-init-deep-advisor.js +2 -2
  14. package/plugin/extensions/omo-member.js +2 -2
  15. package/plugin/extensions/omo-task.js +2 -2
  16. package/plugin/extensions/omo.js +2 -2
  17. package/plugin/package.json +2 -1
  18. package/plugin/runtime/agent-toolkit-sdk/sdk.js +2 -2
  19. package/plugin/runtime/ast-grep-mcp/manifest.json +1 -1
  20. package/plugin/runtime/category-coverage/index.js +9 -3
  21. package/plugin/runtime/lsp-daemon/dist/.omo-runtime-manifest.json +1 -1
  22. package/plugin/scripts/install.mjs +2 -0
  23. package/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  24. package/plugin/skills/coding-agent-sessions/SKILL.md +1 -1
  25. package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/aside_scanner.py +3 -0
  26. package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/cli.py +4 -0
  27. package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/file_scanners.py +6 -2
  28. package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/scanners.py +7 -1
  29. package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/transcript.py +3 -1
  30. package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/types.py +3 -1
  31. package/plugin/skills/coding-agent-sessions/scripts/find-agent-sessions.py +4 -0
@@ -0,0 +1,90 @@
1
+ import { join } from "node:path"
2
+ import { pathToFileURL } from "node:url"
3
+ import { packageManifest, packageRoot } from "./package-paths.js"
4
+
5
+ export const COMPUTER_USE_RUNTIME = join("plugin", "runtime", "category-coverage", "index.js")
6
+
7
+ async function loadRuntime() {
8
+ return import(pathToFileURL(join(packageRoot, COMPUTER_USE_RUNTIME)).href)
9
+ }
10
+
11
+ function baseLine(report) {
12
+ return `INFO computer use: enabled=${report.enabled} supported=${report.supported} host=${report.host}`
13
+ }
14
+
15
+ function readyLines(report) {
16
+ const capabilities = report.capabilities
17
+ const displayServer = capabilities.displayServer ? ` (${capabilities.displayServer})` : ""
18
+ const backendLevel = capabilities.backend === "unavailable" ? "WARN" : "PASS"
19
+ const permissionLevel = capabilities.capture && capabilities.input && capabilities.ax ? "PASS" : "WARN"
20
+ const displayLevel = capabilities.displayCount > 0 && !capabilities.screenLocked ? "PASS" : "WARN"
21
+ const stopLevel = capabilities.stopPath === "none" ? "WARN" : "PASS"
22
+ const stopReason = capabilities.stopReason ? ` reason=${capabilities.stopReason}` : ""
23
+ // The engine arms the stop chord only when a session starts input (`stopPath.start`), so a pre-session
24
+ // probe always sees `none` / `no-global-listener`; that is the normal idle state, not a failure.
25
+ const stopLine =
26
+ capabilities.stopPath === "none" && capabilities.stopReason === "no-global-listener"
27
+ ? "INFO computer use stop path: not armed until computer use starts input (checked when a session activates)"
28
+ : `${stopLevel} computer use stop path: ${capabilities.stopPath}${stopReason}`
29
+ return [
30
+ baseLine(report),
31
+ `PASS computer use engine: ${report.enginePath} (version ${report.hello.engineVersion}, ABI ${report.hello.abi}, protocol ${report.hello.protocolVersion})`,
32
+ `${backendLevel} computer use backend: ${capabilities.backend}${displayServer}`,
33
+ `${permissionLevel} computer use permissions: capture=${capabilities.capturePermission} input=${capabilities.inputPermission} accessibility=${capabilities.axPermission}`,
34
+ `${displayLevel} computer use display: count=${capabilities.displayCount} screenLocked=${capabilities.screenLocked}`,
35
+ stopLine,
36
+ ]
37
+ }
38
+
39
+ export function formatComputerUseDoctorLines(report) {
40
+ switch (report.kind) {
41
+ case "ready":
42
+ return readyLines(report)
43
+ case "skipped":
44
+ return [
45
+ baseLine(report),
46
+ report.reason === "disabled"
47
+ ? "INFO computer use probe: skipped because computer.enabled=false"
48
+ : `INFO computer use probe: skipped because ${report.host} is unsupported`,
49
+ ]
50
+ case "unavailable":
51
+ return [
52
+ baseLine(report),
53
+ `FAIL computer use engine: ${report.diagnostic.code}: ${report.diagnostic.message} ${report.diagnostic.cause}`.trimEnd(),
54
+ `INFO computer use engine paths tried: ${report.diagnostic.attemptedPaths.join(", ") || "<none>"}`,
55
+ ]
56
+ case "not-installed":
57
+ return [
58
+ baseLine(report),
59
+ "INFO computer use engine: not installed yet; it is downloaded the first time computer use starts",
60
+ `INFO computer use engine paths checked: ${report.attemptedPaths.join(", ") || "<none>"}`,
61
+ ]
62
+ case "failed":
63
+ return [
64
+ baseLine(report),
65
+ `FAIL computer use engine: ${report.code}: ${report.message}`,
66
+ `INFO computer use engine path: ${report.enginePath}`,
67
+ ]
68
+ default:
69
+ throw new TypeError(`unknown computer use doctor report kind: ${String(report.kind)}`)
70
+ }
71
+ }
72
+
73
+ export async function doctorComputerUseLines(options = {}) {
74
+ try {
75
+ const runtime = await (options.loadRuntime ?? loadRuntime)()
76
+ const report = await runtime.computerUseDoctorReport({
77
+ cwd: options.cwd ?? process.cwd(),
78
+ env: options.env ?? process.env,
79
+ version: packageManifest().version,
80
+ packageRoot,
81
+ platform: options.platform ?? process.platform,
82
+ arch: options.arch ?? process.arch,
83
+ timeoutMs: options.timeoutMs,
84
+ })
85
+ return formatComputerUseDoctorLines(report)
86
+ } catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error)
88
+ return [`WARN computer use: diagnostics unavailable: ${message}`]
89
+ }
90
+ }
@@ -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 { standaloneBinaryVersion } from "./standalone-binary.js"
7
8
 
8
9
  // Migration leftovers from the OpenCode edition, reported and never touched: another `omo` ahead of
9
10
  // omo-ai's on PATH, the legacy package still installed globally, and the OpenCode plugin still
@@ -51,7 +52,7 @@ export function resolveMigrationEnvironment({ env, platform, homeDir }) {
51
52
  ...opencode.files,
52
53
  ...opencode.directories.flatMap((directory) => TUI_CONFIG_FILES.map((name) => join(directory, name))),
53
54
  ]
54
- return { isWindows, pathDirectories, npmPrefixes, bunRoot, opencodeConfigFiles }
55
+ return { isWindows, homeDir, pathDirectories, npmPrefixes, bunRoot, opencodeConfigFiles }
55
56
  }
56
57
 
57
58
  function npmrcPrefix(homeDir) {
@@ -72,15 +73,27 @@ export function scanOmoBins(environment) {
72
73
  const binPath = suffixes.map((suffix) => join(directory, `omo${suffix}`)).find(pathExists)
73
74
  if (binPath === undefined) continue
74
75
  const owner = resolveOwner(binPath)
75
- entries.push({ binPath, directory, owner, kind: classify(owner) })
76
+ const kind = classify(owner)
77
+ const standalone = kind === "foreign" ? standaloneBinaryVersion(binPath, environment.homeDir, environment.isWindows) : null
78
+ entries.push(standalone === null
79
+ ? { binPath, directory, owner, kind }
80
+ : { binPath, directory, owner: { name: STANDALONE_LABEL, version: standalone }, kind: "standalone" })
76
81
  }
77
82
  return entries
78
83
  }
79
84
 
80
- /** The entries a typed `omo` reaches before omo-ai's own; all of them when omo-ai is not on PATH. */
85
+ const STANDALONE_LABEL = "standalone omo binary"
86
+ const isOmoInstall = (entry) => entry.kind === "native" || entry.kind === "standalone"
87
+
88
+ /** The entries a typed `omo` reaches before OmO's own (omo-ai or a standalone binary); all of them when neither is on PATH. */
81
89
  export function shadowingOmoBins(entries) {
82
- const nativeIndex = entries.findIndex((entry) => entry.kind === "native")
83
- return (nativeIndex < 0 ? entries : entries.slice(0, nativeIndex)).filter((entry) => entry.kind !== "native")
90
+ const firstOmo = entries.findIndex(isOmoInstall)
91
+ return (firstOmo < 0 ? entries : entries.slice(0, firstOmo)).filter((entry) => !isOmoInstall(entry))
92
+ }
93
+
94
+ /** omo-ai and standalone binaries on PATH, in PATH order; only the first one runs. */
95
+ export function omoInstallsOnPath(entries) {
96
+ return entries.filter(isOmoInstall)
84
97
  }
85
98
 
86
99
  function classify(owner) {
@@ -105,13 +118,16 @@ function resolveOwner(binPath) {
105
118
  const fromLink = isInsideNodeModules(real) ? ownerOfFile(real) : null
106
119
  if (fromLink !== null) return fromLink
107
120
  const shim = readFileHead(binPath, SHIM_READ_LIMIT)
108
- if (shim === undefined) return null
109
- if (shim.includes(CODEX_LIGHT_WRAPPER_MARKER)) {
121
+ if (shim?.includes(CODEX_LIGHT_WRAPPER_MARKER)) {
110
122
  const version = shim.match(CODEX_LIGHT_CACHE_VERSION)?.[1]
111
123
  if (version !== undefined) return { name: "lazycodex", version }
112
124
  }
113
- // Ownership comes from an installed package the shim really launches, never from its text alone.
114
- for (const entry of shimEntryPaths(shim, dirname(binPath))) {
125
+ // Ownership comes from an installed package the shim or its Bun sidecar really launches, never text alone.
126
+ const entries = [
127
+ ...(shim === undefined ? [] : shimEntryPaths(shim, dirname(binPath))),
128
+ ...bunxEntryPaths(binPath),
129
+ ]
130
+ for (const entry of entries) {
115
131
  if (!isInsideNodeModules(entry) || !pathExists(entry)) continue
116
132
  const owner = ownerOfFile(entry)
117
133
  if (owner !== null) return owner
@@ -132,6 +148,24 @@ function shimEntryPaths(shim, shimDirectory) {
132
148
  return paths
133
149
  }
134
150
 
151
+ // Bun's Windows bin is a copied `omo.exe` plus an `omo.bunx` sidecar: UTF-16LE, the target path up to
152
+ // a `"` and a NUL. Bun writes that path relative to the bin dir's parent (`..\node_modules\...` or
153
+ // `install\global\node_modules\...` from `~/.bun`), and its shim resolves it against that same dir.
154
+ function bunxEntryPaths(binPath) {
155
+ if (!/\.exe$/i.test(binPath)) return []
156
+ const sidecar = `${binPath.slice(0, -4)}.bunx`
157
+ let contents
158
+ try {
159
+ if (!statSync(sidecar).isFile()) return []
160
+ contents = readFileSync(sidecar).toString("utf16le")
161
+ } catch {
162
+ return []
163
+ }
164
+ const end = contents.indexOf('"\0')
165
+ if (end <= 0) return []
166
+ return [join(dirname(dirname(binPath)), contents.slice(0, end).replace(/\\/g, "/"))]
167
+ }
168
+
135
169
  function isInsideNodeModules(path) {
136
170
  return /(?:^|[\\/])node_modules[\\/]/.test(path)
137
171
  }
@@ -243,23 +277,39 @@ function isPluginEntryFor(entry, name) {
243
277
 
244
278
  function ownerLabel(owner) {
245
279
  if (owner === null) return "unknown owner"
280
+ if (owner.name === STANDALONE_LABEL) return `${owner.name} ${owner.version}`
246
281
  return owner.version === null ? owner.name : `${owner.name}@${owner.version}`
247
282
  }
248
283
 
249
- export function formatMigrationLines({ shadowing, nativeDirectory, legacyPackages, registrations, restoreCommand }) {
284
+ function omoInstallRemoval(entry, bunRoot) {
285
+ if (entry.kind === "standalone") return `remove ${entry.binPath}`
286
+ return realPathOf(entry.binPath).startsWith(realPathOf(bunRoot)) ? "bun remove -g omo-ai" : "npm uninstall -g omo-ai"
287
+ }
288
+
289
+ export function formatMigrationLines({ shadowing, nativeDirectory, legacyPackages, registrations, restoreCommand, omoInstalls = [], bunRoot = "", standalone = false }) {
250
290
  const lines = []
291
+ const first = omoInstalls[0]
292
+ const target = first?.kind === "standalone" || (first === undefined && standalone) ? "the standalone omo binary" : "omo-ai"
251
293
  for (const entry of shadowing) {
252
- const fix = entry.kind === "legacy"
294
+ const fix = entry.kind === "legacy" && target === "omo-ai"
253
295
  ? `${repairCommand()} (repairs it), or remove that file.`
254
296
  : nativeDirectory === null
255
- ? "remove that file, or put omo-ai's bin dir ahead of it on PATH."
297
+ ? `remove that file, or put ${target === "omo-ai" ? "omo-ai's bin dir" : "the omo binary's directory"} ahead of it on PATH.`
256
298
  : `remove that file, or move ${nativeDirectory} ahead of ${entry.directory} on PATH.`
257
- lines.push(`WARN another omo precedes omo-ai on PATH: ${entry.binPath} (${ownerLabel(entry.owner)}). Fix: ${fix}`)
299
+ lines.push(`WARN another omo precedes ${target} on PATH: ${entry.binPath} (${ownerLabel(entry.owner)}). Fix: ${fix}`)
300
+ }
301
+ const shadowedOmo = omoInstalls.slice(1).filter((entry) => entry.kind !== first.kind || entry.kind === "standalone")
302
+ if (shadowedOmo.length > 0) {
303
+ const others = shadowedOmo.map((entry) => `${entry.binPath} (${ownerLabel(entry.owner)})`).join(", ")
304
+ const removals = shadowedOmo.map((entry) => omoInstallRemoval(entry, bunRoot)).join(" and ")
305
+ lines.push(`WARN more than one OmO install is on PATH: ${first.binPath} (${ownerLabel(first.owner)}) runs when you type omo; ${others} never runs. Keep one: ${removals}, or ${omoInstallRemoval(first, bunRoot)} to use the other.`)
258
306
  }
259
307
  for (const found of legacyPackages) {
260
308
  const label = ownerLabel({ name: found.name, version: found.version })
261
309
  const remove = found.manager === "npm"
262
- ? `npm uninstall -g ${found.name}, then re-run ${restoreCommand} if omo disappears.`
310
+ ? restoreCommand === null
311
+ ? `npm uninstall -g ${found.name}`
312
+ : `npm uninstall -g ${found.name}, then re-run ${restoreCommand} if omo disappears.`
263
313
  : `bun remove -g ${found.name}`
264
314
  lines.push(`WARN legacy package ${label} is still installed globally (${found.manager}: ${found.packageDir}). Remove: ${remove}`)
265
315
  }
@@ -279,11 +329,15 @@ export function migrationReport(options, restoreCommand) {
279
329
  homeDir: options.homeDir ?? homedir(),
280
330
  })
281
331
  const bins = scanOmoBins(environment)
332
+ const omoInstalls = omoInstallsOnPath(bins)
282
333
  return formatMigrationLines({
283
334
  shadowing: shadowingOmoBins(bins),
284
- nativeDirectory: bins.find((entry) => entry.kind === "native")?.directory ?? null,
335
+ nativeDirectory: omoInstalls[0]?.directory ?? null,
285
336
  legacyPackages: findLegacyPackages(environment),
286
337
  registrations: findOpenCodeRegistrations(environment.opencodeConfigFiles),
287
338
  restoreCommand,
339
+ omoInstalls,
340
+ bunRoot: environment.bunRoot,
341
+ standalone: options.standalone === true,
288
342
  })
289
343
  }
package/bin/lib/doctor.js CHANGED
@@ -433,8 +433,8 @@ export function runDoctor(inventory, args = [], options = {}) {
433
433
  lines.push(...staleEngineReport(options))
434
434
  lines.push(...retiredPayloadReport(options))
435
435
  lines.push(...transientMemoryReport(options))
436
- lines.push(...daemonReport(options))
437
- lines.push(...(options.categoryCoverage ?? []))
436
+ lines.push(...daemonReport(options), ...(options.computerUse ?? []), ...(options.categoryCoverage ?? []))
437
+ if ((options.computerUse ?? []).some((line) => line.startsWith("FAIL "))) failed = true
438
438
  if (needsSetupSuggestion(inventory)) {
439
439
  lines.push("INFO no credentials found; run omo setup to review sibling stores")
440
440
  }
@@ -1,14 +1,15 @@
1
1
  import { spawnSync } from "node:child_process"
2
- import { existsSync } from "node:fs"
3
- import { delimiter, join } from "node:path"
2
+ import { existsSync, realpathSync } from "node:fs"
3
+ import { delimiter, isAbsolute, join, relative, sep } from "node:path"
4
4
  import { spawnNode } from "./child-process.js"
5
5
  import { doctorCoverageLines } from "./category-coverage.js"
6
+ import { doctorComputerUseLines } from "./computer-use-doctor.js"
6
7
  import { runDaemonCommand } from "./daemon.js"
7
8
  import { runDoctor } from "./doctor.js"
8
9
  import { ensureEnginePrepared } from "./engine-prepare.js"
9
10
  import { migrateLegacyBunGlobalManifest } from "./legacy-bun-global-migration.js"
10
11
  import { adoptLegacyFlatState, canonicalAgentDir } from "./agent-dir.js"
11
- import { nearestNodeBin, packageManifest, packageRoot, readJson, releaseChannel, resolveSenpi, updateTarget } from "./package-paths.js"
12
+ import { nearestNodeBin, packageManifest, packageRoot, readJson, releaseBanner, releaseChannel, resolveSenpi, updateTarget } from "./package-paths.js"
12
13
  import { runSelfUpdate } from "./self-update.js"
13
14
  import { detectHarnesses } from "./setup-detect.js"
14
15
  import { readSetupSuggestionCache, spawnSetupSuggestionRefresh } from "./setup-detect-cache.js"
@@ -78,10 +79,35 @@ function engineVersion() {
78
79
  }
79
80
  }
80
81
 
82
+ function canonicalPath(path) {
83
+ try {
84
+ return realpathSync.native(path)
85
+ } catch {
86
+ return path
87
+ }
88
+ }
89
+
90
+ function containsPath(root, target) {
91
+ const rel = relative(canonicalPath(root), canonicalPath(target))
92
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
93
+ }
94
+
95
+ // A compiled omo session exports its own payload as the brand-scoped package dir, and every shell it
96
+ // spawns inherits that. The engine reads these names before the legacy PI_PACKAGE_DIR, so a release
97
+ // omo started from such a shell would run on the foreign payload. A deliberate relocation names this
98
+ // install's engine and survives; a root that does not contain the engine belongs to another install.
99
+ function dropForeignPackageDirs(env, senpiRoot) {
100
+ for (const name of ["OMO_PACKAGE_DIR", "SENPI_PACKAGE_DIR"]) {
101
+ const root = env[name]
102
+ if (root && !containsPath(root, senpiRoot)) delete env[name]
103
+ }
104
+ }
105
+
81
106
  function senpiEnvironment(senpiRoot) {
82
107
  const env = { ...process.env }
83
108
  delete env.OMO_BIN
84
109
  delete env.SENPI_BIN
110
+ dropForeignPackageDirs(env, senpiRoot)
85
111
  // One directory for every surface. The legacy name travels too, so a bare senpi spawned by a
86
112
  // tool inherits the same state instead of falling back to its own home.
87
113
  const agentDir = canonicalAgentDir(env)
@@ -220,8 +246,17 @@ export async function runLauncher(args = process.argv.slice(2)) {
220
246
  return
221
247
  }
222
248
  if (command === "doctor") {
223
- const categoryCoverage = args[1] === "--reap" ? [] : await doctorCoverageLines({ agentDir: canonicalAgentDir() })
224
- runDoctor(await detectHarnesses(), args.slice(1), { daemonEngine: { run: engineHostCall }, categoryCoverage })
249
+ const [categoryCoverage, computerUse] = args[1] === "--reap"
250
+ ? [[], []]
251
+ : await Promise.all([
252
+ doctorCoverageLines({ agentDir: canonicalAgentDir() }),
253
+ doctorComputerUseLines(),
254
+ ])
255
+ runDoctor(await detectHarnesses(), args.slice(1), {
256
+ daemonEngine: { run: engineHostCall },
257
+ categoryCoverage,
258
+ computerUse,
259
+ })
225
260
  return
226
261
  }
227
262
  if (command === "setup") {
@@ -240,12 +275,18 @@ export async function runLauncher(args = process.argv.slice(2)) {
240
275
  process.exitCode = await runSelfUpdate(args)
241
276
  return
242
277
  }
278
+ // app-server takes the plugin after its subcommand: a leading --extension never reaches the
279
+ // engine's app-server dispatch. It loads into every thread, including the daemon's.
280
+ if (command === "app-server") {
281
+ await spawnSenpi(args.includes("--no-extensions") ? args : [...args, "--extension", join(packageRoot, "plugin")], false)
282
+ return
283
+ }
243
284
  if (earlyCommands.has(command) || command === "update") {
244
285
  await spawnSenpi(args, false)
245
286
  return
246
287
  }
247
288
  if (isInteractiveDefault(args)) {
248
- console.error(`omo (omo-ai beta ${packageManifest().version})`)
289
+ console.error(releaseBanner())
249
290
  if (process.stdout.isTTY === true && setupSuggestionForLaunch()) {
250
291
  console.error("omo: sibling credentials detected; run `omo setup` to review them")
251
292
  }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
2
3
  import { basename, dirname, join, parse } from "node:path"
3
4
  import { fileURLToPath } from "node:url"
4
5
 
@@ -19,6 +20,11 @@ export function releaseChannel(version = packageManifest().version) {
19
20
  return typeof version === "string" && version.includes("-") ? "beta" : "latest"
20
21
  }
21
22
 
23
+ /** The startup banner line: a prerelease names its beta channel, a stable release does not. */
24
+ export function releaseBanner(version = packageManifest().version) {
25
+ return releaseChannel(version) === "beta" ? `omo (omo-ai beta ${version})` : `omo (omo-ai ${version})`
26
+ }
27
+
22
28
  /** The package spec that installs this build's channel: `omo-ai@beta` or the bare `omo-ai`. */
23
29
  export function channelPackageSpec(version = packageManifest().version) {
24
30
  return releaseChannel(version) === "beta" ? "omo-ai@beta" : "omo-ai"
@@ -32,21 +38,32 @@ function installedVersion(root) {
32
38
  }
33
39
  }
34
40
 
35
- export function updateTarget(root = packageRoot, platform = process.platform, version = installedVersion(root)) {
41
+ export function updateTarget(
42
+ root = packageRoot,
43
+ platform = process.platform,
44
+ version = installedVersion(root),
45
+ homeDir = process.env.HOME || process.env.USERPROFILE || homedir(),
46
+ exists = existsSync,
47
+ ) {
36
48
  const spec = channelPackageSpec(version)
37
49
  const updateCwd = dirname(join(root, "package.json"))
38
50
  const normalizedRoot = updateCwd.replaceAll("\\", "/")
39
- if (normalizedRoot.endsWith(BUN_GLOBAL_PACKAGE_SUFFIX)) {
51
+ const normalizedHome = homeDir.replaceAll("\\", "/").replace(/\/+$/, "")
52
+ const bunInstall = normalizedRoot.endsWith(BUN_GLOBAL_PACKAGE_SUFFIX)
53
+ ? normalizedRoot.slice(0, -BUN_GLOBAL_PACKAGE_SUFFIX.length)
54
+ : undefined
55
+ const isLegacyBunGlobal = normalizedRoot === `${normalizedHome}/node_modules/omo-ai`
56
+ && (exists(join(homeDir, "bun.lock")) || exists(join(homeDir, "bun.lockb")))
57
+ if (bunInstall !== undefined || isLegacyBunGlobal) {
40
58
  // `--cwd` into this package dir does not retarget `bun add -g`; bun still installs into
41
59
  // `$BUN_INSTALL/install/global` (or `~/.bun` when that env is unset). The prefix is the
42
60
  // ancestor of `/install/global/`, and the spawn overlays it so this install is the one that
43
- // moves. `platform` stays on the signature because callers pass the host they are describing.
44
- const bunInstall = normalizedRoot.slice(0, -BUN_GLOBAL_PACKAGE_SUFFIX.length)
61
+ // moves. A legacy Bun home-root install must carry Bun's lockfile and keeps its ambient configuration.
45
62
  return {
46
63
  manager: "bun",
47
64
  command: `bun add -g ${spec}`,
48
65
  argv: ["bun", "add", "-g", spec],
49
- env: { BUN_INSTALL: bunInstall },
66
+ ...(bunInstall === undefined ? {} : { env: { BUN_INSTALL: bunInstall } }),
50
67
  }
51
68
  }
52
69
  return {
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Derived from @code-yeongyu/senpi 2026.9.26 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.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.",
3
3
  "builtinProviderIds": [
4
4
  "alibaba-token-plan",
5
5
  "amazon-bedrock",
@@ -0,0 +1,67 @@
1
+ import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs"
2
+ import { join } from "node:path"
3
+
4
+ const SAMPLE_BYTES = 64 * 1024
5
+
6
+ // A compiled omo copies itself to ~/.omo/binary-runtime/<version>/omo on its first run
7
+ // (materializeProvisionedExecutable in compile-runtime.ts), so a standalone binary that has run
8
+ // once is that file or a byte-identical copy of it. Copies are compared by size and a bounded
9
+ // head+tail sample: these binaries are ~100 MB and the doctor must stay fast.
10
+ export function standaloneBinaryVersion(binPath, homeDir, isWindows) {
11
+ const root = join(homeDir, ".omo", "binary-runtime")
12
+ let versions
13
+ try {
14
+ versions = readdirSync(root)
15
+ } catch {
16
+ return null
17
+ }
18
+ const real = realPathOf(binPath)
19
+ const size = regularFileSize(real)
20
+ if (size === undefined) return null
21
+ for (const version of versions) {
22
+ const provisioned = realPathOf(join(root, version, isWindows ? "omo.exe" : "omo"))
23
+ if (provisioned === real) return version
24
+ if (regularFileSize(provisioned) === size && sameSamples(real, provisioned, size)) return version
25
+ }
26
+ return null
27
+ }
28
+
29
+ function realPathOf(path) {
30
+ try {
31
+ return realpathSync(path)
32
+ } catch {
33
+ return path
34
+ }
35
+ }
36
+
37
+ function regularFileSize(path) {
38
+ try {
39
+ const stats = statSync(path)
40
+ return stats.isFile() ? stats.size : undefined
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ function sameSamples(left, right, size) {
47
+ const length = Math.min(SAMPLE_BYTES, size)
48
+ const tail = Math.max(0, size - length)
49
+ const a = readSamples(left, length, tail)
50
+ const b = readSamples(right, length, tail)
51
+ return a !== undefined && b !== undefined && a.equals(b)
52
+ }
53
+
54
+ function readSamples(path, length, tailOffset) {
55
+ let descriptor
56
+ try {
57
+ descriptor = openSync(path, "r")
58
+ const buffer = Buffer.alloc(length * 2)
59
+ readSync(descriptor, buffer, 0, length, 0)
60
+ readSync(descriptor, buffer, length, length, tailOffset)
61
+ return buffer
62
+ } catch {
63
+ return undefined
64
+ } finally {
65
+ if (descriptor !== undefined) closeSync(descriptor)
66
+ }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omo-ai",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
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": {
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@babel/parser": "8.0.4",
21
- "@code-yeongyu/senpi": "2026.9.26"
21
+ "@code-yeongyu/senpi": "2026.9.28-7"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",