Address cubic and oracle review feedback to make the Node/Electron
fallback faithful to Bun.spawn behavior so cross-runtime callers
behave identically.
Changes:
- resolveStdio() default stdio is now ["ignore", "pipe", "inherit"]
to match Bun.spawn defaults (was ["pipe", "pipe", "pipe"]).
Prevents hangs in dispatcher and on-complete-hook callers that
did not explicitly set stdin and would otherwise wait forever
for input on Node.
- spawn-with-windows-hide.ts uses the same defaults so the Windows
Node helper aligns with the rest of the shim.
- wrapNodeProcess now rejects proc.exited with the original error
on "error" events (previously swallowed the error and resolved
to exit code 1, hiding ENOENT and friends).
- spawnSync result returns the real result.pid instead of -1 and
exposes stdout/stderr as Buffer | undefined to match Node's
spawnSync result shape when those streams are not piped.
Tests cover the new defaults, real pid surfacing, and missing
executable error propagation.
Refs cubic review and oracle audit on #3798.
Root cause: bun build --target bun inlines top-level
var { spawn } = globalThis.Bun;
for every file that contains 'import { spawn } from "bun"'. On Node/Electron
where globalThis.Bun is undefined, this crashes with
Cannot destructure property 'spawn' of 'globalThis.Bun' as it is undefined.
26 source files had this import; the bundled output had 25 top-level destructures.
Fix:
- Add src/shared/bun-spawn-shim.ts: a thin wrapper that
- delegates to Bun.spawn/spawnSync when globalThis.Bun is present (real Bun)
- falls back to static ESM imports of node:child_process otherwise
- uses static 'import { spawn } from "node:child_process"' so Bun bundler
does NOT emit any globalThis.Bun destructures for this module
- Replace all 26 'from "bun"' spawn/spawnSync imports with relative paths to shim
- Replace 4 direct Bun.spawn() call sites with shim's spawn()
- Remove src/electron-compat.ts and script/prepend-electron-shim.ts (no longer needed)
- Update src/electron-compat.test.ts to assert 0 top-level globalThis.Bun destructures
Verification: grep -c '} = globalThis.Bun;' dist/index.js → 0 (was 25)
All 5921 tests pass (1 pre-existing timeout failure unrelated to this change).
Fixes#3797
Bare "403" and "forbidden" substring patterns (added in 034744cb to
retry the "Selected provider is forbidden" case from PR #3706) matched
any error message containing those tokens — tool-level 403s, file-perm
"forbidden" messages, unrelated upstream errors that happened to spell
"forbidden". The legacy model-fallback path then armed setPendingModelFallback
on those unrelated errors, jumping Sisyphus to claude-opus-4-7 (first
entry of its fallback chain) regardless of the user's configured model.
Replace the bare patterns with the specific phrases PR #3706 actually
targeted, and add regression tests asserting unrelated 403/forbidden
messages stay non-retryable.
Reported-by: ilove_borshch on Discord (#omo-help)
The plugin_loaded event was emitted on every plugin reload, generating
high event volume on PostHog (proportional to opencode restarts per
user per day). With MAU > 60K and active power users restarting
frequently, this drove unnecessary event spend.
Add a separate daily dedup state field (lastPluginLoadedDayUTC) so the
plugin_loaded capture only fires once per UTC day per machine. The
existing daily activity dedup (lastActiveDayUTC, used by
omo_daily_active) is preserved as an independent gate so the two
dimensions cannot overwrite each other in the activity state file.
The sort shim from the previous commit enforces canonical core ordering at runtime, so ZWSP prefixes are no longer needed. Removing them eliminates the Bun.stringWidth vs terminal-width drift that broke the TUI status bar (#3259).
Drop AGENT_LIST_SORT_PREFIXES and getAgentRuntimeName from agent-display-names; switch all call sites to getAgentDisplayName. getAgentListDisplayName stays as a thin alias for external importers.
Keep stripInvisibleAgentCharacters and the ZWSP regex paths so legacy session state and configs from v3.14.0-v3.16.0 still resolve.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
OpenCode 1.4.x ignores the agent `order` field (sst/opencode#19127), so its
`Agent.list()` sorts purely by `agent.name` via Remeda `sortBy` which uses
native string `<`/`>` comparison. Without intervention, the four core agents
fall into alphabetical order (Atlas -> Hephaestus -> Prometheus -> Sisyphus),
which is not the canonical sisyphus -> hephaestus -> prometheus -> atlas order
the project ships.
Prior attempts to bias the sort key with invisible characters (ZWSP,
U+2060 WORD JOINER, U+00AD SOFT HYPHEN, ANSI escape) all caused
`Bun.stringWidth()` vs terminal-width drift, producing visible gaps and
column truncation in the TUI status bar (#3259, #3238).
Solution: a narrowly-scoped shim of `Array.prototype.toSorted` and
`Array.prototype.sort` that activates only when the array contains two or
more agent objects whose `.name` matches a canonical core display name.
The activation predicate guards against mixed-type arrays so unrelated
`.sort()` / `.toSorted()` calls (string arrays, number arrays, mixed
objects) execute native behavior unchanged. Install is idempotent.
Cubic P1 mitigations from PR #3267:
- `isAgentArray` rejects any array with non-object or null elements,
eliminating the throw-on-mixed-array failure mode.
- Strict activation predicate (>= 2 ranked elements) keeps the global
prototype patch from affecting unrelated sort calls.
Remove this shim once OpenCode honors the agent `order` field
(sst/opencode#19127).
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Discovered via post-implementation review (Oracle goal verification): the
prior commit's prefix swap (ZWSP -> ASCII spaces) inherited a pre-existing
architectural bug from the ZWSP era. `getAgentListDisplayName()` was an
alias for `getAgentRuntimeName()`, which meant every callsite that used
the "list display" name as an OBJECT KEY (config.agent keys, lookup keys,
HTTP-header-bound paths) ended up carrying the sort prefix.
This worked silently with ZWSP because zero-width characters are visually
invisible. With ASCII space prefixes, the same bug becomes user-visible
and violates the explicit RFC 7230 constraint documented in AGENTS.md:
"ZWSP MUST NOT appear in object keys (used as HTTP header values)."
Fix: separate the two concepts that were conflated.
- `getAgentListDisplayName(key)` now returns the CLEAN display name
(alias of `getAgentDisplayName`). Used for object keys, config keys,
and any path where the name will be sent over HTTP.
- `getAgentRuntimeName(key)` keeps its prefixed return value. Used ONLY
for the `.name` field that OpenCode reads for `localeCompare` sort.
`agent-key-remapper.ts` was already correct: it uses `getAgentRuntimeName`
for the `.name` field. The bug was that `getAgentListDisplayName` (used
as the object key) also returned the prefix.
Test updates:
- agent-display-names.test.ts splits the assertions: getAgentListDisplayName
asserts clean names, new getAgentRuntimeName describe asserts prefixes
- All other tests using getAgentListDisplayName as an expected object key
continue to pass because they always wanted clean names
Verification:
- bun test: 5769 pass / 10 pre-existing failures (unchanged)
- bun run typecheck: clean
- Manual: agent-key-remapper output keys verified RFC 7230 safe (no
leading whitespace, no ZWSP); name fields preserve descending-space
prefix for canonical core agent ordering
The ZWSP-based core agent sort prefixes silently failed to produce the
canonical sisyphus -> hephaestus -> prometheus -> atlas order.
Empirical testing of OpenCode's Agent.list() sort behavior shows that
Unicode collation treats zero-width characters as ignorable at the primary
level, so ZWSP-prefixed names sorted alphabetically with non-core agents
interleaved (e.g. Sisyphus, athena, Atlas, explore, Hephaestus, ...).
This commit replaces the ZWSP prefixes with leading ASCII spaces in
descending lengths (sisyphus=4, hephaestus=3, prometheus=2, atlas=1).
ASCII spaces sort reliably before alphabetic characters in localeCompare
under all locales and render correctly in every terminal.
Changes:
- AGENT_LIST_SORT_PREFIXES: ZWSP -> leading spaces (4-3-2-1 descending)
- stripAgentListSortPrefix: now strips both legacy ZWSP and new leading
whitespace, preserving backward compatibility with existing sessions
- normalizeStoredAgentName / normalizeRegisteredAgentName: extract a
shared stripSortPrefix helper that handles both prefix formats
- agent-config-handler: resolve user-provided default_agent display
names through getAgentConfigKey before applying the runtime prefix,
so configs like default_agent="Hephaestus - Deep Agent" are normalized
- agent-runtime-name-sort.test.ts: new regression test simulating
OpenCode's exact sortBy logic (default_agent desc + name asc localeCompare)
to verify canonical core agent order under randomised input permutations
- AGENTS.md: document the empirical finding that ZWSP was broken, why
ASCII spaces work, and the descending prefix-length contract
Existing strip functions retain ZWSP support so legacy session state and
configs continue to resolve correctly without migration.
Commit 708891dab fixed most test expectations after the gpt-5.5 model
promotion but missed 13 tests across 6 files that still expected
openai/gpt-5.4 in DEFAULT_CATEGORIES and AGENT_MODEL_REQUIREMENTS.
Updates all remaining stale expectations to openai/gpt-5.5:
- agents/utils.test.ts: atlas/metis resolution, buildAgent category,
override.category expansion (5 tests)
- plugin-handlers/config-handler.test.ts: ultrabrain config resolution
and fallback (2 tests)
- shared/agent-variant.test.ts: sisyphus chain variant and category
fallback (2 tests)
- shared/model-capability-guardrails.test.ts: built-in requirement
model ID assertion (1 test)
- tools/look-at/multimodal-fallback-chain.test.ts: multimodal-looker
hardcoded variant metadata (1 test)
- cli/config-manager/generate-omo-config.test.ts: sisyphus model and
fallback_models expectations (2 tests)
Updates test expectations across agent, cli, shared, plugin, and tools tests
to match gpt-5.5 as the new default for oracle, hephaestus, and deep agents.
Includes snapshot updates for model-fallback tests.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Adds missing capability entry for gpt-5.5 to supplemental-entries.ts.
This model was promoted as default for oracle, hephaestus, and deep agents,
but the capability snapshot was missing, causing resolution failures.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Now that oracle, hephaestus, and the deep category ship gpt-5.5-native
prompts and default to gpt-5.5 in their fallback chains, user configs
that pin openai/gpt-5.4 should follow the same upgrade on next load.
The existing migration pipeline (migrateModelVersions) picks this up
automatically from MODEL_VERSION_MAP, records the upgrade in the
sidecar file to stay idempotent, and leaves gpt-5.4-mini / -mini-fast
/ -nano alone since those cost-tier variants are intentional picks
and do not have direct gpt-5.5 counterparts yet.
Now that each of these agents ships a native gpt-5.5 prompt variant,
their default OpenAI model is bumped from gpt-5.4 to gpt-5.5. The
gpt-5.4 entries are removed from the same-provider position in each
fallback chain (a single provider doesn't need both version entries -
if gpt-5.5 isn't available there, cross-provider fallback kicks in).
- oracle.fallbackChain: gpt-5.5 high (openai) -> gemini-3.1-pro -> claude -> glm
- hephaestus.fallbackChain: gpt-5.5 medium (openai only, requiresProvider)
- deep category.fallbackChain: gpt-5.5 medium -> claude -> gemini
- deep category.config: openai/gpt-5.5 medium
No changes to sisyphus, sisyphus-junior, ultrabrain, momus, or other
agents that still point at gpt-5.4; those stay on the shared regex-
based routing in the gpt-5-4 prompt family.
Use gpt-5.4-mini-fast as the primary runtime model for librarian and explore.\nKeep the fallback chain intact so older providers still resolve.
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Keep the supplemental OpenAI model available when the bundled snapshot omits it.\nMerge its capabilities at runtime so downstream model resolution can use it.
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Oracle flagged that the previous test file monkey-patched process.kill
and relied on mock.module for 5 modules. Running it after manager.test.ts
in the same Bun process reproduced 2 failures - the test resolution of
`./session-kill` specifier interacted badly with manager.test.ts's
`../../shared/tmux` barrel mock.
Solution: refactor stale-session-sweep.ts to expose
`sweepStaleOmoAgentSessionsWith(deps)` that accepts a SweepDeps record
(isInsideTmux, getTmuxPath, listCandidateSessions, killSession,
processAlive, currentPid, log). The public `sweepStaleOmoAgentSessions()`
still uses runtime-built deps so call sites are unchanged.
The test file now imports the pure function directly and constructs a
fixture with fake deps. Zero mock.module calls, zero process.kill
patching, zero cache-bust dynamic imports. 8 tests (up from 6) run
deterministically in any order with any neighbor.
Before: combined run with manager.test.ts = 2 fail, 50 pass.
After: combined run with manager.test.ts = 0 fail, 54 pass.
Oracle noted that loadSweeper() monkey-patches process.kill without
ever restoring it. Added afterEach hook to set process.kill back to the
captured original. Individual file runs already passed, and
script/run-ci-tests.ts confirms the full CI suite - 4781 pass, 0 fail
across 491 files - but this makes the test file safe under non-isolated
local runs as well.
Follow-up to PR #3507 addressing the Oracle-noted operational limitation:
per-PID isolated session names (getIsolatedSessionName(process.pid)) mean
that when an opencode process is SIGKILL'd (or the machine hard-reboots),
the old omo-agents-<old-pid> tmux session survives forever because nothing
is around to kill it.
Added sweepStaleOmoAgentSessions() that:
1. Lists tmux sessions matching /^omo-agents-(\d+)$/
2. For each, checks process.kill(pid, 0) to detect a dead PID
3. Skips our own PID
4. Calls killTmuxSessionIfExists for every session whose owner process is gone
Wired into TmuxSessionManager.onSessionCreated() as a one-shot (guarded by
staleSweepCompleted flag) so it runs lazily on the first subagent spawn when
isolation="session". The flag is reset in cleanup() so subsequent process
restarts re-run the sweep.
6 new tests cover: outside-tmux no-op, no matching sessions, multiple dead
PIDs, current PID skip, live PID skip, list-sessions failure.
Manual E2E verified on real tmux:
- Created omo-agents-99999, sweep killed it
- Spawned our own omo-agents-<pid>, closeTmuxPane returned true even after
pane auto-destroy from Ctrl+C
- Final tmux list-sessions shows zero omo-agents-* orphans
Oracle flagged the previous commit: "omo-agents" was a shared constant,
so when two plugin instances ran in the same tmux server they wrote into
the same session. One instance's cleanup would then kill-session on the
shared name and tear down the other instance's live attached panes.
Replace the const ISOLATED_SESSION_NAME with getIsolatedSessionName(pid)
which defaults to process.pid, so every opencode process owns its own
"omo-agents-<pid>" session. spawnTmuxSession and cleanup both resolve
the name through this helper. Discovery is straightforward from the
host tmux via 'tmux list-sessions | grep omo-agents-'.
Manager test covers two concurrent managers and asserts each kills a
per-pid session name, proving they no longer collide on a global name.
After send-keys C-c the subprocess running inside the pane (for example
"opencode attach") exits on SIGINT, which causes tmux to destroy the
pane automatically. The subsequent kill-pane then returns exit 1 with
stderr "can't find pane: %NN" even though the end state is exactly
what we wanted.
Before this fix closeTmuxPane reported failure for that branch, which
kept TmuxSessionManager's retryPendingCloses loop marking the (now
deleted) pane as still-pending forever and left stale entries behind
in the tracked sessions map. This is the behavior the user observed
as "screen opens, streaming runs, but cleanup doesn't finish" when
running with tmux.isolation="session".
Now we detect the "can't find pane" stderr and return true, treating
the auto-destroy path the same as an explicit successful kill.
Adds killTmuxSessionIfExists(sessionName), a best-effort no-op when the
named session is absent. Drains both stdio streams so it does not leak
pipe buffers the way closeTmuxPane historically did.
Also exports ISOLATED_SESSION_NAME ("omo-agents") from session-spawn so
callers can tear down the shared isolated session without hard-coding
the name in multiple places.
closeTmuxPane spawned kill-pane with stdout: "pipe" but never drained the
stream, which could leave the subprocess hanging indefinitely when tmux
wrote anything to stdout (for example under --force-close race conditions).
- send-keys now uses stdout: "ignore" so there is no pipe to drain
- kill-pane keeps the pipe but drains stdout/stderr alongside proc.exited
- switch imports to the new spawn-process helper so the behavior is
covered by hermetic tests that mock the spawn boundary