omo-ai 5.1.2 → 5.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lib/doctor.js CHANGED
@@ -3,60 +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"
9
11
  import { piConfigReport } from "./doctor-pi-config.js"
10
12
  import { needsSetupSuggestion } from "./setup-detect.js"
11
13
 
12
- const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/omo-ai/dist-tags"
13
- const NPM_FETCH_TIMEOUT_MS = 5000
14
-
15
- // Doctor is called without await from the launcher and the compiled entry, so the
16
- // registry lookup has to finish before we print. A bounded child fetch keeps that
17
- // synchronous and turns any network failure into "could not check".
18
-
19
14
  export function installedDistTag(version) {
20
15
  return releaseChannel(version)
21
16
  }
22
17
 
23
18
  export function latestFromDistTags(distTags, version) {
24
- if (distTags === null || distTags === undefined || typeof distTags !== "object") return "could not check"
25
- const value = distTags[installedDistTag(version)]
26
- return typeof value === "string" && value.length > 0 ? value : "could not check"
27
- }
28
-
29
- function distTagsFetchScript(url, timeoutMs) {
30
- return `
31
- const url = ${JSON.stringify(url)};
32
- const timeout = ${Number(timeoutMs)};
33
- const ac = new AbortController();
34
- const timer = setTimeout(() => ac.abort(), timeout);
35
- fetch(url, { signal: ac.signal, headers: { accept: "application/json" } })
36
- .then((res) => { if (!res.ok) throw new Error(String(res.status)); return res.text(); })
37
- .then((text) => { JSON.parse(text); process.stdout.write(text); })
38
- .catch(() => { process.exitCode = 1; })
39
- .finally(() => { clearTimeout(timer); });
40
- `
41
- }
42
-
43
- export function fetchNpmDistTagsSync(options = {}) {
44
- const spawn = options.spawn ?? spawnSync
45
- const url = options.url ?? NPM_DIST_TAGS_URL
46
- const timeoutMs = options.timeoutMs ?? NPM_FETCH_TIMEOUT_MS
47
- try {
48
- const result = spawn(process.execPath, ["-e", distTagsFetchScript(url, timeoutMs)], {
49
- encoding: "utf8",
50
- timeout: timeoutMs + 500,
51
- windowsHide: true,
52
- env: process.env,
53
- })
54
- if (result.error || result.status !== 0 || typeof result.stdout !== "string" || result.stdout.trim() === "") return null
55
- const parsed = JSON.parse(result.stdout)
56
- return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null
57
- } catch {
58
- return null
59
- }
19
+ return channelDistTagVersion(distTags, version) ?? "could not check"
60
20
  }
61
21
 
62
22
  function readDistTags(options) {
@@ -435,6 +395,9 @@ export function runDoctor(inventory, args = [], options = {}) {
435
395
  lines.push(...staleEngineReport(options))
436
396
  lines.push(...retiredPayloadReport(options))
437
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)
438
401
  lines.push(...daemonReport(options), ...(options.computerUse ?? []), ...(options.categoryCoverage ?? []))
439
402
  if ((options.computerUse ?? []).some((line) => line.startsWith("FAIL "))) failed = true
440
403
  if (needsSetupSuggestion(inventory)) {
@@ -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-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.",
2
+ "_comment": "Derived from @code-yeongyu/senpi 2026.9.29-5 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;
@@ -1,12 +1,10 @@
1
1
  import { join } from "node:path"
2
2
  import { runChild } from "./child-process.js"
3
- import { packageManifest, readJson, resolveSenpi, updateTarget } from "./package-paths.js"
3
+ import { fetchNpmDistTagsSync } from "./npm-dist-tags.js"
4
+ import { channelDistTagVersion, channelPackageSpec, packageManifest, readJson, releaseChannel, resolveSenpi, updateTarget } from "./package-paths.js"
5
+ import { isPrintOnlyUpdate, updateUsageAnswer } from "./update-args.js"
4
6
 
5
- const PRINT_ONLY_FLAGS = new Set(["--dry-run", "--print"])
6
-
7
- export function isPrintOnlyUpdate(args) {
8
- return args.slice(1).some((arg) => PRINT_ONLY_FLAGS.has(arg))
9
- }
7
+ export { isPrintOnlyUpdate }
10
8
 
11
9
  export function formatUpdateCommand(update) {
12
10
  return `omo is updated via ${update.manager}: ${update.command}`
@@ -27,16 +25,22 @@ export function formatVersionChange(before, after) {
27
25
  }
28
26
 
29
27
  /**
30
- * Prints and optionally runs the package-manager command `updateTarget()` resolved. `--dry-run`
31
- * and `--print` keep the print-only answer; anything else streams the spawn, then reports the
32
- * installed versions. A failed manager run exits non-zero with the same command to retry by hand.
28
+ * Resolves the version the running build's channel dist-tag names (the lookup `omo doctor` uses for
29
+ * "Latest"), then prints and optionally runs the package-manager command that installs exactly that
30
+ * version. `--help`/`-h` print the usage and an unknown flag exits 2, both before the registry lookup,
31
+ * so neither can install anything (#9207). `--dry-run` and `--print` print the resolved command only.
32
+ * Already on the target, it says so and installs nothing. After a manager exit 0 it re-reads the installed version: one that did not
33
+ * reach the target exits non-zero with the retry command, because the manager's own success does not
34
+ * prove the install moved (#9198). An unreachable registry falls back to the unpinned channel spec.
33
35
  * `run` defaults to `runChild` so tests inject a spawn without touching the child-process helper.
34
36
  *
35
37
  * @typedef {{ omo: string, engine: string }} InstalledVersion
36
38
  * @typedef {{ status: number | null, signal: string | null }} ChildResult
37
39
  * @typedef {{ stdio?: "inherit", windowsHide?: boolean, env?: NodeJS.ProcessEnv }} RunOptions
40
+ * @typedef {{ manager: string, command: string, argv: string[], env?: Record<string, string> }} UpdateTarget
38
41
  * @typedef {{
39
- * update?: { manager: string, command: string, argv: string[], env?: Record<string, string> },
42
+ * resolveUpdate?: (targetVersion?: string) => UpdateTarget,
43
+ * fetchDistTags?: () => Record<string, unknown> | null,
40
44
  * log?: (line: string) => void,
41
45
  * error?: (line: string) => void,
42
46
  * run?: (command: string, args: string[], options?: RunOptions) => Promise<ChildResult>,
@@ -48,17 +52,39 @@ export function formatVersionChange(before, after) {
48
52
  * @returns {Promise<number>}
49
53
  */
50
54
  export async function runSelfUpdate(args, options = {}) {
51
- const update = options.update ?? updateTarget()
55
+ const resolveUpdate = options.resolveUpdate
56
+ ?? ((targetVersion) => updateTarget(undefined, undefined, undefined, undefined, undefined, targetVersion))
57
+ const fetchDistTags = options.fetchDistTags ?? fetchNpmDistTagsSync
52
58
  const log = options.log ?? ((line) => console.log(line))
53
59
  const error = options.error ?? ((line) => console.error(line))
54
60
  const run = options.run ?? runChild
55
61
  const readInstalled = options.readInstalled ?? readInstalledVersion
56
62
  const env = options.env ?? process.env
57
63
 
58
- log(formatUpdateCommand(update))
59
- if (isPrintOnlyUpdate(args)) return 0
64
+ const usage = updateUsageAnswer(args)
65
+ if (usage !== undefined) {
66
+ ;(usage.stream === "stderr" ? error : log)(usage.text)
67
+ return usage.exitCode
68
+ }
60
69
 
61
70
  const before = readInstalled()
71
+ const channel = releaseChannel(before.omo)
72
+ const target = channelDistTagVersion(fetchDistTags(), before.omo)
73
+ const update = resolveUpdate(target)
74
+
75
+ if (target === undefined) {
76
+ log(`omo: could not confirm the ${channel} omo-ai version from the npm registry; installing the unpinned ${channelPackageSpec(before.omo)}`)
77
+ }
78
+ if (isPrintOnlyUpdate(args)) {
79
+ log(formatUpdateCommand(update))
80
+ return 0
81
+ }
82
+ if (target !== undefined && target === before.omo) {
83
+ log(`omo ${before.omo} is up to date (omo-ai@${channel} is ${target})`)
84
+ return 0
85
+ }
86
+ log(formatUpdateCommand(update))
87
+
62
88
  const [command, ...argv] = update.argv
63
89
  let result
64
90
  try {
@@ -77,6 +103,12 @@ export async function runSelfUpdate(args, options = {}) {
77
103
  return result.status ?? 1
78
104
  }
79
105
 
80
- log(formatVersionChange(before, readInstalled()))
106
+ const after = readInstalled()
107
+ if (target !== undefined && after.omo !== target) {
108
+ error(`omo is still ${after.omo}; ${target} is published`)
109
+ error(`omo: update failed; retry with: ${update.command}`)
110
+ return 1
111
+ }
112
+ log(formatVersionChange(before, after))
81
113
  return 0
82
114
  }
@@ -0,0 +1,49 @@
1
+ const selfUpdateTargets = new Set(["self", "senpi", "omo"])
2
+ // Updating extensions or model catalogs is the engine's job; everything else under `update`
3
+ // would try to replace the pinned engine, so the launcher answers it instead.
4
+ const engineUpdateTargets = new Set(["--extensions", "--models"])
5
+ const PRINT_ONLY_FLAGS = new Set(["--dry-run", "--print"])
6
+ const HELP_FLAGS = new Set(["--help", "-h"])
7
+ // `--self` is the documented spelling of the bare command (docs/reference/omo-ai-publishing.md).
8
+ const KNOWN_FLAGS = new Set([...PRINT_ONLY_FLAGS, ...HELP_FLAGS, "--self"])
9
+
10
+ export const UPDATE_USAGE = `Usage: omo update [--dry-run | --print]
11
+
12
+ Update omo to the newest release on its channel. The engine is pinned by omo and moves with it.
13
+
14
+ Options:
15
+ --dry-run, --print print the update command without running it
16
+ -h, --help show this help`
17
+
18
+ /**
19
+ * Whether `args` is a product self-update, which the launcher answers itself rather than letting
20
+ * the engine move its pin: bare `update`, the self/senpi/omo targets, or flags only.
21
+ * @param {readonly string[]} args
22
+ */
23
+ export function isSelfUpdate(args) {
24
+ if (args[0] !== "update") return false
25
+ const rest = args.slice(1)
26
+ if (rest.length === 0) return true
27
+ if (rest.some((arg) => engineUpdateTargets.has(arg))) return false
28
+ return rest.every((arg) => arg.startsWith("-") || selfUpdateTargets.has(arg))
29
+ }
30
+
31
+ /** @param {readonly string[]} args */
32
+ export function isPrintOnlyUpdate(args) {
33
+ return args.slice(1).some((arg) => PRINT_ONLY_FLAGS.has(arg))
34
+ }
35
+
36
+ /**
37
+ * Help and usage errors of a self-update, answered before any registry lookup or install:
38
+ * `--help`/`-h` print the usage (exit 0), and a flag `update` does not know is a usage error that
39
+ * names it (exit 2). Undefined means the arguments are a real update request (#9207).
40
+ * @param {readonly string[]} args
41
+ * @returns {{ exitCode: 0 | 2, stream: "stdout" | "stderr", text: string } | undefined}
42
+ */
43
+ export function updateUsageAnswer(args) {
44
+ const rest = args.slice(1)
45
+ if (rest.some((arg) => HELP_FLAGS.has(arg))) return { exitCode: 0, stream: "stdout", text: UPDATE_USAGE }
46
+ const unknown = rest.find((arg) => arg.startsWith("-") && !KNOWN_FLAGS.has(arg))
47
+ if (unknown === undefined) return undefined
48
+ return { exitCode: 2, stream: "stderr", text: `omo update: unknown option ${unknown}\nRun omo update --help for usage.` }
49
+ }
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs"
2
2
  import { dirname, join } from "node:path"
3
3
  import { createRequire } from "node:module"
4
4
  import { fileURLToPath } from "node:url"
5
- import { prepareInstalledEngine, writeEnginePreparedStamp } from "./lib/engine-prepare.js"
5
+ import { prepareInstalledEngine, preparePluginLaunchSpec, writeEnginePreparedStamp } from "./lib/engine-prepare.js"
6
6
 
7
7
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)))
8
8
  const require = createRequire(join(packageRoot, "package.json"))
@@ -23,5 +23,6 @@ try {
23
23
  throw new Error("omo-ai: unable to resolve the installed @code-yeongyu/senpi package", { cause: error })
24
24
  }
25
25
 
26
+ preparePluginLaunchSpec({ pluginRoot: join(packageRoot, "plugin") })
26
27
  prepareInstalledEngine(senpiRoot)
27
28
  writeEnginePreparedStamp(senpiRoot, JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omo-ai",
3
- "version": "5.1.2",
3
+ "version": "5.1.4",
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,7 +17,7 @@
17
17
  "postinstall": "node bin/senpi-patch.mjs"
18
18
  },
19
19
  "dependencies": {
20
- "@code-yeongyu/senpi": "2026.9.29-3"
20
+ "@code-yeongyu/senpi": "2026.9.29-5"
21
21
  },
22
22
  "repository": {
23
23
  "type": "git",
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.1.4] - 2026-09-29
11
+
12
+ ### Changed
13
+
14
+ OmO Native moves from senpi 2026.9.29-4 to senpi 2026.9.29-5, and the engine changes below come with it.
15
+
16
+ **GPT-6.1 Sol arrives, and `deep-low` and Geeky · Normal lead with it at medium.** ([#9214](https://github.com/code-yeongyu/oh-my-openagent/issues/9214), [senpi#2390](https://github.com/code-yeongyu/senpi/issues/2390)) The engine adds `gpt-6.1-sol` and its Fast tier `gpt-6.1-sol-fast` on the OpenAI API, ChatGPT subscription, Azure, GitHub Copilot, OpenCode Zen, OpenRouter, Venice and Vercel lanes with the documented `low`..`max` efforts, and makes it the implicit default for the OpenAI and ChatGPT-subscription providers where GPT-6 Sol was. It runs on the GPT-6 system prompt, which gains the two writing rules from OpenAI's own GPT-6.1 Sol template. The default deep delegation lane now starts on `gpt-6.1-sol` (medium) from your ChatGPT subscription or the OpenAI API, then `gpt-6.1-sol-fast`, and keeps GPT-5.6 Sol (medium) and GPT-5.6 Sol Fast behind them, so GitHub Copilot, OpenCode Zen and an engine that does not list GPT-6.1 Sol yet still run the lane on GPT-5.6 Sol as before. The `geeky-normal` model profile likewise applies GPT-6.1 Sol (medium) first and GPT-5.6 Sol (medium) when 6.1 Sol is not served. The effort stays medium on every rung. The other categories and model profiles are unchanged.
17
+
18
+ **A host can give each session the chat-style app prompt.** ([senpi#2377](https://github.com/code-yeongyu/senpi/issues/2377)) With `SENPI_PROMPT_SURFACE=app`, or `open_session.promptSurface: "app"` on a multi-session host, replies drop the terminal routing line and keep tool and hook feedback with the agent unless it changes what you get. Terminal sessions keep today's prompts.
19
+
20
+ ### Fixed
21
+
22
+ **Editing a file outside a project no longer asks you to install a language server.** ([#9223](https://github.com/code-yeongyu/oh-my-openagent/issues/9223)) After an edit to OmO's own config, a scratch file in a temp folder, or any file with no project around it, the LSP check told the agent to install the missing server "in THIS repository" and to ask you first, which would have created a `package.json` in your home or temp folder, and it came back on every edit. Those files now get no install prompt, and inside a project the prompt for a missing server appears once per session instead of after every edit. Thanks to @haamsuk-collab for the report.
23
+
24
+ **Kibitzer recall and quick delegation work with a single Z.ai or Xiaomi login.** ([#9202](https://github.com/code-yeongyu/oh-my-openagent/issues/9202)) The `quick` category had no Z.ai or Xiaomi model, so on a machine logged in to only one of them recall stayed off and quick tasks were unavailable. It now falls back to `glm-5.3-flash` (Z.ai) and `mimo-v2.6-flash` (Xiaomi) at low effort, after every model it already used, so machines that had a quick model keep the same one.
25
+
26
+ **Engine fixes from senpi 2026.9.29-5.** Remote compaction works again on the ChatGPT subscription lane instead of silently falling back to a local summary ([senpi#2378](https://github.com/code-yeongyu/senpi/issues/2378)). A session no longer leaves an empty `.omo/` folder in the project ([senpi#2386](https://github.com/code-yeongyu/senpi/issues/2386)). RPC `get_auth_providers` reports each login method's status separately ([senpi#2384](https://github.com/code-yeongyu/senpi/issues/2384)). The embedded tree-sitter grammars load on Node again ([senpi#2032](https://github.com/code-yeongyu/senpi/issues/2032)).
27
+
28
+ ## [5.1.3] - 2026-09-29
29
+
30
+ ### Changed
31
+
32
+ - OmO Native moves from senpi 2026.9.29-3 to senpi 2026.9.29-4; the engine changes below come with it.
33
+
34
+ **Breaking: an untrusted project's legacy `.pi/` resources no longer load.** ([senpi#2375](https://github.com/code-yeongyu/senpi/pull/2375)) Extensions, skills, prompt templates, themes and hooks under a project's `.pi/` now follow project trust like the rest of the project config. A project whose only project resources are in `.pi/` now asks whether you trust it instead of opening as trusted. Trusted projects load them as before.
35
+
36
+ **Breaking: interactive sessions no longer join a shared engine host.** ([senpi#2328](https://github.com/code-yeongyu/senpi/issues/2328)) Every interactive launch runs on its own local runtime. Extensions lose `pi.sharedHostEnabled`, and the `experimental.sharedHost` setting and the `SENPI_ENABLE_SHARED_HOST` / `SENPI_DISABLE_SHARED_HOST` variables are ignored if set. On first load `experimental.sharedHost` is removed from the global settings file.
37
+
38
+ **Local sessions can pass messages and whole sessions to each other.** ([senpi#2328](https://github.com/code-yeongyu/senpi/issues/2328)) An extension can open a terminal session to other local sessions with `pi.session.registerControlEndpoint`, and each message delivered that way is admitted once and waits while you are typing. A multi-session host hands a quiet session to your terminal with `release_session`, and with `interrupt: true` it stops the running work first and returns what it took out of the queue. Host status shows each endpoint's kind, whether it answers and who owns it, and a suspended terminal no longer holds the listing for 10 seconds.
39
+
40
+ **Claude answers in your language.** ([senpi#2366](https://github.com/code-yeongyu/senpi/issues/2366)) With a "reply in Korean" rule, or when you write in Korean, the routing line, the handoff block and todo labels come back in that language. The handoff labels (`Ask`, `For you`, `Now`, `Next`) stay as they are. Thanks to @floweredao for the report.
41
+
42
+ **The engine installs smaller and faster.** ([senpi#2360](https://github.com/code-yeongyu/senpi/issues/2360), [senpi#2362](https://github.com/code-yeongyu/senpi/issues/2362)) senpi now declares its real dependencies instead of shipping its whole dependency tree inside the tarball, and it no longer ships sourcemaps.
43
+
44
+ ### Fixed
45
+
46
+ **Memory recall works on a machine whose quick-category pin names only providers you are not logged into.** ([#9216](https://github.com/code-yeongyu/oh-my-openagent/issues/9216)) With `categories.quick.models` pinned to, say, OpenAI while only another provider is connected, Kibitzer started its recall judge on the pinned model, which failed at once with "No API key found", so recalled-memory nudges never arrived. Kibitzer now starts on the first connected model, your pins first and then the category's built-in chain, and when nothing is connected it shows the "Kibitzer unavailable" notice naming the providers to connect. The `task` tool still uses your pin as written.
47
+
48
+ **Teams and process-mode subagents work again on installs made under `umask 002`.** ([#9208](https://github.com/code-yeongyu/oh-my-openagent/issues/9208)) With the Ubuntu default umask for private-group users, npm installed the task-host launch spec group-writable, the task host refused it, and every `team_create` failed with only "The task host is unavailable." OmO now makes that file private (0644) when it launches, never following a symlink and never touching a file another user owns. When the task host still refuses the spec, the task, the `team_create` error and `omo doctor` name the file and the fix (`chmod 644 <path>`). Reported and diagnosed by @devswha.
49
+
50
+ **An npm-installed comment checker is found again, in the old and the new package layout.** ([#9180](https://github.com/code-yeongyu/oh-my-openagent/issues/9180)) The OpenCode edition's comment-checker hook looked for `@code-yeongyu/comment-checker` only in the `bin/` folder its install script used to fill, so an npm install of 0.7.1 or later (binaries under `vendor/`) was never used and the hook fell back to downloading its own copy. The hook, `omo doctor` and the LazyCodex fallback now find the binary under `vendor/` and in the per-platform packages the checker is moving to. Those packages cut its install from about 255 MiB to 51 MiB.
51
+
52
+ **Agent mailbox state no longer lands in your repository.** ([#9201](https://github.com/code-yeongyu/oh-my-openagent/issues/9201)) OmO Native's thread tools kept their mailbox and receipts in `<project>/.omo/thread-tools`. They now live in the same per-project state folder as task state, outside the project. A project that already has the folder keeps using it.
53
+
54
+ **A Claude `Request not allowed` error no longer strands a session on a fallback model.** ([senpi#2376](https://github.com/code-yeongyu/senpi/issues/2376)) A transient 403 from a Claude subscription is retried on the same model before the session falls back. A fallback target that answers with a billing error no longer pins the session: that provider is skipped for its cooldown, and the next turn returns to your original model with a notice saying why.
55
+
56
+ **A project you trusted loads its `.agents/skills` again.** ([senpi#2371](https://github.com/code-yeongyu/senpi/issues/2371)) They appear under `/skill:` without launching with `--approve`. An untrusted project no longer picks up resources another session loaded from the same folder as trusted. Thanks to @sorenjuul for the report and the root-cause analysis.
57
+
58
+ **Session files survive a failed write.** ([senpi#2328](https://github.com/code-yeongyu/senpi/issues/2328)) Opening a session saved by an older version rewrites it through a complete copy, so a full disk or an I/O error keeps the original intact. A write refused for permissions or space no longer leaves the refused entry in the session, and a prompt whose messages could not be saved reports that error. A refused delivery, a half-written first save or the release of a never-saved session no longer leaves a session stuck or ends an engine host.
59
+
60
+ **Every `extension_ui_response` gets a reply.** ([senpi#2372](https://github.com/code-yeongyu/senpi/issues/2372)) A host or a terminal endpoint that settles one answers with the frame's own id, so a client can tell a delivered answer from a lost one.
61
+
62
+ **The startup banner is no longer red.** ([#8442](https://github.com/code-yeongyu/oh-my-openagent/issues/8442)) Under Bun, `omo` printed its version banner, the `omo setup` credentials hint and the legacy-settings notice in the error color, so a healthy start looked like a failure. They now print without color; the text and the stream are unchanged. (#8870 by @cynkai)
63
+
64
+ **`omo update` no longer reports success while leaving the old version installed.** ([#9198](https://github.com/code-yeongyu/oh-my-openagent/issues/9198)) It now looks up the version published on your channel and installs exactly that, for example `bun add -g omo-ai@5.1.2`. If you are already on it, nothing is installed. If the package manager finishes but OmO is still on the old version, `omo update` says `omo is still <old>; <new> is published`, prints the command to retry, and exits with an error. `omo update --dry-run` shows the exact command. When the npm registry can't be reached, it installs the unpinned `omo-ai` as before and tells you it could not confirm the version.
65
+
66
+ **`omo update --help` shows help instead of updating.** ([#9207](https://github.com/code-yeongyu/oh-my-openagent/issues/9207), reported by @devswha) `omo update --help` and `omo update -h` used to run the install, and so did a mistyped flag. They now print what `omo update` does and its flags. A flag it doesn't know, such as `--forse`, stops with `omo update: unknown option --forse` and installs nothing. The compiled `omo` binary answers the same way.
67
+
10
68
  ## [5.1.2] - 2026-09-29
11
69
 
12
70
  ### Added