omo-ai 5.1.33 → 5.1.34

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 (43) hide show
  1. package/bin/lib/doctor.js +18 -5
  2. package/bin/lib/engine-commands.js +1 -2
  3. package/bin/lib/launcher.js +2 -6
  4. package/bin/lib/provider-map.json +2 -2
  5. package/bin/lib/retired-command.js +7 -0
  6. package/bin/lib/service-auth.generated.js +1 -1
  7. package/bin/lib/setup-import.js +3 -1
  8. package/bin/lib/setup-opencode-providers.js +76 -10
  9. package/package.json +2 -2
  10. package/plugin/CHANGELOG.md +92 -1
  11. package/plugin/extensions/assets.generated.json +4 -4
  12. package/plugin/extensions/gateway-rules-extension.mjs +1 -1
  13. package/plugin/extensions/gateway-store-worker.mjs +2 -2
  14. package/plugin/extensions/memory-run-supervisor.mjs +2 -2
  15. package/plugin/extensions/omo-computer-use.js +2 -2
  16. package/plugin/extensions/omo-init-deep-advisor.js +2 -2
  17. package/plugin/extensions/omo-member.js +2 -2
  18. package/plugin/extensions/omo-memory-doctor.js +2 -2
  19. package/plugin/extensions/omo-memory-memfs.js +2 -2
  20. package/plugin/extensions/omo-side-panel.js +2 -2
  21. package/plugin/extensions/omo-task.js +2 -2
  22. package/plugin/extensions/omo-thread-client.js +2 -0
  23. package/plugin/extensions/omo.js +2 -2
  24. package/plugin/package.json +2 -1
  25. package/plugin/runtime/agent-toolkit-sdk/sdk.js +2 -2
  26. package/plugin/runtime/ast-grep-mcp/manifest.json +1 -1
  27. package/plugin/runtime/category-coverage/assets.generated.json +4 -4
  28. package/plugin/runtime/category-coverage/index.js +6 -6
  29. package/plugin/runtime/rollback-migrate.js +2 -2
  30. package/plugin/runtime/task-config/index.js +1 -1
  31. package/plugin/runtime/thread-sdk/sdk.js +2 -2
  32. package/plugin/scripts/install.mjs +1 -0
  33. package/plugin/skills/browser/runtime/omowright/index.js +6 -5
  34. package/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
  35. package/plugin/skills/ulw-loop/SKILL.md +1 -1
  36. package/plugin/skills/ulw-loop/references/full-workflow.md +1 -1
  37. package/plugin/skills/ulw-research/SKILL.md +3 -1
  38. package/plugin/skills/ulw-research/references/deliverable-phase.md +1 -1
  39. package/plugin/skills/ulw-research/references/report-gates.md +2 -0
  40. package/plugin/skills/ulw-research/references/report-visualization.md +126 -0
  41. package/plugin/skills/ulw-research/scripts/contracts.mjs +3 -1
  42. package/plugin/skills/ulw-research/scripts/gates-figures.mjs +33 -1
  43. package/plugin/skills/ulw-research/scripts/gates-static.mjs +2 -2
package/bin/lib/doctor.js CHANGED
@@ -111,8 +111,9 @@ function listProcesses() {
111
111
  // A standalone binary runs its engine as the provisioned runtime executable itself. The same
112
112
  // executable also serves omo's own commands, internal hosts and bundled scripts, so only a bare launch
113
113
  // or engine flags count; those other roles are never engines.
114
- const BINARY_ENGINE = /[\\/]binary-runtime[\\/][^\\/\s]+[\\/]omo(?:\.exe)?(?=\s|$)\s*(\S*)/
115
- const BINARY_NON_ENGINE_COMMANDS = new Set(["doctor", "setup", "daemon", "update", "upgrade", "host", "install", "remove", "list", "config", "auth", "app-server", "ulw-loop", "--version", "-v"])
114
+ const BINARY_RUNTIME_EXECUTABLE = String.raw`[\\/]binary-runtime[\\/][^\\/\s]+[\\/]omo(?:\.exe)?`
115
+ const BINARY_ENGINE = new RegExp(String.raw`${BINARY_RUNTIME_EXECUTABLE}(?=\s|$)\s*(\S*)`)
116
+ const BINARY_NON_ENGINE_COMMANDS = new Set(["doctor", "setup", "daemon", "update", "upgrade", "host", "install", "remove", "list", "config", "auth", "ulw-loop", "--version", "-v"])
116
117
 
117
118
  function isBinaryEngine(command) {
118
119
  const match = BINARY_ENGINE.exec(command)
@@ -126,6 +127,16 @@ function isEngine(entry) {
126
127
  return ENGINE_MARKERS.some((marker) => entry.command.includes(marker)) || isBinaryEngine(entry.command)
127
128
  }
128
129
 
130
+ // A daemon started by an older engine as `... app-server` keeps running after an upgrade. It has no
131
+ // `--mode` and is reparented to init, so it would otherwise read as an orphaned session.
132
+ const RETIRED_APP_SERVER = new RegExp(
133
+ String.raw`(?:senpi[\\/]dist[\\/](?:bundle[\\/])?cli\.js|${BINARY_RUNTIME_EXECUTABLE})\s+app-server(?=\s|$)`,
134
+ )
135
+
136
+ function isRetiredAppServer(entry) {
137
+ return RETIRED_APP_SERVER.test(entry.command)
138
+ }
139
+
129
140
  function isInteractive(entry) {
130
141
  return !entry.command.includes(MANAGED_MODE_FLAG)
131
142
  }
@@ -134,7 +145,7 @@ function isInteractive(entry) {
134
145
  * Splits every engine process this machine is running into the three groups that matter:
135
146
  * - stale: interactive engines reparented to init, i.e. their launcher died underneath them
136
147
  * - attached: interactive engines still owned by a live parent (somebody's session)
137
- * - managed: rpc / app-server engines, which are owned by whatever embeds them
148
+ * - managed: `--mode` engines such as rpc, which are owned by whatever embeds them
138
149
  * Only the first group is ever reportable, and even then only as a report.
139
150
  */
140
151
  export function classifyEngineProcesses(entries) {
@@ -144,7 +155,7 @@ export function classifyEngineProcesses(entries) {
144
155
  for (const entry of entries) {
145
156
  if (!isEngine(entry)) continue
146
157
  if (!isInteractive(entry)) managed.push(entry)
147
- else if (entry.ppid === 1) stale.push(entry)
158
+ else if (entry.ppid === 1) stale.push(isRetiredAppServer(entry) ? { ...entry, retiredAppServer: true } : entry)
148
159
  else attached.push(entry)
149
160
  }
150
161
  return { stale, attached, managed }
@@ -153,7 +164,9 @@ export function classifyEngineProcesses(entries) {
153
164
  export function formatStaleEngineLines(stale) {
154
165
  if (stale.length === 0) return []
155
166
  const lines = stale.map((entry) =>
156
- `WARN stale engine pid ${entry.pid} (age ${entry.elapsed}, tty ${entry.tty}) has no launcher; it was orphaned by a signaled launcher`
167
+ entry.retiredAppServer
168
+ ? `WARN stale engine pid ${entry.pid} (age ${entry.elapsed}, tty ${entry.tty}) is an app-server daemon started by an older version; that command was removed, so stop it`
169
+ : `WARN stale engine pid ${entry.pid} (age ${entry.elapsed}, tty ${entry.tty}) has no launcher; it was orphaned by a signaled launcher`
157
170
  )
158
171
  lines.push(`INFO reap them explicitly by pid: omo doctor --reap ${stale.map((entry) => entry.pid).join(" ")}`)
159
172
  return lines
@@ -1,7 +1,6 @@
1
1
  // The engine dispatches these one-shot commands on argv[0] before any session starts, so they must
2
2
  // reach it with the command first; a leading `--extension` turns them into a chat launch (#9572).
3
- // `app-server` is excluded on purpose: it reads the plugin after its subcommand. The list is checked
4
- // against the installed engine's own dispatch by test/engine-commands.test.ts.
3
+ // The list is checked against the installed engine's own dispatch by test/engine-commands.test.ts.
5
4
  const ENGINE_COMMANDS = new Set(["install", "remove", "uninstall", "update", "list", "config", "auth", "host", "schedule"])
6
5
 
7
6
  export function isEngineCommand(args) {
@@ -19,6 +19,7 @@ import { isSelfUpdate } from "./update-args.js"
19
19
  import { detectHarnesses } from "./setup-detect.js"
20
20
  import { readSetupSuggestionCache, spawnSetupSuggestionRefresh } from "./setup-detect-cache.js"
21
21
  import { printSetupReport } from "./setup-report.js"
22
+ import { refuseRetiredCommand } from "./retired-command.js"
22
23
 
23
24
  // Identity the engine adopts for this install: what the user sees, where state lives, which
24
25
  // environment prefix is read first, what goes on the wire, and which channel to check for
@@ -309,12 +310,7 @@ export async function runLauncher(args = process.argv.slice(2)) {
309
310
  process.exitCode = await runSelfUpdate(args)
310
311
  return
311
312
  }
312
- // app-server takes the plugin after its subcommand: a leading --extension never reaches the
313
- // engine's app-server dispatch. It loads into every thread, including the daemon's.
314
- if (command === "app-server") {
315
- await spawnSenpi(args.includes("--no-extensions") ? args : [...args, "--extension", join(packageRoot, "plugin")], false)
316
- return
317
- }
313
+ if (refuseRetiredCommand(args)) return
318
314
  // `host status --all` stays the engine's answer; omo only adds each terminal row's last activity.
319
315
  if (isHostStatusAll(args, process.env)) {
320
316
  process.exitCode = await runHostStatusAll(args, {
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Derived from @code-yeongyu/senpi 2026.10.13 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.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.",
3
3
  "builtinProviderIds": [
4
4
  "alibaba-token-plan",
5
5
  "amazon-bedrock",
@@ -52,7 +52,6 @@
52
52
  "zai-coding-cn"
53
53
  ],
54
54
  "oauthProviderIds": [
55
- "anthropic",
56
55
  "chatgpt-subscription",
57
56
  "cursor",
58
57
  "devin",
@@ -74,6 +73,7 @@
74
73
  "zhipuai": "zai"
75
74
  },
76
75
  "oauthLogins": {
76
+ "anthropic": "anthropic-subscription",
77
77
  "claude-sdk-oauth": "anthropic-subscription",
78
78
  "kimi-for-coding": "kimi-coding",
79
79
  "openai": "chatgpt-subscription",
@@ -0,0 +1,7 @@
1
+ /** Old app-server invocations must never fall through into a model turn. */
2
+ export function refuseRetiredCommand(args) {
3
+ if (args[0] !== "app-server") return false
4
+ process.stderr.write("omo app-server has been removed. Use omo --mode rpc or omo host.\n")
5
+ process.exitCode = 2
6
+ return true
7
+ }
@@ -116,4 +116,4 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
116
116
  ${process.pid}
117
117
  ${Date.now()}
118
118
  ${lockOwnerInstanceId}
119
- `}function markLockRecordHeld(content){heldLockRecords.add(content)}function markLockRecordReleased(content){heldLockRecords.delete(content)}function errorCode(error){if(!(error instanceof Error)||!("code"in error))return null;return typeof error.code==="string"?error.code:null}function isPathAbsenceError(error){let code=errorCode(error);return code==="ENOENT"||code==="ENOTDIR"}function parseLockOwner(content){let lines=content.split(/\r?\n/).filter((line)=>line.length>0);if(lines.length!==3&&lines.length!==4)return null;let ownerPid=Number.parseInt(lines[1]??"",10),acquiredAtEpochMs=Number.parseInt(lines[2]??"",10);if(!Number.isInteger(ownerPid)||ownerPid<=0)return null;if(!Number.isInteger(acquiredAtEpochMs)||acquiredAtEpochMs<=0)return null;return{ownerPid,acquiredAtEpochMs,instanceId:lines.length===4?lines[3]??null:null}}function isPidAlive(pid){try{return process.kill(pid,0),!0}catch(error){if(!(error instanceof Error))throw error;return!1}}async function readLockRecord(lockPath){try{let content=await readFile(lockPath,"utf8"),{mtimeMs}=await stat(lockPath);return{content,modifiedAtEpochMs:mtimeMs}}catch(error){if(!(error instanceof Error))throw error;return null}}function isLockRecordStale(record,staleAfterMs){let owner=parseLockOwner(record.content);if(owner===null)return Date.now()-record.modifiedAtEpochMs>staleAfterMs;if(owner.instanceId!==null&&owner.ownerPid===process.pid)return owner.instanceId!==lockOwnerInstanceId||!heldLockRecords.has(record.content);if(isPidAlive(owner.ownerPid))return!1;if(owner.instanceId!==null)return!0;return Date.now()-owner.acquiredAtEpochMs>staleAfterMs}async function pathMayExist(path,deps={}){let accessPath=deps.access??access;try{return await accessPath(path),!0}catch(error){if(!(error instanceof Error))throw error;return!isPathAbsenceError(error)}}async function assertRetryableLockOpenError(lockPath,error,deps){let code=errorCode(error);if(code==="EEXIST")return;if(code==="EPERM"){if(await pathMayExist(lockPath,deps))return;if((deps?.platform??process.platform)==="win32"&&await pathMayExist(dirname(lockPath),deps))return}throw error}async function publishLockRecord(lockPath,record){markLockRecordHeld(record);let fileHandle;try{fileHandle=await open2(lockPath,"wx")}catch(error){return markLockRecordReleased(record),await assertRetryableLockOpenError(lockPath,error),!1}try{await fileHandle.writeFile(record)}catch(error){throw markLockRecordReleased(record),await fileHandle.close(),await unlink(lockPath).catch(()=>{return}),error}return await fileHandle.close(),!0}import{createHash as createHash3}from"node:crypto";import{unlink as unlink2}from"node:fs/promises";var RECLAIM_UNLINK_ATTEMPTS=3,RECLAIM_UNLINK_RETRY_MS=25;function delay(ms){return new Promise((resolve)=>{setTimeout(resolve,ms)})}function reclaimGuardPath(lockPath,record){return`${lockPath}.reclaim-${createHash3("sha256").update(record).digest("hex").slice(0,16)}`}async function unlinkWithRetry(path,deps){let wait=deps.delay??delay,unlinkFile=deps.unlink??unlink2;for(let attempt=1;;attempt+=1)try{return await unlinkFile(path),"removed"}catch(error){if(!(error instanceof Error))throw error;if(isPathAbsenceError(error))return"absent";let code=errorCode(error);if(code!=="EPERM"&&code!=="EBUSY"||attempt===RECLAIM_UNLINK_ATTEMPTS)return"stuck";await wait(RECLAIM_UNLINK_RETRY_MS)}}async function removeLockRecordIfUnchanged(lockPath,expectedRecord,staleAfterMs,deps={}){let guardPath=reclaimGuardPath(lockPath,expectedRecord),guardRecord=buildLockRecord("lock-reclaim");if(!await publishLockRecord(guardPath,guardRecord)){let guard=await readLockRecord(guardPath);if(guard!==null&&isLockRecordStale(guard,staleAfterMs))await removeLockRecordIfUnchanged(guardPath,guard.content,staleAfterMs,deps);return"busy"}try{let current=await readLockRecord(lockPath);if(current===null)return"absent";if(current.content!==expectedRecord||!isLockRecordStale(current,staleAfterMs))return"changed";let unlinked=await unlinkWithRetry(lockPath,deps);return unlinked==="absent"?"absent":unlinked}finally{markLockRecordReleased(guardRecord),await unlinkWithRetry(guardPath,deps)}}var LOCK_RETRY_MS=50,LOCK_WAIT_TIMEOUT_MS=15000,DEFAULT_STALE_AFTER_MS=300000;function delay2(ms){return new Promise((resolve)=>{setTimeout(resolve,ms)})}async function acquireLock(lockPath,ownerTag,staleAfterMs){let startedAt=Date.now();for(;;){if(Date.now()-startedAt>LOCK_WAIT_TIMEOUT_MS)throw Error(`Timed out acquiring lock: ${lockPath}`);let record=buildLockRecord(ownerTag);if(await publishLockRecord(lockPath,record))return record;let outcome=await reapStaleLock(lockPath,staleAfterMs);if(outcome==="removed"||outcome==="absent")continue;await delay2(LOCK_RETRY_MS)}}async function withLock(lockPath,fn,opts){let staleAfterMs=opts?.staleAfterMs??DEFAULT_STALE_AFTER_MS,ownerTag=opts?.ownerTag??"owner",record=await acquireLock(lockPath,ownerTag,staleAfterMs);try{return await fn()}finally{markLockRecordReleased(record),await removeLockRecordIfUnchanged(lockPath,record,staleAfterMs)}}async function reapStaleLock(lockPath,staleAfterMs=DEFAULT_STALE_AFTER_MS,deps={}){let observed=await readLockRecord(lockPath);if(observed===null)return"absent";if(!isLockRecordStale(observed,staleAfterMs))return"changed";return await removeLockRecordIfUnchanged(lockPath,observed.content,staleAfterMs,deps)}class SessionEnded extends SignInError{deviceRevoked;constructor(message,deviceRevoked){super(message);this.deviceRevoked=deviceRevoked}}var DEVICE_REVOKING_REFUSALS=new Set(["account_deleted","reauth_required"]);function createSession(options){let api=serviceOrigin(options.api),{store,signal}=options,directory=join(options.home??homedir(),".omo","session-locks"),lockPath=join(directory,`${credentialId(api)}.lock`);async function locked(action){return await mkdir(directory,{recursive:!0,mode:448}),signal?.throwIfAborted(),withLock(lockPath,async()=>(signal?.throwIfAborted(),action()),{ownerTag:"cli-session",staleAfterMs:Number.POSITIVE_INFINITY})}let request=(path,init)=>requestApi(api,path,init,signal),uncertain=()=>new SignInError("The refresh outcome is unknown. Saved credentials and keys were retained, but this refresh token will not be sent again. Retry sign-in with omo login.",!0);async function refreshedAccess(current){if(current.refreshState==="uncertain")throw uncertain();if(Date.parse(current.accessTokenExpiresAt)>Date.now()+30000)return current.accessToken;await store.write({...current,refreshState:"uncertain"});try{let tokens=parseReply(tokenSchema,await request("/v1/session/refresh",jsonRequest({refreshToken:current.refreshToken})));return await store.write({...current,...tokens}),tokens.accessToken}catch(error){if(error instanceof ApiRefusal&&error.status<500&&["unauthorized","account_deleted","reauth_required","invalid_grant"].includes(error.code))throw await store.clear(),new SessionEnded(error.code==="reauth_required"?"This session was ended because its refresh token was reused. Sign in again with omo login.":"Sign in again with omo login.",DEVICE_REVOKING_REFUSALS.has(error.code));if(error instanceof RequestFailure&&error.notSent||error instanceof ApiRefusal&&error.status===429&&error.code==="rate_limited")throw await store.write(current),error;throw uncertain()}}return{request,signal,async ready(){await store.read()},async save(value){await locked(()=>store.write(value))},async logout(){return locked(async()=>{try{let current=await store.read();if(current!==null){if(current.refreshState==="uncertain")return!1;let access=Date.parse(current.accessTokenExpiresAt)<=Date.now()?await refreshedAccess(current):current.accessToken;await request(`/v1/devices/${encodeURIComponent(current.device.id)}`,{method:"DELETE",headers:{authorization:`Bearer ${access}`}})}return!0}catch(error){if(error instanceof SessionEnded)return error.deviceRevoked;if(error instanceof SignInError)return!1;throw error}finally{await store.clear()}})},async accessToken(){return locked(async()=>{let current=await store.read();if(current===null)throw new SignInError("Not signed in. Run omo login.");return refreshedAccess(current)})},async removeForLimit(error,choose){if(!(error instanceof ApiRefusal)||error.detail===void 0)throw error;let headers={authorization:`Bearer ${error.detail.managementToken}`},devices=parseReply(deviceListSchema,await request("/v1/devices",{headers})).devices.filter((device)=>device.revokedAt===null),chosen=await choose(devices);if(chosen===null)return!1;if(!devices.some((device)=>device.id===chosen))throw new SignInError("No matching device was selected.");return await request(`/v1/devices/${encodeURIComponent(chosen)}`,{method:"DELETE",headers}),!0}}}async function ensureServiceAvailable(session,api){try{await session.request("/v1/health",{method:"GET"})}catch(error){if(!(error instanceof SignInError))throw error;if(error instanceof RequestFailure)throw new SignInError(`Can't reach OmO accounts at ${api}. The service may not be available yet, or your network, proxy or certificate settings are blocking it.`);throw new SignInError(`OmO accounts at ${api} aren't available right now. Try again later.`)}}function deviceChoices(devices){return devices.map((device)=>({value:device.id,label:terminalText(device.name)}))}var chooseDevice=async(devices)=>{if(!process.stdin.isTTY||devices.length===0)return console.error("Device limit reached. Run omo login in an interactive terminal to choose a device to remove."),null;let chosen=await select({message:"Device limit reached. Remove a device to sign in?",options:[{value:"",label:"Cancel (keep all devices)"},...deviceChoices(devices)]});if(isCancel(chosen)||chosen==="")return null;return await confirm({message:"Sign out the selected device?",initialValue:!1})===!0?chosen:null},DEFAULT_ACCOUNTS_ORIGIN="https://signin.omo.dev";function accountsOrigin(options,env){return serviceOrigin(options.accounts??env.OMO_SERVICE_ACCOUNTS_URL??DEFAULT_ACCOUNTS_ORIGIN)}async function run(action,options){let controller=new AbortController,cancel=()=>controller.abort();process.once("SIGINT",cancel),process.once("SIGTERM",cancel);try{let api=serviceOrigin(options.api??process.env.OMO_SERVICE_API_URL??"https://api.omo.dev"),store=createCredentialStore(api),session=createSession({api,store,signal:controller.signal});if(action==="logout"){let revoked=await session.logout();if(console.log("Signed out of OmO. Stored credentials and device keys were removed."),!revoked)console.error("Server device revocation could not be confirmed. Remove this device from the Devices page of your OmO account when connected.");return}if(action==="whoami"){await session.accessToken();let saved=await store.read();console.log(saved?`Signed in to ${api} as ${terminalText(saved.device.name)}.`:"Not signed in.");return}await ensureServiceAvailable(session,api);let name=deviceName(),platform=devicePlatform();if(!options.device&&options.browser!==!1)try{await loginLoopback(session,{accounts:accountsOrigin(options,process.env),name,platform,open:openSignInBrowser,chooseDevice}),console.log("Signed in to OmO.");return}catch(error){if(!(error instanceof BrowserUnavailable))throw error}await loginDevice(session,{name,platform,chooseDevice,show:(code)=>{console.log(`Open ${code.verificationUri}`),console.log(`User code: ${code.userCode}`),console.log(`Matching code: ${code.matchingCode}`)}}),console.log("Signed in to OmO.")}catch(error){console.error(controller.signal.aborted?"Sign-in cancelled.":error instanceof SignInError?error.message:"Unable to complete OmO sign-in. Check the OS credential store and try again."),process.exitCode=1}finally{process.removeListener("SIGINT",cancel),process.removeListener("SIGTERM",cancel)}}function configureServiceAuthCommands(program){program.command("login").description("Sign in to OmO using the OS credential store").option("--device","Use a device code instead of a browser callback").option("--no-browser","Sign in by device code without opening a browser").option("--api <url>","OmO API origin (or OMO_SERVICE_API_URL)").option("--accounts <url>","OmO sign-in page origin (or OMO_SERVICE_ACCOUNTS_URL)").action((options)=>run("login",options));for(let action of["logout","whoami"])program.command(action).description(action==="logout"?"Remove stored OmO credentials and device keys":"Show the current OmO device sign-in").option("--api <url>","OmO API origin (or OMO_SERVICE_API_URL)").action((options)=>run(action,options))}async function runServiceAuthCommand(args){let program=new Command("omo");configureServiceAuthCommands(program),await program.parseAsync([...args],{from:"user"})}export{DEFAULT_ACCOUNTS_ORIGIN,accountsOrigin,configureServiceAuthCommands,deviceChoices,runServiceAuthCommand};
119
+ `}function markLockRecordHeld(content){heldLockRecords.add(content)}function markLockRecordReleased(content){heldLockRecords.delete(content)}function errorCode(error){if(!(error instanceof Error)||!("code"in error))return null;return typeof error.code==="string"?error.code:null}function isPathAbsenceError(error){let code=errorCode(error);return code==="ENOENT"||code==="ENOTDIR"}function parseLockOwner(content){let lines=content.split(/\r?\n/).filter((line)=>line.length>0);if(lines.length!==3&&lines.length!==4)return null;let ownerPid=Number.parseInt(lines[1]??"",10),acquiredAtEpochMs=Number.parseInt(lines[2]??"",10);if(!Number.isInteger(ownerPid)||ownerPid<=0)return null;if(!Number.isInteger(acquiredAtEpochMs)||acquiredAtEpochMs<=0)return null;return{ownerPid,acquiredAtEpochMs,instanceId:lines.length===4?lines[3]??null:null}}function isPidAlive(pid){try{return process.kill(pid,0),!0}catch(error){if(!(error instanceof Error))throw error;return!1}}async function readLockRecord(lockPath){try{let content=await readFile(lockPath,"utf8"),{mtimeMs}=await stat(lockPath);return{content,modifiedAtEpochMs:mtimeMs}}catch(error){if(!(error instanceof Error))throw error;return null}}function isLockRecordStale(record,staleAfterMs){let owner=parseLockOwner(record.content);if(owner===null)return Date.now()-record.modifiedAtEpochMs>staleAfterMs;if(owner.instanceId!==null&&owner.ownerPid===process.pid)return owner.instanceId!==lockOwnerInstanceId||!heldLockRecords.has(record.content);if(isPidAlive(owner.ownerPid))return!1;if(owner.instanceId!==null)return!0;return Date.now()-owner.acquiredAtEpochMs>staleAfterMs}async function pathMayExist(path,deps={}){let accessPath=deps.access??access;try{return await accessPath(path),!0}catch(error){if(!(error instanceof Error))throw error;return!isPathAbsenceError(error)}}async function assertRetryableLockOpenError(lockPath,error,deps){let code=errorCode(error);if(code==="EEXIST")return;if(code==="EPERM"){if(await pathMayExist(lockPath,deps))return;if((deps?.platform??process.platform)==="win32"&&await pathMayExist(dirname(lockPath),deps))return}throw error}async function publishLockRecord(lockPath,record){markLockRecordHeld(record);let fileHandle;try{fileHandle=await open2(lockPath,"wx")}catch(error){return markLockRecordReleased(record),await assertRetryableLockOpenError(lockPath,error),!1}try{await fileHandle.writeFile(record)}catch(error){throw markLockRecordReleased(record),await fileHandle.close(),await unlink(lockPath).catch(()=>{return}),error}return await fileHandle.close(),!0}import{createHash as createHash3}from"node:crypto";import{unlink as unlink2}from"node:fs/promises";var RECLAIM_UNLINK_ATTEMPTS=3,RECLAIM_UNLINK_RETRY_MS=25;function delay(ms){return new Promise((resolve)=>{setTimeout(resolve,ms)})}function reclaimGuardPath(lockPath,record){return`${lockPath}.reclaim-${createHash3("sha256").update(record).digest("hex").slice(0,16)}`}async function unlinkWithRetry(path,deps){let wait=deps.delay??delay,unlinkFile=deps.unlink??unlink2;for(let attempt=1;;attempt+=1)try{return await unlinkFile(path),"removed"}catch(error){if(!(error instanceof Error))throw error;if(isPathAbsenceError(error))return"absent";let code=errorCode(error);if(code!=="EPERM"&&code!=="EBUSY"||attempt===RECLAIM_UNLINK_ATTEMPTS)return"stuck";await wait(RECLAIM_UNLINK_RETRY_MS)}}async function removeLockRecordIfUnchanged(lockPath,expectedRecord,staleAfterMs,deps={}){let guardPath=reclaimGuardPath(lockPath,expectedRecord),guardRecord=buildLockRecord("lock-reclaim");if(!await publishLockRecord(guardPath,guardRecord)){let guard=await readLockRecord(guardPath);if(guard!==null&&isLockRecordStale(guard,staleAfterMs))await removeLockRecordIfUnchanged(guardPath,guard.content,staleAfterMs,deps);return"busy"}try{let current=await readLockRecord(lockPath);if(current===null)return"absent";if(current.content!==expectedRecord||!isLockRecordStale(current,staleAfterMs))return"changed";let unlinked=await unlinkWithRetry(lockPath,deps);return unlinked==="absent"?"absent":unlinked}finally{markLockRecordReleased(guardRecord),await unlinkWithRetry(guardPath,deps)}}var LOCK_RETRY_MS=50,LOCK_WAIT_TIMEOUT_MS=15000,DEFAULT_STALE_AFTER_MS=300000;function delay2(ms){return new Promise((resolve)=>{setTimeout(resolve,ms)})}async function acquireLock(lockPath,ownerTag,staleAfterMs){let startedAt=Date.now();for(;;){if(Date.now()-startedAt>LOCK_WAIT_TIMEOUT_MS)throw Error(`Timed out acquiring lock: ${lockPath}`);let record=buildLockRecord(ownerTag);if(await publishLockRecord(lockPath,record))return record;let outcome=await reapStaleLock(lockPath,staleAfterMs);if(outcome==="removed"||outcome==="absent")continue;await delay2(LOCK_RETRY_MS)}}async function withLock(lockPath,fn,opts){let staleAfterMs=opts?.staleAfterMs??DEFAULT_STALE_AFTER_MS,ownerTag=opts?.ownerTag??"owner",record=await acquireLock(lockPath,ownerTag,staleAfterMs);try{return await fn()}finally{markLockRecordReleased(record),await removeLockRecordIfUnchanged(lockPath,record,staleAfterMs)}}async function reapStaleLock(lockPath,staleAfterMs=DEFAULT_STALE_AFTER_MS,deps={}){let observed=await readLockRecord(lockPath);if(observed===null)return"absent";if(!isLockRecordStale(observed,staleAfterMs))return"changed";return await removeLockRecordIfUnchanged(lockPath,observed.content,staleAfterMs,deps)}class SessionEnded extends SignInError{deviceRevoked;constructor(message,deviceRevoked){super(message);this.deviceRevoked=deviceRevoked}}var DEVICE_REVOKING_REFUSALS=new Set(["account_deleted","reauth_required"]);function createSession(options){let api=serviceOrigin(options.api),{store,signal}=options,directory=join(options.home??homedir(),".omo","session-locks"),lockPath=join(directory,`${credentialId(api)}.lock`);async function locked(action){return await mkdir(directory,{recursive:!0,mode:448}),signal?.throwIfAborted(),withLock(lockPath,async()=>(signal?.throwIfAborted(),action()),{ownerTag:"cli-session",staleAfterMs:Number.POSITIVE_INFINITY})}let request=(path,init)=>requestApi(api,path,init,signal),uncertain=()=>new SignInError("The refresh outcome is unknown. Saved credentials and keys were retained, but this refresh token will not be sent again. Retry sign-in with omo login.",!0);async function refreshedAccess(current){if(current.refreshState==="uncertain")throw uncertain();if(Date.parse(current.accessTokenExpiresAt)>Date.now()+30000)return current.accessToken;await store.write({...current,refreshState:"uncertain"});try{let tokens=parseReply(tokenSchema,await request("/v1/session/refresh",jsonRequest({refreshToken:current.refreshToken})));return await store.write({...current,...tokens}),tokens.accessToken}catch(error){if(error instanceof ApiRefusal&&error.status<500&&["unauthorized","account_deleted","reauth_required","invalid_grant"].includes(error.code))throw await store.clear(),new SessionEnded(error.code==="reauth_required"?"This session was ended because its refresh token was reused. Sign in again with omo login.":"Sign in again with omo login.",DEVICE_REVOKING_REFUSALS.has(error.code));if(error instanceof RequestFailure&&error.notSent||error instanceof ApiRefusal&&error.status===429&&error.code==="rate_limited")throw await store.write(current),error;throw uncertain()}}return{request,signal,async ready(){await store.read()},async save(value){await locked(()=>store.write(value))},async logout(){return locked(async()=>{try{let current=await store.read();if(current!==null){if(current.refreshState==="uncertain")return!1;let access=Date.parse(current.accessTokenExpiresAt)<=Date.now()?await refreshedAccess(current):current.accessToken;await request(`/v1/devices/${encodeURIComponent(current.device.id)}`,{method:"DELETE",headers:{authorization:`Bearer ${access}`}})}return!0}catch(error){if(error instanceof SessionEnded)return error.deviceRevoked;if(error instanceof SignInError)return!1;throw error}finally{await store.clear()}})},async accessToken(){return locked(async()=>{let current=await store.read();if(current===null)throw new SignInError("Not signed in. Run omo login.");return refreshedAccess(current)})},async removeForLimit(error,choose){if(!(error instanceof ApiRefusal)||error.detail===void 0)throw error;let headers={authorization:`Bearer ${error.detail.managementToken}`},devices=parseReply(deviceListSchema,await request("/v1/devices",{headers})).devices.filter((device)=>device.revokedAt===null),chosen=await choose(devices);if(chosen===null)return!1;if(!devices.some((device)=>device.id===chosen))throw new SignInError("No matching device was selected.");return await request(`/v1/devices/${encodeURIComponent(chosen)}`,{method:"DELETE",headers}),!0}}}async function ensureServiceAvailable(session,api){try{await session.request("/v1/health",{method:"GET"})}catch(error){if(!(error instanceof SignInError))throw error;if(error instanceof RequestFailure)throw new SignInError(`Can't reach OmO accounts at ${api}. The service may not be available yet, or your network, proxy or certificate settings are blocking it.`);throw new SignInError(`OmO accounts at ${api} aren't available right now. Try again later.`)}}function deviceChoices(devices){return devices.map((device)=>({value:device.id,label:terminalText(device.name)}))}var chooseDevice=async(devices)=>{if(!process.stdin.isTTY||devices.length===0)return console.error("Device limit reached. Run omo login in an interactive terminal to choose a device to remove."),null;let chosen=await select({message:"Device limit reached. Remove a device to sign in?",options:[{value:"",label:"Cancel (keep all devices)"},...deviceChoices(devices)]});if(isCancel(chosen)||chosen==="")return null;return await confirm({message:"Sign out the selected device?",initialValue:!1})===!0?chosen:null},DEFAULT_ACCOUNTS_ORIGIN="https://signin.omo.dev";function accountsOrigin(options,env){return serviceOrigin(options.accounts??env.OMO_SERVICE_ACCOUNTS_URL??DEFAULT_ACCOUNTS_ORIGIN)}async function run(action,options){let controller=new AbortController,cancel=()=>controller.abort();process.once("SIGINT",cancel),process.once("SIGTERM",cancel);try{let api=serviceOrigin(options.api??process.env.OMO_SERVICE_API_URL??"https://api.omo.dev"),store=createCredentialStore(api),session=createSession({api,store,signal:controller.signal});if(action==="logout"){let revoked=await session.logout();if(console.log("Signed out of OmO. Stored credentials and device keys were removed."),!revoked)console.error("Server device revocation could not be confirmed. Remove this device from the Devices page of your OmO account when connected.");return}if(action==="whoami"){await session.accessToken();let saved=await store.read();console.log(saved?`Signed in to ${api} as ${terminalText(saved.device.name)}.`:"Not signed in.");return}await ensureServiceAvailable(session,api);let name=deviceName(),platform=devicePlatform();if(!options.device&&options.browser!==!1)try{await loginLoopback(session,{accounts:accountsOrigin(options,process.env),name,platform,open:openSignInBrowser,chooseDevice}),console.log("Signed in to OmO.");return}catch(error){if(!(error instanceof BrowserUnavailable))throw error}await loginDevice(session,{name,platform,chooseDevice,show:(code)=>{console.log(`Open ${code.verificationUri}`),console.log(`User code: ${code.userCode}`),console.log(`Matching code: ${code.matchingCode}`)}}),console.log("Signed in to OmO.")}catch(error){console.error(controller.signal.aborted?"Sign-in cancelled.":error instanceof SignInError?error.message:"Unable to complete OmO sign-in. Check the OS credential store and try again."),process.exitCode=1}finally{process.removeListener("SIGINT",cancel),process.removeListener("SIGTERM",cancel)}}var SERVICE_ACCOUNTS_OPEN=!1,SERVICE_ACCOUNTS_CLOSED_MESSAGE="OmO accounts aren't available yet.",SERVICE_AUTH_COMMANDS=["login","logout","whoami"],testOpenKey=Symbol.for("omo.test.serviceAccountsOpen");function serviceAccountsOpen(){return SERVICE_ACCOUNTS_OPEN||globalThis[testOpenKey]===!0}function refuseClosedServiceAuth(){console.error(SERVICE_ACCOUNTS_CLOSED_MESSAGE),process.exit(1)}function configureClosedServiceAuthCommands(program){for(let name of SERVICE_AUTH_COMMANDS){let command=program.command(name,{hidden:!0}).helpOption(!1).allowUnknownOption().allowExcessArguments().action(()=>{console.error(SERVICE_ACCOUNTS_CLOSED_MESSAGE),process.exitCode=1});command.help=refuseClosedServiceAuth}}function configureServiceAuthCommands(program,open=serviceAccountsOpen()){if(!open){configureClosedServiceAuthCommands(program);return}program.command("login").description("Sign in to OmO using the OS credential store").option("--device","Use a device code instead of a browser callback").option("--no-browser","Sign in by device code without opening a browser").option("--api <url>","OmO API origin (or OMO_SERVICE_API_URL)").option("--accounts <url>","OmO sign-in page origin (or OMO_SERVICE_ACCOUNTS_URL)").action((options)=>run("login",options));for(let action of["logout","whoami"])program.command(action).description(action==="logout"?"Remove stored OmO credentials and device keys":"Show the current OmO device sign-in").option("--api <url>","OmO API origin (or OMO_SERVICE_API_URL)").action((options)=>run(action,options))}async function runServiceAuthCommand(args){let program=new Command("omo");configureServiceAuthCommands(program),await program.parseAsync([...args],{from:"user"})}export{DEFAULT_ACCOUNTS_ORIGIN,SERVICE_ACCOUNTS_CLOSED_MESSAGE,SERVICE_ACCOUNTS_OPEN,accountsOrigin,configureServiceAuthCommands,deviceChoices,runServiceAuthCommand};
@@ -58,7 +58,9 @@ function targetRoots(plans, agentDir, home) {
58
58
 
59
59
  async function planAll(runtime, agentDir) {
60
60
  const providerSource = planOpencodeProviders(runtime)
61
- const credentials = await planCredentials(runtime, join(agentDir, "auth.json"), new Set(providerSource.providers.map((item) => item.id)))
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)
62
64
  const assets = planAssets({ runtime, agentDir })
63
65
  const providers = planProviders(providerSource, agentDir)
64
66
  const oauthProviders = opencodeOauthProviders(runtime)
@@ -73,11 +73,12 @@ function readOpencodeAuth(home, env, notices) {
73
73
  // OpenCode's order (provider.ts): `options.apiKey` wins, else the auth.json `api` entry, else the
74
74
  // one env var `env` names - a list of several names never yields a key.
75
75
  function convertKey(id, entry, opencodeAuth, notices) {
76
+ const kind = entry.catalog === true ? "catalog provider" : "custom provider"
76
77
  const inline = text(entry.options?.apiKey)
77
78
  if (inline !== undefined) {
78
79
  const key = configValue(inline)
79
80
  if (key !== undefined) return { key, source: "opencode config apiKey" }
80
- notices.push(`NOTICE opencode: custom provider ${id} apiKey uses a {file:...} or {env:...} placeholder omo cannot express; its key was not imported - start omo and run /login ${id}`)
81
+ notices.push(`NOTICE opencode: ${kind} ${id} apiKey uses a {file:...} or {env:...} placeholder omo cannot express; its key was not imported - start omo and run /login ${id}`)
81
82
  return undefined
82
83
  }
83
84
  const stored = opencodeAuth[id]
@@ -140,6 +141,7 @@ function convertHeaders(headers) {
140
141
 
141
142
  function convertProvider(id, entry, context) {
142
143
  const { notices } = context
144
+ const kind = isPlainObject(entry) && entry.catalog === true ? "catalog provider" : "custom provider"
143
145
  if (context.builtin.has(id)) {
144
146
  notices.push(`NOTICE opencode: provider ${id} is one omo already serves; its opencode overrides (baseURL, models) are not imported - set them in models.json by hand if you need them`)
145
147
  return undefined
@@ -148,29 +150,29 @@ function convertProvider(id, entry, context) {
148
150
  const npm = text(entry.npm) ?? DEFAULT_NPM
149
151
  const api = API_BY_NPM[npm]
150
152
  if (api === undefined) {
151
- notices.push(`NOTICE opencode: custom provider ${id} uses npm package ${npm}, which omo has no API adapter for; not imported`)
153
+ notices.push(`NOTICE opencode: ${kind} ${id} uses npm package ${npm}, which omo has no API adapter for; not imported`)
152
154
  return undefined
153
155
  }
154
156
  const baseURL = text(entry.options?.baseURL) ?? text(entry.api)
155
157
  if (fixedUrl(baseURL) === undefined) {
156
- notices.push(`NOTICE opencode: custom provider ${id} has no fixed baseURL; not imported - omo needs a literal endpoint URL`)
158
+ notices.push(`NOTICE opencode: ${kind} ${id} has no fixed baseURL; not imported - omo needs a literal endpoint URL`)
157
159
  return undefined
158
160
  }
159
161
  const baseUrl = engineBaseUrl(api, baseURL)
160
162
  if (baseUrl === undefined) {
161
- notices.push(`NOTICE opencode: custom provider ${id} baseURL ${baseURL} does not end in /v1, which omo's Anthropic client needs; not imported`)
163
+ notices.push(`NOTICE opencode: ${kind} ${id} baseURL ${baseURL} does not end in /v1, which omo's Anthropic client needs; not imported`)
162
164
  return undefined
163
165
  }
164
166
  const headers = convertHeaders(entry.options?.headers)
165
167
  if (headers === undefined) {
166
- notices.push(`NOTICE opencode: custom provider ${id} has a header with a {file:...} or {env:...} placeholder omo cannot express; not imported`)
168
+ notices.push(`NOTICE opencode: ${kind} ${id} has a header with a {file:...} or {env:...} placeholder omo cannot express; not imported`)
167
169
  return undefined
168
170
  }
169
171
  const models = Object.entries(isPlainObject(entry.models) ? entry.models : {})
170
172
  .map(([modelId, model]) => convertModel({ id, api, baseURL, baseUrl, optionsBaseURL: text(entry.options?.baseURL) }, modelId, model, notices))
171
173
  .filter((model) => model !== undefined)
172
174
  if (models.length === 0) {
173
- notices.push(`NOTICE opencode: custom provider ${id} declares no models omo can use; not imported`)
175
+ notices.push(`NOTICE opencode: ${kind} ${id} declares no models omo can use; not imported`)
174
176
  return undefined
175
177
  }
176
178
  // The shape model-config-schema.js ProviderConfigSchema validates and provider-composer.js
@@ -185,15 +187,79 @@ function convertProvider(id, entry, context) {
185
187
  return { id, npm, config, key: convertKey(id, entry, context.opencodeAuth, notices) }
186
188
  }
187
189
 
188
- /** Every OpenCode custom provider the engine can serve, converted; everything else as a notice. */
190
+ // OpenCode's own copy of the models.dev catalog (core/src/models-dev.ts: Global.Path.cache, or
191
+ // OPENCODE_MODELS_PATH): every provider OpenCode serves without a config block is defined there.
192
+ function readOpencodeCatalog(home, env, notices) {
193
+ const path = env.OPENCODE_MODELS_PATH || join(env.XDG_CACHE_HOME || join(home, ".cache"), "opencode", "models.json")
194
+ if (!existsSync(path)) return undefined
195
+ try {
196
+ const parsed = JSON.parse(readFileSync(path, "utf8"))
197
+ return isPlainObject(parsed) ? parsed : undefined
198
+ } catch {
199
+ notices.push("WARN opencode: could not parse its models.dev catalog cache; catalog providers were not imported")
200
+ return undefined
201
+ }
202
+ }
203
+
204
+ // OpenCode extends a catalog model field by field ("extend database from config"): nested objects such
205
+ // as `limit`, `modalities` and `provider` are merged, so overriding `limit.context` keeps `limit.output`.
206
+ function overlay(base, own) {
207
+ if (!isPlainObject(base) || !isPlainObject(own)) return own
208
+ const merged = { ...base }
209
+ for (const [key, value] of Object.entries(own)) merged[key] = isPlainObject(value) && isPlainObject(base[key]) ? overlay(base[key], value) : value
210
+ return merged
211
+ }
212
+
213
+ // A catalog provider in the shape of a `provider.<id>` block, so one converter serves both. Models
214
+ // models.dev marks deprecated stay behind, as OpenCode hides them.
215
+ function catalogEntry(entry) {
216
+ if (!isPlainObject(entry) || !isPlainObject(entry.models)) return undefined
217
+ const models = Object.fromEntries(Object.entries(entry.models).filter(([, model]) => isPlainObject(model) && model.status !== "deprecated"))
218
+ return { name: entry.name, npm: entry.npm, api: entry.api, env: entry.env, models, catalog: true }
219
+ }
220
+
221
+ // OpenCode serves a catalog provider as soon as it has a key, and a `provider.<id>` block for it
222
+ // extends the catalog entry rather than replacing it (#10158). Every non-builtin provider that
223
+ // OpenCode holds an API key for, or that a config block names, is looked up in the catalog.
224
+ function withCatalog(config, opencodeAuth, builtin, home, env, notices) {
225
+ const keyed = Object.keys(opencodeAuth).filter((id) => opencodeAuth[id]?.type === "api")
226
+ const ids = [...new Set([...keyed, ...Object.keys(config)])].filter((id) => !builtin.has(id))
227
+ if (ids.length === 0) return config
228
+ // Only an id that has no endpoint of its own depends on the catalog; a broken cache is worth a
229
+ // warning for that one alone, while a block with its own endpoint still extends the catalog when found.
230
+ const ownEndpoint = (id) => isPlainObject(config[id]) && (text(config[id].options?.baseURL) !== undefined || text(config[id].api) !== undefined)
231
+ const catalog = readOpencodeCatalog(home, env, ids.some((id) => !ownEndpoint(id)) ? notices : [])
232
+ if (catalog === undefined) {
233
+ const missing = ids.filter((id) => !Object.hasOwn(config, id))
234
+ if (missing.length > 0) notices.push(`NOTICE opencode: no models.dev catalog cache found, so ${missing.join(", ")} could not be described - if opencode serves them, run opencode once, then omo setup again`)
235
+ return config
236
+ }
237
+ const merged = { ...config }
238
+ for (const id of ids) {
239
+ const base = catalogEntry(catalog[id])
240
+ if (base === undefined) continue
241
+ const own = isPlainObject(config[id]) ? config[id] : {}
242
+ const ownModels = isPlainObject(own.models) ? own.models : {}
243
+ const models = { ...base.models }
244
+ for (const [modelId, model] of Object.entries(ownModels)) models[modelId] = overlay(models[modelId], model)
245
+ merged[id] = { ...base, ...own, models }
246
+ }
247
+ return merged
248
+ }
249
+
250
+ /** Every OpenCode custom or catalog provider the engine can serve, converted; everything else as a notice. */
189
251
  export function planOpencodeProviders(options = {}) {
190
252
  const home = options.home ?? homedir()
191
253
  const env = options.env ?? process.env
192
254
  const notices = []
193
- const declared = readOpencodeSection(opencodeConfigSources(home, env).files, "provider", "custom providers", notices)
194
- // auth.json is only a key source here; with no provider block it is the credential stage's alone.
255
+ const config = readOpencodeSection(opencodeConfigSources(home, env).files, "provider", "custom providers", notices)
256
+ const builtin = builtinProviderIds()
257
+ // With no provider block, a bad auth.json is the credential stage's to report.
258
+ const opencodeAuth = readOpencodeAuth(home, env, Object.keys(config).length > 0 ? notices : [])
259
+ const declared = withCatalog(config, opencodeAuth, builtin, home, env, notices)
260
+ // auth.json is only a key source here; with no provider to describe it is the credential stage's alone.
195
261
  if (Object.keys(declared).length === 0) return { providers: [], skipped: [], notices }
196
- const context = { builtin: builtinProviderIds(), opencodeAuth: readOpencodeAuth(home, env, notices), notices }
262
+ const context = { builtin, opencodeAuth, notices }
197
263
  const providers = []
198
264
  // The ids declared but not carried; each one's reason is in `notices`.
199
265
  const skipped = []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omo-ai",
3
- "version": "5.1.33",
3
+ "version": "5.1.34",
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.10.13"
20
+ "@code-yeongyu/senpi": "2026.10.16"
21
21
  },
22
22
  "repository": {
23
23
  "type": "git",
@@ -7,6 +7,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.1.34] - 2026-10-16
11
+
12
+ **`oracle`, `momus` and `plan-reviewer` fall back to Argon before the previous pro model.** In all three chains, Argon at high thinking (Google, Vertex or OpenCode) now sits directly ahead of the previous pro rung, which stays in place for Copilot and for engine pins that do not serve Argon yet. The GPT heads keep first place: on 8 consult and plan-review tasks graded blind, Argon beat the previous pro model in all 16 judgments but did not beat the current GPT heads on correctness, and its median answer took 57 s against 15 s. Telemetry now names the Qwen 3.7 Plus rung of `explore` and `librarian` instead of reporting it as `custom`. ([#10180](https://github.com/code-yeongyu/oh-my-openagent/issues/10180), [#10263](https://github.com/code-yeongyu/oh-my-openagent/pull/10263))
13
+
14
+ **`omo login`, `omo logout` and `omo whoami` are switched off until OmO accounts open.** OmO accounts aren't open to everyone yet. On every launcher (the npm `omo`, the compiled `omo` binary and the OpenCode-edition CLI), running `omo login`, `omo logout` or `omo whoami`, with any options including `--help`, prints "OmO accounts aren't available yet." and exits 1 without contacting the service or touching the OS credential store. They are gone from the OpenCode-edition CLI's `--help` command list, where `help login`, `help logout` and `help whoami` refuse the same way, and from the short command list `omo --help` prints when the engine can't start. Signing in to model providers (`/login`, `omo mcp oauth login`) works as before. ([#10262](https://github.com/code-yeongyu/oh-my-openagent/issues/10262), [#10264](https://github.com/code-yeongyu/oh-my-openagent/pull/10264))
15
+
16
+ **`omo account keys` gives each machine its own device identity and keeps its private keys off the process list.** `omo account keys init` now generates a random device id per machine and stores it with the keys, where every CLI install used to share the id `cli`. `OMO_DEVICE_NAME` no longer sets the id. On macOS without `Bun.secrets`, the Keychain write passes the key record to `security -i` on stdin; before, it went on the command line, where other processes could read it. The memory sync device keyring signs only RFC 8785 canonical JSON, checked by a full parse and re-serialization. It gains a snapshot signing purpose, and it opens epoch key wraps with the sync-design §8.1 HPKE `info` and associated data, so a wrap moved to another account, epoch or device does not open. Nothing syncs yet. ([#10194](https://github.com/code-yeongyu/oh-my-openagent/pull/10194))
17
+
18
+ **Internal: an authenticated XPC transport for the desktop engine, not used yet.** A hidden `--xpc-agent` mode serves the existing JSON-RPC over XPC. It accepts only a verified signed controller, gives each connection its own session, and carries no requests over the public discovery channel. No shipped client selects it. ([#10207](https://github.com/code-yeongyu/oh-my-openagent/issues/10207), [#10220](https://github.com/code-yeongyu/oh-my-openagent/pull/10220))
19
+
20
+ **Full access no longer stops on permission prompts, and every session on a shared host can sign in the same way (senpi 2026.10.16).** Under the `full-access` preset a configured `ask` rule no longer raises a prompt, while a configured `deny` still refuses and now names the rule and the settings layer it came from. A session opened on the shared task host lists the same sign-in options as a standalone one, so an OAuth provider that an extension registers is no longer missing there. Claude subscription sessions on 1M-window models such as Opus 5.5 no longer fail with "Prompt is too long" past 200k tokens, sessions sharing one credential pool no longer fail with "Credential store is busy", and opening the TUI or running `/new` without sending a message no longer leaves an empty session in `/resume`. The built-in Anthropic default is Opus 5.5 and the Xiaomi default is MiMo V2.6 Pro, used only when no model is saved or passed ([senpi v2026.10.16](https://github.com/code-yeongyu/senpi/releases/tag/v2026.10.16), [#10260](https://github.com/code-yeongyu/oh-my-openagent/pull/10260))
21
+
22
+ **A background child aborted by another client no longer reports a failure.** Another client's user abort on a shared child session now ends that task as `interrupted` with whatever partial answer it had produced, instead of `error`: `task_output` keeps the partial text, no failure notification fires, and the task stays resumable with `task_send`. The parent's own abort still ends the task as `cancelled`, and provider or host-system aborts keep the failure classification ([#10095](https://github.com/code-yeongyu/oh-my-openagent/pull/10095)).
23
+
24
+ **Interrupted background tasks recover without crash warnings.** Tasks that cannot reconnect resume automatically from their saved conversation and launch instructions within the existing retry budget. If recovery still fails, one notice names the task and its handle, explains why, and gives the corrective action for that cause; technical crash details stay in logs. Finished tasks with undelivered queued messages ask you to resend those messages rather than repeat finished work. Recovery completions retain partial output, the transcript tail, and a file pointer for large results ([#10023](https://github.com/code-yeongyu/oh-my-openagent/issues/10023), [#10031](https://github.com/code-yeongyu/oh-my-openagent/pull/10031)).
25
+
26
+ **ulw-research reports always carry visuals.** The final-report phase now requires at least one chart drawn from the run's own data, a diagram wherever structure matters, and the topic's screenshots or video with attribution, every one captioned and given alt text. A new playbook says which visual fits which finding and how to render it: data charts with live text, Mermaid diagrams themed to the report, PlotNeuralNet for paper-style reports that describe a model's architecture, and Andrej Karpathy's tips on diagrams, HTML pages and explainer videos. The report checker now flags a report with no rendered visual and an image with no alt text; an empty alt counts as decorative only when the image is also marked `role="presentation"` or `aria-hidden`. ([#10258](https://github.com/code-yeongyu/oh-my-openagent/issues/10258), [#10261](https://github.com/code-yeongyu/oh-my-openagent/pull/10261))
27
+
28
+ ## [5.1.34-beta.1] - 2026-10-16
29
+
30
+ **The browser skill no longer gets stuck when its BrowserSkill connection file goes missing or a page script hangs.** If the background daemon is still running but its `daemon.json` disappeared, the skill reconnects to it instead of trying to start a second one and failing with "port 52800 is already in use" over and over. A script or other browser action that runs past its own time limit is now stopped in the browser, so the next action works; a step that waits for you, such as signing in or approving a tab, is never cut short. Reported by @devswha. ([#10216](https://github.com/code-yeongyu/oh-my-openagent/issues/10216), [#10217](https://github.com/code-yeongyu/oh-my-openagent/issues/10217), [#10254](https://github.com/code-yeongyu/oh-my-openagent/pull/10254))
31
+
32
+ **`visual-engineering` and `writing` lead with Argon at its lowest thinking level once the engine serves it.** Both category chains and defaults put Google's Argon model (low thinking) first; every previous entry keeps its place one rung down. The rung resolves only when the model is available: on Senpi that means an engine pin whose catalog carries it (senpi 2026.10.15 does not yet, so Senpi sessions keep resolving Claude Fable 5.1 and Claude Opus 5.5 until the next adoption), while the OpenCode edition reads OpenCode's own provider list and resolves Argon as soon as Google is connected ([#10187](https://github.com/code-yeongyu/oh-my-openagent/issues/10187), [#10192](https://github.com/code-yeongyu/oh-my-openagent/pull/10192))
33
+
34
+ **A usage limit or a flaky network no longer strands you on a fallback model (senpi 2026.10.15).** After a usage-limit fallback, your model comes back once that limit resets, and a timeout or dropped connection that outlasts the same-model retries moves only that one turn to the next model. Fallback chains that list one model at several thinking levels now try every level. MCP servers that follow the newest MCP spec (2026-07-28) now connect. If you signed in to a Claude Pro/Max subscription through the plain Anthropic provider, omo moves it to the Claude Agent SDK lane, the one that draws from your plan ([senpi v2026.10.15](https://github.com/code-yeongyu/senpi/releases/tag/v2026.10.15), [#10183](https://github.com/code-yeongyu/oh-my-openagent/pull/10183))
35
+
36
+ **Memory gets a CRDT engine to sync on, loaded the same way everywhere omo runs.** The groundwork for end-to-end encrypted memory sync: memory-core now ships the Loro CRDT runtime behind one loader that fires change events, finds its WebAssembly from any working directory, and works under Bun, plain Node and the compiled `omo` binary alike. `omo loro-smoke` checks that a given install can load it. Nothing syncs yet. ([#10178](https://github.com/code-yeongyu/oh-my-openagent/issues/10178), [#10193](https://github.com/code-yeongyu/oh-my-openagent/pull/10193))
37
+
38
+ **`omo --version` and `omo --help` still answer when `OMO_EXTRA_CA_CERTIFICATES` is invalid, without starting anything.** The compiled binary refuses to start (exit 78) when that value is not a valid CA bundle, and it used to refuse these too. Now the exact commands `omo --version` and `omo --help` are answered from local data before the engine or any connection starts: the version line, or a short usage naming the main commands that says the full list needs the setting fixed. A warning names the problem. Every other command, including `-v`, `-h` and any subcommand's `--help`, still refuses. The engine's trust report also counts the certificates `NODE_EXTRA_CA_CERTS` added. ([#10236](https://github.com/code-yeongyu/oh-my-openagent/issues/10236), [#10240](https://github.com/code-yeongyu/oh-my-openagent/pull/10240))
39
+
40
+ **Turning telemetry off now also stops the session summary from being kept on disk.** Since the session summary arrived on dev, a session with telemetry off still recorded its per-turn totals locally, and if it crashed, a later session with telemetry on could send them. A session with telemetry off now writes nothing, a mid-session opt-out deletes what it wrote within seconds, and a leftover file is sent only if its own project still has telemetry on when it is recovered. No published release contained the earlier behavior. ([#10229](https://github.com/code-yeongyu/oh-my-openagent/issues/10229)) ([#10232](https://github.com/code-yeongyu/oh-my-openagent/pull/10232))
41
+
42
+ **Team-mode panes open in the worktree path exactly as written.** The member's worktree path was passed to tmux `split-window -c`, which expands its argument as a tmux format, so `#{...}` in a model-supplied `team_create` path changed the directory a pane opened in, and `#(...)` is tmux's shell-exec format. The path now has every `#` doubled, the same escaping the subagent pane titles use. ([#10233](https://github.com/code-yeongyu/oh-my-openagent/issues/10233)) ([#10235](https://github.com/code-yeongyu/oh-my-openagent/pull/10235))
43
+
44
+ **The tmux subagent placeholder pane prints the task description exactly as written, and never runs it.** The "OMO subagent pane ready" banner was escaped for one shell level while tmux runs it through two, so backticks, `$(...)` and `$VAR` in a model-written description were evaluated in your pane, `"` vanished, and `; | & # ( )` showed stray backslashes. Both banner arguments now use the same nested quoting as the attach command. Thanks to @cynkai. ([#10228](https://github.com/code-yeongyu/oh-my-openagent/issues/10228), [#9867](https://github.com/code-yeongyu/oh-my-openagent/pull/9867), [#10231](https://github.com/code-yeongyu/oh-my-openagent/pull/10231))
45
+
46
+ **The compiled omo binary trusts the operating system's certificate store, so it works behind a TLS-inspecting proxy.** A `bun build --compile` binary trusted only Bun's bundled CA list and ignored `--use-system-ca`. It now installs the bundled list, the OS store and the PEM certificates in `OMO_EXTRA_CA_CERTIFICATES` at every start, and refuses to start (exit 78) when that value is not a valid CA bundle. ([#10215](https://github.com/code-yeongyu/oh-my-openagent/issues/10215), [#10219](https://github.com/code-yeongyu/oh-my-openagent/pull/10219))
47
+
48
+ **Thread link labels escape `&` and backticks.** A session name holding an entity (`&#x5D;`) now displays as typed, and a backtick in a name can no longer open a code span that swallows the thread link; omo and the desktop app escape the same set. Internal: an unused `agentDir` option left on the thread tools by the skills change is removed. ([#10204](https://github.com/code-yeongyu/oh-my-openagent/pull/10204))
49
+
50
+ **Task inputs keep their identity when an acknowledgement is lost.** Every task RPC prompt, steer, and follow-up carries a persisted `clientMessageId`. A timed-out send reports `uncertain` with that id and asks callers not to resend the text; one background retry uses the same id and payload, and unresolved inputs are retried on reconciliation. An identity bound to different text is a failure with an action, never a delivery receipt. ([#10015](https://github.com/code-yeongyu/oh-my-openagent/issues/10015)) ([#10129](https://github.com/code-yeongyu/oh-my-openagent/pull/10129))
51
+
52
+ **Internal: a macOS code-identity verifier for the desktop engine's coming XPC service.** The new `senpi-desktop-xpc` crate checks who sent a message from the kernel-stamped sender of that message, not from a process id: Developer ID requirement, live hardened-runtime status, no debugger, no code-injection entitlements, and a signed security epoch. Nothing uses it yet. ([#10207](https://github.com/code-yeongyu/oh-my-openagent/issues/10207)) ([#10214](https://github.com/code-yeongyu/oh-my-openagent/pull/10214))
53
+
54
+ **Thread tools keep non-ASCII text whole, refuse look-alike names, and keep fuzzy hand-offs in the workspace.** A reply from a session host whose Korean, Japanese or emoji text arrived split across socket reads came back with replacement characters; frames are now decoded as one UTF-8 stream. `thread_create` and `thread_rename` compare names the way addressing resolves them (NFKC, case, collapsed spaces), so a full-width or double-spaced copy of an existing name is `name_conflict` instead of a second thread that later turns every lookup into `ambiguous_target`. A fuzzy `thread_handoff` picks among threads in the caller's workspace unless `all_scope` is set, where it used to pick a better-scoring thread in another project and then refuse it. Listing a dead endpoint's sessions reads two bounded windows of each transcript instead of the whole file. ([#9356](https://github.com/code-yeongyu/oh-my-openagent/issues/9356)) ([#10155](https://github.com/code-yeongyu/oh-my-openagent/pull/10155))
55
+
56
+ **On macOS the desktop engine refuses to run when it is its own responsible process.** A same-user process could start the signed engine with the responsibility-disclaim spawn attribute, or as a launchd job, and use the engine's own Accessibility and Screen Recording grants. The engine now exits before serving anything in that case, in every mode. Engines started by the app, a terminal or an agent host keep working. `--serve`, `--oneshot` and `--mcp` also need `--enable-daemon` now; reinstall the computer-use desktop capability to pick it up. ([#10207](https://github.com/code-yeongyu/oh-my-openagent/issues/10207)) ([#10212](https://github.com/code-yeongyu/oh-my-openagent/pull/10212))
57
+
58
+ **The thread tools' socket client loads on first use.** The thread component still registers its tools, store and lifecycle handlers at startup, but the client that talks to session hosts now ships as its own `omo-thread-client.js` and loads with the first thread or gateway call, so about 11 KB leave the main extension bundle. Thanks to @Dante-dan. ([#9624](https://github.com/code-yeongyu/oh-my-openagent/pull/9624))
59
+
60
+ **Session-gateway store extensions hold the write lock for less time and can no longer commit a partial operation.** A call to a current extension takes the lock once and re-checks the extension's version under it, an exact session id resolves from its session file without enumerating live endpoints inside the lock, and a statement run while an `enqueue`/`bind`/... helper is still in flight fails the call instead of landing inside that helper's savepoint. ([#9508](https://github.com/code-yeongyu/oh-my-openagent/pull/9508))
61
+
62
+ **`thread_create` can start a thread with skills it follows from its first turn.** Pass `skills: ["<name>", ...]` with the names of installed skills; the new session applies each one as if it were loaded with `/skill`, without a slash command and without starting a turn, and keeps applying it after a restart. A path or raw text refuses the create, and nothing is opened. The new session applies each skill its own registry holds (the same roots and trust rules as `/skill`) and logs any it does not; the result echoes the names passed as `thread.requested_skills`. ([#10157](https://github.com/code-yeongyu/oh-my-openagent/pull/10157))
63
+
64
+ **`omo config migrate` names every backup it wrote.** Before rewriting an existing `~/.omo/omo.jsonc`, the migration keeps a timestamped copy (`omo.jsonc.bak.<time>`), and now prints `backup: <path>` for it (`targetBackupPath` in `--json`), including after recovering an interrupted migration. The startup toast names the backup too ("Previous config saved as ..."), so a user who disagrees with a rewrite can find the previous file. Edits in `omo.jsonc` survive the migration, and when there is nothing to migrate the file is not touched at all (same bytes, same modification time). Both are now pinned by a regression test. ([#8187](https://github.com/code-yeongyu/oh-my-openagent/issues/8187), [#10195](https://github.com/code-yeongyu/oh-my-openagent/pull/10195))
65
+
66
+ **Thread tool results carry a link to the thread.** `thread_list`, `thread_create`, `thread_read`, `thread_send` and `thread_handoff` now return `link`, a ready `[title](omo-thread://v1/<thread_id>)` the agent pastes when it mentions a thread, so a client can render it as something the user clicks to open that thread. ([#10160](https://github.com/code-yeongyu/oh-my-openagent/pull/10160))
67
+
68
+ **A background task whose child ends on a provider error that will not be retried now ends `failed` with that error.** It used to stay `running` and go stale when the engine skipped its idle signal. After one heartbeat with the child idle and nothing scheduled to wake it, the task fails with the provider's own message. A goal waiting out a provider outage, a terminal monitor or a wake channel still keeps it running, including a monitor started in an earlier turn. ([senpi#3262](https://github.com/code-yeongyu/senpi/issues/3262), [#10149](https://github.com/code-yeongyu/oh-my-openagent/pull/10149))
69
+
70
+ **Internal: the ScreenCaptureKit wire tests no longer time out on a busy machine.** Their fake helpers are Python scripts that can take over five seconds just to start under heavy load, so the test-only capture deadline is now 30 seconds; the production capture budget is unchanged. ([#10189](https://github.com/code-yeongyu/oh-my-openagent/issues/10189)) ([#10190](https://github.com/code-yeongyu/oh-my-openagent/pull/10190))
71
+
72
+ **macOS captures through a persistent ScreenCaptureKit worker on macOS 14 and later.** A window on another Space is captured without switching to it, and the result says whether the window is on screen. A minimized window is refused with `CaptureUnavailable` (reason `minimized`). Display captures can leave out windows by title (`excludeWindowTitles`). On older macOS, or when the capture helper is missing or fails to start, capture falls back to the previous path without an error and reports `legacyCapture: true`. ([#9651](https://github.com/code-yeongyu/oh-my-openagent/issues/9651)) ([#10038](https://github.com/code-yeongyu/oh-my-openagent/pull/10038))
73
+
74
+ **Usage data sends one summary per session instead of one event per turn.** OmO Native used to send a `turn_completed` event after every assistant turn, about four in five of all its usage events. A session now sends `session_summary` rows instead: turn count, token and cost totals, per-turn token percentiles, error and tool counts, and the totals of its three most used models, closed every 50 turns or 30 minutes, on reload and at quit. The open totals are kept in a small local file so a crash loses nothing; the next OmO start sends them once. Raw `turn_completed` rows still go to PostHog for a fixed 5% of sessions, marked with `sample_rate`; exact totals for every session are in the summaries. Nothing new is collected: the summary holds only numbers and the model names the turn events already sent, and every opt-out works as before. ([#10182](https://github.com/code-yeongyu/oh-my-openagent/issues/10182), [#10184](https://github.com/code-yeongyu/oh-my-openagent/pull/10184))
75
+
76
+ **A background task resumed onto a session that can no longer move says so, and one call restarts it.** After a host restart, a task whose child had stopped on an aborted or unanswered turn used to sit on `starting` forever. It now shows `resumed, waiting for input` with the reason in `task_output`, the task list and the RPC status, and `task_send` with `redispatch: true` relaunches it on a fresh child under the same task, while a cancel still works as before. A task resumed onto a live, working session is never flagged, and the flag clears as soon as the child moves again. ([senpi#3266](https://github.com/code-yeongyu/senpi/issues/3266), [#10151](https://github.com/code-yeongyu/oh-my-openagent/pull/10151))
77
+
78
+ **`omo.dev/support` exists, so the desktop app's Report a problem no longer lands on a 404.** The page shows the report the app passes, as text only, with a copy button. It opens a prefilled GitHub issue, kept under GitHub's URL limit, or the community Discord, in all four site languages. The page saves nothing, and the report reaches GitHub only when the user submits the issue. Thanks to 문광훈 for the report. ([#10179](https://github.com/code-yeongyu/oh-my-openagent/issues/10179)) ([#10181](https://github.com/code-yeongyu/oh-my-openagent/pull/10181))
79
+
80
+ **A background task keeps the session awake until it really finishes.** The task engine's wake-source snapshot now lists each live child as an item the engine reads, and it updates when one child finishes while another starts, not only when the number of children changes. Before, that swap left a stale snapshot, so the todo backstop could warn that the agent stopped while it was still waiting on a child. ([#10026](https://github.com/code-yeongyu/oh-my-openagent/issues/10026), [#10131](https://github.com/code-yeongyu/oh-my-openagent/pull/10131))
81
+
82
+ **A screenshot never captures a live virtual cursor that capture could not exclude.** When another session's cursor helper is still open but missing from what ScreenCaptureKit lists, the capture now fails instead of taking a frame that could show that cursor; a helper that has really closed is still skipped without an error. ([#10161](https://github.com/code-yeongyu/oh-my-openagent/pull/10161), follow-up to [#9889](https://github.com/code-yeongyu/oh-my-openagent/pull/9889))
83
+
84
+ **`omo setup` carries OpenCode's catalog providers, such as Neuralwatt.** OpenCode serves every provider in its models.dev catalog once it has a key, but setup used to carry only omo's built-in providers and `provider.<id>` blocks. A key for a provider like `neuralwatt` was dropped as "a provider omo does not serve", and a default model on it was lost. Setup now describes such a provider from OpenCode's own catalog cache: endpoint, models and limits. A config block for it extends the catalog the way OpenCode does, so a proxy `baseURL` or a single model's limit works. Thanks to @taxilian for the report. ([#10158](https://github.com/code-yeongyu/oh-my-openagent/issues/10158), [#10163](https://github.com/code-yeongyu/oh-my-openagent/pull/10163))
85
+
86
+ **macOS computer use shows a display-only virtual cursor by default.** The overlay follows the agent's pointer actions without moving your real cursor or changing input permissions; set `computer.showCursor: false` to hide it. ([#9887](https://github.com/code-yeongyu/oh-my-openagent/issues/9887), [#9889](https://github.com/code-yeongyu/oh-my-openagent/pull/9889))
87
+
88
+ **Internal: the model-version migration skip bookkeeping is cleaned up.** The set of already-migrated models is now built once per run, and the comment describes the source-keyed rule; behaviour is unchanged. ([#10145](https://github.com/code-yeongyu/oh-my-openagent/pull/10145))
89
+
90
+ **The [CC] `opus` alias and the retired-model migration no longer land on Opus 4.8.** `mapClaudeModelToOpenCode("opus")` now resolves to the current Opus 5.5 id instead of 4.8, and the config migration that upgrades a retired `anthropic/` entry now targets Opus 5.5, so a default or an automatic migration never puts a user on Opus 4.8 without an explicit pick. ([#10140](https://github.com/code-yeongyu/oh-my-openagent/pull/10140))
91
+
92
+ **`omo app-server` is retired.** The Codex app-server protocol is gone from the engine, so omo no longer forwards `omo app-server`: it exits with status 2 and points to `omo --mode rpc` or `omo host`, which are the supported ways to drive a session from another program. `omo doctor`, the ulw-loop skill and the task status view no longer mention it. ([#10033](https://github.com/code-yeongyu/oh-my-openagent/issues/10033), [#10098](https://github.com/code-yeongyu/oh-my-openagent/pull/10098))
93
+
94
+ **A busy task host no longer holds your turn.** When the shared task host is slow to answer, `task` returns at once with the task id (the child keeps starting, and a failed start arrives as its notification), and `task_send` returns `delivering` after a second or as soon as you press Esc, with the outcome posted back to the session that sent it. Before, both waited inside the tool call for up to 10 minutes. ([#10042](https://github.com/code-yeongyu/oh-my-openagent/issues/10042), [#10045](https://github.com/code-yeongyu/oh-my-openagent/pull/10045))
95
+
96
+ **`omo doctor` names an app-server daemon left running from an older version.** After an upgrade, a daemon started as `app-server` keeps running with no session attached. Doctor used to call it a session orphaned by a crashed launcher; it now says it is a leftover app-server daemon to stop, and the usual `omo doctor --reap <pid>` line still covers it. ([#10033](https://github.com/code-yeongyu/oh-my-openagent/issues/10033), [#10113](https://github.com/code-yeongyu/oh-my-openagent/pull/10113))
97
+
98
+ **The file changes summary after a subagent run lists renamed, non-ASCII and newly created files under their real paths.** It read git's output without `-z`, so a renamed file was listed as `old => new` (or `dir/{a => b}/x`) instead of its new name, a path with non-ASCII characters or a tab came out C-quoted (and an untracked one lost its line count, +0), and an untracked file with a space in its name or inside a new directory was listed as modified instead of created. Paths are now read NUL-separated, with rename detection on regardless of `diff.renames` and `status.renames`: each file appears once under its real path, a rename under its new name and a copy as created. A path that contains control characters is still shown quoted and escaped, so a file name cannot add lines to the summary. Thanks to @spicysharkco. ([#10133](https://github.com/code-yeongyu/oh-my-openagent/pull/10133))
99
+
100
+ **`omo worktree-sweep --apply` started inside a linked worktree no longer deletes that worktree.** It ran its git commands from the worktree it was started in, so it removed that worktree, every git call after that failed (`cannot change to`, or `ENOENT ... posix_spawn 'git'` from the CLI), the command exited 1, and the merged worktrees listed after it were left behind. It now runs git from the main worktree, reports the worktree it was started from as `KEEP current`, and removes every merged sibling. ([#10130](https://github.com/code-yeongyu/oh-my-openagent/pull/10130))
101
+
10
102
  ## [5.1.33] - 2026-10-13
11
103
 
12
104
  **omo runs on senpi 2026.10.13: a busy host gives memory back when its sessions end.** An RPC host serving many sessions used to keep the heap of every session it had run until it exited, so a long day of terminal-heavy work held its peak footprint; it now returns freed heap as sessions drop ([senpi#3240](https://github.com/code-yeongyu/senpi/pull/3240), [senpi#3221](https://github.com/code-yeongyu/senpi/pull/3221)). Tool calls through DeepSeek's official API, and gateways that forward to it, stop failing with `400 Invalid schema ... got 'type: null'`, because every tool schema now declares a root `type: "object"` ([senpi#3250](https://github.com/code-yeongyu/senpi/pull/3250)). The Claude subscription lane runs Claude Code 2.1.296 ([senpi#3258](https://github.com/code-yeongyu/senpi/pull/3258)), the model catalog is refreshed from the live providers ([senpi#3248](https://github.com/code-yeongyu/senpi/pull/3248)), large eval data cells no longer stall the shared host ([senpi#3206](https://github.com/code-yeongyu/senpi/pull/3206)), and RPC clients see a scheduled goal continuation and can run it now ([senpi#3208](https://github.com/code-yeongyu/senpi/pull/3208)). Full notes: [senpi 2026.10.13](https://github.com/code-yeongyu/senpi/releases/tag/v2026.10.13).
@@ -43,7 +135,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
135
  **Computer use can list and choose menu commands on Windows.** `menus.items` and `menus.select` read a window's classic menu bar directly, or its UI Automation menu when it has none, and run the chosen command without keyboard input. Elevated windows, ambiguous or disabled labels, submenus, and menus that change mid-request are refused before anything runs; apps that take focus while they act are refused in the background and work with `delivery: "foreground"`. ([#9651](https://github.com/code-yeongyu/oh-my-openagent/issues/9651), [#9991](https://github.com/code-yeongyu/oh-my-openagent/pull/9991))
44
136
 
45
137
  **Computer use screenshots the monitor you are working on by default, and `computer.display(id)` reaches any other one.** An unset `computer.display` now captures the active monitor, the one the focused window mostly sits on, instead of compositing every display; `"all"` and display ids work as before. `computer.display("all" | "active" | id)` gives a screenshot and input handle for one display without changing the session default, and `computer.displays()` lists every monitor. ([#9651](https://github.com/code-yeongyu/oh-my-openagent/issues/9651), [#9995](https://github.com/code-yeongyu/oh-my-openagent/pull/9995))
46
-
47
138
  **OpenCode requests now follow the selected model's live capabilities instead of a stale model cache.** When OpenCode passes the live model to `chat.params`, its variants, thinking support, temperature support and output limit take precedence over the cached provider list, and the SDK's `provider.info.id` provider context is accepted alongside `provider.id`; calls that carry only model IDs keep the cached and bundled fallback. Thanks to @MoerAI. ([#10001](https://github.com/code-yeongyu/oh-my-openagent/pull/10001))
48
139
 
49
140
  **`background_task.taskCleanupDelayMs` now also sets how long finished background tasks stay retrievable.** A delay longer than 30 minutes is no longer cut short; leaving it unset keeps the existing timing. Thanks to @imadal1n. ([#9984](https://github.com/code-yeongyu/oh-my-openagent/pull/9984))