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.
- package/bin/lib/claude-code-floor.js +8 -4
- package/bin/lib/doctor-migration.js +22 -7
- package/bin/lib/doctor-pi-config.js +70 -0
- package/bin/lib/doctor.js +9 -44
- package/bin/lib/engine-dependency.js +23 -0
- package/bin/lib/engine-models.js +5 -2
- package/bin/lib/engine-prepare.js +15 -0
- package/bin/lib/launch-spec-mode.js +78 -0
- package/bin/lib/launcher.js +14 -17
- package/bin/lib/npm-dist-tags.js +42 -0
- package/bin/lib/package-paths.js +21 -1
- package/bin/lib/provider-map.json +1 -1
- package/bin/lib/rpc-stream-errors.js +2 -2
- package/bin/lib/self-update.js +46 -14
- package/bin/lib/setup-opencode-models.d.ts +53 -0
- package/bin/lib/setup-opencode-models.js +121 -34
- package/bin/lib/update-args.js +49 -0
- package/bin/senpi-patch.mjs +2 -1
- package/package.json +2 -3
- package/plugin/CHANGELOG.md +100 -0
- package/plugin/extensions/memory-run-supervisor.mjs +1 -1
- package/plugin/extensions/omo-computer-use.js +1 -1
- package/plugin/extensions/omo-init-deep-advisor.js +1 -1
- package/plugin/extensions/omo-member.js +2 -2
- package/plugin/extensions/omo-task.js +2 -2
- package/plugin/extensions/omo.js +2 -2
- package/plugin/package.json +1 -1
- package/plugin/runtime/agent-toolkit-sdk/sdk.js +2 -2
- package/plugin/runtime/ast-grep-mcp/manifest.json +1 -1
- package/plugin/runtime/category-coverage/assets.generated.json +7 -0
- package/plugin/runtime/rollback-migrate.js +2 -2
- package/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
- package/plugin/skills/hyperplan/SKILL.md +1 -0
- package/plugin/skills/init-deep/SKILL.md +1 -0
- package/plugin/skills/mass-ulw/SKILL.md +1 -0
- package/plugin/skills/refactor/SKILL.md +1 -0
- package/plugin/skills/remove-ai-slops/SKILL.md +1 -0
- package/plugin/skills/ulw-execute/SKILL.md +1 -0
- package/plugin/skills/ulw-loop/SKILL.md +1 -0
- package/plugin/skills/ulw-plan/SKILL.md +1 -0
- 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
|
|
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
|
|
17
|
-
|
|
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 ${
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/bin/lib/engine-models.js
CHANGED
|
@@ -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()`),
|
|
18
|
+
// (`builtinProviderCatalog.builtinProviders()`), in the pi-ai the engine itself resolves.
|
|
18
19
|
async function builtinCatalog() {
|
|
19
20
|
const { packageRoot } = resolveSenpi()
|
|
20
|
-
const
|
|
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
|
+
}
|
package/bin/lib/launcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
301
|
+
notice(releaseBanner())
|
|
305
302
|
if (process.stdout.isTTY === true && setupSuggestionForLaunch()) {
|
|
306
|
-
|
|
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
|
+
}
|
package/bin/lib/package-paths.js
CHANGED
|
@@ -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
|
-
|
|
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;
|