Skip to content

Add OpenCode child-session panes for omo#2517

Open
lawrencecchen wants to merge 3 commits intomainfrom
task-2479-opencode-hook-splits
Open

Add OpenCode child-session panes for omo#2517
lawrencecchen wants to merge 3 commits intomainfrom
task-2479-opencode-hook-splits

Conversation

@lawrencecchen
Copy link
Copy Markdown
Contributor

@lawrencecchen lawrencecchen commented Apr 1, 2026

Summary

  • replace the old cmux omo oh-my-openagent tmux shim path with a native OpenCode child-session bridge
  • inject a local OpenCode plugin that maps first-level child sessions to cmux splits through cmux omo-hook and opencode attach
  • add bridge state and CLI tests, and update omo usage copy to match the new implementation

Testing

  • 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 (SwiftUICore direct-link rejection and unresolved libghostty symbols); full log: /tmp/cmux-xcodebuild-task-2479-opencode-hook-splits.log

Issues


Summary by cubic

Replaces the old cmux omo tmux shim with a native OpenCode child‑session bridge that opens first‑level child sessions as cmux splits via opencode attach. Setup now prefers downloading a pinned GhosttyKit.xcframework for faster builds, with a local build fallback.

  • New Features

    • Added omo-hook subcommands: set-root, clear-root, spawn, delete.
    • Injects local cmux-omo-bridge.js under a shadow OPENCODE_CONFIG_DIR; stale config auto‑resets on layout changes.
    • Per‑workspace/surface state with file locking (OMOBridgeStateStore); child panes close on root clear or change.
    • Launches OpenCode on a stable local port; panes auto‑equalize when >1 split.
    • Supports --continue/--session resume and --fork to skip backfill; updated help and OpenCodeBridgeTests.
  • Migration

    • No tmux shim or oh-my-openagent dependency; cmux omo usage stays the same.
    • Requires an active cmux surface and opencode on PATH.
    • scripts/setup.sh fetches a pinned GhosttyKit.xcframework when 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

    • Added a new cmux omo-hook command for managing OpenCode child sessions and state.
  • Changes

    • Redesigned cmux omo to use a bridge-based OpenCode integration and simplified shim/attach flow.
    • Installer now prefers pinned prebuilt GhosttyKit with fallback to local build.
  • Documentation

    • Updated help text and localized usage for OpenCode-related commands (EN/JA/UKR).
  • Tests

    • Added unit tests covering the OpenCode bridge behaviors.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 2, 2026 2:00am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 2026

📝 Walkthrough

Walkthrough

Introduces an OpenCode (OMO) bridge for native child-session panes: file-backed bridge state, an omo-hook dispatch with lifecycle subcommands, rewrites cmux omo to use the bridge/env-based attach flow, adds tests and Xcode test registration, updates setup script and docs for prebuilt GhosttyKit handling, and updates localized help text.

Changes

Cohort / File(s) Summary
OMO Bridge State & CLI
CLI/cmux.swift
Added persistent types (OMOResumeBehavior, OMOBridgeChildRecord, OMOBridgeStateFile), a flock-locked OMOBridgeStateStore with load/reset/atomic mutate, new omo-hook subcommands (set-root,clear-root,spawn,delete), and reworked cmux omo to emit bridge plugin config, new env vars (CMUX_OMO_ROLE, CMUX_OMO_SERVER_URL, CMUX_OMO_STATE_PATH, OPENCODE_*), and an attach-script path that routes child attaches through omo-hook.
Tests & Xcode Project
cmuxTests/OpenCodeBridgeTests.swift, GhosttyTabs.xcodeproj/project.pbxproj
Added OpenCodeBridgeTests.swift with tests for resume-flag parsing, state store persistence/locking, and omoAttachCommand generation; registered the test file in the cmuxTests target.
Localization & Help
Resources/Localizable.xcstrings
Rewrote cmux omo localized help (EN/JA/UKR) to describe the OpenCode child-session bridge and attach workflow instead of the prior tmux-shim description.
Setup Script & Cache
scripts/setup.sh
Replaced global zig check with require_zig only for local builds; added prebuilt-selection via CMUX_GHOSTTYKIT_PREFER_PREBUILT, pinned-commit checksum lookup, cache metadata stamp files, seeding from local xcframework when SHA matches, download fallback, and cache layout/version checks.
Contributing Docs
CONTRIBUTING.md
Clarified setup: prefer downloading pinned GhosttyKit.xcframework and only build from source if download unavailable.
Telemetry / Error Handling
CLI/cmux.swift (same file cohort)
Added routing/telemetry breadcrumbs and centralized error capture around omo-hook dispatch.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I tunneled through state files, locks held tight,
Spawned little sessions into pane-lit light.
Hooks whispered "attach", cmux made the split,
Records kept tidy—no duplicate bit.
Hooray — a bridge, snug, hopping into sight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the primary change: adding OpenCode child-session panes for the omo command, which aligns with the main objective of replacing the old tmux shim with a native OpenCode bridge.
Linked Issues check ✅ Passed The PR fulfills Phase 1 MVP requirements from issue #2479: launches root OpenCode on a stable port, injects a plugin via OMOBridgeStateStore bridge, maps child sessions to splits via omo-hook, maintains state mapping, and respects constraints (no auto-close on idle, explicit deletion cleanup, root pane stays attached).
Out of Scope Changes check ✅ Passed All changes are tightly scoped to the linked objective #2479: CLI omo/omo-hook implementation, bridge state store, plugin generation, tests, and help text. The setup.sh changes support faster builds by preferring prebuilt GhosttyKit, which is a reasonable supporting infrastructure improvement.
Description check ✅ Passed The pull request description follows the template with clear Summary, Testing, and Issues sections; it includes the issue number and provides adequate context for the changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-2479-opencode-hook-splits

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread CLI/cmux.swift
Comment on lines 10193 to +10197
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 1, 2026

Greptile Summary

This PR replaces the previous cmux omo tmux-shim approach (which proxied oh-my-openagent's tmux calls through a PATH shim) with a native OpenCode child-session bridge. The implementation injects a local JS plugin (cmux-omo-bridge.js) into OpenCode's shadow config directory; the plugin listens for session.created / session.deleted events and calls back into cmux omo-hook to open or close cmux splits for first-level child sessions. A new OMOBridgeStateStore provides file-locked, JSON-backed state shared between the root OpenCode process and the omo-hook child processes, and OMOBridgeChildRecord maps child session IDs to their corresponding cmux surface IDs.

Key concerns found during review:

  • TOCTOU state corruption (P1): All four omo-hook subcommands (set-root, clear-root, spawn, delete) call store.load() before acquiring the lock, then call store.mutate { $0 = state } which internally re-reads the file but immediately overwrites it with the stale local copy. This renders the file lock ineffective — any concurrent write between the two calls is silently lost. The spawn subcommand compounds this with three separate non-atomic mutations in a single invocation. All modifications should be consolidated into a single store.mutate { ... } block per subcommand.
  • EXIT trap masks attach exit code (P1): In omoAttachCommand, exit "$cmux_omo_status" does not propagate the attach exit status because POSIX sh sets the final exit code to the last command in the EXIT trap handler — which is always 0 due to || true. The trap should capture _rc=$? at its start and call exit "$_rc" at its end.
  • Shim dir recovered by PATH suffix scan (P2): spawn locates the shim directory by scanning PATH for the hard-coded suffix /.cmuxterm/omo-bin rather than reading a dedicated env var, which is fragile. configureOMOEnvironment could set CMUX_OMO_SHIM_DIR alongside the other omo-specific env vars.

Confidence Score: 2/5

  • Not safe to merge as-is — two P1 bugs in CLI/cmux.swift need fixes before the bridge is reliable.
  • Two P1 issues lower the score significantly: the TOCTOU load+mutate anti-pattern across all four omo-hook subcommands can silently lose state updates under concurrent access, and the EXIT trap in omoAttachCommand always masks the real exit code. The broader architecture and test coverage are solid, but these correctness issues should be resolved first.
  • CLI/cmux.swift — specifically runOMOHook (all four subcommands) and omoAttachCommand

Important Files Changed

Filename Overview
CLI/cmux.swift Core implementation of the OMO bridge — adds OMOResumeBehavior parser, OMOBridgeStateStore (file-locked JSON state), omo-hook subcommand dispatcher, and omoAttachCommand shell-script builder. Two P1 bugs: all four hook subcommands use a TOCTOU load+mutate pattern that defeats the file lock, and the EXIT trap in omoAttachCommand always masks the real exit code with 0.
cmuxTests/OpenCodeBridgeTests.swift New test file covering OMOResumeBehavior flag parsing, OMOBridgeStateStore persistence, and omoAttachCommand output shape; tests use correct single-mutate patterns (not the TOCTOU pattern present in production code), so they pass cleanly without exercising the race.
GhosttyTabs.xcodeproj/project.pbxproj Adds OpenCodeBridgeTests.swift to the test target's Sources build phase and file references; mechanical change, no issues.
Resources/Localizable.xcstrings Updates the cli.omo.usage string in English, Japanese, and Ukrainian to reflect the new native-pane description, replacing all references to oh-my-openagent and the tmux shim; translations are consistent with each other.

Sequence Diagram

sequenceDiagram
    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)
Loading

Reviews (1): Last reviewed commit: "Add OpenCode child-session panes for omo" | Re-trigger Greptile

Comment thread CLI/cmux.swift
Comment on lines +12477 to +12486
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 }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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:

  1. var state = try store.load() — acquires the lock, reads, then releases the lock
  2. Local state is mutated
  3. 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 stale state captured 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-root at lines 12499 + 12503
  • spawn at lines 12527 + 12539 / 12546 / 12611 (three separate non-atomic mutations in a single subcommand invocation)
  • delete at 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

Comment thread CLI/cmux.swift
Comment on lines +10232 to +10235
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\"")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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).

Comment thread CLI/cmux.swift
Comment on lines +12559 to +12562
let shimDirectoryPath = env["PATH"]?
.split(separator: ":")
.map(String.init)
.first(where: { $0.hasSuffix("/.cmuxterm/omo-bin") || $0.hasSuffix("/.cmuxterm/omo-bin/") })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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)
    .nilIfEmpty

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CLI/cmux.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))
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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))
Fix with Cubic

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 41ed8b6 and cd20288.

📒 Files selected for processing (4)
  • CLI/cmux.swift
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Resources/Localizable.xcstrings
  • cmuxTests/OpenCodeBridgeTests.swift

Comment thread CLI/cmux.swift
Comment on lines +620 to +635
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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().

Comment thread CLI/cmux.swift
Comment on lines +10224 to +10235
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\"")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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:


🏁 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 -10

Repository: 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 -50

Repository: 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 -60

Repository: 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 -20

Repository: 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 3

Repository: 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.

Comment thread CLI/cmux.swift
Comment on lines +12477 to +12486
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 }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread CLI/cmux.swift
Comment on lines +621 to +623
let lockPath = statePath + ".lock"
let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR))
if fd < 0 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread scripts/setup.sh
Comment on lines +154 to +155
if [ -d "$CACHE_XCFRAMEWORK" ] && [ "$CURRENT_LAYOUT" = "$CACHE_LAYOUT_VERSION" ] && [ "$CURRENT_SOURCE" = "$DESIRED_SOURCE" ]; then
echo "==> Reusing cached GhosttyKit.xcframework ($CURRENT_SOURCE)"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/setup.sh
CURRENT_SOURCE="$(cache_source)"
CURRENT_LAYOUT="$(cache_layout)"

if [ -d "$CACHE_XCFRAMEWORK" ] && [ "$CURRENT_LAYOUT" = "$CACHE_LAYOUT_VERSION" ] && [ "$CURRENT_SOURCE" = "$DESIRED_SOURCE" ]; then
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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
Fix with Cubic

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 source and layout inside the $GHOSTTY_SHA bucket. 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 into CACHE_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

📥 Commits

Reviewing files that changed from the base of the PR and between cd20288 and 9691a7f.

📒 Files selected for processing (2)
  • CONTRIBUTING.md
  • scripts/setup.sh
✅ Files skipped from review due to trivial changes (1)
  • CONTRIBUTING.md

Comment thread scripts/setup.sh
Comment on lines +110 to +130
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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native OpenCode subagent panes via session bridge

1 participant