Add OpenCode child-session panes for omo#2517
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughIntroduces an OpenCode (OMO) bridge for native child-session panes: file-backed bridge state, an Changes
Sequence DiagramsequenceDiagram
participant User as User/cmux
participant OC as OpenCode<br/>Server (root)
participant Hook as omo-hook
participant Store as OMO State<br/>Store (file)
participant Cmux as cmux<br/>Server
User->>OC: launch root OpenCode (stable port) + bridge plugin
OC->>OC: plugin watches session events
OC->>Hook: plugin runs `omo-hook spawn` (child session ID)
Hook->>Store: load state (flock lock)
Store-->>Hook: return workspace/leader/root info
Hook->>Cmux: request split with `opencode attach --session <id>`
Cmux-->>Hook: split created (surface id)
Hook->>Store: mutate (record child↔surface mapping) and persist
Store-->>Hook: ack persisted
Note right of Hook: later: `omo-hook delete` -> remove mapping & request cmux close
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd20288581
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let shadowDir = omoShadowConfigDir() | ||
| let fm = FileManager.default | ||
|
|
||
| try fm.createDirectory(at: shadowDir, withIntermediateDirectories: true, attributes: nil) | ||
|
|
||
| // Read the user's opencode.json (if any), add the plugin, write to shadow dir | ||
| let userJsonURL = userDir.appendingPathComponent("opencode.json") | ||
| let shadowJsonURL = shadowDir.appendingPathComponent("opencode.json") | ||
| let pluginsDir = shadowDir.appendingPathComponent("plugins", isDirectory: true) | ||
| try FileManager.default.createDirectory(at: pluginsDir, withIntermediateDirectories: true, attributes: nil) | ||
| try writeShimIfChanged(omoBridgePluginSource(), to: omoBridgePluginURL()) | ||
| return shadowDir |
There was a problem hiding this comment.
Preserve user OpenCode config in the bridge shadow dir
omoEnsureBridgeConfig now creates a fresh ~/.cmuxterm/omo-config containing only the bridge plugin, and runOMO later points OPENCODE_CONFIG_DIR at that directory, so existing user config (for example ~/.config/opencode/opencode.json model/provider/auth settings) is no longer visible in cmux omo. In environments that depend on non-default OpenCode configuration, this causes cmux omo to start with defaults or fail to authenticate, which is a functional regression from the previous behavior that layered the user config into the shadow directory.
Useful? React with 👍 / 👎.
Greptile SummaryThis PR replaces the previous Key concerns found during review:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant OpenCode as OpenCode (root)
participant Plugin as cmux-omo-bridge.js
participant Hook as cmux omo-hook
participant Store as OMOBridgeStateStore
participant Cmux as cmux app
User->>OpenCode: cmux omo [args]
Note over OpenCode: configureOMOEnvironment()<br/>sets CMUX_OMO_STATE_PATH,<br/>CMUX_OMO_ROLE=root, etc.
OpenCode->>Store: reset(workspaceId, leaderSurfaceId)
OpenCode->>Plugin: load cmux-omo-bridge.js
Plugin->>Hook: omo-hook set-root --root <sessionID>
Hook->>Store: load() + mutate { $0 = state }
Plugin-->>Cmux: (root session registered)
OpenCode->>OpenCode: spawn child session
OpenCode-->>Plugin: event: session.created (parentID == rootID)
Plugin->>Hook: omo-hook spawn --session <childID>
Hook->>Store: load() + mutate { $0 = state }
Hook->>Cmux: surface.split
Cmux-->>Hook: surfaceId
Hook->>Cmux: surface.send_text(omoAttachCommand)
Note over Cmux: new pane runs:<br/>exec /bin/sh -lc<br/>"opencode attach <url> --session <id>"
Hook->>Store: mutate { $0 = state } (add child record)
OpenCode->>OpenCode: delete child session
OpenCode-->>Plugin: event: session.deleted
Plugin->>Hook: omo-hook delete --session <childID>
Hook->>Store: load() + mutate { $0 = state }
Hook->>Cmux: surface.close(surfaceId)
Reviews (1): Last reviewed commit: "Add OpenCode child-session panes for omo" | Re-trigger Greptile |
| var state = try store.load() | ||
| let surfacesToClose: [String] | ||
| if let existingRoot = state.rootSessionId, existingRoot != normalizedRoot { | ||
| surfacesToClose = state.children.values.map(\.surfaceId) | ||
| state.children.removeAll() | ||
| } else { | ||
| surfacesToClose = [] | ||
| } | ||
| state.rootSessionId = normalizedRoot | ||
| try store.mutate { $0 = state } |
There was a problem hiding this comment.
TOCTOU:
store.load() + store.mutate { $0 = staleState } defeats the file lock
All four subcommands (set-root, clear-root, spawn, delete) share the same broken pattern:
var state = try store.load()— acquires the lock, reads, then releases the lock- Local
stateis mutated try store.mutate { $0 = state }— acquires the lock again, reads the current on-disk state into$0, but the closure immediately overwrites it with the stalestatecaptured before the lock
Any write made by another omo-hook invocation between steps 1 and 3 is silently discarded. The mutate API is designed for atomic read-modify-write, but using { $0 = state } defeats that entirely.
The same pattern appears in:
clear-rootat lines 12499 + 12503spawnat lines 12527 + 12539 / 12546 / 12611 (three separate non-atomic mutations in a single subcommand invocation)deleteat lines 12632 + 12634
The fix is to consolidate all modifications for each subcommand into a single store.mutate { state in ... } call, so no store.load() call is needed separately:
// set-root — correct pattern
var surfacesToClose: [String] = []
try store.mutate { state in
if let existingRoot = state.rootSessionId, existingRoot != normalizedRoot {
surfacesToClose = state.children.values.map(\.surfaceId)
state.children.removeAll()
}
state.rootSessionId = normalizedRoot
}
// surface.close calls can happen after the atomic write| scriptLines.append("trap cmux_omo_cleanup EXIT HUP INT TERM") | ||
| scriptLines.append("\(shellQuote(openCodeExecutablePath)) attach \(shellQuote(serverURL)) --session \(shellQuote(sessionId))") | ||
| scriptLines.append("cmux_omo_status=$?") | ||
| scriptLines.append("exit \"$cmux_omo_status\"") |
There was a problem hiding this comment.
EXIT trap masks
$cmux_omo_status — script always exits with 0
In POSIX sh (and bash), when exit N triggers an EXIT trap, the script's final exit code is the exit code of the last command in the trap handler, not N. Because cmux_omo_cleanup ends with || true, the EXIT trap always succeeds with status 0. The exit "$cmux_omo_status" line is therefore never effective — the child pane always exits with 0 regardless of opencode attach's outcome.
To preserve the exit status through the trap, capture it before the trap fires and restore it inside the handler:
let deleteCommand = "\(shellQuote(openCodeExecutablePath)) session delete \(shellQuote(sessionId)) >/dev/null 2>&1 || true"
scriptLines.append("cmux_omo_cleanup() { _rc=$?; \(deleteCommand); exit \"$_rc\"; }")
scriptLines.append("trap cmux_omo_cleanup EXIT")
scriptLines.append("\(shellQuote(openCodeExecutablePath)) attach \(shellQuote(serverURL)) --session \(shellQuote(sessionId))")Using _rc=$? at the start of the trap captures the exit code that triggered the EXIT, then exit "$_rc" propagates it after cleanup. This also removes the redundant explicit exit "$cmux_omo_status" line and the HUP/INT/TERM signal registrations (since EXIT fires on those too in most POSIX shells).
| let shimDirectoryPath = env["PATH"]? | ||
| .split(separator: ":") | ||
| .map(String.init) | ||
| .first(where: { $0.hasSuffix("/.cmuxterm/omo-bin") || $0.hasSuffix("/.cmuxterm/omo-bin/") }) |
There was a problem hiding this comment.
Fragile shim-dir lookup via PATH suffix scan
The shim directory path is recovered by scanning PATH for an entry matching the hard-coded suffix /.cmuxterm/omo-bin. This will silently return nil if the entry appears with a trailing slash variant not covered by the two checks, or if a user's HOME is not a canonical path (e.g. via a symlink).
configureOMOEnvironment already calls setenv("CMUX_OMO_OPENCODE_BIN", ...) for the binary — a parallel CMUX_OMO_SHIM_DIR env var set alongside it would be cleaner and more reliable:
// in configureOMOEnvironment
setenv("CMUX_OMO_SHIM_DIR", shimDirectory.path, 1)
// in spawn
let shimDirectoryPath = env["CMUX_OMO_SHIM_DIR"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nilIfEmptyThere was a problem hiding this comment.
1 issue found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="CLI/cmux.swift">
<violation number="1" location="CLI/cmux.swift:622">
P1: Create the state directory before opening the OMO lock file. First-run `cmux omo` can fail because the lock path’s parent directory may not exist.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
|
|
||
| private func withLockedState<T>(_ body: (inout OMOBridgeStateFile) throws -> T) throws -> T { | ||
| let lockPath = statePath + ".lock" | ||
| let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) |
There was a problem hiding this comment.
P1: Create the state directory before opening the OMO lock file. First-run cmux omo can fail because the lock path’s parent directory may not exist.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 622:
<comment>Create the state directory before opening the OMO lock file. First-run `cmux omo` can fail because the lock path’s parent directory may not exist.</comment>
<file context>
@@ -514,6 +514,148 @@ private final class ClaudeHookSessionStore {
+
+ private func withLockedState<T>(_ body: (inout OMOBridgeStateFile) throws -> T) throws -> T {
+ let lockPath = statePath + ".lock"
+ let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR))
+ if fd < 0 {
+ throw CLIError(message: "Failed to open OMO bridge state lock: \(lockPath)")
</file context>
| let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) | |
| let stateURL = URL(fileURLWithPath: statePath) | |
| try fileManager.createDirectory(at: stateURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) | |
| let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@CLI/cmux.swift`:
- Around line 12477-12486: The code currently calls store.load() then later
calls store.mutate { $0 = state }, which allows race conditions; change all
those spots (the block using state, normalizedRoot, rootSessionId, children,
surfacesToClose and similar blocks at the other noted ranges) to perform the
read/modify/write inside a single store.mutate transaction: inside mutate read
the current state, compute and update state.rootSessionId and state.children,
collect and return a list of actions (surface IDs to close or messages to send)
from the mutate call, then run surface.close / surface.send_text only after the
mutate lock is released; replace any separate store.load + later mutate pattern
with this single atomic mutate approach to avoid clobbering concurrent updates.
- Around line 620-635: The lock file is opened before its parent state directory
exists which causes ENOENT on first run; in withLockedState(_:) ensure the
directory containing statePath exists before calling open(lockPath,...). Create
the parent directory (using FileManager.createDirectory with intermediate
directories true) for statePath or lockPath, handle/throw any directory-creation
errors, then proceed to open the lock, call loadUnlocked(), run body(&state),
and saveUnlocked(state) as before; reference symbols: withLockedState(_:),
statePath, lockPath, loadUnlocked(), saveUnlocked().
- Around line 10224-10235: The cleanup currently builds deleteCommand without
the server target, so cmux_omo_cleanup may delete the wrong session; export the
server URL into the child shell and include it in the delete invocation. Add an
export for CMUX_OMO_SERVER_URL using the existing serverURL (via
shellQuote(serverURL)) before building deleteCommand, and update deleteCommand
(and the cmux_omo_cleanup wrapper) to pass that server target (or rely on the
exported CMUX_OMO_SERVER_URL) when invoking the executable at
openCodeExecutablePath to delete sessionId so it matches the attach call.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0fdb8775-e3b8-40d9-9ebe-742c0684b180
📒 Files selected for processing (4)
CLI/cmux.swiftGhosttyTabs.xcodeproj/project.pbxprojResources/Localizable.xcstringscmuxTests/OpenCodeBridgeTests.swift
| private func withLockedState<T>(_ body: (inout OMOBridgeStateFile) throws -> T) throws -> T { | ||
| let lockPath = statePath + ".lock" | ||
| let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) | ||
| if fd < 0 { | ||
| throw CLIError(message: "Failed to open OMO bridge state lock: \(lockPath)") | ||
| } | ||
| defer { Darwin.close(fd) } | ||
|
|
||
| if flock(fd, LOCK_EX) != 0 { | ||
| throw CLIError(message: "Failed to lock OMO bridge state: \(lockPath)") | ||
| } | ||
| defer { _ = flock(fd, LOCK_UN) } | ||
|
|
||
| var state = loadUnlocked() | ||
| let result = try body(&state) | ||
| try saveUnlocked(state) |
There was a problem hiding this comment.
Create the state directory before opening the lock file.
Line 622 opens the .lock file before the new omo-state parent directory exists. saveUnlocked(_:) only creates that directory later, so the first reset() on a clean machine fails with ENOENT and cmux omo never starts.
Suggested fix
private func withLockedState<T>(_ body: (inout OMOBridgeStateFile) throws -> T) throws -> T {
let lockPath = statePath + ".lock"
+ let lockURL = URL(fileURLWithPath: lockPath)
+ try fileManager.createDirectory(
+ at: lockURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true,
+ attributes: nil
+ )
let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR))
if fd < 0 {
throw CLIError(message: "Failed to open OMO bridge state lock: \(lockPath)")
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CLI/cmux.swift` around lines 620 - 635, The lock file is opened before its
parent state directory exists which causes ENOENT on first run; in
withLockedState(_:) ensure the directory containing statePath exists before
calling open(lockPath,...). Create the parent directory (using
FileManager.createDirectory with intermediate directories true) for statePath or
lockPath, handle/throw any directory-creation errors, then proceed to open the
lock, call loadUnlocked(), run body(&state), and saveUnlocked(state) as before;
reference symbols: withLockedState(_:), statePath, lockPath, loadUnlocked(),
saveUnlocked().
| if let password = processEnvironment["OPENCODE_SERVER_PASSWORD"]? | ||
| .trimmingCharacters(in: .whitespacesAndNewlines), | ||
| !password.isEmpty { | ||
| scriptLines.append("export OPENCODE_SERVER_PASSWORD=\(shellQuote(password))") | ||
| } | ||
|
|
||
| // Ensure tmux mode is enabled in oh-my-opencode config. | ||
| // Without this, the TmuxSessionManager won't spawn visual panes even though | ||
| // $TMUX is set (tmux.enabled defaults to false). | ||
| let omoConfigURL = shadowDir.appendingPathComponent("oh-my-opencode.json") | ||
| var omoConfig: [String: Any] | ||
| if let data = try? Data(contentsOf: omoConfigURL), | ||
| let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { | ||
| omoConfig = existing | ||
| } else { | ||
| // Check if user has a config we symlinked, read from source | ||
| let userOmoConfig = userDir.appendingPathComponent("oh-my-opencode.json") | ||
| if let data = try? Data(contentsOf: userOmoConfig), | ||
| let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { | ||
| omoConfig = existing | ||
| // Remove the symlink so we can write our own copy | ||
| try? fm.removeItem(at: omoConfigURL) | ||
| } else { | ||
| omoConfig = [:] | ||
| } | ||
| } | ||
| var tmuxConfig = (omoConfig["tmux"] as? [String: Any]) ?? [:] | ||
| var needsWrite = false | ||
| if tmuxConfig["enabled"] as? Bool != true { | ||
| tmuxConfig["enabled"] = true | ||
| needsWrite = true | ||
| } | ||
| // Lower the default min widths so agent panes spawn in normal-sized windows. | ||
| // oh-my-openagent defaults: main_pane_min_width=120, agent_pane_min_width=40, | ||
| // requiring 161+ columns. Most terminal windows are narrower. | ||
| if tmuxConfig["main_pane_min_width"] == nil { | ||
| tmuxConfig["main_pane_min_width"] = 60 | ||
| needsWrite = true | ||
| } | ||
| if tmuxConfig["agent_pane_min_width"] == nil { | ||
| tmuxConfig["agent_pane_min_width"] = 30 | ||
| needsWrite = true | ||
| } | ||
| if tmuxConfig["main_pane_size"] == nil { | ||
| tmuxConfig["main_pane_size"] = 50 | ||
| needsWrite = true | ||
| } | ||
| if needsWrite { | ||
| omoConfig["tmux"] = tmuxConfig | ||
| // Remove symlink if it exists (we need a real file) | ||
| if let attrs = try? fm.attributesOfItem(atPath: omoConfigURL.path), | ||
| attrs[.type] as? FileAttributeType == .typeSymbolicLink { | ||
| try? fm.removeItem(at: omoConfigURL) | ||
| } | ||
| let output = try JSONSerialization.data(withJSONObject: omoConfig, options: [.prettyPrinted, .sortedKeys]) | ||
| try output.write(to: omoConfigURL, options: .atomic) | ||
| } | ||
| let deleteCommand = "\(shellQuote(openCodeExecutablePath)) session delete \(shellQuote(sessionId)) >/dev/null 2>&1 || true" | ||
| scriptLines.append("cmux_omo_cleanup() { \(deleteCommand); }") | ||
| scriptLines.append("trap cmux_omo_cleanup EXIT HUP INT TERM") | ||
| scriptLines.append("\(shellQuote(openCodeExecutablePath)) attach \(shellQuote(serverURL)) --session \(shellQuote(sessionId))") | ||
| scriptLines.append("cmux_omo_status=$?") | ||
| scriptLines.append("exit \"$cmux_omo_status\"") |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does the current OpenCode CLI session deletecommand target a non-default local server using onlyOPENCODE_CONFIG_DIR, or does it require an explicit server URL / OPENCODE_PORTlikeopencode attach?
💡 Result:
The current OpenCode CLI does not have a documented session delete subcommand. Official docs (opencode.ai/docs/cli, open-code.ai/docs/en/cli) list opencode session list, but no delete. GitHub issue #13447 requested "Support session deletion via cli" and was closed as completed in Feb 2026, with PRs like #13571 mentioning "feat(cli): add session delete command", suggesting it was implemented post-docs update. However, no explicit docs confirm syntax or flags. Sessions appear stored locally in ~/.local/share/opencode/ (db, messages/, storage/sessions), managed via local config influenced by OPENCODE_CONFIG_DIR (loads custom config dir for agents/plugins/config). Default (non-attach) mode uses local storage/server, so session delete likely targets local/non-default server via OPENCODE_CONFIG_DIR without needing explicit URL/OPENCODE_PORT. In contrast, opencode attach [url] explicitly requires server URL (e.g., http://localhost:4096) for remote/non-local servers. Thus, session delete uses only OPENCODE_CONFIG_DIR for non-default local, unlike attach requiring explicit URL/port.
Citations:
- 1: [FEATURE]: Support session deletion via cli anomalyco/opencode#13447
- 2: https://open-code.ai/docs/en/cli
- 3: https://opencode.ai/docs/cli/
- 4: https://opencode.ai/docs/config/
- 5: OPENCODE_CONFIG_DIR does not read custom config file. anomalyco/opencode#4399
- 6: https://opencode.ai/docs/cli
- 7: https://www.mintlify.com/anomalyco/opencode/cli/attach
- 8: feat(session): filter sessions by current directory anomalyco/opencode#6724
🏁 Script executed:
# Search for OpenCode CLI session delete implementation
git log --all --oneline --grep="session delete" 2>/dev/null | head -20 || echo "Git history unavailable"
# Check if there are any references to OpenCode CLI source in this repo
find . -type f -name "*.swift" -o -name "*.ts" -o -name "*.py" 2>/dev/null | head -10Repository: manaflow-ai/cmux
Length of output: 351
🏁 Script executed:
# Search for session delete usage patterns in the codebase
rg "session delete" -t swift -t sh -t txt -A 5 -B 5 2>/dev/null | head -50
# Search for OPENCODE_PORT usage context
rg "OPENCODE_PORT" -t swift -A 3 -B 3 2>/dev/null | head -50Repository: manaflow-ai/cmux
Length of output: 2976
🏁 Script executed:
# Get full context around the deleteCommand and scriptLines building
sed -n '10200,10250p' CLI/cmux.swift
# Search for where launcherEnvironment or processEnvironment variables are exported to scriptLines
rg "scriptLines.append.*export" CLI/cmux.swift -B 3 -A 1 | head -60Repository: manaflow-ai/cmux
Length of output: 3120
🏁 Script executed:
# Check if there are any parameters or flags that can be passed to session delete in the code
rg "session delete" -B 10 -A 10 CLI/cmux.swift
# Check what serverURL is and where it comes from
rg "serverURL" CLI/cmux.swift | head -20Repository: manaflow-ai/cmux
Length of output: 1464
🏁 Script executed:
# Find where serverURL comes from and what it represents
rg "CMUX_OMO_SERVER_URL" CLI/cmux.swift -B 5 -A 5
# Check the full context of the function call to omoAttachCommand
rg "omoAttachCommand" CLI/cmux.swift -B 3 -A 3Repository: manaflow-ai/cmux
Length of output: 1825
Pass server URL to cleanup delete command to match attach target.
The cleanup trap calls session delete <id> without specifying a server URL, while attach explicitly targets serverURL. The child shell environment does not export CMUX_OMO_SERVER_URL or OPENCODE_PORT, so session delete cannot reliably target the same server that attach connected to. If the session was created on serverURL, but cleanup tries to delete it from a different server (inferred from OPENCODE_CONFIG_DIR alone), the cleanup silently succeeds without deleting the correct session, leaving orphans.
Export the server URL to the child shell or pass it directly to the delete command:
scriptLines.append("export CMUX_OMO_SERVER_URL=\(shellQuote(serverURL))")
and update the delete command to use it (e.g., via a wrapper or flag if session delete supports server targeting).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CLI/cmux.swift` around lines 10224 - 10235, The cleanup currently builds
deleteCommand without the server target, so cmux_omo_cleanup may delete the
wrong session; export the server URL into the child shell and include it in the
delete invocation. Add an export for CMUX_OMO_SERVER_URL using the existing
serverURL (via shellQuote(serverURL)) before building deleteCommand, and update
deleteCommand (and the cmux_omo_cleanup wrapper) to pass that server target (or
rely on the exported CMUX_OMO_SERVER_URL) when invoking the executable at
openCodeExecutablePath to delete sessionId so it matches the attach call.
| var state = try store.load() | ||
| let surfacesToClose: [String] | ||
| if let existingRoot = state.rootSessionId, existingRoot != normalizedRoot { | ||
| surfacesToClose = state.children.values.map(\.surfaceId) | ||
| state.children.removeAll() | ||
| } else { | ||
| surfacesToClose = [] | ||
| } | ||
| state.rootSessionId = normalizedRoot | ||
| try store.mutate { $0 = state } |
There was a problem hiding this comment.
Keep bridge-state updates inside one lock scope.
These branches load() a snapshot and later overwrite the whole file in a separate mutate { $0 = state }. Two overlapping hooks can clobber each other; e.g. concurrent spawn calls for different children can drop one record, which breaks dedupe and later cleanup.
Do the read/modify/write in a single store.mutate transaction and return any surface.close / surface.send_text work to run after the lock is released.
Also applies to: 12499-12503, 12527-12547, 12603-12611, 12632-12634
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CLI/cmux.swift` around lines 12477 - 12486, The code currently calls
store.load() then later calls store.mutate { $0 = state }, which allows race
conditions; change all those spots (the block using state, normalizedRoot,
rootSessionId, children, surfacesToClose and similar blocks at the other noted
ranges) to perform the read/modify/write inside a single store.mutate
transaction: inside mutate read the current state, compute and update
state.rootSessionId and state.children, collect and return a list of actions
(surface IDs to close or messages to send) from the mutate call, then run
surface.close / surface.send_text only after the mutate lock is released;
replace any separate store.load + later mutate pattern with this single atomic
mutate approach to avoid clobbering concurrent updates.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9691a7f452
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let lockPath = statePath + ".lock" | ||
| let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) | ||
| if fd < 0 { |
There was a problem hiding this comment.
Create OMO state lock directory before acquiring flock
runOMO now points CMUX_OMO_STATE_PATH at ~/.cmuxterm/omo-state/bridge-...json, but withLockedState opens statePath + ".lock" before any parent-directory creation. On a clean install, ~/.cmuxterm/omo-state does not exist, so open(..., O_CREAT) fails with ENOENT and cmux omo exits before OpenCode starts. The directory creation in saveUnlocked is too late because execution never reaches it on first run.
Useful? React with 👍 / 👎.
| if [ -d "$CACHE_XCFRAMEWORK" ] && [ "$CURRENT_LAYOUT" = "$CACHE_LAYOUT_VERSION" ] && [ "$CURRENT_SOURCE" = "$DESIRED_SOURCE" ]; then | ||
| echo "==> Reusing cached GhosttyKit.xcframework ($CURRENT_SOURCE)" |
There was a problem hiding this comment.
Reuse fallback GhosttyKit cache after download failure
In scripts/setup.sh, reuse is gated on CURRENT_SOURCE == DESIRED_SOURCE; when pinned prebuilt download fails and fallback builds locally, metadata becomes local-build while DESIRED_SOURCE stays download. This makes every later setup.sh run retry download-prebuilt-ghosttykit.sh instead of reusing the known-good cache, which can add repeated multi-minute delays in offline/unreliable-network environments (the download script defaults to 30 retries with 20s delay).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="scripts/setup.sh">
<violation number="1" location="scripts/setup.sh:154">
P2: The cache-source equality gate causes repeated download retry loops after a fallback build, making setup slow/offline-unfriendly despite an existing valid cache.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| CURRENT_SOURCE="$(cache_source)" | ||
| CURRENT_LAYOUT="$(cache_layout)" | ||
|
|
||
| if [ -d "$CACHE_XCFRAMEWORK" ] && [ "$CURRENT_LAYOUT" = "$CACHE_LAYOUT_VERSION" ] && [ "$CURRENT_SOURCE" = "$DESIRED_SOURCE" ]; then |
There was a problem hiding this comment.
P2: The cache-source equality gate causes repeated download retry loops after a fallback build, making setup slow/offline-unfriendly despite an existing valid cache.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/setup.sh, line 154:
<comment>The cache-source equality gate causes repeated download retry loops after a fallback build, making setup slow/offline-unfriendly despite an existing valid cache.</comment>
<file context>
@@ -41,41 +143,34 @@ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
+CURRENT_SOURCE="$(cache_source)"
+CURRENT_LAYOUT="$(cache_layout)"
+
+if [ -d "$CACHE_XCFRAMEWORK" ] && [ "$CURRENT_LAYOUT" = "$CACHE_LAYOUT_VERSION" ] && [ "$CURRENT_SOURCE" = "$DESIRED_SOURCE" ]; then
+ echo "==> Reusing cached GhosttyKit.xcframework ($CURRENT_SOURCE)"
else
</file context>
| if [ -d "$CACHE_XCFRAMEWORK" ] && [ "$CURRENT_LAYOUT" = "$CACHE_LAYOUT_VERSION" ] && [ "$CURRENT_SOURCE" = "$DESIRED_SOURCE" ]; then | |
| if [ -d "$CACHE_XCFRAMEWORK" ] && [ "$CURRENT_LAYOUT" = "$CACHE_LAYOUT_VERSION" ] && \ | |
| { [ "$CURRENT_SOURCE" = "$DESIRED_SOURCE" ] || { [ "$DESIRED_SOURCE" = "download" ] && [ "$CURRENT_SOURCE" = "local-build" ]; }; }; then |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
scripts/setup.sh (1)
62-66: Consider persisting the pinned download checksum in the cache metadata.The reuse guard only validates
sourceandlayoutinside the$GHOSTTY_SHAbucket. If the pinned tarball ever needs to be republished for the same commit, existing download caches will keep winning until the user clears them manually. Recording the resolved checksum, or folding it intoCACHE_DIR, would make the pin fully cache-invalidating.Also applies to: 88-107, 154-155
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/setup.sh` around lines 62 - 66, write_cache_metadata currently only writes source and layout stamps, which lets a republished pinned tarball bypass the cache; modify write_cache_metadata to accept (or read) the resolved pinned tarball checksum (e.g., PINNED_TARBALL_SHA or RESOLVED_TARBALL_CHECKSUM) and persist it into a new stamp file (e.g., CACHE_TARBALL_SHA) or incorporate it into CACHE_DIR naming so the checksum becomes part of the cache key; update all callers (the other cache-write/read sites referenced around the 88-107 and 154-155 changes) to pass/consume this checksum so cache reuse is correctly invalidated when the pinned tarball changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@scripts/setup.sh`:
- Around line 110-130: The function build_or_seed_local_ghosttykit currently
keys reuse only on LOCAL_SHA_STAMP and GHOSTTY_SHA and misses dirty worktree
detection; update it to check the git working tree inside the ghostty directory
(e.g., git -C ghostty status --porcelain or equivalent) and set an is_clean
flag, then only treat the local cache as valid when LOCAL_XCFRAMEWORK exists,
"$local_sha" = "$GHOSTTY_SHA", and is_clean is true; when the worktree is dirty
force a source build (do not reuse prebuilt) and only write/overwrite
LOCAL_SHA_STAMP (printf '%s\n' "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP") when the
ghostty repo is clean; keep install_cache_from_dir and the build steps but gate
reuse/write of the .ghostty_sha on the clean check (affecting
build_or_seed_local_ghosttykit and the same logic at the other mentioned block).
---
Nitpick comments:
In `@scripts/setup.sh`:
- Around line 62-66: write_cache_metadata currently only writes source and
layout stamps, which lets a republished pinned tarball bypass the cache; modify
write_cache_metadata to accept (or read) the resolved pinned tarball checksum
(e.g., PINNED_TARBALL_SHA or RESOLVED_TARBALL_CHECKSUM) and persist it into a
new stamp file (e.g., CACHE_TARBALL_SHA) or incorporate it into CACHE_DIR naming
so the checksum becomes part of the cache key; update all callers (the other
cache-write/read sites referenced around the 88-107 and 154-155 changes) to
pass/consume this checksum so cache reuse is correctly invalidated when the
pinned tarball changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 666a7799-5a0a-4982-bad4-6ea45dc9b4ee
📒 Files selected for processing (2)
CONTRIBUTING.mdscripts/setup.sh
✅ Files skipped from review due to trivial changes (1)
- CONTRIBUTING.md
| build_or_seed_local_ghosttykit() { | ||
| local local_sha | ||
|
|
||
| local_sha="" | ||
| if [ -f "$LOCAL_SHA_STAMP" ]; then | ||
| local_sha="$(cat "$LOCAL_SHA_STAMP")" | ||
| fi | ||
|
|
||
| if [ -d "$LOCAL_XCFRAMEWORK" ] && [ "$local_sha" = "$GHOSTTY_SHA" ]; then | ||
| echo "==> Seeding cache from existing local GhosttyKit.xcframework (SHA matches)" | ||
| else | ||
| require_zig | ||
| echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." | ||
| ( | ||
| cd ghostty | ||
| zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast | ||
| ) | ||
| printf '%s\n' "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP" | ||
| fi | ||
|
|
||
| install_cache_from_dir "$LOCAL_XCFRAMEWORK" "local-build" |
There was a problem hiding this comment.
Don't treat a dirty ghostty/ worktree as clean HEAD.
Both the download choice and the local seed path key off the commit SHA only. If ghostty/ has local edits, this can still pick the pinned prebuilt or reuse a .ghostty_sha artifact from an older working tree, so Ghostty changes get silently ignored. Force a source build for dirty worktrees and only reuse/write .ghostty_sha for clean trees.
Also applies to: 146-149
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/setup.sh` around lines 110 - 130, The function
build_or_seed_local_ghosttykit currently keys reuse only on LOCAL_SHA_STAMP and
GHOSTTY_SHA and misses dirty worktree detection; update it to check the git
working tree inside the ghostty directory (e.g., git -C ghostty status
--porcelain or equivalent) and set an is_clean flag, then only treat the local
cache as valid when LOCAL_XCFRAMEWORK exists, "$local_sha" = "$GHOSTTY_SHA", and
is_clean is true; when the worktree is dirty force a source build (do not reuse
prebuilt) and only write/overwrite LOCAL_SHA_STAMP (printf '%s\n' "$GHOSTTY_SHA"
> "$LOCAL_SHA_STAMP") when the ghostty repo is clean; keep
install_cache_from_dir and the build steps but gate reuse/write of the
.ghostty_sha on the clean check (affecting build_or_seed_local_ghosttykit and
the same logic at the other mentioned block).
Summary
cmux omooh-my-openagent tmux shim path with a native OpenCode child-session bridgecmux omo-hookandopencode attachomousage copy to match the new implementationTesting
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-cli -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-task-2479-opencode-hook-splits-cli build✅./scripts/reload.sh --tag task-2479-opencode-hook-splits❌ upstream Ghostty/macOS app link failure on this machine (SwiftUICoredirect-link rejection and unresolvedlibghosttysymbols); full log:/tmp/cmux-xcodebuild-task-2479-opencode-hook-splits.logIssues
Summary by cubic
Replaces the old
cmux omotmux shim with a native OpenCode child‑session bridge that opens first‑level child sessions as cmux splits viaopencode attach. Setup now prefers downloading a pinnedGhosttyKit.xcframeworkfor faster builds, with a local build fallback.New Features
omo-hooksubcommands:set-root,clear-root,spawn,delete.cmux-omo-bridge.jsunder a shadowOPENCODE_CONFIG_DIR; stale config auto‑resets on layout changes.OMOBridgeStateStore); child panes close on root clear or change.--continue/--sessionresume and--forkto skip backfill; updated help andOpenCodeBridgeTests.Migration
oh-my-openagentdependency;cmux omousage stays the same.cmuxsurface andopencodeonPATH.scripts/setup.shfetches a pinnedGhosttyKit.xcframeworkwhen available; falls back to a local build (Zig only needed for fallback).Written for commit ff3b067. Summary will update on new commits.
Summary by CodeRabbit
New Features
Changes
Documentation
Tests