omo-ai 5.1.1 → 5.1.3

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/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 +9 -44
  5. package/bin/lib/engine-dependency.js +23 -0
  6. package/bin/lib/engine-models.js +5 -2
  7. package/bin/lib/engine-prepare.js +15 -0
  8. package/bin/lib/launch-spec-mode.js +78 -0
  9. package/bin/lib/launcher.js +14 -17
  10. package/bin/lib/npm-dist-tags.js +42 -0
  11. package/bin/lib/package-paths.js +21 -1
  12. package/bin/lib/provider-map.json +1 -1
  13. package/bin/lib/rpc-stream-errors.js +2 -2
  14. package/bin/lib/self-update.js +46 -14
  15. package/bin/lib/setup-opencode-models.d.ts +53 -0
  16. package/bin/lib/setup-opencode-models.js +121 -34
  17. package/bin/lib/update-args.js +49 -0
  18. package/bin/senpi-patch.mjs +2 -1
  19. package/package.json +2 -3
  20. package/plugin/CHANGELOG.md +100 -0
  21. package/plugin/extensions/memory-run-supervisor.mjs +1 -1
  22. package/plugin/extensions/omo-computer-use.js +1 -1
  23. package/plugin/extensions/omo-init-deep-advisor.js +1 -1
  24. package/plugin/extensions/omo-member.js +2 -2
  25. package/plugin/extensions/omo-task.js +2 -2
  26. package/plugin/extensions/omo.js +2 -2
  27. package/plugin/package.json +1 -1
  28. package/plugin/runtime/agent-toolkit-sdk/sdk.js +2 -2
  29. package/plugin/runtime/ast-grep-mcp/manifest.json +1 -1
  30. package/plugin/runtime/category-coverage/assets.generated.json +7 -0
  31. package/plugin/runtime/rollback-migrate.js +2 -2
  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
@@ -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
@@ -3,59 +3,20 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import { join, resolve } from "node:path"
5
5
  import { canonicalAgentDir } from "./agent-dir.js"
6
- import { packageManifest, packageRoot, readJson, releaseChannel, resolveSenpi, updateTarget } from "./package-paths.js"
6
+ import { fetchNpmDistTagsSync } from "./npm-dist-tags.js"
7
+ import { channelDistTagVersion, packageManifest, packageRoot, readJson, releaseChannel, resolveSenpi, updateTarget } from "./package-paths.js"
7
8
  import { daemonReportLines } from "./daemon.js"
8
9
  import { migrationReport } from "./doctor-migration.js"
10
+ import { launchSpecDoctorLines } from "./launch-spec-mode.js"
11
+ import { piConfigReport } from "./doctor-pi-config.js"
9
12
  import { needsSetupSuggestion } from "./setup-detect.js"
10
13
 
11
- const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/omo-ai/dist-tags"
12
- const NPM_FETCH_TIMEOUT_MS = 5000
13
-
14
- // Doctor is called without await from the launcher and the compiled entry, so the
15
- // registry lookup has to finish before we print. A bounded child fetch keeps that
16
- // synchronous and turns any network failure into "could not check".
17
-
18
14
  export function installedDistTag(version) {
19
15
  return releaseChannel(version)
20
16
  }
21
17
 
22
18
  export function latestFromDistTags(distTags, version) {
23
- if (distTags === null || distTags === undefined || typeof distTags !== "object") return "could not check"
24
- const value = distTags[installedDistTag(version)]
25
- return typeof value === "string" && value.length > 0 ? value : "could not check"
26
- }
27
-
28
- function distTagsFetchScript(url, timeoutMs) {
29
- return `
30
- const url = ${JSON.stringify(url)};
31
- const timeout = ${Number(timeoutMs)};
32
- const ac = new AbortController();
33
- const timer = setTimeout(() => ac.abort(), timeout);
34
- fetch(url, { signal: ac.signal, headers: { accept: "application/json" } })
35
- .then((res) => { if (!res.ok) throw new Error(String(res.status)); return res.text(); })
36
- .then((text) => { JSON.parse(text); process.stdout.write(text); })
37
- .catch(() => { process.exitCode = 1; })
38
- .finally(() => { clearTimeout(timer); });
39
- `
40
- }
41
-
42
- export function fetchNpmDistTagsSync(options = {}) {
43
- const spawn = options.spawn ?? spawnSync
44
- const url = options.url ?? NPM_DIST_TAGS_URL
45
- const timeoutMs = options.timeoutMs ?? NPM_FETCH_TIMEOUT_MS
46
- try {
47
- const result = spawn(process.execPath, ["-e", distTagsFetchScript(url, timeoutMs)], {
48
- encoding: "utf8",
49
- timeout: timeoutMs + 500,
50
- windowsHide: true,
51
- env: process.env,
52
- })
53
- if (result.error || result.status !== 0 || typeof result.stdout !== "string" || result.stdout.trim() === "") return null
54
- const parsed = JSON.parse(result.stdout)
55
- return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null
56
- } catch {
57
- return null
58
- }
19
+ return channelDistTagVersion(distTags, version) ?? "could not check"
59
20
  }
60
21
 
61
22
  function readDistTags(options) {
@@ -430,9 +391,13 @@ export function runDoctor(inventory, args = [], options = {}) {
430
391
  lines.push(`INFO Update: ${updateTarget().command}`)
431
392
  lines.push(...migrationReport(options, updateTarget().command))
432
393
  lines.push(...warningsForSettings())
394
+ lines.push(...piConfigReport({ env: options.env, homeDir: options.homeDir }))
433
395
  lines.push(...staleEngineReport(options))
434
396
  lines.push(...retiredPayloadReport(options))
435
397
  lines.push(...transientMemoryReport(options))
398
+ const launchSpec = launchSpecDoctorLines(options.pluginRoot ?? join(packageRoot, "plugin"), options.launchSpecIo)
399
+ if (launchSpec.some((line) => line.startsWith("FAIL "))) failed = true
400
+ lines.push(...launchSpec)
436
401
  lines.push(...daemonReport(options), ...(options.computerUse ?? []), ...(options.categoryCoverage ?? []))
437
402
  if ((options.computerUse ?? []).some((line) => line.startsWith("FAIL "))) failed = true
438
403
  if (needsSetupSuggestion(inventory)) {
@@ -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
  }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
  import { floorClaudeCodeVersion } from "./claude-code-floor.js"
4
4
  import { prepareCompileSafeEngine } from "./compile-safe-engine.js"
5
+ import { LAUNCH_SPEC_FILENAME, normalizeLaunchSpecMode } from "./launch-spec-mode.js"
5
6
  import { prepareRpcStreamErrors } from "./rpc-stream-errors.js"
6
7
 
7
8
  // Written inside the engine tree, so reinstalling or upgrading the engine drops it with the tree.
@@ -28,6 +29,20 @@ function isPreparedFor(senpiRoot, omoVersion) {
28
29
  * A failure is reported with the reinstall command and never blocks the launch: an unprepared
29
30
  * engine still runs, only without the guards.
30
31
  */
32
+ /**
33
+ * The plugin's launch spec is re-checked on every launch, not behind the engine stamp: a reinstall
34
+ * of omo-ai under a loose umask rewrites the plugin while the engine tree keeps its stamp (#9208).
35
+ * Fail-open like the engine preparation: a failure warns with the manual fix and never blocks.
36
+ */
37
+ export function preparePluginLaunchSpec({ pluginRoot, report = (line) => { process.stderr.write(line) }, io = {} }) {
38
+ try {
39
+ normalizeLaunchSpecMode(pluginRoot, io)
40
+ } catch (error) {
41
+ const path = join(pluginRoot, LAUNCH_SPEC_FILENAME)
42
+ report(`omo: could not make ${path} private (${error.message}); task children and teams need it, run: chmod 644 ${path}\n`)
43
+ }
44
+ }
45
+
31
46
  export function ensureEnginePrepared({ senpiRoot, omoVersion, reinstallCommand, report = (line) => { process.stderr.write(line) } }) {
32
47
  if (isPreparedFor(senpiRoot, omoVersion)) return
33
48
  try {
@@ -0,0 +1,78 @@
1
+ import { chmodSync, lstatSync, statSync } from "node:fs"
2
+ import { userInfo } from "node:os"
3
+ import { join } from "node:path"
4
+
5
+ export const LAUNCH_SPEC_FILENAME = "daemon-launch-spec.json"
6
+
7
+ // The task host refuses a spec another user could rewrite (senpi-task `rejectInsecureMode`):
8
+ // any group/world write bit, or an owner other than the current user.
9
+ const WRITABLE_BY_OTHERS = 0o022
10
+
11
+ function currentUid() {
12
+ return process.getuid?.()
13
+ }
14
+
15
+ function isMissing(error) {
16
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")
17
+ }
18
+
19
+ /**
20
+ * npm and bun extract package files with the installing user's umask, so a `umask 002` install
21
+ * leaves the spec 0664 and every task host start refuses it (#9208). Only the group/world write
22
+ * bits are removed, only on a regular file this user owns; a symlink is never followed, so a link
23
+ * (or a file owned by someone else) is left exactly as it is and the host still rejects it.
24
+ */
25
+ export function normalizeLaunchSpecMode(pluginRoot, io = {}) {
26
+ const path = join(pluginRoot, LAUNCH_SPEC_FILENAME)
27
+ if ((io.platform ?? process.platform) === "win32") return { action: "skipped", path, reason: "win32" }
28
+ let stat
29
+ try {
30
+ stat = (io.lstat ?? lstatSync)(path)
31
+ } catch (error) {
32
+ if (isMissing(error)) return { action: "skipped", path, reason: "missing" }
33
+ throw error
34
+ }
35
+ if (!stat.isFile()) return { action: "left", path, reason: "not_regular_file" }
36
+ const uid = io.getuid === undefined ? currentUid() : io.getuid()
37
+ if (uid !== undefined && stat.uid !== uid) return { action: "left", path, reason: "foreign_owner" }
38
+ const from = stat.mode & 0o7777
39
+ if ((from & WRITABLE_BY_OTHERS) === 0) return { action: "unchanged", path }
40
+ const to = from & ~WRITABLE_BY_OTHERS
41
+ ;(io.chmod ?? chmodSync)(path, to)
42
+ return { action: "normalized", path, from, to }
43
+ }
44
+
45
+ function ownerName() {
46
+ try {
47
+ return userInfo().username
48
+ } catch {
49
+ return "$USER"
50
+ }
51
+ }
52
+
53
+ /**
54
+ * The doctor view applies the task host's own rule (the reader follows symlinks), so it reports
55
+ * exactly the specs the host would refuse: the ones a launch could not normalize.
56
+ */
57
+ export function launchSpecDoctorLines(pluginRoot, io = {}) {
58
+ if ((io.platform ?? process.platform) === "win32") return []
59
+ const path = join(pluginRoot, LAUNCH_SPEC_FILENAME)
60
+ let stat
61
+ try {
62
+ stat = (io.stat ?? statSync)(path)
63
+ } catch (error) {
64
+ if (isMissing(error)) return []
65
+ throw error
66
+ }
67
+ if (!stat.isFile()) return []
68
+ const uid = io.getuid === undefined ? currentUid() : io.getuid()
69
+ const impact = "the task host refuses it, so process-mode task children and every team_create fail"
70
+ if (uid !== undefined && stat.uid !== uid) {
71
+ return [`FAIL launch spec: launch_spec_insecure: ${path} is not owned by you (uid ${stat.uid}); ${impact}. Fix: reinstall omo as this user, or run: sudo chown ${ownerName()} ${path}`]
72
+ }
73
+ if ((stat.mode & WRITABLE_BY_OTHERS) !== 0) {
74
+ const mode = (stat.mode & 0o777).toString(8)
75
+ return [`FAIL launch spec: launch_spec_insecure: ${path} is group/world-writable (${mode}); ${impact}. Fix: chmod 644 ${path}`]
76
+ }
77
+ return []
78
+ }
@@ -6,29 +6,17 @@ import { doctorCoverageLines } from "./category-coverage.js"
6
6
  import { doctorComputerUseLines } from "./computer-use-doctor.js"
7
7
  import { runDaemonCommand } from "./daemon.js"
8
8
  import { runDoctor } from "./doctor.js"
9
- import { ensureEnginePrepared } from "./engine-prepare.js"
9
+ import { ensureEnginePrepared, preparePluginLaunchSpec } from "./engine-prepare.js"
10
10
  import { migrateLegacyBunGlobalManifest } from "./legacy-bun-global-migration.js"
11
11
  import { adoptLegacyFlatState, canonicalAgentDir } from "./agent-dir.js"
12
12
  import { nearestNodeBin, packageManifest, packageRoot, readJson, releaseBanner, releaseChannel, resolveSenpi, updateTarget } from "./package-paths.js"
13
13
  import { runSelfUpdate } from "./self-update.js"
14
+ import { isSelfUpdate } from "./update-args.js"
14
15
  import { detectHarnesses } from "./setup-detect.js"
15
16
  import { readSetupSuggestionCache, spawnSetupSuggestionRefresh } from "./setup-detect-cache.js"
16
17
  import { printSetupReport } from "./setup-report.js"
17
18
 
18
19
  const earlyCommands = new Set(["install", "remove", "list", "config", "auth", "app-server", "host"])
19
- const selfUpdateTargets = new Set(["self", "senpi", "omo"])
20
- // Updating extensions or model catalogs is the engine's job; everything else under `update`
21
- // would try to replace the pinned engine, so the launcher answers it instead.
22
- const engineUpdateTargets = new Set(["--extensions", "--models"])
23
-
24
- function isSelfUpdate(args) {
25
- if (args[0] !== "update") return false
26
- const rest = args.slice(1)
27
- if (rest.length === 0) return true
28
- if (rest.some((arg) => engineUpdateTargets.has(arg))) return false
29
- return rest.every((arg) => arg.startsWith("-") || selfUpdateTargets.has(arg))
30
- }
31
-
32
20
  // Identity the engine adopts for this install: what the user sees, where state lives, which
33
21
  // environment prefix is read first, what goes on the wire, and which channel to check for
34
22
  // updates. The engine consumes this once and scrubs it, so nested engine processes are
@@ -136,6 +124,7 @@ function senpiEnvironment(senpiRoot) {
136
124
  }
137
125
 
138
126
  function preparedSenpi() {
127
+ preparePluginLaunchSpec({ pluginRoot: join(packageRoot, "plugin") })
139
128
  const senpi = resolveSenpi()
140
129
  ensureEnginePrepared({
141
130
  senpiRoot: senpi.packageRoot,
@@ -162,6 +151,12 @@ async function spawnSenpi(args, withExtension) {
162
151
  await spawnNode(senpi.cliPath, finalArgs, { env })
163
152
  }
164
153
 
154
+ // Routine launch notices go straight to stderr: Bun renders every console.error line red on a
155
+ // color terminal, which made a healthy startup look like a failure (#8442).
156
+ function notice(line) {
157
+ process.stderr.write(`${line}\n`)
158
+ }
159
+
165
160
  function isInteractiveDefault(args) {
166
161
  return process.stderr.isTTY === true && !args.includes("-p") && !args.includes("--print")
167
162
  }
@@ -180,7 +175,7 @@ function reportLegacyFlatAdoption() {
180
175
  }
181
176
  if (!result.adopted) return
182
177
  const moved = [...result.copied, ...result.backfilled].join(", ")
183
- console.error(`omo: carried forward settings from the legacy ~/.omo layout (${moved})`)
178
+ notice(`omo: carried forward settings from the legacy ~/.omo layout (${moved})`)
184
179
  }
185
180
 
186
181
  /**
@@ -261,6 +256,8 @@ export async function runLauncher(args = process.argv.slice(2)) {
261
256
  return
262
257
  }
263
258
  if (command === "doctor") {
259
+ // Doctor is a launch too: it reports only what the launch-time preparation could not fix.
260
+ preparePluginLaunchSpec({ pluginRoot: join(packageRoot, "plugin") })
264
261
  const [categoryCoverage, computerUse] = args[1] === "--reap"
265
262
  ? [[], []]
266
263
  : await Promise.all([
@@ -301,9 +298,9 @@ export async function runLauncher(args = process.argv.slice(2)) {
301
298
  return
302
299
  }
303
300
  if (isInteractiveDefault(args)) {
304
- console.error(releaseBanner())
301
+ notice(releaseBanner())
305
302
  if (process.stdout.isTTY === true && setupSuggestionForLaunch()) {
306
- console.error("omo: sibling credentials detected; run `omo setup` to review them")
303
+ notice("omo: sibling credentials detected; run `omo setup` to review them")
307
304
  }
308
305
  }
309
306
  await spawnSenpi(args, true)
@@ -0,0 +1,42 @@
1
+ import { spawnSync } from "node:child_process"
2
+
3
+ const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/omo-ai/dist-tags"
4
+ const NPM_FETCH_TIMEOUT_MS = 5000
5
+
6
+ // The dist-tags of omo-ai, read by `omo doctor` for "Latest" and by `omo update` for the version it
7
+ // installs. Doctor is called without await from the launcher and the compiled entry, so the
8
+ // registry lookup has to finish before we print. A bounded child fetch keeps that synchronous
9
+ // and turns any network failure into null.
10
+
11
+ function distTagsFetchScript(url, timeoutMs) {
12
+ return `
13
+ const url = ${JSON.stringify(url)};
14
+ const timeout = ${Number(timeoutMs)};
15
+ const ac = new AbortController();
16
+ const timer = setTimeout(() => ac.abort(), timeout);
17
+ fetch(url, { signal: ac.signal, headers: { accept: "application/json" } })
18
+ .then((res) => { if (!res.ok) throw new Error(String(res.status)); return res.text(); })
19
+ .then((text) => { JSON.parse(text); process.stdout.write(text); })
20
+ .catch(() => { process.exitCode = 1; })
21
+ .finally(() => { clearTimeout(timer); });
22
+ `
23
+ }
24
+
25
+ export function fetchNpmDistTagsSync(options = {}) {
26
+ const spawn = options.spawn ?? spawnSync
27
+ const url = options.url ?? NPM_DIST_TAGS_URL
28
+ const timeoutMs = options.timeoutMs ?? NPM_FETCH_TIMEOUT_MS
29
+ try {
30
+ const result = spawn(process.execPath, ["-e", distTagsFetchScript(url, timeoutMs)], {
31
+ encoding: "utf8",
32
+ timeout: timeoutMs + 500,
33
+ windowsHide: true,
34
+ env: process.env,
35
+ })
36
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string" || result.stdout.trim() === "") return null
37
+ const parsed = JSON.parse(result.stdout)
38
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null
39
+ } catch {
40
+ return null
41
+ }
42
+ }
@@ -20,6 +20,13 @@ export function releaseChannel(version = packageManifest().version) {
20
20
  return typeof version === "string" && version.includes("-") ? "beta" : "latest"
21
21
  }
22
22
 
23
+ /** The version the npm dist-tag of `version`'s channel names, or undefined when the tags lack it. */
24
+ export function channelDistTagVersion(distTags, version = packageManifest().version) {
25
+ if (distTags === null || distTags === undefined || typeof distTags !== "object") return undefined
26
+ const value = distTags[releaseChannel(version)]
27
+ return typeof value === "string" && value.length > 0 ? value : undefined
28
+ }
29
+
23
30
  /** The startup banner line: a prerelease names its beta channel, a stable release does not. */
24
31
  export function releaseBanner(version = packageManifest().version) {
25
32
  return releaseChannel(version) === "beta" ? `omo (omo-ai beta ${version})` : `omo (omo-ai ${version})`
@@ -38,14 +45,27 @@ function installedVersion(root) {
38
45
  }
39
46
  }
40
47
 
48
+ /**
49
+ * The package-manager command that updates the install at `root`. `targetVersion` pins the exact
50
+ * `omo-ai@<version>` spec; without it the channel spec is installed.
51
+ *
52
+ * @param {string} [root]
53
+ * @param {NodeJS.Platform} [platform]
54
+ * @param {string} [version]
55
+ * @param {string} [homeDir]
56
+ * @param {(path: string) => boolean} [exists]
57
+ * @param {string} [targetVersion]
58
+ */
41
59
  export function updateTarget(
42
60
  root = packageRoot,
43
61
  platform = process.platform,
44
62
  version = installedVersion(root),
45
63
  homeDir = process.env.HOME || process.env.USERPROFILE || homedir(),
46
64
  exists = existsSync,
65
+ targetVersion = undefined,
47
66
  ) {
48
- const spec = channelPackageSpec(version)
67
+ // A resolved target installs that exact version; without one the channel spec is the only answer.
68
+ const spec = targetVersion === undefined ? channelPackageSpec(version) : `omo-ai@${targetVersion}`
49
69
  const updateCwd = dirname(join(root, "package.json"))
50
70
  const normalizedRoot = updateCwd.replaceAll("\\", "/")
51
71
  const normalizedHome = homeDir.replaceAll("\\", "/").replace(/\/+$/, "")
@@ -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-4 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",
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
 
4
- const serialization = 'return JSON.stringify(value.type === "message_update" ? toJsonEvent(value) : value);'
5
- const guardedSerialization = `try {
4
+ export const serialization = 'return JSON.stringify(value.type === "message_update" ? toJsonEvent(value) : value);'
5
+ export const guardedSerialization = `try {
6
6
  ${serialization}
7
7
  } catch (error) {
8
8
  if (!(error instanceof Error)) throw error;