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
8 changes: 6 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ jobs:
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25.x"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
Expand All @@ -26,6 +30,6 @@ jobs:
- name: Install dependencies
run: bun install
- name: Build
run: bun run build
run: OPENTMUX_GO_RELEASE=1 bun run build
- name: Publish to NPM with provenance
run: npm publish --access public --provenance
run: npm publish --access public --provenance
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/

# Build output
dist/
/bin/

# Logs
*.log
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
[![npm downloads](https://img.shields.io/npm/dt/opentmux)](https://www.npmjs.com/package/opentmux)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

An [OpenCode](https://opencode.ai) plugin that provides **smart tmux integration** for viewing agent execution in real-time. Automatically spawns panes, streams output, and manages your terminal workspace.
An [OpenCode](https://opencode.ai) plugin that provides **smart tmux integration** for viewing agent execution in real-time. It now uses a **Go-first runtime** (with a thin TypeScript compatibility shim) for lower overhead and stronger process/concurrency control.

> **Warning:** The Go rewrite is still in active testing/validation.
> Use `refactor/go-rewrite` for evaluation, but expect iterative changes while parity hardening is in progress.

- **Agent-Agnostic**: Works with oh-my-opencode, omoc-slim, or vanilla OpenCode.
- **Cross-Platform**: Full support for **macOS**, **Linux**, and **Windows** (via PowerShell or WSL).
- **Go Runtime Core**: Session management, spawn queue, tmux control, and process reaping are handled in Go.

![Opencode Agent Tmux Demo](https://raw.githubusercontent.com/angansamadder/opentmux/main/assets/demo.png)
*Note: The demo shows the "sisyphus" agent from [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode), but this plugin supports ALL OpenCode subagents.*
Expand Down Expand Up @@ -75,11 +79,38 @@ To install this plugin, please perform the following steps for the user:

For contributors working on this plugin locally, see [LOCAL_DEVELOPMENT.md](docs/LOCAL_DEVELOPMENT.md) for setup instructions.

> **Go rewrite branch:** The Go-first runtime currently lives on `refactor/go-rewrite`.
> Switch with: `git checkout refactor/go-rewrite`

### Runtime Architecture

`opentmux` now runs on a Go-first runtime with a thin TypeScript compatibility shim.

- Go binaries:
- `opentmux` (CLI wrapper)
- `opentmuxd` (runtime daemon)
- `opentmuxctl` (control client used by shim)
- TS shim:
- `src/index.ts` controls daemon lifecycle and forwards events
- `src/bin/opentmux.ts` delegates CLI execution to Go binary
- Legacy TS runtime fallback remains available for compatibility safety.

Build steps:

```bash
# TypeScript bundle + local-platform Go binaries in dist/runtime/<os-arch>/
bun run build

# 100-session burst benchmark harness
bun run bench:burst
```

## ✨ Features

- **Automatic Tmux Pane Spawning**: When any agent starts, automatically spawns a tmux pane
- **Live Streaming**: Each pane runs `opencode attach` to show real-time agent output
- **Auto-Cleanup**: Panes automatically close when agents complete
- **High-Performance Queueing**: Go-based spawn queue with retry/backoff, stale protection, and dedupe controls
- **Configurable Layout**: Support multiple tmux layouts (`main-vertical`, `tiled`, etc.)
- **Multi-Port Support**: Automatically finds available ports (4096-4106) when running multiple instances
- **Smart Wrapper**: Automatically detects if you are in tmux; if not, launches a session for you.
Expand Down Expand Up @@ -111,7 +142,7 @@ You can customize behavior by creating `~/.config/opencode/opentmux.json`:
### Panes Not Spawning
1. Verify you're inside tmux: `echo $TMUX`
2. Check tmux is installed: `which tmux` (or `where tmux` on Windows)
3. Check logs: `cat /tmp/opentmux.log`
3. Check logs: `cat /tmp/opencode-agent-tmux.log`

### Server Not Found
Make sure OpenCode is started with the `--port` flag matching your config (the wrapper does this automatically).
Expand Down
22 changes: 22 additions & 0 deletions buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
version: v2
managed:
enabled: true
plugins:
- remote: buf.build/protocolbuffers/go
out: gen/go
opt:
- paths=source_relative
- remote: buf.build/connectrpc/go
out: gen/go
opt:
- paths=source_relative
- remote: buf.build/bufbuild/es
out: gen/ts
opt:
- target=ts
- import_extension=none
- remote: buf.build/connectrpc/es
out: gen/ts
opt:
- target=ts
- import_extension=none
6 changes: 6 additions & 0 deletions buf.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Generated by buf. DO NOT EDIT.
version: v2
deps:
- name: buf.build/bufbuild/protovalidate
commit: 80ab13bee0bf4272b6161a72bf7034e0
digest: b5:1aa6a965be5d02d64e1d81954fa2e78ef9d1e33a0c30f92bc2626039006a94deb3a5b05f14ed8893f5c3ffce444ac008f7e968188ad225c4c29c813aa5f2daa1
11 changes: 11 additions & 0 deletions buf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
version: v2
modules:
- path: proto
deps:
- buf.build/bufbuild/protovalidate
lint:
use:
- STANDARD
breaking:
use:
- FILE
240 changes: 240 additions & 0 deletions cmd/opentmux/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package main

import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"

"github.com/AnganSamadder/opentmux/internal/config"
"github.com/AnganSamadder/opentmux/internal/process"
"github.com/AnganSamadder/opentmux/internal/reaper"
)

var nonTUICommands = map[string]struct{}{
"auth": {}, "config": {}, "plugins": {}, "update": {}, "upgrade": {}, "completion": {}, "stats": {},
"run": {}, "exec": {}, "doctor": {}, "debug": {}, "clean": {}, "uninstall": {}, "agent": {}, "session": {},
"export": {}, "import": {}, "github": {}, "pr": {}, "serve": {}, "web": {}, "acp": {}, "mcp": {}, "models": {},
"--version": {}, "-v": {}, "--help": {}, "-h": {},
}

func main() {
cfg := config.LoadConfig("")
args := os.Args[1:]
if len(args) > 0 && (args[0] == "--reap" || args[0] == "-reap") {
reaper.ReapAll(cfg.MaxPorts)
return
}

isInteractive := len(args) == 0
_, isCLI := nonTUICommands[firstArg(args)]

opencodeBin := findOpencodeBin()
if opencodeBin == "" {
fmt.Fprintln(os.Stderr, "Error: Could not find \"opencode\" binary in PATH.")
os.Exit(1)
}

if isInteractive || isCLI {
runPassthrough(opencodeBin, args)
return
}

port := findAvailablePort(cfg)
if port == 0 {
if cfg.RotatePort {
port = rotateOldestPort(cfg)
}
}
if port == 0 {
fmt.Fprintf(os.Stderr, "Error: No available ports found in range %d-%d.\n", cfg.Port, cfg.Port+cfg.MaxPorts)
os.Exit(1)
}

env := append([]string{}, os.Environ()...)
env = append(env, "OPENCODE_PORT="+strconv.Itoa(port))
childArgs := append([]string{"--port", strconv.Itoa(port)}, args...)

inTmux := os.Getenv("TMUX") != ""
tmuxAvailable := hasTmux()

if inTmux || !tmuxAvailable {
cmd := exec.Command(opencodeBin, childArgs...)
cmd.Env = env
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
runOrExit(cmd)
return
}

shellCommand := fmt.Sprintf("%s %s || { echo 'Exit code: $?'; echo 'Press Enter to close...'; read; }", quoteArg(opencodeBin), joinQuoted(childArgs))
cmd := exec.Command("tmux", "new-session", shellCommand)
cmd.Env = env
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
runOrExit(cmd)
}

func firstArg(args []string) string {
if len(args) == 0 {
return ""
}
return args[0]
}

func runPassthrough(opencodeBin string, args []string) {
passthrough := append([]string{}, args...)
hasPrintLogs := false
hasLogLevel := false
for _, arg := range args {
if arg == "--print-logs" {
hasPrintLogs = true
}
if strings.HasPrefix(arg, "--log-level") {
hasLogLevel = true
}
}
if !hasPrintLogs && !hasLogLevel {
passthrough = append(passthrough, "--log-level", "ERROR")
}
cmd := exec.Command(opencodeBin, passthrough...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
runOrExit(cmd)
}

func runOrExit(cmd *exec.Cmd) {
if err := cmd.Run(); err != nil {
if ee, ok := err.(*exec.ExitError); ok {
if status, ok := ee.Sys().(syscall.WaitStatus); ok {
os.Exit(status.ExitStatus())
}
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func quoteArg(arg string) string {
if strings.ContainsAny(arg, " '\"\t\n") {
return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'"
}
return arg
}

func joinQuoted(args []string) string {
out := make([]string, 0, len(args))
for _, arg := range args {
out = append(out, quoteArg(arg))
}
return strings.Join(out, " ")
}

func hasTmux() bool {
_, err := exec.LookPath("tmux")
return err == nil
}

func findOpencodeBin() string {
bins := strings.Split(process.SafeExec("which -a opencode"), "\n")
for _, bin := range bins {
b := strings.TrimSpace(bin)
if b == "" {
continue
}
if strings.Contains(b, "opentmux") {
continue
}
if filepath.Base(b) == "opencode" || filepath.Base(b) == "opencode.exe" {
return b
}
}
if runtime.GOOS == "windows" {
return ""
}
for _, p := range []string{"/usr/local/bin/opencode", "/usr/bin/opencode"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}

func findAvailablePort(cfg config.Config) int {
start := cfg.Port
if start <= 0 {
start = 4096
}
end := start + cfg.MaxPorts
for port := start; port <= end; port++ {
if checkPort(port) {
return port
}
}
return 0
}

func rotateOldestPort(cfg config.Config) int {
start := cfg.Port
if start <= 0 {
start = 4096
}
end := start + cfg.MaxPorts
oldestPID := 0
oldestStart := time.Now().UnixMilli()
targetPort := 0

for port := start; port <= end; port++ {
for _, pid := range process.GetListeningPIDs(port) {
cmd := process.GetProcessCommand(pid)
if !(strings.Contains(cmd, "opencode") || strings.Contains(cmd, "node") || strings.Contains(cmd, "bun")) {
continue
}
startTime := process.SafeExec(fmt.Sprintf("ps -p %d -o lstart=", pid))
if startTime == "" {
continue
}
parsed, err := time.Parse("Mon Jan _2 15:04:05 2006", startTime)
if err != nil {
continue
}
if parsed.UnixMilli() < oldestStart {
oldestStart = parsed.UnixMilli()
oldestPID = pid
targetPort = port
}
}
}

if oldestPID == 0 {
return 0
}
process.SafeKill(oldestPID, syscall.SIGTERM)
_ = process.WaitForProcessExit(oldestPID, 2*time.Second)
if process.IsProcessAlive(oldestPID) {
process.SafeKill(oldestPID, syscall.SIGKILL)
_ = process.WaitForProcessExit(oldestPID, time.Second)
}
if checkPort(targetPort) {
return targetPort
}
return 0
}

func checkPort(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return false
}
_ = ln.Close()
return true
}
Loading