Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 31 additions & 21 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10414,7 +10414,7 @@ struct CMUXCLI {
}
}

private static let omoPluginName = "oh-my-opencode"
private static let omoPluginName = "oh-my-openagent"

private func resolveExecutableInPath(_ name: String) -> String? {
let entries = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? []
Expand Down Expand Up @@ -10456,7 +10456,7 @@ struct CMUXCLI {

// Keep the shadow package isolated from stale/yanked pins in the user's
// opencode package.json. bun will update this manifest with the resolved
// oh-my-opencode version when installation succeeds.
// oh-my-openagent version when installation succeeds.
let packageManifest: [String: Any] = [
"dependencies": [
Self.omoPluginName: "latest"
Expand Down Expand Up @@ -10600,7 +10600,7 @@ struct CMUXCLI {
return "4096"
}

/// Creates a shadow config directory that layers oh-my-opencode on top of the user's
/// Creates a shadow config directory that layers oh-my-openagent on top of the user's
/// existing opencode config without modifying the original. Sets OPENCODE_CONFIG_DIR
/// to point at the shadow directory.
private func omoEnsurePlugin() throws {
Expand All @@ -10625,6 +10625,8 @@ struct CMUXCLI {
}

var plugins = (config["plugin"] as? [String]) ?? []
// Remove legacy oh-my-opencode entries to avoid duplicate plugin registration
plugins.removeAll { $0 == "oh-my-opencode" || $0.hasPrefix("oh-my-opencode@") }
let alreadyPresent = plugins.contains {
$0 == Self.omoPluginName || $0.hasPrefix("\(Self.omoPluginName)@")
}
Expand All @@ -10650,12 +10652,18 @@ struct CMUXCLI {
try? fm.removeItem(at: shadowBunLockURL)
}

// Copy oh-my-opencode plugin config (jsonc) if the user has one
for filename in ["oh-my-opencode.json", "oh-my-opencode.jsonc"] {
let userFile = userDir.appendingPathComponent(filename)
let shadowFile = shadowDir.appendingPathComponent(filename)
if fm.fileExists(atPath: userFile.path) && !fm.fileExists(atPath: shadowFile.path) {
// Copy oh-my-openagent plugin config if the user has one (fall back to legacy oh-my-opencode name)
for ext in ["json", "jsonc"] {
let newName = "oh-my-openagent.\(ext)"
let legacyName = "oh-my-opencode.\(ext)"
let shadowFile = shadowDir.appendingPathComponent(newName)
guard !fm.fileExists(atPath: shadowFile.path) else { continue }
let userFile = userDir.appendingPathComponent(newName)
let legacyFile = userDir.appendingPathComponent(legacyName)
if fm.fileExists(atPath: userFile.path) {
try fm.createSymbolicLink(at: shadowFile, withDestinationURL: userFile)
} else if fm.fileExists(atPath: legacyFile.path) {
try fm.createSymbolicLink(at: shadowFile, withDestinationURL: legacyFile)
}
}

Expand All @@ -10664,15 +10672,15 @@ struct CMUXCLI {
if !fm.fileExists(atPath: pluginPackageDir.path) {
let installDir = shadowDir
if let bunPath = resolveExecutableInPath("bun") {
FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!)
FileHandle.standardError.write("Installing oh-my-openagent plugin (this may take a minute on first run)...\n".data(using: .utf8)!)
let installArguments = ["add", Self.omoPluginName]
let firstAttemptStatus = try omoRunPackageInstall(
executablePath: bunPath,
arguments: installArguments,
currentDirectoryURL: installDir
)
if firstAttemptStatus != 0 {
FileHandle.standardError.write("Retrying oh-my-opencode install with a clean shadow package state...\n".data(using: .utf8)!)
FileHandle.standardError.write("Retrying oh-my-openagent install with a clean shadow package state...\n".data(using: .utf8)!)
try? fm.removeItem(at: shadowBunLockURL)
try? fm.removeItem(at: shadowNodeModules)
try omoEnsureShadowNodeModulesSymlink(shadowNodeModules: shadowNodeModules, userNodeModules: userNodeModules)
Expand All @@ -10682,37 +10690,39 @@ struct CMUXCLI {
currentDirectoryURL: installDir
)
if retryStatus != 0 {
throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode")
throw CLIError(message: "Failed to install oh-my-openagent. Try manually: npm install -g oh-my-openagent")
}
}
} else if let npmPath = resolveExecutableInPath("npm") {
FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!)
FileHandle.standardError.write("Installing oh-my-openagent plugin (this may take a minute on first run)...\n".data(using: .utf8)!)
let status = try omoRunPackageInstall(
executablePath: npmPath,
arguments: ["install", Self.omoPluginName],
currentDirectoryURL: installDir
)
if status != 0 {
throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode")
throw CLIError(message: "Failed to install oh-my-openagent. Try manually: npm install -g oh-my-openagent")
}
} else {
throw CLIError(message: "Neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install")
throw CLIError(message: "Neither bun nor npm found in PATH. Install oh-my-openagent manually: bunx oh-my-openagent install")
}
FileHandle.standardError.write("oh-my-opencode plugin installed\n".data(using: .utf8)!)
FileHandle.standardError.write("oh-my-openagent plugin installed\n".data(using: .utf8)!)
}

// Ensure tmux mode is enabled in oh-my-opencode config.
// Ensure tmux mode is enabled in oh-my-openagent 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")
let omoConfigURL = shadowDir.appendingPathComponent("oh-my-openagent.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),
// Check if user has a config we symlinked, read from source (new name first, fall back to legacy)
let userOmoConfig = userDir.appendingPathComponent("oh-my-openagent.json")
let legacyOmoConfig = userDir.appendingPathComponent("oh-my-opencode.json")
let userConfigData = (try? Data(contentsOf: userOmoConfig)) ?? (try? Data(contentsOf: legacyOmoConfig))
if let data = userConfigData,
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
omoConfig = existing
// Remove the symlink so we can write our own copy
Expand Down Expand Up @@ -10809,7 +10819,7 @@ struct CMUXCLI {
}
}

// Ensure oh-my-opencode plugin is registered and installed
// Ensure oh-my-openagent plugin is registered and installed
try omoEnsurePlugin()

let shimDirectory = try createOMOShimDirectory()
Expand Down
58 changes: 39 additions & 19 deletions daemon/remote/cmd/cmuxd-remote/agent_launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func runOMORelay(socketPath string, args []string, refreshAddr func() string) in
return 1
}

// Ensure oh-my-opencode plugin is set up
// Ensure oh-my-openagent plugin is set up
if err := omoEnsurePlugin(originalPath); err != nil {
fmt.Fprintf(os.Stderr, "cmux omo: plugin setup: %v\n", err)
return 1
Expand Down Expand Up @@ -407,9 +407,9 @@ func configureAgentEnvironment(cfg agentConfig) {
}
}

// --- oh-my-opencode plugin setup ---
// --- oh-my-openagent plugin setup ---

const omoPluginName = "oh-my-opencode"
const omoPluginName = "oh-my-openagent"

func omoUserConfigDir() string {
home, _ := os.UserHomeDir()
Expand All @@ -422,7 +422,7 @@ func omoShadowConfigDir() string {
}

// omoEnsurePlugin creates a shadow config directory that layers the
// oh-my-opencode plugin on top of the user's opencode config, installs
// oh-my-openagent plugin on top of the user's opencode config, installs
// the plugin if needed, and sets OPENCODE_CONFIG_DIR.
func omoEnsurePlugin(searchPath string) error {
userDir := omoUserConfigDir()
Expand All @@ -445,7 +445,7 @@ func omoEnsurePlugin(searchPath string) error {
config = map[string]any{}
}

// Add oh-my-opencode to the plugins list
// Add oh-my-openagent to the plugins list
var plugins []string
if raw, ok := config["plugin"].([]any); ok {
for _, p := range raw {
Expand All @@ -454,6 +454,14 @@ func omoEnsurePlugin(searchPath string) error {
}
}
}
// Remove legacy oh-my-opencode entries to avoid duplicate plugin registration
var cleaned []string
for _, p := range plugins {
if p != "oh-my-opencode" && !strings.HasPrefix(p, "oh-my-opencode@") {
cleaned = append(cleaned, p)
}
}
plugins = cleaned
alreadyPresent := false
for _, p := range plugins {
if p == omoPluginName || strings.HasPrefix(p, omoPluginName+"@") {
Expand Down Expand Up @@ -494,12 +502,20 @@ func omoEnsurePlugin(searchPath string) error {
}
}

// Symlink oh-my-opencode config files
for _, filename := range []string{"oh-my-opencode.json", "oh-my-opencode.jsonc"} {
userFile := filepath.Join(userDir, filename)
shadowFile := filepath.Join(shadowDir, filename)
if fileExists(userFile) && !fileExists(shadowFile) {
// Symlink oh-my-openagent config files (fall back to legacy oh-my-opencode name)
for _, ext := range []string{"json", "jsonc"} {
newName := "oh-my-openagent." + ext
legacyName := "oh-my-opencode." + ext
shadowFile := filepath.Join(shadowDir, newName)
if fileExists(shadowFile) {
continue
}
userFile := filepath.Join(userDir, newName)
legacyFile := filepath.Join(userDir, legacyName)
if fileExists(userFile) {
os.Symlink(userFile, shadowFile)
} else if fileExists(legacyFile) {
os.Symlink(legacyFile, shadowFile)
}
}

Expand All @@ -516,10 +532,10 @@ func omoEnsurePlugin(searchPath string) error {
bunPath := findExecutableInPath("bun", searchPath, "")
npmPath := findExecutableInPath("npm", searchPath, "")
if bunPath == "" && npmPath == "" {
return fmt.Errorf("neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install")
return fmt.Errorf("neither bun nor npm found in PATH. Install oh-my-openagent manually: bunx oh-my-openagent install")
}

fmt.Fprintf(os.Stderr, "Installing oh-my-opencode plugin...\n")
fmt.Fprintf(os.Stderr, "Installing oh-my-openagent plugin...\n")
var cmd *exec.Cmd
if bunPath != "" {
cmd = exec.Command(bunPath, "add", omoPluginName)
Expand All @@ -530,28 +546,32 @@ func omoEnsurePlugin(searchPath string) error {
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to install oh-my-opencode: %v\nTry manually: npm install -g oh-my-opencode", err)
return fmt.Errorf("failed to install oh-my-openagent: %v\nTry manually: npm install -g oh-my-openagent", err)
}
fmt.Fprintf(os.Stderr, "oh-my-opencode plugin installed\n")
fmt.Fprintf(os.Stderr, "oh-my-openagent plugin installed\n")

// Re-create symlink if we installed into user dir
if installDir == userDir && !fileExists(shadowNodeModules) {
os.Symlink(userNodeModules, shadowNodeModules)
}
}

// Configure oh-my-opencode.json with tmux settings
omoConfigPath := filepath.Join(shadowDir, "oh-my-opencode.json")
// Configure oh-my-openagent.json with tmux settings
omoConfigPath := filepath.Join(shadowDir, "oh-my-openagent.json")
var omoConfig map[string]any
if data, err := os.ReadFile(omoConfigPath); err == nil {
json.Unmarshal(data, &omoConfig)
}
if omoConfig == nil {
// Check if user had one we symlinked
userOmoConfig := filepath.Join(userDir, "oh-my-opencode.json")
// Check if user had one we symlinked (new name first, fall back to legacy)
userOmoConfig := filepath.Join(userDir, "oh-my-openagent.json")
legacyOmoConfig := filepath.Join(userDir, "oh-my-opencode.json")
if data, err := os.ReadFile(userOmoConfig); err == nil {
json.Unmarshal(data, &omoConfig)
os.Remove(omoConfigPath) // Remove symlink so we can write our own copy
os.Remove(omoConfigPath)
} else if data, err := os.ReadFile(legacyOmoConfig); err == nil {
json.Unmarshal(data, &omoConfig)
os.Remove(omoConfigPath)
Comment on lines +559 to +574
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 5, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Don't silently replace malformed OMO config.

Lines 563, 570, and 573 ignore json.Unmarshal errors. If the user's OMO config is malformed, the fallback branch still removes omoConfigPath at Lines 571 and 574 and later writes defaults, which hides the syntax error instead of surfacing it.

🛠️ Proposed fix
 	omoConfigPath := filepath.Join(shadowDir, "oh-my-openagent.json")
 	var omoConfig map[string]any
 	if data, err := os.ReadFile(omoConfigPath); err == nil {
-		json.Unmarshal(data, &omoConfig)
+		if err := json.Unmarshal(data, &omoConfig); err != nil {
+			return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", omoConfigPath)
+		}
 	}
 	if omoConfig == nil {
 		// Check if user had one we symlinked (new name first, fall back to legacy)
 		userOmoConfig := filepath.Join(userDir, "oh-my-openagent.json")
 		legacyOmoConfig := filepath.Join(userDir, "oh-my-opencode.json")
 		if data, err := os.ReadFile(userOmoConfig); err == nil {
-			json.Unmarshal(data, &omoConfig)
-			os.Remove(omoConfigPath)
+			if err := json.Unmarshal(data, &omoConfig); err != nil {
+				return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", userOmoConfig)
+			}
+			_ = os.Remove(omoConfigPath)
 		} else if data, err := os.ReadFile(legacyOmoConfig); err == nil {
-			json.Unmarshal(data, &omoConfig)
-			os.Remove(omoConfigPath)
+			if err := json.Unmarshal(data, &omoConfig); err != nil {
+				return fmt.Errorf("failed to parse %s: fix the JSON syntax and retry", legacyOmoConfig)
+			}
+			_ = os.Remove(omoConfigPath)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@daemon/remote/cmd/cmuxd-remote/agent_launch.go` around lines 559 - 574, The
code currently ignores json.Unmarshal errors for omoConfig (and for
userOmoConfig/legacyOmoConfig) and still removes omoConfigPath, which hides
malformed user config; update the logic around omoConfigPath, omoConfig,
userOmoConfig and legacyOmoConfig so that you check and handle the error
returned by json.Unmarshal (call sites referencing json.Unmarshal), do not call
os.Remove(omoConfigPath) when Unmarshal fails, and instead surface or log the
parsing error and abort/return it (or preserve the existing file) so malformed
JSON is not silently replaced by defaults.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid concern, but this is pre-existing behavior — the json.Unmarshal error handling pattern is identical on main (lines 543-555). This PR only changes the filenames, not the error handling logic. Better addressed in a separate follow-up.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@lark1115, that's a fair point — the json.Unmarshal error-handling pattern is pre-existing and this PR doesn't alter it. I'll drop the concern for this PR.

Would you like me to open a follow-up GitHub issue to track the improvement (surfacing parse errors instead of silently falling back to defaults for the OMO config files)?


🧠 Learnings used
Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2525
File: Sources/GhosttyTerminalView.swift:481-513
Timestamp: 2026-04-02T10:13:39.235Z
Learning: Repo: manaflow-ai/cmux — In Sources/GhosttyTerminalView.swift, terminal file-link resolution trims trailing unmatched closing delimiters “) ] } >” only when they are dangling (more closers than openers), preserving wrapped tokens like “(file:///tmp/a.png)”. Implemented via terminalFileLinkTrailingClosingDelimiters and count comparison inside trimTrailingTerminalFileLinkPunctuation(_:) and exercised by a regression test (PR `#2525`, commit 3f5c5b6d).

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2564
File: CLI/cmux.swift:0-0
Timestamp: 2026-04-04T02:33:03.680Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift, baseSSHArguments(_:, localCommand:) now percent-escapes LocalCommand by replacing "%" with "%%" to prevent OpenSSH percent-token expansion. A CLI regression test asserts that the emitted -o LocalCommand retains doubled percent signs.

Learnt from: outoftime
Repo: manaflow-ai/cmux PR: 1528
File: Resources/shell-integration/fish/vendor_conf.d/cmux-fish-integration.fish:541-546
Timestamp: 2026-03-17T13:59:10.665Z
Learning: Repo: manaflow-ai/cmux — The socket command `report_git_branch` (parsed in Sources/TerminalController.swift) expects the branch name as an **unquoted, bare token**. Wrapping the branch name in double quotes causes it to be silently discarded by the parser. This matches the bash/zsh shell integration convention. Do not suggest quoting the branch argument for this command; the fix was attempted in commit 829437c7 and immediately reverted because it broke branch reporting.

}
}
if omoConfig == nil {
Expand Down