omo-ai 5.1.35 → 5.1.40

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 (84) hide show
  1. package/bin/lib/engine-environment.js +113 -0
  2. package/bin/lib/herdr-agent-hint.js +29 -0
  3. package/bin/lib/launcher.js +7 -102
  4. package/bin/lib/provider-map.json +1 -1
  5. package/bin/lib/setup-assets-import.js +2 -1
  6. package/bin/lib/setup-credentials.js +7 -1
  7. package/bin/lib/setup-import.js +13 -63
  8. package/bin/lib/setup-model-choices-import.js +13 -6
  9. package/bin/lib/setup-providers-import.js +11 -4
  10. package/bin/lib/setup-stream-items.js +64 -0
  11. package/bin/lib/setup-stream.d.ts +94 -0
  12. package/bin/lib/setup-stream.js +238 -0
  13. package/package.json +2 -2
  14. package/plugin/CHANGELOG.md +144 -0
  15. package/plugin/extensions/CaseFolding.txt +1654 -0
  16. package/plugin/extensions/gateway-rules-extension.mjs +1 -1
  17. package/plugin/extensions/gateway-store-worker.mjs +2 -2
  18. package/plugin/extensions/loro_wasm_bg.wasm +0 -0
  19. package/plugin/extensions/memory-run-supervisor.mjs +2 -2
  20. package/plugin/extensions/omo-computer-use.js +2 -2
  21. package/plugin/extensions/omo-init-deep-advisor.js +1 -1
  22. package/plugin/extensions/omo-member.js +2 -2
  23. package/plugin/extensions/omo-memory-doctor.js +2 -2
  24. package/plugin/extensions/omo-memory-memfs.js +2 -2
  25. package/plugin/extensions/omo-memory-palace.js +2 -2
  26. package/plugin/extensions/omo-memory-sync.js +2 -0
  27. package/plugin/extensions/omo-onboarding-scan.js +2 -0
  28. package/plugin/extensions/omo-recall-store.js +2 -0
  29. package/plugin/extensions/omo-session-history.js +2 -0
  30. package/plugin/extensions/omo-side-panel.js +2 -2
  31. package/plugin/extensions/omo-task.js +2 -2
  32. package/plugin/extensions/omo-telemetry-posthog.js +2 -0
  33. package/plugin/extensions/omo-thread-client.js +2 -2
  34. package/plugin/extensions/omo.js +2 -2
  35. package/plugin/package.json +5 -1
  36. package/plugin/runtime/agent-toolkit-sdk/sdk.js +2 -2
  37. package/plugin/runtime/ast-grep-mcp/manifest.json +1 -1
  38. package/plugin/runtime/category-coverage/index.js +3 -3
  39. package/plugin/runtime/lsp-daemon/dist/.omo-runtime-manifest.json +1 -1
  40. package/plugin/runtime/rollback-migrate.js +2 -2
  41. package/plugin/runtime/task-config/index.js +1 -1
  42. package/plugin/runtime/thread-sdk/sdk.js +2 -2
  43. package/plugin/scripts/install.mjs +6 -0
  44. package/plugin/skills/browser/runtime/omowright/index.js +4 -4
  45. package/plugin/skills/browser/runtime/omowright/manifest.json +2 -2
  46. package/plugin/skills/frontend/SKILL.md +8 -1
  47. package/plugin/skills/frontend/references/design/README.md +3 -2
  48. package/plugin/skills/frontend/references/perfection/README.md +2 -1
  49. package/plugin/skills/frontend/scripts/perfection/accent-stripes/color.ts +132 -0
  50. package/plugin/skills/frontend/scripts/perfection/accent-stripes/css-parse.ts +103 -0
  51. package/plugin/skills/frontend/scripts/perfection/accent-stripes/declarations.ts +156 -0
  52. package/plugin/skills/frontend/scripts/perfection/accent-stripes/findings.ts +34 -0
  53. package/plugin/skills/frontend/scripts/perfection/accent-stripes/gradient.ts +70 -0
  54. package/plugin/skills/frontend/scripts/perfection/accent-stripes/lint.ts +110 -0
  55. package/plugin/skills/frontend/scripts/perfection/accent-stripes/markup.ts +148 -0
  56. package/plugin/skills/frontend/scripts/perfection/accent-stripes/selector.ts +46 -0
  57. package/plugin/skills/frontend/scripts/perfection/accent-stripes/tailwind.ts +117 -0
  58. package/plugin/skills/frontend/scripts/perfection/check-accent-stripes.ts +50 -0
  59. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/negative/exempt-states.css +4 -0
  60. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/negative/flat-stripe-side.tsx +11 -0
  61. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/negative/full-surface-gradients.css +34 -0
  62. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/negative/inline-ok.tsx +7 -0
  63. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/negative/neutral-and-flat.css +6 -0
  64. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/negative/pseudo-overlays.css +3 -0
  65. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/negative/tailwind-ok.tsx +11 -0
  66. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/negative/uniform-and-rings.css +3 -0
  67. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/negative/wide-blocks-and-hairlines.css +3 -0
  68. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/apply.css +3 -0
  69. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/border-top-card.css +5 -0
  70. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/cva-variant.ts +1 -0
  71. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/gradient-edges.css +26 -0
  72. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/inline-styles.tsx +6 -0
  73. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/inset-shadow.css +4 -0
  74. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/non-visible-focus-and-nested.tsx +6 -0
  75. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/pseudo-bar.css +13 -0
  76. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/split-rule-and-token.scss +14 -0
  77. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/styled-components.tsx +14 -0
  78. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/tailwind-cards.tsx +8 -0
  79. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/tailwind-inset-and-style.vue +11 -0
  80. package/plugin/skills/frontend/scripts/perfection/fixtures/accent-stripes/positive/tailwind-pseudo.html +3 -0
  81. package/plugin/skills/git-master/SKILL.md +1 -1
  82. package/plugin/skills/onboarding/SKILL.md +56 -23
  83. package/plugin/skills/ulw-plan/SKILL.md +1 -1
  84. package/plugin/skills/ulw-plan/references/full-workflow.md +6 -5
@@ -0,0 +1,113 @@
1
+ import { existsSync, realpathSync } from "node:fs"
2
+ import { delimiter, isAbsolute, join, relative, sep } from "node:path"
3
+ import { canonicalAgentDir } from "./agent-dir.js"
4
+ import { HERDR_AGENT_MARKER } from "./herdr-agent-hint.js"
5
+ import { nearestNodeBin, packageManifest, packageRoot, readJson, releaseChannel, updateTarget } from "./package-paths.js"
6
+
7
+ // Identity the engine adopts for this install: what the user sees, where state lives, which
8
+ // environment prefix is read first, what goes on the wire, and which channel to check for
9
+ // updates. The engine consumes this once and scrubs it, so nested engine processes are
10
+ // unaffected.
11
+ function pluginChangelogSource() {
12
+ try {
13
+ const pluginRoot = join(packageRoot, "plugin")
14
+ const changelogPath = join(pluginRoot, "CHANGELOG.md")
15
+ if (!existsSync(changelogPath)) return undefined
16
+ const version = readJson(join(pluginRoot, "package.json")).version
17
+ return typeof version === "string" && version ? { path: changelogPath, version } : { path: changelogPath }
18
+ } catch {
19
+ return undefined
20
+ }
21
+ }
22
+
23
+ function brandProfile() {
24
+ const update = updateTarget()
25
+ // The changelog source is advisory: a missing plugin manifest or file must disable
26
+ // startup notes, never fail the launch.
27
+ const changelog = pluginChangelogSource()
28
+ return {
29
+ name: "OmO",
30
+ command: "omo",
31
+ displayVersion: packageManifest().version,
32
+ configDir: ".omo",
33
+ // Engine state lives at the canonical `~/.omo/agent`, never directly under the config
34
+ // directory: a flat home would disagree with the directory every omo surface resolves.
35
+ flatLayout: false,
36
+ envPrefix: "OMO",
37
+ userAgent: "omo",
38
+ originator: "omo",
39
+ ...(changelog ? { changelog } : {}),
40
+ update: {
41
+ packageName: "omo-ai",
42
+ distTag: releaseChannel(),
43
+ command: update.command,
44
+ changelogUrl: "https://github.com/code-yeongyu/oh-my-openagent/releases",
45
+ },
46
+ }
47
+ }
48
+
49
+ function canonicalPath(path) {
50
+ try {
51
+ return realpathSync.native(path)
52
+ } catch {
53
+ return path
54
+ }
55
+ }
56
+
57
+ function containsPath(root, target) {
58
+ const rel = relative(canonicalPath(root), canonicalPath(target))
59
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
60
+ }
61
+
62
+ // A compiled omo session exports its own payload as the brand-scoped package dir, and every shell it
63
+ // spawns inherits that. The engine reads these names before the legacy PI_PACKAGE_DIR, so a release
64
+ // omo started from such a shell would run on the foreign payload. A deliberate relocation names this
65
+ // install's engine and survives; a root that does not contain the engine belongs to another install.
66
+ function dropForeignPackageDirs(env, senpiRoot) {
67
+ for (const name of ["OMO_PACKAGE_DIR", "SENPI_PACKAGE_DIR"]) {
68
+ const root = env[name]
69
+ if (root && !containsPath(root, senpiRoot)) delete env[name]
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The one environment every engine this launcher starts gets. `overrides` (a host call's own
75
+ * environment) is merged in here, before the last step, so nothing a caller passes can put back
76
+ * what this function removes.
77
+ */
78
+ export function senpiEnvironment(senpiRoot, overrides = {}) {
79
+ const env = { ...process.env }
80
+ delete env.OMO_BIN
81
+ delete env.SENPI_BIN
82
+ dropForeignPackageDirs(env, senpiRoot)
83
+ // One directory for every surface. The legacy name travels too, so a bare senpi spawned by a
84
+ // tool inherits the same state instead of falling back to its own home.
85
+ const agentDir = canonicalAgentDir(env)
86
+ env.OMO_CODING_AGENT_DIR = agentDir
87
+ env.SENPI_CODING_AGENT_DIR = agentDir
88
+ // senpi's footer reads this marker to show the OmO Native badge for omo-ai installs, which load
89
+ // the plugin via --extension and therefore never match the settings-packages detection gates.
90
+ env.OMO_NATIVE = "1"
91
+ // This launcher already decided which runtime the product runs on, possibly by re-execing itself
92
+ // under bun. Handing that answer down stops the engine from making its own, conflicting choice.
93
+ env.SENPI_RUNTIME = process.versions.bun ? "bun" : "node"
94
+ env.SENPI_BRAND = JSON.stringify(brandProfile())
95
+ const binDir = nearestNodeBin(senpiRoot)
96
+ if (binDir) {
97
+ const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"
98
+ const path = env[pathKey]
99
+ env[pathKey] = path ? `${binDir}${delimiter}${path}` : binDir
100
+ const shim = join(binDir, process.platform === "win32" ? "senpi.cmd" : "senpi")
101
+ if (existsSync(shim)) env.SENPI_BIN = shim
102
+ }
103
+ // Anything resolving the product by name must re-enter through this launcher, otherwise it
104
+ // would reach the engine directly and lose the brand.
105
+ env.OMO_BIN = join(packageRoot, "bin", "omo.js")
106
+ const merged = { ...env, ...overrides }
107
+ // Only the interactive launch writes this record of the herdr hint it set (herdr-agent-hint.js),
108
+ // after this function returns. No engine started here may carry one from the parent environment
109
+ // or from a caller's overrides: the plugin would treat it as the launcher's and could remove a
110
+ // HERDR_AGENT the user set (#10448).
111
+ delete merged[HERDR_AGENT_MARKER]
112
+ return merged
113
+ }
@@ -0,0 +1,29 @@
1
+ // herdr names the agent that owns a pane from the pane's foreground process (#10448). The engine
2
+ // runs as `bun …/cli.js` (or `node …/cli.js`), which herdr cannot place, so `herdr agent prompt`
3
+ // refused every OmO pane. HERDR_AGENT is the hint herdr reads from that process's environment; OmO
4
+ // is a pi-family agent to herdr, which already lists these panes as `pi`.
5
+ //
6
+ // The hint is only for an interactive engine the user sees in the pane: a TTY on stdin and no
7
+ // print or protocol flag before `--`. A HERDR_AGENT the user set is left alone. The launcher also
8
+ // sets OMO_HERDR_AGENT_HINT to the value it wrote, so the plugin releases only a hint the launcher
9
+ // set (packages/omo-senpi/src/extension/herdr-agent-hint.ts). Only this function writes that
10
+ // record: one inherited from the parent environment is dropped first, so it can never vouch for a
11
+ // HERDR_AGENT the user set.
12
+
13
+ const NON_INTERACTIVE_FLAGS = new Set(["-p", "--print", "--mode"])
14
+
15
+ export const HERDR_AGENT_LABEL = "pi"
16
+ export const HERDR_AGENT_MARKER = "OMO_HERDR_AGENT_HINT"
17
+
18
+ export function withHerdrAgentHint(env, args, stdinIsTTY = process.stdin.isTTY === true) {
19
+ delete env[HERDR_AGENT_MARKER]
20
+ if (env.HERDR_ENV !== "1") return env
21
+ if (env.HERDR_AGENT !== undefined && env.HERDR_AGENT !== "") return env
22
+ if (!stdinIsTTY) return env
23
+ const end = args.indexOf("--")
24
+ const flags = end === -1 ? args : args.slice(0, end)
25
+ if (flags.some((arg) => NON_INTERACTIVE_FLAGS.has(arg) || arg.startsWith("--mode="))) return env
26
+ env.HERDR_AGENT = HERDR_AGENT_LABEL
27
+ env[HERDR_AGENT_MARKER] = HERDR_AGENT_LABEL
28
+ return env
29
+ }
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "node:child_process"
2
- import { existsSync, realpathSync } from "node:fs"
3
- import { delimiter, isAbsolute, join, relative, sep } from "node:path"
2
+ import { existsSync } from "node:fs"
3
+ import { join } from "node:path"
4
4
  import { isEngineCommand } from "./engine-commands.js"
5
5
  import { spawnNode } from "./child-process.js"
6
6
  import { doctorCoverageLines } from "./category-coverage.js"
@@ -13,7 +13,9 @@ import { runDoctor } from "./doctor.js"
13
13
  import { ensureEnginePrepared, preparePluginLaunchSpec } from "./engine-prepare.js"
14
14
  import { migrateLegacyBunGlobalManifest } from "./legacy-bun-global-migration.js"
15
15
  import { adoptLegacyFlatState, canonicalAgentDir } from "./agent-dir.js"
16
- import { nearestNodeBin, packageManifest, packageRoot, readJson, releaseBanner, releaseChannel, resolveSenpi, updateTarget } from "./package-paths.js"
16
+ import { senpiEnvironment } from "./engine-environment.js"
17
+ import { withHerdrAgentHint } from "./herdr-agent-hint.js"
18
+ import { packageManifest, packageRoot, readJson, releaseBanner, resolveSenpi, updateTarget } from "./package-paths.js"
17
19
  import { runSelfUpdate } from "./self-update.js"
18
20
  import { isSelfUpdate } from "./update-args.js"
19
21
  import { detectHarnesses } from "./setup-detect.js"
@@ -21,48 +23,6 @@ import { readSetupSuggestionCache, spawnSetupSuggestionRefresh } from "./setup-d
21
23
  import { printSetupReport } from "./setup-report.js"
22
24
  import { refuseRetiredCommand } from "./retired-command.js"
23
25
 
24
- // Identity the engine adopts for this install: what the user sees, where state lives, which
25
- // environment prefix is read first, what goes on the wire, and which channel to check for
26
- // updates. The engine consumes this once and scrubs it, so nested engine processes are
27
- // unaffected.
28
- function pluginChangelogSource() {
29
- try {
30
- const pluginRoot = join(packageRoot, "plugin")
31
- const changelogPath = join(pluginRoot, "CHANGELOG.md")
32
- if (!existsSync(changelogPath)) return undefined
33
- const version = readJson(join(pluginRoot, "package.json")).version
34
- return typeof version === "string" && version ? { path: changelogPath, version } : { path: changelogPath }
35
- } catch {
36
- return undefined
37
- }
38
- }
39
-
40
- function brandProfile() {
41
- const update = updateTarget()
42
- // The changelog source is advisory: a missing plugin manifest or file must disable
43
- // startup notes, never fail the launch.
44
- const changelog = pluginChangelogSource()
45
- return {
46
- name: "OmO",
47
- command: "omo",
48
- displayVersion: packageManifest().version,
49
- configDir: ".omo",
50
- // Engine state lives at the canonical `~/.omo/agent`, never directly under the config
51
- // directory: a flat home would disagree with the directory every omo surface resolves.
52
- flatLayout: false,
53
- envPrefix: "OMO",
54
- userAgent: "omo",
55
- originator: "omo",
56
- ...(changelog ? { changelog } : {}),
57
- update: {
58
- packageName: "omo-ai",
59
- distTag: releaseChannel(),
60
- command: update.command,
61
- changelogUrl: "https://github.com/code-yeongyu/oh-my-openagent/releases",
62
- },
63
- }
64
- }
65
-
66
26
  function engineVersion() {
67
27
  try {
68
28
  return readJson(join(resolveSenpi().packageRoot, "package.json")).version
@@ -71,62 +31,6 @@ function engineVersion() {
71
31
  }
72
32
  }
73
33
 
74
- function canonicalPath(path) {
75
- try {
76
- return realpathSync.native(path)
77
- } catch {
78
- return path
79
- }
80
- }
81
-
82
- function containsPath(root, target) {
83
- const rel = relative(canonicalPath(root), canonicalPath(target))
84
- return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
85
- }
86
-
87
- // A compiled omo session exports its own payload as the brand-scoped package dir, and every shell it
88
- // spawns inherits that. The engine reads these names before the legacy PI_PACKAGE_DIR, so a release
89
- // omo started from such a shell would run on the foreign payload. A deliberate relocation names this
90
- // install's engine and survives; a root that does not contain the engine belongs to another install.
91
- function dropForeignPackageDirs(env, senpiRoot) {
92
- for (const name of ["OMO_PACKAGE_DIR", "SENPI_PACKAGE_DIR"]) {
93
- const root = env[name]
94
- if (root && !containsPath(root, senpiRoot)) delete env[name]
95
- }
96
- }
97
-
98
- function senpiEnvironment(senpiRoot) {
99
- const env = { ...process.env }
100
- delete env.OMO_BIN
101
- delete env.SENPI_BIN
102
- dropForeignPackageDirs(env, senpiRoot)
103
- // One directory for every surface. The legacy name travels too, so a bare senpi spawned by a
104
- // tool inherits the same state instead of falling back to its own home.
105
- const agentDir = canonicalAgentDir(env)
106
- env.OMO_CODING_AGENT_DIR = agentDir
107
- env.SENPI_CODING_AGENT_DIR = agentDir
108
- // senpi's footer reads this marker to show the OmO Native badge for omo-ai installs, which load
109
- // the plugin via --extension and therefore never match the settings-packages detection gates.
110
- env.OMO_NATIVE = "1"
111
- // This launcher already decided which runtime the product runs on, possibly by re-execing itself
112
- // under bun. Handing that answer down stops the engine from making its own, conflicting choice.
113
- env.SENPI_RUNTIME = process.versions.bun ? "bun" : "node"
114
- env.SENPI_BRAND = JSON.stringify(brandProfile())
115
-
116
- const binDir = nearestNodeBin(senpiRoot)
117
- if (binDir) {
118
- const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"
119
- const path = env[pathKey]
120
- env[pathKey] = path ? `${binDir}${delimiter}${path}` : binDir
121
- const shim = join(binDir, process.platform === "win32" ? "senpi.cmd" : "senpi")
122
- if (existsSync(shim)) env.SENPI_BIN = shim
123
- }
124
- // Anything resolving the product by name must re-enter through this launcher, otherwise it
125
- // would reach the engine directly and lose the brand.
126
- env.OMO_BIN = join(packageRoot, "bin", "omo.js")
127
- return env
128
- }
129
-
130
34
  function preparedSenpi() {
131
35
  preparePluginLaunchSpec({ pluginRoot: join(packageRoot, "plugin") })
132
36
  const senpi = resolveSenpi()
@@ -144,6 +48,7 @@ async function spawnSenpi(args, withExtension) {
144
48
  ? ["--extension", join(packageRoot, "plugin"), ...args]
145
49
  : args
146
50
  const env = senpiEnvironment(senpi.packageRoot)
51
+ if (withExtension) withHerdrAgentHint(env, args)
147
52
  if (process.platform !== "win32" && typeof process.execve === "function") {
148
53
  try {
149
54
  process.execve(process.execPath, [process.execPath, senpi.cliPath, ...finalArgs], env)
@@ -203,7 +108,7 @@ export function engineHostCall(engineArgs, options) {
203
108
  const senpi = preparedSenpi()
204
109
  const result = spawnSync(process.execPath, [senpi.cliPath, ...engineArgs], {
205
110
  encoding: "utf8",
206
- env: { ...senpiEnvironment(senpi.packageRoot), ...options.env },
111
+ env: senpiEnvironment(senpi.packageRoot, options.env),
207
112
  windowsHide: true,
208
113
  })
209
114
  return { exitCode: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Derived from @code-yeongyu/senpi 2026.10.16 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.10.19-2 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",
@@ -119,7 +119,8 @@ export function planAssets({ runtime, agentDir }) {
119
119
  const notices = [...source.notices]
120
120
  const present = source.mcpServers.length > 0 || source.skills.length > 0
121
121
  const paths = { mcp: join(agentDir, "mcp.json"), skills: join(agentDir, "skills") }
122
- const result = classifyAssets(source, paths, bundledSkillNames(join(packageRoot, "plugin", "skills")))
122
+ const pluginRoot = runtime.pluginRoot ?? join(packageRoot, "plugin")
123
+ const result = classifyAssets(source, paths, bundledSkillNames(join(pluginRoot, "skills")))
123
124
  if (result.servers.blocked.length > 0) notices.push(`WARN senpi: malformed mcp.json; these servers were not imported: ${result.servers.blocked.join(", ")}`)
124
125
  return {
125
126
  agentDir,
@@ -165,7 +165,13 @@ export async function planCredentials(runtime, target, customProviderIds) {
165
165
  }
166
166
  }
167
167
 
168
- export function applyCredentials(plan) {
168
+ /**
169
+ * The stage's one writer. With `options.writeAuth` (the engine's locked credential store) the
170
+ * additions merge into whatever the store holds under its lock; the CLI path keeps its direct
171
+ * backup-and-write.
172
+ */
173
+ export function applyCredentials(plan, options = {}) {
174
+ if (options.writeAuth !== undefined) return options.writeAuth(plan.target, plan.result.additions)
169
175
  writeAuthStore(plan.target, plan.current, plan.result.additions)
170
176
  }
171
177
 
@@ -1,20 +1,16 @@
1
1
  /**
2
- * `omo setup`: every stage's plan is built first (nothing is written while planning), the one
3
- * summary renders them all, one consent covers the whole plan, then each stage applies its part.
4
- * `--ask-each` asks per stage instead; `--yes` consents; `--dry-run` prints the summary and exits.
2
+ * `omo setup`: every stage's plan is built first from the setup stream (nothing is written while
3
+ * planning), the one summary renders them all, one consent covers the whole plan, then each stage
4
+ * applies its part. `--ask-each` asks per stage instead; `--yes` consents; `--dry-run` prints the
5
+ * summary and exits.
5
6
  */
6
7
 
7
8
  import { homedir } from "node:os"
8
- import { dirname, join, relative } from "node:path"
9
+ import { dirname, relative } from "node:path"
9
10
  import { createInterface } from "node:readline/promises"
10
11
  import { canonicalAgentDir } from "./agent-dir.js"
11
12
  import { setupCoverage } from "./category-coverage.js"
12
- import { detectHarnesses } from "./setup-detect.js"
13
- import { applyCredentials, credentialCounts, credentialPlanLines, credentialQuestion, opencodeOauthProviders, planCredentials } from "./setup-credentials.js"
14
- import { applyAssets, assetCounts, assetPlanLines, assetQuestion, planAssets } from "./setup-assets-import.js"
15
- import { planOpencodeProviders } from "./setup-opencode-providers.js"
16
- import { applyProviders, planProviders, providerCounts, providerPlanLines, providerQuestion } from "./setup-providers-import.js"
17
- import { applyModelChoices, modelChoiceCounts, modelChoicePlanLines, modelChoiceQuestion, planModelChoices } from "./setup-model-choices-import.js"
13
+ import { applySetupStages, collectSetupPlans, scanSetup, setupStages } from "./setup-stream.js"
18
14
  import { displayPath, formatSetupSummary, TELEMETRY_NOTICE } from "./setup-summary.js"
19
15
 
20
16
  function write(lines) {
@@ -37,17 +33,6 @@ async function ask(question, options, defaultYes) {
37
33
  }
38
34
  }
39
35
 
40
- // In apply order: the model-choice stage runs last because a default model or category may name a
41
- // custom provider the stage before it carries. `present` stages report counts after the run.
42
- function stageList(plans) {
43
- return [
44
- { plan: plans.credentials, present: !plans.credentials.malformed, lines: credentialPlanLines, counts: credentialCounts, question: credentialQuestion, apply: applyCredentials },
45
- { plan: plans.assets, present: plans.assets.present, lines: assetPlanLines, counts: assetCounts, question: assetQuestion, apply: applyAssets },
46
- { plan: plans.providers, present: plans.providers.present, lines: providerPlanLines, counts: providerCounts, question: providerQuestion, apply: applyProviders },
47
- { plan: plans.modelChoices, present: plans.modelChoices.present, lines: modelChoicePlanLines, counts: modelChoiceCounts, question: modelChoiceQuestion, apply: applyModelChoices },
48
- ]
49
- }
50
-
51
36
  // The directories the whole plan writes into, a directory inside another one folded into it.
52
37
  function targetRoots(plans, agentDir, home) {
53
38
  const choiceDirs = plans.modelChoices.items.filter((item) => item.state === "pending").map((item) => dirname(item.target.path))
@@ -56,51 +41,20 @@ function targetRoots(plans, agentDir, home) {
56
41
  return roots.filter((path) => !roots.some((root) => inside(path, root))).map((path) => displayPath(path, home))
57
42
  }
58
43
 
59
- async function planAll(runtime, agentDir) {
60
- const providerSource = planOpencodeProviders(runtime)
61
- // A provider the custom-provider stage described (carried or refused with its reason) is reported there only.
62
- const described = new Set([...providerSource.providers.map((item) => item.id), ...providerSource.skipped])
63
- const credentials = await planCredentials(runtime, join(agentDir, "auth.json"), described)
64
- const assets = planAssets({ runtime, agentDir })
65
- const providers = planProviders(providerSource, agentDir)
66
- const oauthProviders = opencodeOauthProviders(runtime)
67
- const planChoices = (customProviders) => planModelChoices({ runtime, agentDir, customProviders, oauthProviders })
68
- return { credentials, assets, providers, modelChoices: await planChoices(providers.result.added), planChoices }
69
- }
70
-
71
- // Per-stage consent. A declined provider stage leaves models.json as it is, so the model choices
72
- // are re-planned against it rather than against providers that will not exist.
73
- async function applyEach(plans, confirm) {
74
- const applied = new Set()
75
- for (const [index, stage] of stageList(plans).entries()) {
76
- if (index === 3 && plans.providers.pending > 0 && !applied.has(2)) {
77
- const shown = new Set(plans.modelChoices.notices)
78
- plans.modelChoices = await plans.planChoices([])
79
- stage.plan = plans.modelChoices
80
- write(plans.modelChoices.notices.filter((notice) => !shown.has(notice)))
81
- }
82
- if (stage.plan.pending === 0) continue
83
- if (!await confirm(stage.question(stage.plan), false)) continue
84
- stage.apply(stage.plan)
85
- applied.add(index)
86
- }
87
- return applied
88
- }
89
-
90
44
  export async function runSetup(args = process.argv.slice(2), options = {}) {
91
45
  const home = options.home ?? homedir()
92
46
  const env = options.env ?? process.env
93
47
  const agentDir = canonicalAgentDir(env, home)
94
48
  const runtime = { stdin: process.stdin, stdout: process.stdout, ...options, home, env }
95
49
  const dryRun = args.includes("--dry-run")
96
- const inventory = await detectHarnesses(runtime)
97
- const plans = await planAll(runtime, agentDir)
50
+ // The CLI has always carried OpenCode's custom providers; the onboarding RPC leaves them opt-in.
51
+ const { inventory, plans } = await collectSetupPlans(scanSetup(runtime, agentDir, { includeCustomProviders: true }))
98
52
  if (dryRun) process.stdout.write("DRY RUN: no files will be written\n")
99
53
  const categories = await setupCoverage({ agentDir, home, env, plans, loadRuntime: options.loadCoverageRuntime, loadEngine: options.loadCoverageEngine })
100
54
  process.stdout.write(formatSetupSummary({ home, agentDir, inventory, ...plans, categories }))
101
55
  process.stdout.write(`${TELEMETRY_NOTICE}\n`)
102
56
  if (dryRun) {
103
- write(stageList(plans).filter((stage) => stage.present).flatMap((stage) => stage.lines(stage.plan)))
57
+ write(setupStages(plans).filter((stage) => stage.present).flatMap((stage) => stage.lines(stage.plan)))
104
58
  return
105
59
  }
106
60
  const confirm = async (question, defaultYes) => {
@@ -108,19 +62,15 @@ export async function runSetup(args = process.argv.slice(2), options = {}) {
108
62
  if (!accepted && runtime.stdin.isTTY === true) process.stdout.write("Import cancelled\n")
109
63
  return accepted
110
64
  }
111
- const pending = stageList(plans).reduce((total, stage) => total + stage.plan.pending, 0)
65
+ const pending = setupStages(plans).reduce((total, stage) => total + stage.plan.pending, 0)
112
66
  let applied = new Set()
113
67
  if (pending === 0) {
114
68
  process.stdout.write("Nothing new to import.\n")
115
69
  } else if (args.includes("--ask-each")) {
116
- applied = await applyEach(plans, confirm)
70
+ applied = await applySetupStages(plans, (stage) => confirm(stage.question(stage.plan), false), (line) => write([line]))
117
71
  } else {
118
72
  if (!await confirm(`Import all of the above into ${targetRoots(plans, agentDir, home).join(" and ")}? [Y/n] `, true)) return
119
- for (const [index, stage] of stageList(plans).entries()) {
120
- if (stage.plan.pending === 0) continue
121
- stage.apply(stage.plan)
122
- applied.add(index)
123
- }
73
+ applied = await applySetupStages(plans, () => true, (line) => write([line]))
124
74
  }
125
- write(stageList(plans).filter((stage, index) => stage.present && (applied.has(index) || stage.plan.pending === 0)).flatMap((stage) => stage.counts(stage.plan)))
75
+ write(setupStages(plans).filter((stage) => stage.present && (applied.has(stage.id) || stage.plan.pending === 0)).flatMap((stage) => stage.counts(stage.plan)))
126
76
  }
@@ -151,7 +151,7 @@ function writeText(target, text) {
151
151
 
152
152
  // Every edit is an insertion into the file's own text (comments, order and formatting survive);
153
153
  // a file the edit cannot reproduce exactly is left untouched and reported.
154
- function writeTarget(target, items) {
154
+ function writeTarget(target, items, report) {
155
155
  try {
156
156
  let text = target.text ?? "{}\n"
157
157
  for (const item of items) {
@@ -160,7 +160,7 @@ function writeTarget(target, items) {
160
160
  writeText(target, text)
161
161
  return items.map((item) => item.label)
162
162
  } catch (error) {
163
- process.stdout.write(`WARN senpi: could not edit ${target.path} safely (${error.message}); these model choices were not written: ${items.map((item) => item.label).join(", ")}\n`)
163
+ report(`WARN senpi: could not edit ${target.path} safely (${error.message}); these model choices were not written: ${items.map((item) => item.label).join(", ")}`)
164
164
  return []
165
165
  }
166
166
  }
@@ -177,7 +177,11 @@ export async function planModelChoices(stage) {
177
177
  if (!hasModelChoices(raw)) return empty
178
178
  let registry
179
179
  try {
180
- registry = await (stage.loadRegistry ?? loadEngineModels)({ agentDir: stage.agentDir, customProviders: stage.customProviders })
180
+ registry = await (stage.loadRegistry ?? loadEngineModels)({
181
+ agentDir: stage.agentDir,
182
+ customProviders: stage.customProviders,
183
+ loadCatalog: stage.runtime.loadModelCatalog,
184
+ })
181
185
  } catch (error) {
182
186
  return { ...empty, notices: [...raw.notices, `WARN senpi: could not read the engine's model list (${error.message}); model choices were not carried`] }
183
187
  }
@@ -194,9 +198,12 @@ export async function planModelChoices(stage) {
194
198
  }
195
199
  }
196
200
 
197
- /** The stage's one writer; records what it wrote as `plan.carried` for the counts. */
198
- export function applyModelChoices(plan) {
201
+ /** The stage's one writer; records what it wrote as `plan.carried` for the counts. `options.report`
202
+ * receives the warning when a target cannot be edited (the CLI prints it; callers inside the
203
+ * engine route it to their own reply). */
204
+ export function applyModelChoices(plan, options = {}) {
205
+ const report = options.report ?? ((line) => process.stdout.write(`${line}\n`))
199
206
  const pending = plan.items.filter((item) => item.state === "pending")
200
207
  const targets = [...new Set(pending.map((item) => item.target))]
201
- plan.carried = targets.flatMap((target) => writeTarget(target, pending.filter((item) => item.target === target)))
208
+ plan.carried = targets.flatMap((target) => writeTarget(target, pending.filter((item) => item.target === target), report))
202
209
  }
@@ -101,18 +101,25 @@ function writeModels(path, target, added) {
101
101
  export function planProviders(source, agentDir) {
102
102
  const paths = { models: join(agentDir, "models.json"), auth: join(agentDir, "auth.json") }
103
103
  const models = readModelsTarget(paths.models)
104
- const result = classify(source, models, readAuthStore(paths.auth))
104
+ const auth = readAuthStore(paths.auth)
105
+ const result = classify(source, models, auth)
105
106
  const notices = [...source.notices]
106
107
  if (result.blocked.length > 0) notices.push(`WARN senpi: malformed models.json; these custom providers were not imported: ${result.blocked.join(", ")}`)
107
- return { paths, models, notices, result, skipped: source.skipped, present: source.providers.length > 0, pending: result.added.length }
108
+ return { paths, models, authBytes: auth.bytes, notices, result, skipped: source.skipped, present: source.providers.length > 0, pending: result.added.length }
108
109
  }
109
110
 
110
- export function applyProviders(plan) {
111
+ /**
112
+ * The stage's one writer. With `options.writeAuth` the provider keys merge into whatever the
113
+ * store holds under its lock; the CLI path keeps its direct backup-and-write.
114
+ */
115
+ export function applyProviders(plan, options = {}) {
111
116
  writeModels(plan.paths.models, plan.models, plan.result.added)
112
117
  // Re-read: the credential stage of the same run may have just written auth.json, after this plan
113
118
  // was classified. A mapped credential target can also be a custom provider id (claude-sdk-oauth ->
114
119
  // anthropic-subscription), so a key written since the plan is kept, never overwritten.
115
120
  const auth = readAuthStore(plan.paths.auth)
116
121
  plan.result.keys = auth.malformed ? [] : plan.result.keys.filter((item) => !Object.hasOwn(auth.entries, item.provider))
117
- if (plan.result.keys.length > 0) writeAuthStore(plan.paths.auth, auth, plan.result.keys)
122
+ if (plan.result.keys.length === 0) return
123
+ if (options.writeAuth !== undefined) return options.writeAuth(plan.paths.auth, plan.result.keys)
124
+ writeAuthStore(plan.paths.auth, auth, plan.result.keys)
118
125
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * One `{id, kind, state, detail?}` row per thing a setup source found, built from the stage plan
3
+ * the summary renders. Rows travel over RPC, so they carry ids and states only: never a key.
4
+ */
5
+
6
+ const MODEL_CHOICE_STATES = { pending: "import", kept: "exists", carried: "carried", blocked: "blocked" }
7
+
8
+ function rows(ids, kind, state) {
9
+ return ids.map((id) => ({ id, kind, state }))
10
+ }
11
+
12
+ function detectItems(inventory) {
13
+ return inventory.harnesses
14
+ .filter((harness) => harness.installed)
15
+ .map((harness) => ({ id: harness.id, kind: "harness", state: "found", detail: `${harness.entryCount} login(s), ${harness.importableCount} importable` }))
16
+ }
17
+
18
+ function providerItems(plan) {
19
+ const { result } = plan
20
+ return [
21
+ ...result.added.map((provider) => ({ id: provider.id, kind: "provider", state: "import", detail: `${provider.config.models.length} model(s)` })),
22
+ ...rows(result.skippedExisting, "provider", "exists"),
23
+ ...rows(result.blocked, "provider", "blocked"),
24
+ ...rows(plan.skipped, "provider", "skipped"),
25
+ ]
26
+ }
27
+
28
+ function credentialItems(plan) {
29
+ if (plan.malformed) return []
30
+ const { result, guidance } = plan
31
+ return [
32
+ ...result.additions.map((item) => ({ id: item.provider, kind: "api-key", state: "import", detail: `from ${item.source}` })),
33
+ ...rows(result.skippedExisting, "api-key", "exists"),
34
+ ...guidance.logins.map((login) => ({ id: login.provider, kind: "oauth", state: login.state, ...(login.target === undefined ? {} : { detail: `/login ${login.target}` }) })),
35
+ ...rows(result.skippedUnmapped, "api-key", "unmapped"),
36
+ ]
37
+ }
38
+
39
+ function assetItems({ result }) {
40
+ return [
41
+ ...result.servers.added.map((server) => ({ id: server.name, kind: "mcp", state: "import" })),
42
+ ...rows(result.servers.skippedExisting, "mcp", "exists"),
43
+ ...rows(result.servers.blocked, "mcp", "blocked"),
44
+ ...result.skills.added.map((skill) => ({ id: skill.name, kind: "skill", state: "import" })),
45
+ ...rows(result.skills.skippedExisting, "skill", "exists"),
46
+ ...rows(result.skills.skippedBundled, "skill", "bundled"),
47
+ ]
48
+ }
49
+
50
+ function modelChoiceItems(plan) {
51
+ return plan.items.map((item) => ({ id: item.label, kind: item.kind, state: MODEL_CHOICE_STATES[item.state] ?? item.state }))
52
+ }
53
+
54
+ const ITEMS_BY_SOURCE = {
55
+ detect: detectItems,
56
+ providers: providerItems,
57
+ credentials: credentialItems,
58
+ assets: assetItems,
59
+ "model-choices": modelChoiceItems,
60
+ }
61
+
62
+ export function setupStageItems(source, plan) {
63
+ return ITEMS_BY_SOURCE[source](plan)
64
+ }